diff --git a/cli/src/export.rs b/cli/src/export.rs index 2b9cd0e..631b46a 100644 --- a/cli/src/export.rs +++ b/cli/src/export.rs @@ -1,11 +1,12 @@ //! `txcript export` — write a session as a Simple interchange document. //! -//! The source is resolved exactly like `view` (id or exact title, optional -//! `#range`). The output is the full-fidelity Simple rendering of the -//! canonical model — every `Transcript` field has a slot in Simple — -//! so the document is the session as txcript sees it, detached from any -//! harness's store. `txcript continue --with ` brings it -//! back into a harness, on this machine or another. +//! The source is resolved exactly like `view` (id, exact title, or an existing +//! Simple document file or stdin `-`, with optional `#range`). The output is the +//! full-fidelity Simple rendering of the canonical model — every +//! `Transcript` field has a slot in Simple — so the document is the +//! session as txcript sees it, detached from any harness's store. +//! `txcript continue --with ` brings it back into a harness, +//! on this machine or another. use std::path::Path; use std::process::ExitCode; @@ -178,4 +179,32 @@ mod tests { let back = Simple::to_common(&parsed).unwrap(); assert_eq!(back, original); } + + #[test] + fn cmd_export_slices_existing_simple_document() { + let dir = tempfile::tempdir().unwrap(); + let input_path = dir.path().join("input.json"); + let output_path = dir.path().join("sliced.json"); + std::fs::write( + &input_path, + r#"{ + "id": "doc-export-test", + "messages": [ + {"role": "user", "content": "msg 1"}, + {"role": "assistant", "content": "msg 2"}, + {"role": "user", "content": "msg 3"} + ] + }"#, + ) + .unwrap(); + + let source = format!("{}#1-2", input_path.display()); + let status = super::cmd_export(&source, None, Some(&output_path)).unwrap(); + assert_eq!(status, std::process::ExitCode::SUCCESS); + + let out_text = std::fs::read_to_string(&output_path).unwrap(); + let parsed = Simple::from_text(&out_text).unwrap(); + let common = Simple::to_common(&parsed).unwrap(); + assert_eq!(common.body.len(), 2); + } } diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 4bb7309..68120c9 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -19,10 +19,10 @@ //! --with [...] # for `-`) into ; see //! # docs/formats/simple.md //! # --with grok_bot never launches a CLI -//! txcript crop [#range] # interactively cut messages and save a copy +//! txcript crop [#range] # interactively cut messages and save a copy //! [--with ] # optionally convert the cropped copy //! [--from ] # scope the source lookup -//! txcript view [#range] # view a session; compact text when piped +//! txcript view [#range] # view a session; compact text when piped //! [--from ] # scope the id lookup to one harness //! [--no-pager] # print the terminal view directly //! txcript query '' # one-shot literal search, ranked hits @@ -161,7 +161,7 @@ pub enum SessionCommand { /// `#range` of 1-based inclusive message numbers (`abc#5-12`, `#7`, /// `#5-`, `#-10`) // Other: without a hint, generated completions fall back to filenames. - #[arg(value_hint = clap::ValueHint::Other)] + #[arg(value_hint = clap::ValueHint::Other, allow_hyphen_values = true)] id: String, /// Continue in this harness instead of the session's own #[arg(long, value_name = "HARNESS", value_parser = HarnessParser)] @@ -194,9 +194,10 @@ pub enum SessionCommand { /// The source is never modified. By default the cropped copy is written /// to the source harness; --with converts it to another harness instead. Crop { - /// Session id (any unambiguous prefix) or exact title, optionally with - /// an initial message range (`abc#5-12`, `abc#7`, `abc#5-`, `abc#-10`) - #[arg(value_hint = clap::ValueHint::Other)] + /// Session id (any unambiguous prefix) or exact title; or a Simple + /// document file. Optionally with an initial message range (`abc#5-12`, + /// `abc#7`, `abc#5-`, `abc#-10`) + #[arg(value_hint = clap::ValueHint::Other, allow_hyphen_values = true)] source: String, /// Write the cropped copy in this harness instead of the source harness #[arg(long, value_name = "HARNESS", value_parser = HarnessParser)] @@ -216,11 +217,12 @@ pub enum SessionCommand { /// number messages so a printed ordinal can be fed straight back as a /// `#range`. View { - /// Session id (any unambiguous prefix) or its exact title, with an - /// optional `#range` of 1-based inclusive message numbers - /// (`abc#5-12`, `#7`, `#5-`, `#-10`) + /// Session id (any unambiguous prefix) or its exact title; or a + /// Simple document (a file path, `-` for stdin). Takes an optional + /// `#range` of 1-based inclusive message numbers (`abc#5-12`, `#7`, + /// `#5-`, `#-10`) // Other: without a hint, generated completions fall back to filenames. - #[arg(value_hint = clap::ValueHint::Other)] + #[arg(value_hint = clap::ValueHint::Other, allow_hyphen_values = true)] source: String, /// Only look for the session in this harness #[arg(long, value_name = "HARNESS", value_parser = HarnessParser)] @@ -236,10 +238,11 @@ pub enum SessionCommand { /// Move it to another machine and `continue --with ` /// picks the session up there; a `#range` exports just those messages. Export { - /// Session id (any unambiguous prefix) or its exact title, with an - /// optional `#range` of 1-based inclusive message numbers - /// (`abc#5-12`, `#7`, `#5-`, `#-10`) - #[arg(value_hint = clap::ValueHint::Other)] + /// Session id (any unambiguous prefix) or its exact title; or a + /// Simple document (a file path, `-` for stdin). Takes an optional + /// `#range` of 1-based inclusive message numbers (`abc#5-12`, `#7`, + /// `#5-`, `#-10`) + #[arg(value_hint = clap::ValueHint::Other, allow_hyphen_values = true)] source: String, /// Only look for the session in this harness #[arg(long, value_name = "HARNESS", value_parser = HarnessParser)] @@ -951,6 +954,37 @@ mod identity_tests { crate::Command::Session(crate::SessionCommand::Continue { ref id, .. }) if id == "session-123" )); } + + #[test] + fn hyphen_prefixed_source_arguments_parse_across_commands() { + use clap::Parser; + + let cli = + crate::Cli::try_parse_from(["txcript", "continue", "-#1", "--with", "codex"]).unwrap(); + assert!(matches!( + cli.command, + crate::Command::Session(crate::SessionCommand::Continue { ref id, .. }) if id == "-#1" + )); + + let cli = crate::Cli::try_parse_from(["txcript", "view", "-#1"]).unwrap(); + assert!(matches!( + cli.command, + crate::Command::Session(crate::SessionCommand::View { ref source, .. }) if source == "-#1" + )); + + let cli = crate::Cli::try_parse_from(["txcript", "export", "-#1-5"]).unwrap(); + assert!(matches!( + cli.command, + crate::Command::Session(crate::SessionCommand::Export { ref source, .. }) if source == "-#1-5" + )); + + let cli = crate::Cli::try_parse_from(["txcript", "crop", "-#1", "--with", "claude_code"]) + .unwrap(); + assert!(matches!( + cli.command, + crate::Command::Session(crate::SessionCommand::Crop { ref source, .. }) if source == "-#1" + )); + } } fn cmd_list( @@ -1111,6 +1145,29 @@ fn cmd_crop( ensure_crop_target(target)?; } + if let Some((input, request)) = document_source(source) { + if from.is_some() { + return Err( + "--from scopes the search for a local session; a Simple document is its own input" + .to_string(), + ); + } + let target = with.ok_or_else(|| { + "a Simple document has no harness of its own to crop into; \ + pass --with (e.g. --with claude_code)" + .to_string() + })?; + ensure_crop_target(target)?; + if matches!(input, DocInput::Stdin) { + return Err( + "cannot crop a document from stdin because crop requires interactive terminal input" + .to_string(), + ); + } + let common = read_document_input(&input)?; + return crop_loaded(&common, HarnessId::Simple, target, request.as_ref()); + } + if let Some(loaded) = load_direct_claude_chat(source, from) { let target = with.unwrap_or(HarnessId::ClaudeChat); ensure_crop_target(target)?; @@ -1365,9 +1422,9 @@ fn parse_metadata_specs(specs: &[String]) -> Result { Ok(serde_json::Value::Object(map)) } -/// What `continue` received when it wasn't a session id: a Simple document -/// on stdin or in a file. -enum DocInput { +/// What `continue`, `view`, `export`, or `crop` received when it wasn't a +/// session id: a Simple document on stdin or in a file. +pub(crate) enum DocInput { Stdin, File(PathBuf), } @@ -1377,7 +1434,7 @@ enum DocInput { /// document. A whole argument that names one wins over the range /// interpretation, so a filename containing `#` still opens. Everything /// else is a session reference for the discovery path. -fn document_source(input: &str) -> Option<(DocInput, Option)> { +pub(crate) fn document_source(input: &str) -> Option<(DocInput, Option)> { if input == "-" { return Some((DocInput::Stdin, None)); } @@ -1401,22 +1458,8 @@ fn readable_document(path: &str) -> bool { std::fs::metadata(path).is_ok_and(|m| !m.is_dir()) } -/// Continue a Simple document into `--with`: parse, convert, write into the -/// target's store, launch. The document is read once and never modified; -/// from here on the conversation lives in the target harness. -fn continue_document( - input: &DocInput, - span_req: Option<&fragment::SpanReq>, - with: Option, - out: Option<&std::path::Path>, - resume: bool, - metadata: Option<&serde_json::Value>, -) -> Result { - let target = with.ok_or_else(|| { - "a Simple document has no harness of its own to resume; \ - pass --with (e.g. --with claude_code)" - .to_string() - })?; +/// Read a Simple document input into the canonical Common model. +pub(crate) fn read_document_input(input: &DocInput) -> Result, String> { let text = match input { DocInput::Stdin => { let mut buffer = String::new(); @@ -1434,7 +1477,26 @@ fn continue_document( DocInput::Stdin => None, DocInput::File(path) => Some(path.as_path()).filter(|p| p.is_file()), }; - let common = document_to_common(&text, origin)?; + document_to_common(&text, origin) +} + +/// Continue a Simple document into `--with`: parse, convert, write into the +/// target's store, launch. The document is read once and never modified; +/// from here on the conversation lives in the target harness. +fn continue_document( + input: &DocInput, + span_req: Option<&fragment::SpanReq>, + with: Option, + out: Option<&std::path::Path>, + resume: bool, + metadata: Option<&serde_json::Value>, +) -> Result { + let target = with.ok_or_else(|| { + "a Simple document has no harness of its own to resume; \ + pass --with (e.g. --with claude_code)" + .to_string() + })?; + let common = read_document_input(input)?; let mut copy = match span_req { Some(req) => fragment::sliced(&common, req)?, diff --git a/cli/src/view.rs b/cli/src/view.rs index 9055772..1f015b9 100644 --- a/cli/src/view.rs +++ b/cli/src/view.rs @@ -35,6 +35,16 @@ pub fn load_source( source: &str, from: Option, ) -> Result<(Transcript, Option), String> { + if let Some((input, request)) = super::document_source(source) { + if from.is_some() { + return Err( + "--from scopes the search for a local session; a Simple document is its own input" + .to_string(), + ); + } + let common = super::read_document_input(&input)?; + return Ok((common, request)); + } if let Some(loaded) = super::load_direct_claude_chat(source, from) { return loaded; } @@ -1148,4 +1158,42 @@ mod tests { "rendered session\n" ); } + + #[test] + fn load_source_reads_simple_document_file_with_optional_range() { + let dir = tempfile::tempdir().unwrap(); + let doc_path = dir.path().join("transcript.json"); + std::fs::write( + &doc_path, + r#"{ + "id": "my-doc", + "messages": [ + {"role": "user", "content": "one"}, + {"role": "assistant", "content": "two"} + ] + }"#, + ) + .unwrap(); + + let (transcript, req) = load_source(doc_path.to_str().unwrap(), None).unwrap(); + assert_eq!(transcript.meta.id, "my-doc"); + assert_eq!(transcript.body.len(), 2); + assert!(req.is_none()); + + let ranged = format!("{}#1", doc_path.display()); + let (transcript, req) = load_source(&ranged, None).unwrap(); + assert_eq!(transcript.meta.id, "my-doc"); + assert!(req.is_some()); + } + + #[test] + fn load_source_rejects_from_scoping_on_simple_document() { + let dir = tempfile::tempdir().unwrap(); + let doc_path = dir.path().join("transcript.json"); + std::fs::write(&doc_path, "{}").unwrap(); + + let err = + load_source(doc_path.to_str().unwrap(), Some(txcript::HarnessId::Codex)).unwrap_err(); + assert!(err.contains("--from scopes the search for a local session")); + } }