diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eecd9e2..2af90a98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ 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. `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 + +- **`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/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 77131a28..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", @@ -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..39350a5d 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" } @@ -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..388420a5 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -26,30 +26,41 @@ use std::path::PathBuf; use crate::cache::cache_ref; use crate::remote::RepoSpec; -#[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, +#[cfg(all(feature = "resume-remote", not(target_os = "emscripten")))] +mod remote_session; - /// 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, +/// 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, - /// Output JSONL to this file. Mutually exclusive with --project. - #[arg(short, long, conflicts_with = "project")] - output: 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, - /// 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, - }, + /// 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)] + pub(crate) force: bool, + + #[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 @@ -204,12 +215,7 @@ pub enum ExportTarget { pub fn run(target: ExportTarget) -> Result<()> { match target { - ExportTarget::Claude { - input, - project, - output, - force, - } => run_claude(input, project, output, force), + ExportTarget::Claude(args) => run_claude(args), ExportTarget::Gemini { input, project, @@ -632,28 +638,31 @@ pub(crate) fn project_pi( Ok(session.header.id) } -fn run_claude( - input: String, - project: Option, - output: Option, - force: bool, -) -> 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) = &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) → {}", @@ -2142,12 +2151,11 @@ mod tests { let doc = make_path_doc(); 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, - ) + 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(); @@ -2190,8 +2198,11 @@ 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(ClaudeArgs { + input: input_path.to_string_lossy().to_string(), + ..Default::default() + }) + .unwrap_err(); assert!(err.to_string().contains("single-path graph")); } @@ -2200,8 +2211,11 @@ 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(ClaudeArgs { + input: input_path.to_string_lossy().to_string(), + ..Default::default() + }) + .unwrap_err(); assert!(err.to_string().contains("parse") || err.to_string().contains("Failed")); } @@ -3274,9 +3288,17 @@ 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 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), @@ -3477,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 new file mode 100644 index 00000000..4780140b --- /dev/null +++ b/crates/path-cli/src/cmd_export/remote_session.rs @@ -0,0 +1,56 @@ +//! `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::*; + + #[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..8c097530 100644 --- a/crates/path-cli/src/cmd_incept.rs +++ b/crates/path-cli/src/cmd_incept.rs @@ -54,12 +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, - }) + 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 f96db7a8..007adf68 100644 --- a/crates/path-cli/src/cmd_project.rs +++ b/crates/path-cli/src/cmd_project.rs @@ -26,13 +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, - }) - } + ..Default::default() + }), + ), } } diff --git a/crates/path-cli/tests/integration.rs b/crates/path-cli/tests/integration.rs index c10e758e..5fc3addf 100644 --- a/crates/path-cli/tests/integration.rs +++ b/crates/path-cli/tests/integration.rs @@ -553,6 +553,56 @@ fn export_help_lists_claude_and_pathbase() { .stdout(predicate::str::contains("pathbase")); } +#[cfg(feature = "resume-remote")] +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 ")); + } + + #[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_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-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/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/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'" diff --git a/site/_data/crates.json b/site/_data/crates.json index 489fdd57..3dadd72c 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", @@ -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",