From 9661ef646f36fe73a7265b75c576fd37605e4a6a Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:51:45 -0400 Subject: [PATCH 1/6] feat(claude): Conversation::reroot sets the session's directory `reroot(dir)` sets the directory everywhere the format carries it: `project_path`, every entry's `cwd` that is present, and a top-level `cwd` on a preamble line. Message content and tool results are not touched. toolpath-claude 0.13.1. --- CHANGELOG.md | 7 +++++ Cargo.lock | 2 +- Cargo.toml | 2 +- crates/toolpath-claude/Cargo.toml | 2 +- crates/toolpath-claude/src/types.rs | 45 +++++++++++++++++++++++++++++ site/_data/crates.json | 2 +- 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eecd9e2..3c785e1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to the Toolpath workspace are documented here. +## toolpath-claude 0.13.1 — 2026-08-27 + +- **`toolpath-claude`** (0.13.1): `Conversation::reroot(dir)` sets the + directory everywhere the format carries it: `project_path`, every + entry's `cwd` that is present, and a top-level `cwd` on a preamble + line. + ## toolpath-claude 0.13.0 — 2026-08-23 - **Breaking:** `Conversation.segment_ids` replaces `Conversation.session_ids`. diff --git a/Cargo.lock b/Cargo.lock index 77131a28..dc1a45bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4226,7 +4226,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.13.0" +version = "0.13.1" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5944d9e9..9094b238 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "Apache-2.0" toolpath = { version = "0.7.1", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.13.0", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.13.1", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index a0aaf7ab..e49e3345 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.13.0" +version = "0.13.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/types.rs b/crates/toolpath-claude/src/types.rs index 88c92155..b8f01106 100644 --- a/crates/toolpath-claude/src/types.rs +++ b/crates/toolpath-claude/src/types.rs @@ -433,6 +433,21 @@ impl Conversation { self.entries.push(entry); } + /// Sets the directory everywhere the format carries it: + /// `project_path`, every entry's `cwd` that is present, and a + /// top-level `cwd` on a preamble line. + pub fn reroot(&mut self, dir: &str) { + self.project_path = Some(dir.to_string()); + for slot in self.entries.iter_mut().filter_map(|e| e.cwd.as_mut()) { + *slot = dir.to_string(); + } + for raw in &mut self.preamble { + if let Some(slot) = raw.get_mut("cwd").filter(|v| v.is_string()) { + *slot = serde_json::Value::String(dir.to_string()); + } + } + } + pub fn user_messages(&self) -> Vec<&ConversationEntry> { self.entries .iter() @@ -548,6 +563,36 @@ pub struct ConversationMetadata { mod tests { use super::*; + fn entry(json: &str) -> ConversationEntry { + serde_json::from_str(json).unwrap() + } + + #[test] + fn reroot_sets_project_path_and_every_present_cwd() { + let mut convo = Conversation::new("s".to_string()); + convo.preamble.push(serde_json::json!({ + "type": "custom-title", "cwd": "/old", "customTitle": "x" + })); + convo + .preamble + .push(serde_json::json!({"type": "last-prompt", "lastPrompt": "hi"})); + convo.add_entry(entry( + r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","cwd":"/old","message":{"role":"user","content":"hi"}}"#, + )); + convo.add_entry(entry( + r#"{"uuid":"u2","type":"user","timestamp":"2024-01-01T00:00:01Z","message":{"role":"user","content":"hi"}}"#, + )); + assert_eq!(convo.project_path.as_deref(), Some("/old")); + + convo.reroot("/new"); + + assert_eq!(convo.project_path.as_deref(), Some("/new")); + assert_eq!(convo.entries[0].cwd.as_deref(), Some("/new")); + assert_eq!(convo.entries[1].cwd, None); + assert_eq!(convo.preamble[0]["cwd"], "/new"); + assert!(convo.preamble[1].get("cwd").is_none()); + } + fn create_test_conversation() -> Conversation { let mut convo = Conversation::new("test-session".to_string()); diff --git a/site/_data/crates.json b/site/_data/crates.json index 489fdd57..47cbf2ef 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.13.0", + "version": "0.13.1", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", From 80a7de7f866fd1149e17385a2262ecf34379c0a8 Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:18:53 -0400 Subject: [PATCH 2/6] feat(cli): p export claude takes --cwd behind the resume-remote feature `--cwd ` roots the session: the directory becomes the `cwd` of every line that carries one, through `Conversation::reroot`. It must be an absolute POSIX path in normalized form (no `.`, `..`, or empty component; one trailing `/` is dropped) and does not have to exist on this machine. It conflicts with `--project`. Message content and tool results are not touched. The rewrite runs only when the flag is given; clap validates the value at parse time. The flag and its tests live in `cmd_export/remote_session.rs` and compile only with the `resume-remote` cargo feature, off by default. The feature keeps the flag out of the default binary: it serves `scripts/resume-remote.sh`, which builds with the feature, and is not a supported `path` surface. Without the feature, `p export claude --help` shows no trace of the flag. The gate is `all(feature = "resume-remote", not(target_os = "emscripten"))`: `p export claude` bails on emscripten, so the feature has no effect on the wasm build and the module carries no emscripten cfgs. Both states pass `cargo test -p path-cli` and clippy with `-D warnings`. path-cli 0.19.0; toolpath-cli 0.19.0 (lockstep bump of the shim). --- CHANGELOG.md | 11 ++ CLAUDE.md | 1 + Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/Cargo.toml | 5 +- crates/path-cli/src/cmd_export.rs | 76 +++++++-- .../path-cli/src/cmd_export/remote_session.rs | 161 ++++++++++++++++++ crates/path-cli/src/cmd_incept.rs | 2 + crates/path-cli/src/cmd_project.rs | 2 + crates/path-cli/tests/integration.rs | 48 ++++++ crates/toolpath-cli/Cargo.toml | 4 +- site/_data/crates.json | 4 +- 12 files changed, 301 insertions(+), 17 deletions(-) create mode 100644 crates/path-cli/src/cmd_export/remote_session.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c785e1b..0f781c74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to the Toolpath workspace are documented here. +## path-cli 0.19.0 — 2026-08-27 + +- **`path-cli`** (0.19.0): new cargo feature `resume-remote`, off by + default. It gates `p export claude --cwd `, which roots the + session: the directory becomes the `cwd` of every line that carries + one; it must be an absolute POSIX path in normalized form (no `.`, + `..`, or empty component; one trailing `/` is dropped), does not have + to exist on this machine, and conflicts with `--project`. Message + content and tool results are not touched. +- **`toolpath-cli`** (0.19.0): lockstep bump of the deprecated shim. + ## toolpath-claude 0.13.1 — 2026-08-27 - **`toolpath-claude`** (0.13.1): `Conversation::reroot(dir)` sets the diff --git a/CLAUDE.md b/CLAUDE.md index 51f0f467..3f74aec0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -151,6 +151,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`); provider crates also h - `toolpath-claude` has a `watcher` feature (default: on) gating `notify`/`tokio` dependencies for filesystem watching - `toolpath-gemini` has a `watcher` feature (default: on) gating the polling-based `ConversationWatcher` module +- `path-cli` has `embedded-picker` (default: on; the skim picker) and `resume-remote` (default: off) gating the `p export claude` flag `--cwd` and the code behind it (`crates/path-cli/src/cmd_export/remote_session.rs`); `scripts/resume-remote.sh` builds with it. The gate is `all(feature = "resume-remote", not(target_os = "emscripten"))`: the feature has no effect on the wasm build. Test both states: `cargo test -p path-cli` and `cargo test -p path-cli --features resume-remote`. ## Desktop app diff --git a/Cargo.lock b/Cargo.lock index dc1a45bd..273a7f12 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2487,7 +2487,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.18.0" +version = "0.19.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 9094b238..39350a5d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.18.0", path = "crates/path-cli" } +path-cli = { version = "0.19.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 27bda67b..d71d90e8 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.18.0" +version = "0.19.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" @@ -86,6 +86,9 @@ vendored-openssl = ["git2/vendored-openssl"] # isn't on PATH. Adds ~2 MB to the release binary; turn off # (`--no-default-features`) for the minimal build. embedded-picker = ["dep:skim", "dep:regex"] +# Remote resume (scripts/resume-remote.sh): experimental, off by +# default. Gates `p export claude --cwd`. +resume-remote = [] [dev-dependencies] assert_cmd = "2" diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 68805fd5..13549530 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -26,6 +26,11 @@ use std::path::PathBuf; use crate::cache::cache_ref; use crate::remote::RepoSpec; +#[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] +mod remote_session; +#[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] +pub use remote_session::RemoteSessionArgs; + #[derive(Subcommand, Debug)] pub enum ExportTarget { /// Project a toolpath document into a Claude Code session @@ -49,6 +54,10 @@ pub enum ExportTarget { /// clobbering local history. #[arg(long)] force: bool, + + #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] + #[command(flatten)] + remote: RemoteSessionArgs, }, /// Project a toolpath document into a Gemini CLI session Gemini { @@ -209,7 +218,16 @@ pub fn run(target: ExportTarget) -> Result<()> { project, output, force, - } => run_claude(input, project, output, force), + #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] + remote, + } => run_claude( + input, + project, + output, + force, + #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] + remote, + ), ExportTarget::Gemini { input, project, @@ -637,6 +655,7 @@ fn run_claude( project: Option, output: Option, force: bool, + #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] remote: RemoteSessionArgs, ) -> Result<()> { #[cfg(target_os = "emscripten")] { @@ -648,6 +667,14 @@ fn run_claude( { let path = load_path_doc(&input)?; let conversation = build_claude_conversation(&path)?; + #[cfg(feature = "resume-remote")] + let conversation = { + let mut conversation = conversation; + if let Some(dir) = &remote.cwd { + conversation.reroot(dir); + } + conversation + }; let jsonl = serialize_jsonl(&conversation)?; match (project, output) { @@ -2065,7 +2092,7 @@ mod tests { use std::collections::HashMap; use toolpath::v1::{ArtifactChange, PathIdentity, Step, StepIdentity, StructuralChange}; - fn make_path_doc() -> toolpath::v1::Graph { + pub(super) fn make_path_doc() -> toolpath::v1::Graph { let artifact_key = "agent://claude/test-session"; let init_step = Step { @@ -2133,6 +2160,23 @@ mod tests { toolpath::v1::Graph::from_path(path) } + /// `run_claude` with no remote-session flag set. + fn run_claude_without_remote_session( + input: String, + project: Option, + output: Option, + force: bool, + ) -> Result<()> { + run_claude( + input, + project, + output, + force, + #[cfg(feature = "resume-remote")] + RemoteSessionArgs::default(), + ) + } + #[test] fn claude_output_to_file() { let temp = tempfile::tempdir().unwrap(); @@ -2142,7 +2186,7 @@ mod tests { let doc = make_path_doc(); std::fs::write(&input_path, serde_json::to_string(&doc).unwrap()).unwrap(); - run_claude( + run_claude_without_remote_session( input_path.to_string_lossy().to_string(), None, Some(output_path.clone()), @@ -2190,8 +2234,13 @@ mod tests { }; std::fs::write(&input_path, serde_json::to_string(&multi).unwrap()).unwrap(); - let err = - run_claude(input_path.to_string_lossy().to_string(), None, None, false).unwrap_err(); + let err = run_claude_without_remote_session( + input_path.to_string_lossy().to_string(), + None, + None, + false, + ) + .unwrap_err(); assert!(err.to_string().contains("single-path graph")); } @@ -2200,8 +2249,13 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let input_path = temp.path().join("input.json"); std::fs::write(&input_path, "not json").unwrap(); - let err = - run_claude(input_path.to_string_lossy().to_string(), None, None, false).unwrap_err(); + let err = run_claude_without_remote_session( + input_path.to_string_lossy().to_string(), + None, + None, + false, + ) + .unwrap_err(); assert!(err.to_string().contains("parse") || err.to_string().contains("Failed")); } @@ -3274,9 +3328,11 @@ mod tests { unsafe { std::env::set_var("HOME", &fake_home); } - let first = run_claude(input.clone(), Some(cwd.clone()), None, false); - let second = run_claude(input.clone(), Some(cwd.clone()), None, false); - let forced = run_claude(input, Some(cwd.clone()), None, true); + let first = + run_claude_without_remote_session(input.clone(), Some(cwd.clone()), None, false); + let second = + run_claude_without_remote_session(input.clone(), Some(cwd.clone()), None, false); + let forced = run_claude_without_remote_session(input, Some(cwd.clone()), None, true); unsafe { match prior_home { Some(v) => std::env::set_var("HOME", v), diff --git a/crates/path-cli/src/cmd_export/remote_session.rs b/crates/path-cli/src/cmd_export/remote_session.rs new file mode 100644 index 00000000..fcfb76ef --- /dev/null +++ b/crates/path-cli/src/cmd_export/remote_session.rs @@ -0,0 +1,161 @@ +//! `p export claude --cwd`: the session's cwd on the host that resumes +//! it. + +use anyhow::Result; + +/// The `p export claude` flags that rewrite the projected session +/// before it is written. +#[derive(clap::Args, Debug, Default)] +#[command(next_help_heading = "Remote session")] +pub struct RemoteSessionArgs { + /// Root the session at this directory: it becomes the `cwd` of + /// every line that carries one. Absolute POSIX path in + /// normalized form; it does not have to exist on this machine. + /// Mutually exclusive with --project. + // `--project` files the session under the slug of the project + // directory, and Claude Code reads every entry's `cwd` as that + // directory. A second directory value can only repeat it or + // contradict it. + #[arg(long, value_name = "DIR", conflicts_with = "project", value_parser = parse_cwd_arg)] + pub(super) cwd: Option, +} + +/// Claude Code keys a session on the exact `cwd` string, so the value +/// must be an absolute POSIX path in normalized form: no `.`, `..`, or +/// empty component. One trailing `/` is dropped. The directory may be +/// on another machine, so it is not required to exist. +fn parse_cwd_arg(raw: &str) -> Result { + let Some(rest) = raw.strip_prefix('/') else { + anyhow::bail!("--cwd must be an absolute POSIX path (got {raw:?})"); + }; + if rest.is_empty() { + return Ok("/".to_string()); + } + let rest = rest.strip_suffix('/').unwrap_or(rest); + if rest + .split('/') + .any(|c| c.is_empty() || c == "." || c == "..") + { + anyhow::bail!("--cwd must not contain an empty, `.`, or `..` component (got {raw:?})"); + } + Ok(format!("/{rest}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cmd_export::run_claude; + use crate::cmd_export::tests::make_path_doc; + use std::collections::HashMap; + use toolpath::v1::{ArtifactChange, Step, StepIdentity, StructuralChange}; + + /// `make_path_doc` with `cwd` recorded on every step, plus one + /// headerless line that carries a `cwd`. + fn make_path_doc_with_cwd(cwd: &str) -> toolpath::v1::Graph { + let mut path = make_path_doc().into_single_path().unwrap(); + for step in &mut path.steps { + for change in step.change.values_mut() { + if let Some(structural) = change.structural.as_mut() { + structural + .extra + .insert("cwd".to_string(), serde_json::json!(cwd)); + } + } + } + let artifact_key = path.steps[0].change.keys().next().unwrap().clone(); + let mut extra = HashMap::new(); + extra.insert("entry_type".to_string(), serde_json::json!("custom-title")); + extra.insert( + "raw".to_string(), + serde_json::json!({"type": "custom-title", "cwd": cwd, "customTitle": "x"}), + ); + path.steps.push(Step { + step: StepIdentity { + id: "step-003".to_string(), + parents: vec!["step-002".to_string()], + actor: "tool:claude-code".to_string(), + timestamp: "2024-01-01T00:00:02Z".to_string(), + }, + change: HashMap::from([( + artifact_key, + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "conversation.event".to_string(), + extra, + }), + }, + )]), + meta: None, + }); + path.path.head = "step-003".to_string(); + toolpath::v1::Graph::from_path(path) + } + + /// Runs `p export claude --output` on `doc` and parses the lines. + fn export_claude_lines(doc: &toolpath::v1::Graph, cwd: Option<&str>) -> Vec { + let temp = tempfile::tempdir().unwrap(); + let input_path = temp.path().join("input.json"); + let output_path = temp.path().join("out.jsonl"); + std::fs::write(&input_path, serde_json::to_string(doc).unwrap()).unwrap(); + run_claude( + input_path.to_string_lossy().to_string(), + None, + Some(output_path.clone()), + false, + RemoteSessionArgs { + cwd: cwd.map(str::to_string), + }, + ) + .unwrap(); + std::fs::read_to_string(&output_path) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } + + fn values_of<'a>(lines: &'a [serde_json::Value], key: &str) -> Vec<&'a str> { + lines.iter().filter_map(|v| v.get(key)?.as_str()).collect() + } + + #[test] + fn cwd_flag_rewrites_every_cwd() { + let doc = make_path_doc_with_cwd("/old/project"); + let plain = export_claude_lines(&doc, None); + let old = values_of(&plain, "cwd"); + assert!(!old.is_empty()); + assert!(old.iter().all(|c| *c == "/old/project")); + + let rooted = export_claude_lines(&doc, Some("/new/dir")); + assert_eq!(rooted.len(), plain.len()); + let new = values_of(&rooted, "cwd"); + assert_eq!(new.len(), old.len()); + assert!(new.iter().all(|c| *c == "/new/dir")); + let preamble = rooted + .iter() + .find(|v| v["type"] == "custom-title") + .expect("the headerless line survives export"); + assert_eq!(preamble["cwd"], "/new/dir"); + } + + #[test] + fn cwd_flag_leaves_session_ids_alone() { + let doc = make_path_doc_with_cwd("/old/project"); + let plain = export_claude_lines(&doc, None); + let rooted = export_claude_lines(&doc, Some("/new/dir")); + assert_eq!( + values_of(&plain, "sessionId"), + values_of(&rooted, "sessionId") + ); + } + + #[test] + fn cwd_flag_rejects_unnormalized_paths() { + for bad in ["relative/dir", "/a/../b", "/a/./b", "/a//b", "//", ""] { + assert!(parse_cwd_arg(bad).is_err(), "{bad:?}"); + } + assert_eq!(parse_cwd_arg("/a/b/").unwrap(), "/a/b"); + assert_eq!(parse_cwd_arg("/").unwrap(), "/"); + } +} diff --git a/crates/path-cli/src/cmd_incept.rs b/crates/path-cli/src/cmd_incept.rs index 6edcc7db..b43e86f5 100644 --- a/crates/path-cli/src/cmd_incept.rs +++ b/crates/path-cli/src/cmd_incept.rs @@ -59,6 +59,8 @@ pub fn run(target: InceptTarget) -> Result<()> { project, output, force: false, + #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] + remote: crate::cmd_export::RemoteSessionArgs::default(), }) } InceptTarget::Cursor { diff --git a/crates/path-cli/src/cmd_project.rs b/crates/path-cli/src/cmd_project.rs index f96db7a8..bb013588 100644 --- a/crates/path-cli/src/cmd_project.rs +++ b/crates/path-cli/src/cmd_project.rs @@ -32,6 +32,8 @@ pub fn run(target: ProjectTarget) -> Result<()> { project: None, output, force: false, + #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] + remote: crate::cmd_export::RemoteSessionArgs::default(), }) } } diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index c10e758e..750521e0 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -553,6 +553,54 @@ fn export_help_lists_claude_and_pathbase() { .stdout(predicate::str::contains("pathbase")); } +#[cfg(feature = "resume-remote")] +#[test] +fn export_claude_help_lists_cwd_under_remote_session() { + cmd() + .args(["p", "export", "claude", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Remote session:")) + .stdout(predicate::str::contains("--cwd ")); +} + +#[cfg(feature = "resume-remote")] +#[test] +fn export_claude_cwd_conflicts_with_project() { + cmd() + .args(["p", "export", "claude", "--input", "doc.json"]) + .args(["--project", ".", "--cwd", "/remote/project"]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); +} + +#[cfg(feature = "resume-remote")] +#[test] +fn export_claude_rejects_an_unnormalized_cwd() { + cmd() + .args([ + "p", "export", "claude", "--input", "doc.json", "--cwd", "rel/dir", + ]) + .assert() + .failure() + .stderr(predicate::str::contains( + "invalid value 'rel/dir' for '--cwd '", + )) + .stderr(predicate::str::contains("absolute POSIX path")); +} + +#[cfg(not(feature = "resume-remote"))] +#[test] +fn export_claude_help_omits_cwd() { + cmd() + .args(["p", "export", "claude", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Remote session").not()) + .stdout(predicate::str::contains("--cwd").not()); +} + #[test] fn import_git_no_cache_emits_stdout_json() { let (dir, branch) = git_fixture(); diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index a3aa6782..31b0dd21 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.18.0" +version = "0.19.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.18.0" } +path-cli = { path = "../path-cli", version = "0.19.0" } anyhow = "1.0" [workspace] diff --git a/site/_data/crates.json b/site/_data/crates.json index 47cbf2ef..3dadd72c 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.18.0", + "version": "0.19.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", @@ -121,7 +121,7 @@ }, { "name": "toolpath-cli", - "version": "0.18.0", + "version": "0.19.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli", From df558e15bc8ad60b20805daee5051400b4a245c5 Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Thu, 27 Aug 2026 13:41:23 -0400 Subject: [PATCH 3/6] feat(scripts): resume-remote.sh projects with p export claude --cwd The script builds path-cli with the `resume-remote` feature. Step 5 exports with `--cwd `, checks the output carries the remote project directory as its cwd, and rewrites only the sessionId keys to the minted ID. --- CHANGELOG.md | 3 ++- scripts/resume-remote.sh | 37 ++++++++++++++++--------------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f781c74..2af90a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,8 @@ All notable changes to the Toolpath workspace are documented here. one; it must be an absolute POSIX path in normalized form (no `.`, `..`, or empty component; one trailing `/` is dropped), does not have to exist on this machine, and conflicts with `--project`. Message - content and tool results are not touched. + content and tool results are not touched. `scripts/resume-remote.sh` + builds with the feature and passes the flag. - **`toolpath-cli`** (0.19.0): lockstep bump of the deprecated shim. ## toolpath-claude 0.13.1 — 2026-08-27 diff --git a/scripts/resume-remote.sh b/scripts/resume-remote.sh index 9dc3b479..e985c0b1 100755 --- a/scripts/resume-remote.sh +++ b/scripts/resume-remote.sh @@ -54,7 +54,6 @@ # the script quotes nothing and escapes nothing. # - --session is a UUID. At least one Claude session exists for # --project. -# - The projected JSONL records --project as its cwd. # Remote (two read-only ssh calls, both before any remote write): # - Each reply is exactly the TP_* lines the probe prints. A login # banner or a registration notice fails the run verbatim. @@ -67,8 +66,8 @@ # known. # # Steps (always in this order): -# 1. cargo build -p path-cli; the script runs target/debug/path and does -# not touch any installed `path`. +# 1. cargo build -p path-cli --features resume-remote; the script runs +# target/debug/path and does not touch any installed `path`. # 2. Resolve the session. `path p import claude --no-cache` writes # the document to $TMPDIR/path-resume-remote/. # [shell] Mint the remote session id from the key-sorted document @@ -76,9 +75,9 @@ # 3. Optional VM creation (--create). # 4. [shell] Call 1: remote home, claude path, tmux presence. Derive # from the remote home unless -C is given. -# 5. `path p export claude` projects the document to JSONL. -# [shell] Rewrite the cwd and sessionId keys to the remote values -# (sed). +# 5. `path p export claude --cwd ` projects the document +# to JSONL rooted at the remote project directory. +# [shell] Rewrite the sessionId keys to the minted ID (sed). # [shell] Compute the remote Claude project slug (/, _, and . # become -). # 6. [shell] Call 2: the physical project dir, whether the tmux @@ -237,7 +236,7 @@ echo "ok: local tools, $REMOTE, $PROJECT" ROOT="$(cd "$(dirname "$0")/.." && pwd)" step "Build path from $(git -C "$ROOT" rev-parse --short HEAD) ($(git -C "$ROOT" branch --show-current))" -run cargo build -q -p path-cli --manifest-path "$ROOT/Cargo.toml" +run cargo build -q -p path-cli --features resume-remote --manifest-path "$ROOT/Cargo.toml" PATH_BIN="$ROOT/target/debug/path" "$PATH_BIN" --version @@ -346,23 +345,19 @@ echo "remote project dir: $REMOTE_DIR" step "Project session $SESSION to JSONL" JSONL_SRC="$WORK_DIR/$SESSION.jsonl" -run "$PATH_BIN" p export claude --input "$DOC" >"$JSONL_SRC" -N_CWD="$(grep -cF "\"cwd\":\"$PROJECT\"" "$JSONL_SRC" || true)" -[[ $N_CWD -gt 0 ]] || die "the projected JSONL has no cwd equal to $PROJECT; pass --project matching the session's recorded cwd" +run "$PATH_BIN" p export claude --input "$DOC" --cwd "$REMOTE_DIR" >"$JSONL_SRC" +N_CWD="$(grep -cF "\"cwd\":\"$REMOTE_DIR\"" "$JSONL_SRC" || true)" +[[ $N_CWD -gt 0 ]] || die "the projected JSONL carries no cwd key; the document records no cwd" N_SID="$(grep -cF "\"sessionId\":\"$SESSION\"" "$JSONL_SRC" || true)" [[ $N_SID -gt 0 ]] || die "the projected JSONL has no sessionId equal to $SESSION" -# [shell] Rewrite cwd and sessionId to the remote values. PROJECT and -# REMOTE_DIR match PLAIN_PATH_RE, so `.` is the only sed-special -# character in the pattern and `|` is a safe delimiter. +# [shell] Rewrite sessionId to the minted ID. Both values are UUIDs, so +# `|` is a safe delimiter. JSONL="$WORK_DIR/$REMOTE_ID.jsonl" -CWD_RE="${PROJECT//./\\.}" -show "sed -e 's|\"cwd\":\"$PROJECT\"|\"cwd\":\"$REMOTE_DIR\"|g' -e 's|\"sessionId\":\"$SESSION\"|\"sessionId\":\"$REMOTE_ID\"|g' $JSONL_SRC > $JSONL" -sed -e "s|\"cwd\":\"$CWD_RE\"|\"cwd\":\"$REMOTE_DIR\"|g" \ - -e "s|\"sessionId\":\"$SESSION\"|\"sessionId\":\"$REMOTE_ID\"|g" \ - "$JSONL_SRC" >"$JSONL" -LEFT="$(grep -cF -e "\"cwd\":\"$PROJECT\"" -e "\"sessionId\":\"$SESSION\"" "$JSONL" || true)" -[[ $LEFT -eq 0 ]] || die "$LEFT source cwd/sessionId keys survived the rewrite in $JSONL" +show "sed -e 's|\"sessionId\":\"$SESSION\"|\"sessionId\":\"$REMOTE_ID\"|g' $JSONL_SRC > $JSONL" +sed -e "s|\"sessionId\":\"$SESSION\"|\"sessionId\":\"$REMOTE_ID\"|g" "$JSONL_SRC" >"$JSONL" +LEFT="$(grep -cF "\"sessionId\":\"$SESSION\"" "$JSONL" || true)" +[[ $LEFT -eq 0 ]] || die "$LEFT source sessionId keys survived the rewrite in $JSONL" [[ "$(wc -l <"$JSONL")" -eq "$(wc -l <"$JSONL_SRC")" ]] || die "line count changed during the rewrite" # [shell] Claude project slug: /, _, and . become -. @@ -443,7 +438,7 @@ cat < ~/.claude.json'" From 4ecd4bff1ab0be752784d93cda12de33abbb7e50 Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:58:40 -0400 Subject: [PATCH 4/6] fixup! feat(cli): p export claude takes --cwd behind the resume-remote feature --- crates/path-cli/src/cmd_export.rs | 152 +++++++----------- .../path-cli/src/cmd_export/remote_session.rs | 15 +- crates/path-cli/src/cmd_incept.rs | 16 +- crates/path-cli/src/cmd_project.rs | 13 +- 4 files changed, 79 insertions(+), 117 deletions(-) diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 13549530..cd522f35 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -28,37 +28,39 @@ use crate::remote::RepoSpec; #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] mod remote_session; -#[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] -pub use remote_session::RemoteSessionArgs; -#[derive(Subcommand, Debug)] -pub enum ExportTarget { - /// Project a toolpath document into a Claude Code session - Claude { - /// Input: cache id (e.g. `claude-abc`) or path to a toolpath JSON file - #[arg(short, long)] - input: String, +/// Arguments of `p export claude`. +#[derive(clap::Args, Debug, Default)] +pub struct ClaudeArgs { + /// Input: cache id (e.g. `claude-abc`) or path to a toolpath JSON file + #[arg(short, long)] + pub(crate) input: String, - /// Target project directory. With this flag, writes the JSONL into - /// `~/.claude/projects//.jsonl` so `claude -r ` - /// can resume it. Defaults to cwd when no `--output` is given. - #[arg(short, long)] - project: Option, + /// Target project directory. With this flag, writes the JSONL into + /// `~/.claude/projects//.jsonl` so `claude -r ` + /// can resume it. Defaults to cwd when no `--output` is given. + #[arg(short, long)] + pub(crate) project: Option, - /// Output JSONL to this file. Mutually exclusive with --project. - #[arg(short, long, conflicts_with = "project")] - output: Option, + /// Output JSONL to this file. Mutually exclusive with --project. + #[arg(short, long, conflicts_with = "project")] + pub(crate) output: Option, - /// Overwrite the session file if this session id already exists in - /// the target project. Without it the export refuses rather than - /// clobbering local history. - #[arg(long)] - force: bool, + /// Overwrite the session file if this session id already exists in + /// the target project. Without it the export refuses rather than + /// clobbering local history. + #[arg(long)] + pub(crate) force: bool, - #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] - #[command(flatten)] - remote: RemoteSessionArgs, - }, + #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] + #[command(flatten)] + pub(crate) remote: remote_session::RemoteSessionArgs, +} + +#[derive(Subcommand, Debug)] +pub enum ExportTarget { + /// Project a toolpath document into a Claude Code session + Claude(ClaudeArgs), /// Project a toolpath document into a Gemini CLI session Gemini { /// Input: cache id (e.g. `claude-abc`) or path to a toolpath JSON file @@ -213,21 +215,7 @@ pub enum ExportTarget { pub fn run(target: ExportTarget) -> Result<()> { match target { - ExportTarget::Claude { - input, - project, - output, - force, - #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] - remote, - } => run_claude( - input, - project, - output, - force, - #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] - remote, - ), + ExportTarget::Claude(args) => run_claude(args), ExportTarget::Gemini { input, project, @@ -650,37 +638,31 @@ pub(crate) fn project_pi( Ok(session.header.id) } -fn run_claude( - input: String, - project: Option, - output: Option, - force: bool, - #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] remote: RemoteSessionArgs, -) -> Result<()> { +fn run_claude(args: ClaudeArgs) -> Result<()> { #[cfg(target_os = "emscripten")] { - let _ = (input, project, output, force); + let _ = args; anyhow::bail!("'path export claude' requires a native environment"); } #[cfg(not(target_os = "emscripten"))] { - let path = load_path_doc(&input)?; + let path = load_path_doc(&args.input)?; let conversation = build_claude_conversation(&path)?; #[cfg(feature = "resume-remote")] let conversation = { let mut conversation = conversation; - if let Some(dir) = &remote.cwd { + if let Some(dir) = &args.remote.cwd { conversation.reroot(dir); } conversation }; let jsonl = serialize_jsonl(&conversation)?; - match (project, output) { + match (args.project, args.output) { (Some(project_dir), None) => { let out_path = - write_into_claude_project(&conversation, &jsonl, &project_dir, force)?; + write_into_claude_project(&conversation, &jsonl, &project_dir, args.force)?; let session_id = &conversation.session_id; eprintln!( "Exported session {} ({} entries) → {}", @@ -2160,23 +2142,6 @@ mod tests { toolpath::v1::Graph::from_path(path) } - /// `run_claude` with no remote-session flag set. - fn run_claude_without_remote_session( - input: String, - project: Option, - output: Option, - force: bool, - ) -> Result<()> { - run_claude( - input, - project, - output, - force, - #[cfg(feature = "resume-remote")] - RemoteSessionArgs::default(), - ) - } - #[test] fn claude_output_to_file() { let temp = tempfile::tempdir().unwrap(); @@ -2186,12 +2151,11 @@ mod tests { let doc = make_path_doc(); std::fs::write(&input_path, serde_json::to_string(&doc).unwrap()).unwrap(); - run_claude_without_remote_session( - input_path.to_string_lossy().to_string(), - None, - Some(output_path.clone()), - false, - ) + run_claude(ClaudeArgs { + input: input_path.to_string_lossy().to_string(), + output: Some(output_path.clone()), + ..Default::default() + }) .unwrap(); let out = std::fs::read_to_string(&output_path).unwrap(); @@ -2234,12 +2198,10 @@ mod tests { }; std::fs::write(&input_path, serde_json::to_string(&multi).unwrap()).unwrap(); - let err = run_claude_without_remote_session( - input_path.to_string_lossy().to_string(), - None, - None, - false, - ) + let err = run_claude(ClaudeArgs { + input: input_path.to_string_lossy().to_string(), + ..Default::default() + }) .unwrap_err(); assert!(err.to_string().contains("single-path graph")); } @@ -2249,12 +2211,10 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let input_path = temp.path().join("input.json"); std::fs::write(&input_path, "not json").unwrap(); - let err = run_claude_without_remote_session( - input_path.to_string_lossy().to_string(), - None, - None, - false, - ) + let err = run_claude(ClaudeArgs { + input: input_path.to_string_lossy().to_string(), + ..Default::default() + }) .unwrap_err(); assert!(err.to_string().contains("parse") || err.to_string().contains("Failed")); } @@ -3328,11 +3288,17 @@ mod tests { unsafe { std::env::set_var("HOME", &fake_home); } - let first = - run_claude_without_remote_session(input.clone(), Some(cwd.clone()), None, false); - let second = - run_claude_without_remote_session(input.clone(), Some(cwd.clone()), None, false); - let forced = run_claude_without_remote_session(input, Some(cwd.clone()), None, true); + let export = |input: String, force: bool| { + run_claude(ClaudeArgs { + input, + project: Some(cwd.clone()), + force, + ..Default::default() + }) + }; + let first = export(input.clone(), false); + let second = export(input.clone(), false); + let forced = export(input, true); unsafe { match prior_home { Some(v) => std::env::set_var("HOME", v), diff --git a/crates/path-cli/src/cmd_export/remote_session.rs b/crates/path-cli/src/cmd_export/remote_session.rs index fcfb76ef..10a77540 100644 --- a/crates/path-cli/src/cmd_export/remote_session.rs +++ b/crates/path-cli/src/cmd_export/remote_session.rs @@ -44,8 +44,8 @@ fn parse_cwd_arg(raw: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::cmd_export::run_claude; use crate::cmd_export::tests::make_path_doc; + use crate::cmd_export::{ClaudeArgs, run_claude}; use std::collections::HashMap; use toolpath::v1::{ArtifactChange, Step, StepIdentity, StructuralChange}; @@ -98,15 +98,14 @@ mod tests { let input_path = temp.path().join("input.json"); let output_path = temp.path().join("out.jsonl"); std::fs::write(&input_path, serde_json::to_string(doc).unwrap()).unwrap(); - run_claude( - input_path.to_string_lossy().to_string(), - None, - Some(output_path.clone()), - false, - RemoteSessionArgs { + run_claude(ClaudeArgs { + input: input_path.to_string_lossy().to_string(), + output: Some(output_path.clone()), + remote: RemoteSessionArgs { cwd: cwd.map(str::to_string), }, - ) + ..Default::default() + }) .unwrap(); std::fs::read_to_string(&output_path) .unwrap() diff --git a/crates/path-cli/src/cmd_incept.rs b/crates/path-cli/src/cmd_incept.rs index b43e86f5..8c097530 100644 --- a/crates/path-cli/src/cmd_incept.rs +++ b/crates/path-cli/src/cmd_incept.rs @@ -54,14 +54,14 @@ pub fn run(target: InceptTarget) -> Result<()> { } => { let input = resolve_input(input)?; let (project, output) = default_project(project, output); - crate::cmd_export::run(crate::cmd_export::ExportTarget::Claude { - input, - project, - output, - force: false, - #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] - remote: crate::cmd_export::RemoteSessionArgs::default(), - }) + crate::cmd_export::run(crate::cmd_export::ExportTarget::Claude( + crate::cmd_export::ClaudeArgs { + input, + project, + output, + ..Default::default() + }, + )) } InceptTarget::Cursor { input, diff --git a/crates/path-cli/src/cmd_project.rs b/crates/path-cli/src/cmd_project.rs index bb013588..007adf68 100644 --- a/crates/path-cli/src/cmd_project.rs +++ b/crates/path-cli/src/cmd_project.rs @@ -26,15 +26,12 @@ pub enum ProjectTarget { pub fn run(target: ProjectTarget) -> Result<()> { match target { - ProjectTarget::Claude { input, output } => { - crate::cmd_export::run(crate::cmd_export::ExportTarget::Claude { + ProjectTarget::Claude { input, output } => crate::cmd_export::run( + crate::cmd_export::ExportTarget::Claude(crate::cmd_export::ClaudeArgs { input, - project: None, output, - force: false, - #[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] - remote: crate::cmd_export::RemoteSessionArgs::default(), - }) - } + ..Default::default() + }), + ), } } From bcda351613aac88277514f8d6e67940a183a4998 Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:58:40 -0400 Subject: [PATCH 5/6] fixup! feat(cli): p export claude takes --cwd behind the resume-remote feature --- crates/path-cli/tests/integration.rs | 66 ++++++++++++++-------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index 750521e0..5fc3addf 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -554,40 +554,42 @@ fn export_help_lists_claude_and_pathbase() { } #[cfg(feature = "resume-remote")] -#[test] -fn export_claude_help_lists_cwd_under_remote_session() { - cmd() - .args(["p", "export", "claude", "--help"]) - .assert() - .success() - .stdout(predicate::str::contains("Remote session:")) - .stdout(predicate::str::contains("--cwd ")); -} +mod resume_remote { + use super::*; + + #[test] + fn export_claude_help_lists_cwd_under_remote_session() { + cmd() + .args(["p", "export", "claude", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("Remote session:")) + .stdout(predicate::str::contains("--cwd ")); + } -#[cfg(feature = "resume-remote")] -#[test] -fn export_claude_cwd_conflicts_with_project() { - cmd() - .args(["p", "export", "claude", "--input", "doc.json"]) - .args(["--project", ".", "--cwd", "/remote/project"]) - .assert() - .failure() - .stderr(predicate::str::contains("cannot be used with")); -} + #[test] + fn export_claude_cwd_conflicts_with_project() { + cmd() + .args(["p", "export", "claude", "--input", "doc.json"]) + .args(["--project", ".", "--cwd", "/remote/project"]) + .assert() + .failure() + .stderr(predicate::str::contains("cannot be used with")); + } -#[cfg(feature = "resume-remote")] -#[test] -fn export_claude_rejects_an_unnormalized_cwd() { - cmd() - .args([ - "p", "export", "claude", "--input", "doc.json", "--cwd", "rel/dir", - ]) - .assert() - .failure() - .stderr(predicate::str::contains( - "invalid value 'rel/dir' for '--cwd '", - )) - .stderr(predicate::str::contains("absolute POSIX path")); + #[test] + fn export_claude_rejects_an_unnormalized_cwd() { + cmd() + .args([ + "p", "export", "claude", "--input", "doc.json", "--cwd", "rel/dir", + ]) + .assert() + .failure() + .stderr(predicate::str::contains( + "invalid value 'rel/dir' for '--cwd '", + )) + .stderr(predicate::str::contains("absolute POSIX path")); + } } #[cfg(not(feature = "resume-remote"))] From 080f15529bc3dcec2b9af6f924af1469f46b6338 Mon Sep 17 00:00:00 2001 From: Robert Queenin <2177841+ecalifornica@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:46:10 -0400 Subject: [PATCH 6/6] fixup! feat(cli): p export claude takes --cwd behind the resume-remote feature --- crates/path-cli/src/cmd_export.rs | 111 +++++++++++++++++- .../path-cli/src/cmd_export/remote_session.rs | 104 ---------------- 2 files changed, 110 insertions(+), 105 deletions(-) diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index cd522f35..388420a5 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -2074,7 +2074,7 @@ mod tests { use std::collections::HashMap; use toolpath::v1::{ArtifactChange, PathIdentity, Step, StepIdentity, StructuralChange}; - pub(super) fn make_path_doc() -> toolpath::v1::Graph { + fn make_path_doc() -> toolpath::v1::Graph { let artifact_key = "agent://claude/test-session"; let init_step = Step { @@ -3499,4 +3499,113 @@ mod tests { let pi_sessions = fake_home.join(".pi/agent/sessions"); assert!(pi_sessions.exists(), "pi sessions dir missing"); } + + #[cfg(feature = "resume-remote")] + mod resume_remote { + use super::*; + use crate::cmd_export::remote_session::RemoteSessionArgs; + + /// `make_path_doc` with `cwd` recorded on every step, plus one + /// headerless line that carries a `cwd`. + fn make_path_doc_with_cwd(cwd: &str) -> toolpath::v1::Graph { + let mut path = make_path_doc().into_single_path().unwrap(); + for step in &mut path.steps { + for change in step.change.values_mut() { + if let Some(structural) = change.structural.as_mut() { + structural + .extra + .insert("cwd".to_string(), serde_json::json!(cwd)); + } + } + } + let artifact_key = path.steps[0].change.keys().next().unwrap().clone(); + let mut extra = HashMap::new(); + extra.insert("entry_type".to_string(), serde_json::json!("custom-title")); + extra.insert( + "raw".to_string(), + serde_json::json!({"type": "custom-title", "cwd": cwd, "customTitle": "x"}), + ); + path.steps.push(Step { + step: StepIdentity { + id: "step-003".to_string(), + parents: vec!["step-002".to_string()], + actor: "tool:claude-code".to_string(), + timestamp: "2024-01-01T00:00:02Z".to_string(), + }, + change: HashMap::from([( + artifact_key, + ArtifactChange { + raw: None, + structural: Some(StructuralChange { + change_type: "conversation.event".to_string(), + extra, + }), + }, + )]), + meta: None, + }); + path.path.head = "step-003".to_string(); + toolpath::v1::Graph::from_path(path) + } + + /// Runs `p export claude --output` on `doc` and parses the lines. + fn export_claude_lines( + doc: &toolpath::v1::Graph, + cwd: Option<&str>, + ) -> Vec { + let temp = tempfile::tempdir().unwrap(); + let input_path = temp.path().join("input.json"); + let output_path = temp.path().join("out.jsonl"); + std::fs::write(&input_path, serde_json::to_string(doc).unwrap()).unwrap(); + run_claude(ClaudeArgs { + input: input_path.to_string_lossy().to_string(), + output: Some(output_path.clone()), + remote: RemoteSessionArgs { + cwd: cwd.map(str::to_string), + }, + ..Default::default() + }) + .unwrap(); + std::fs::read_to_string(&output_path) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() + } + + fn values_of<'a>(lines: &'a [serde_json::Value], key: &str) -> Vec<&'a str> { + lines.iter().filter_map(|v| v.get(key)?.as_str()).collect() + } + + #[test] + fn cwd_flag_rewrites_every_cwd() { + let doc = make_path_doc_with_cwd("/old/project"); + let plain = export_claude_lines(&doc, None); + let old = values_of(&plain, "cwd"); + assert!(!old.is_empty()); + assert!(old.iter().all(|c| *c == "/old/project")); + + let rooted = export_claude_lines(&doc, Some("/new/dir")); + assert_eq!(rooted.len(), plain.len()); + let new = values_of(&rooted, "cwd"); + assert_eq!(new.len(), old.len()); + assert!(new.iter().all(|c| *c == "/new/dir")); + let preamble = rooted + .iter() + .find(|v| v["type"] == "custom-title") + .expect("the headerless line survives export"); + assert_eq!(preamble["cwd"], "/new/dir"); + } + + #[test] + fn cwd_flag_leaves_session_ids_alone() { + let doc = make_path_doc_with_cwd("/old/project"); + let plain = export_claude_lines(&doc, None); + let rooted = export_claude_lines(&doc, Some("/new/dir")); + assert_eq!( + values_of(&plain, "sessionId"), + values_of(&rooted, "sessionId") + ); + } + } } diff --git a/crates/path-cli/src/cmd_export/remote_session.rs b/crates/path-cli/src/cmd_export/remote_session.rs index 10a77540..4780140b 100644 --- a/crates/path-cli/src/cmd_export/remote_session.rs +++ b/crates/path-cli/src/cmd_export/remote_session.rs @@ -44,110 +44,6 @@ fn parse_cwd_arg(raw: &str) -> Result { #[cfg(test)] mod tests { use super::*; - use crate::cmd_export::tests::make_path_doc; - use crate::cmd_export::{ClaudeArgs, run_claude}; - use std::collections::HashMap; - use toolpath::v1::{ArtifactChange, Step, StepIdentity, StructuralChange}; - - /// `make_path_doc` with `cwd` recorded on every step, plus one - /// headerless line that carries a `cwd`. - fn make_path_doc_with_cwd(cwd: &str) -> toolpath::v1::Graph { - let mut path = make_path_doc().into_single_path().unwrap(); - for step in &mut path.steps { - for change in step.change.values_mut() { - if let Some(structural) = change.structural.as_mut() { - structural - .extra - .insert("cwd".to_string(), serde_json::json!(cwd)); - } - } - } - let artifact_key = path.steps[0].change.keys().next().unwrap().clone(); - let mut extra = HashMap::new(); - extra.insert("entry_type".to_string(), serde_json::json!("custom-title")); - extra.insert( - "raw".to_string(), - serde_json::json!({"type": "custom-title", "cwd": cwd, "customTitle": "x"}), - ); - path.steps.push(Step { - step: StepIdentity { - id: "step-003".to_string(), - parents: vec!["step-002".to_string()], - actor: "tool:claude-code".to_string(), - timestamp: "2024-01-01T00:00:02Z".to_string(), - }, - change: HashMap::from([( - artifact_key, - ArtifactChange { - raw: None, - structural: Some(StructuralChange { - change_type: "conversation.event".to_string(), - extra, - }), - }, - )]), - meta: None, - }); - path.path.head = "step-003".to_string(); - toolpath::v1::Graph::from_path(path) - } - - /// Runs `p export claude --output` on `doc` and parses the lines. - fn export_claude_lines(doc: &toolpath::v1::Graph, cwd: Option<&str>) -> Vec { - let temp = tempfile::tempdir().unwrap(); - let input_path = temp.path().join("input.json"); - let output_path = temp.path().join("out.jsonl"); - std::fs::write(&input_path, serde_json::to_string(doc).unwrap()).unwrap(); - run_claude(ClaudeArgs { - input: input_path.to_string_lossy().to_string(), - output: Some(output_path.clone()), - remote: RemoteSessionArgs { - cwd: cwd.map(str::to_string), - }, - ..Default::default() - }) - .unwrap(); - std::fs::read_to_string(&output_path) - .unwrap() - .lines() - .map(|l| serde_json::from_str(l).unwrap()) - .collect() - } - - fn values_of<'a>(lines: &'a [serde_json::Value], key: &str) -> Vec<&'a str> { - lines.iter().filter_map(|v| v.get(key)?.as_str()).collect() - } - - #[test] - fn cwd_flag_rewrites_every_cwd() { - let doc = make_path_doc_with_cwd("/old/project"); - let plain = export_claude_lines(&doc, None); - let old = values_of(&plain, "cwd"); - assert!(!old.is_empty()); - assert!(old.iter().all(|c| *c == "/old/project")); - - let rooted = export_claude_lines(&doc, Some("/new/dir")); - assert_eq!(rooted.len(), plain.len()); - let new = values_of(&rooted, "cwd"); - assert_eq!(new.len(), old.len()); - assert!(new.iter().all(|c| *c == "/new/dir")); - let preamble = rooted - .iter() - .find(|v| v["type"] == "custom-title") - .expect("the headerless line survives export"); - assert_eq!(preamble["cwd"], "/new/dir"); - } - - #[test] - fn cwd_flag_leaves_session_ids_alone() { - let doc = make_path_doc_with_cwd("/old/project"); - let plain = export_claude_lines(&doc, None); - let rooted = export_claude_lines(&doc, Some("/new/dir")); - assert_eq!( - values_of(&plain, "sessionId"), - values_of(&rooted, "sessionId") - ); - } #[test] fn cwd_flag_rejects_unnormalized_paths() {