Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
{
"name": "path",
"description": "Slash commands for the Toolpath path CLI: /path:share uploads an agent session to Pathbase, /path:query runs jaq queries over your local session cache. Installs the path binary globally on first use",
"version": "0.1.4",
"version": "0.2.0",
"author": {
"name": "Empathic"
},
Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,37 @@ All notable changes to the Toolpath workspace are documented here.
consent flow first — deferred to issue #179.
- **`toolpath-cli`** (0.17.0): lockstep bump of the deprecated shim.

## Plugin `/path:resume` + `/path:link-pr`; export clobber guard — 2026-07-30

Two new plugin commands (plugin `path` 0.2.0), plus the guard that keeps
same-machine round-trips from destroying local history. (The two
resume-blocking projector fixes discovered in the same investigation
shipped separately — see "Projected Claude sessions are resumable
again" below.)

- **`path-cli`** (unreleased): `p export claude --project` refuses to
overwrite an existing session file (the error names the id and
suggests `claude -r`); a new `--force` flag restores the old
clobbering behavior. Found the hard way: same-machine round-trips
(share your own session, then resume it) silently replaced the richer
local original with the lossy projection. `path resume`
short-circuits the same case — an already-local session skips
projection entirely and resumes the local copy, which may be newer
than the shared document.
- **Plugin `path` 0.2.0** — two new commands:
- `/path:resume <pathbase-url>` fetches a shared session and projects
it into the current project, then hands the user the exact resume
step (`/resume <id>` in the running UI, or `claude -r <id>`). The
running TUI cannot be switched programmatically — Claude Code has no
such mechanism — so the handoff is the floor. The clobber guard
lives in the CLI (see above), so an already-local session turns
into a direct `/resume <id>` handoff instead of an overwrite.
- `/path:link-pr [pr]` shares the current conversation (same selection
and auth rules as `/path:share`) and appends the Pathbase link to a
PR description — the PR from the arguments, else the current
branch's, else the one under discussion. Idempotent: an already
linked URL is not added twice.

## Projected Claude sessions are resumable again — 2026-07-30

Two fixes found by live-resuming a projected session against the real
Expand Down
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ Format references for the agent on-disk formats live at `docs/agents/formats/`

### Claude Code plugin

- `.claude-plugin/marketplace.json` (marketplace `toolpath`) + `plugins/claude-code/` (plugin `path`; commands `/path:share`, `/path:query`). No binaries committed — commands bootstrap the CLI via `plugins/claude-code/scripts/ensure-path.sh` (Toolpath `path` on PATH → `~/.local/bin/path` → `~/.toolpath/bin/path` → sha256-verified GitHub release download).
- `.claude-plugin/marketplace.json` (marketplace `toolpath`) + `plugins/claude-code/` (plugin `path`; commands `/path:share`, `/path:query`, `/path:resume`, `/path:link-pr`). No binaries committed — commands bootstrap the CLI via `plugins/claude-code/scripts/ensure-path.sh` (Toolpath `path` on PATH → `~/.local/bin/path` → `~/.toolpath/bin/path` → sha256-verified GitHub release download).
- `/path:resume` projects a shared session into the current project via `p import pathbase` + `p export claude` and hands the user `/resume <id>` — the running TUI cannot be switched programmatically, and the command guards against re-exporting a session that already exists locally (export overwrites the file). `/path:link-pr` runs the share flow and appends the link to a PR description via `gh pr view/edit`.
- Two hard constraints in the command docs: slash-command `!` context commands and model-issued Bash must not contain `$PWD`/variables (Claude Code's permission checker rejects commands it can't statically analyze — hence the `sessions`/`current-session` helper modes), and `--project` must always be absolute (relative values silently match nothing).
- Tests: `scripts/test-plugin.sh` (the `plugin` quality gate); plugin scripts are shellchecked. Dev loop: `claude --plugin-dir ./plugins/claude-code`. Keep `plugins/claude-code/.claude-plugin/plugin.json` and the marketplace entry version in lockstep; `MIN_VERSION` in ensure-path.sh names the oldest CLI the command docs support.
180 changes: 168 additions & 12 deletions crates/path-cli/src/cmd_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ pub enum ExportTarget {
/// Output JSONL to this file. Mutually exclusive with --project.
#[arg(short, long, conflicts_with = "project")]
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)]
force: bool,
},
/// Project a toolpath document into a Gemini CLI session
Gemini {
Expand Down Expand Up @@ -202,7 +208,8 @@ pub fn run(target: ExportTarget) -> Result<()> {
input,
project,
output,
} => run_claude(input, project, output),
force,
} => run_claude(input, project, output, force),
ExportTarget::Gemini {
input,
project,
Expand Down Expand Up @@ -280,17 +287,52 @@ pub(crate) struct PathbaseUploadArgs {
// projected session id. They are called by `path resume`; the existing
// `run_<harness>` functions are untouched.

/// Project `path` into a Claude session under `project_dir` and return
/// the resulting session id.
/// Outcome of projecting a Path into a Claude project directory.
#[cfg(not(target_os = "emscripten"))]
pub(crate) enum ClaudeProjection {
/// The session file was written.
Written { session_id: String },
/// A session with this id already exists in the target project; nothing
/// was written. Resuming the local copy is the least destructive move —
/// it may be newer than the shared document.
AlreadyLocal { session_id: String },
}

/// Project `path` into a Claude session under `project_dir`.
///
/// Never overwrites: if the session already exists locally the projection is
/// skipped and `AlreadyLocal` is returned (callers that want to clobber go
/// through `p export claude --force`).
#[cfg(not(target_os = "emscripten"))]
pub(crate) fn project_claude(
path: &toolpath::v1::Path,
project_dir: &std::path::Path,
) -> Result<String> {
) -> Result<ClaudeProjection> {
let conv = build_claude_conversation(path)?;
if claude_session_file(&conv.session_id, project_dir)?.is_some() {
return Ok(ClaudeProjection::AlreadyLocal {
session_id: conv.session_id,
});
}
let jsonl = serialize_jsonl(&conv)?;
write_into_claude_project(&conv, &jsonl, project_dir)?;
Ok(conv.session_id)
write_into_claude_project(&conv, &jsonl, project_dir, false)?;
Ok(ClaudeProjection::Written {
session_id: conv.session_id,
})
}

/// Path of the session file for `session_id` under `project_dir`'s Claude
/// project directory, if it exists.
#[cfg(not(target_os = "emscripten"))]
fn claude_session_file(session_id: &str, project_dir: &std::path::Path) -> Result<Option<PathBuf>> {
let project_dir = std::fs::canonicalize(project_dir)
.with_context(|| format!("resolve project path {}", project_dir.display()))?;
let resolver = toolpath_claude::PathResolver::new();
let claude_project_dir = resolver
.project_dir(&project_dir.to_string_lossy())
.map_err(|e| anyhow::anyhow!("Cannot resolve Claude project dir: {}", e))?;
let candidate = claude_project_dir.join(format!("{}.jsonl", session_id));
Ok(candidate.exists().then_some(candidate))
}

/// Project `path` into a Gemini session under `project_dir` and return
Expand Down Expand Up @@ -590,10 +632,15 @@ pub(crate) fn project_pi(
Ok(session.header.id)
}

fn run_claude(input: String, project: Option<PathBuf>, output: Option<PathBuf>) -> Result<()> {
fn run_claude(
input: String,
project: Option<PathBuf>,
output: Option<PathBuf>,
force: bool,
) -> Result<()> {
#[cfg(target_os = "emscripten")]
{
let _ = (input, project, output);
let _ = (input, project, output, force);
anyhow::bail!("'path export claude' requires a native environment");
}

Expand All @@ -605,7 +652,8 @@ fn run_claude(input: String, project: Option<PathBuf>, output: Option<PathBuf>)

match (project, output) {
(Some(project_dir), None) => {
let out_path = write_into_claude_project(&conversation, &jsonl, &project_dir)?;
let out_path =
write_into_claude_project(&conversation, &jsonl, &project_dir, force)?;
let session_id = &conversation.session_id;
eprintln!(
"Exported session {} ({} entries) → {}",
Expand Down Expand Up @@ -677,6 +725,7 @@ fn write_into_claude_project(
conv: &toolpath_claude::Conversation,
jsonl: &str,
project_dir: &std::path::Path,
force: bool,
) -> Result<PathBuf> {
let project_dir = std::fs::canonicalize(project_dir)
.with_context(|| format!("resolve project path {}", project_dir.display()))?;
Expand All @@ -692,6 +741,15 @@ fn write_into_claude_project(

let session_id = &conv.session_id;
let out_path = claude_project_dir.join(format!("{}.jsonl", session_id));
if !force && out_path.exists() {
anyhow::bail!(
"Session {} already exists in this project ({}). Resume it directly with \
`claude -r {}`, or pass --force to overwrite the local session file.",
session_id,
out_path.display(),
session_id
);
}
std::fs::write(&out_path, jsonl).with_context(|| format!("write {}", out_path.display()))?;
Ok(out_path)
}
Expand Down Expand Up @@ -2097,6 +2155,7 @@ mod tests {
input_path.to_string_lossy().to_string(),
None,
Some(output_path.clone()),
false,
)
.unwrap();

Expand Down Expand Up @@ -2140,7 +2199,8 @@ 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).unwrap_err();
let err =
run_claude(input_path.to_string_lossy().to_string(), None, None, false).unwrap_err();
assert!(err.to_string().contains("single-path graph"));
}

Expand All @@ -2149,7 +2209,8 @@ 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).unwrap_err();
let err =
run_claude(input_path.to_string_lossy().to_string(), None, None, false).unwrap_err();
assert!(err.to_string().contains("parse") || err.to_string().contains("Failed"));
}

Expand Down Expand Up @@ -3136,7 +3197,10 @@ mod tests {
}
}

let returned_id = result.expect("project_claude should succeed");
let returned_id = match result.expect("project_claude should succeed") {
ClaudeProjection::Written { session_id } => session_id,
ClaudeProjection::AlreadyLocal { .. } => panic!("fresh project dir must be Written"),
};
assert_eq!(returned_id, session_id);

let claude_projects = fake_home.join(".claude/projects");
Expand All @@ -3146,6 +3210,98 @@ mod tests {
);
}

#[test]
fn project_claude_never_overwrites_an_existing_session() {
let temp = tempfile::tempdir().unwrap();
let fake_home = temp.path().join("home");
std::fs::create_dir_all(&fake_home).unwrap();
let cwd = temp.path().join("proj");
std::fs::create_dir_all(&cwd).unwrap();

let session_id = "claude-clobber-test-session";
let path = make_convo_path(&format!("claude-code://{}", session_id));

let _g = crate::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prior_home = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", &fake_home);
}
let first = project_claude(&path, &cwd);
// Simulate local divergence: the session gained content after the
// first projection.
let session_file = claude_session_file(session_id, &cwd)
.unwrap()
.expect("first projection must have written the session file");
let mut contents = std::fs::read_to_string(&session_file).unwrap();
contents.push_str("{\"local\":\"divergence\"}\n");
std::fs::write(&session_file, &contents).unwrap();

let second = project_claude(&path, &cwd);
unsafe {
match prior_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}

assert!(matches!(
first.expect("first projection should succeed"),
ClaudeProjection::Written { .. }
));
match second.expect("second projection should succeed") {
ClaudeProjection::AlreadyLocal { session_id: id } => assert_eq!(id, session_id),
ClaudeProjection::Written { .. } => panic!("existing session must not be re-projected"),
}
assert_eq!(
std::fs::read_to_string(&session_file).unwrap(),
contents,
"existing session file must be untouched"
);
}

#[test]
fn export_claude_refuses_existing_session_without_force() {
let temp = tempfile::tempdir().unwrap();
let fake_home = temp.path().join("home");
std::fs::create_dir_all(&fake_home).unwrap();
let cwd = temp.path().join("proj");
std::fs::create_dir_all(&cwd).unwrap();

let session_id = "claude-force-test-session";
let path = make_convo_path(&format!("claude-code://{}", session_id));
let input_path = temp.path().join("input.json");
let doc = toolpath::v1::Graph::from_path(path);
std::fs::write(&input_path, serde_json::to_string(&doc).unwrap()).unwrap();
let input = input_path.to_string_lossy().to_string();

let _g = crate::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let prior_home = std::env::var_os("HOME");
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);
unsafe {
match prior_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}

first.expect("first export should succeed");
let err = second.expect_err("re-export without --force must fail");
assert!(
err.to_string().contains("--force"),
"unhelpful error: {err}"
);
forced.expect("re-export with --force should succeed");
}

#[test]
fn project_gemini_returns_session_id_and_writes_chat_file() {
let temp = tempfile::tempdir().unwrap();
Expand Down
1 change: 1 addition & 0 deletions crates/path-cli/src/cmd_incept.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ pub fn run(target: InceptTarget) -> Result<()> {
input,
project,
output,
force: false,
})
}
InceptTarget::Cursor {
Expand Down
1 change: 1 addition & 0 deletions crates/path-cli/src/cmd_project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ pub fn run(target: ProjectTarget) -> Result<()> {
input,
project: None,
output,
force: false,
})
}
}
Expand Down
10 changes: 9 additions & 1 deletion crates/path-cli/src/cmd_resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,7 +460,15 @@ pub(crate) fn project_into_harness(
cwd: &std::path::Path,
) -> Result<String> {
match harness {
Harness::Claude => crate::cmd_export::project_claude(path, cwd),
Harness::Claude => match crate::cmd_export::project_claude(path, cwd)? {
crate::cmd_export::ClaudeProjection::Written { session_id } => Ok(session_id),
crate::cmd_export::ClaudeProjection::AlreadyLocal { session_id } => {
eprintln!(
"Session {session_id} already exists in this project; resuming the local copy."
);
Ok(session_id)
}
},
Harness::Gemini => crate::cmd_export::project_gemini(path, cwd),
Harness::Codex => crate::cmd_export::project_codex(path, cwd),
Harness::Copilot => crate::cmd_export::project_copilot(path, cwd),
Expand Down
2 changes: 1 addition & 1 deletion plugins/claude-code/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "path",
"version": "0.1.4",
"version": "0.2.0",
"description": "Toolpath for Claude Code — /path:share uploads an agent session to Pathbase, /path:query answers questions about your local session history. Bundles the path CLI, installed globally on first use",
"author": {
"name": "Empathic"
Expand Down
2 changes: 2 additions & 0 deletions plugins/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ Inside Claude Code:
|---------|-------------|
| `/path:share` | Share an agent session to Pathbase and get a link. With no arguments it shares the current conversation; pass a hint to pick another session, `--harness <name>` for another harness, and `--anon` / `--public` / `--repo` / `--name` / `--url` to control the upload. |
| `/path:query` | Ask questions about your local agent-session history. Takes plain English (translated to a jaq filter) or a jaq filter verbatim, plus `--source` / `--project` scoping. |
| `/path:resume` | Bring a shared session (Pathbase URL, `owner/repo/slug`, file, or cache id) into this project and get the exact resume step — `/resume <id>` here, or `claude -r <id>` from a terminal. |
| `/path:link-pr` | Share the current conversation and append the Pathbase link to a PR description — the PR you name, or the current branch's. |

## How the binary is bundled

Expand Down
Loading
Loading