Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>`, 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`.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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"] }
Expand Down
5 changes: 4 additions & 1 deletion crates/path-cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down
231 changes: 181 additions & 50 deletions crates/path-cli/src/cmd_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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/<sanitized>/<session>.jsonl` so `claude -r <id>`
/// can resume it. Defaults to cwd when no `--output` is given.
#[arg(short, long)]
project: Option<PathBuf>,
/// 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<PathBuf>,
/// Target project directory. With this flag, writes the JSONL into
/// `~/.claude/projects/<sanitized>/<session>.jsonl` so `claude -r <id>`
/// can resume it. Defaults to cwd when no `--output` is given.
#[arg(short, long)]
pub(crate) project: Option<PathBuf>,

/// 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<PathBuf>,

/// 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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -632,28 +638,31 @@ pub(crate) fn project_pi(
Ok(session.header.id)
}

fn run_claude(
input: String,
project: Option<PathBuf>,
output: Option<PathBuf>,
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) → {}",
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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"));
}

Expand All @@ -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"));
}

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<serde_json::Value> {
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")
);
}
}
}
Loading
Loading