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() {