From 9e80f6724563477c2d5170575356572192b1f1ac Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 30 Jul 2026 16:32:09 -0400 Subject: [PATCH 1/2] feat(cli): p export claude refuses to clobber an existing session; path resume short-circuits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 instead: project_claude returns AlreadyLocal and the resume execs the local copy, which may be newer than the shared document. Tests cover the diverged-local untouched case and the refuse/--force pair. (The signature-serialization and trailing-newline fixes discovered in the same investigation landed on main separately via #151.) --- crates/path-cli/src/cmd_export.rs | 180 +++++++++++++++++++++++++++-- crates/path-cli/src/cmd_incept.rs | 1 + crates/path-cli/src/cmd_project.rs | 1 + crates/path-cli/src/cmd_resume.rs | 10 +- 4 files changed, 179 insertions(+), 13 deletions(-) diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 82f490e5..b95a67f0 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -43,6 +43,12 @@ pub enum ExportTarget { /// Output JSONL to this file. Mutually exclusive with --project. #[arg(short, long, conflicts_with = "project")] 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, }, /// Project a toolpath document into a Gemini CLI session Gemini { @@ -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, @@ -280,17 +287,52 @@ pub(crate) struct PathbaseUploadArgs { // projected session id. They are called by `path resume`; the existing // `run_` 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 { +) -> Result { 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> { + 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 @@ -590,10 +632,15 @@ pub(crate) fn project_pi( Ok(session.header.id) } -fn run_claude(input: String, project: Option, output: Option) -> Result<()> { +fn run_claude( + input: String, + project: Option, + output: Option, + 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"); } @@ -605,7 +652,8 @@ fn run_claude(input: String, project: Option, output: Option) 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) → {}", @@ -677,6 +725,7 @@ fn write_into_claude_project( conv: &toolpath_claude::Conversation, jsonl: &str, project_dir: &std::path::Path, + force: bool, ) -> Result { let project_dir = std::fs::canonicalize(project_dir) .with_context(|| format!("resolve project path {}", project_dir.display()))?; @@ -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) } @@ -2097,6 +2155,7 @@ mod tests { input_path.to_string_lossy().to_string(), None, Some(output_path.clone()), + false, ) .unwrap(); @@ -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")); } @@ -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")); } @@ -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"); @@ -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(); diff --git a/crates/path-cli/src/cmd_incept.rs b/crates/path-cli/src/cmd_incept.rs index 0650b3a9..6edcc7db 100644 --- a/crates/path-cli/src/cmd_incept.rs +++ b/crates/path-cli/src/cmd_incept.rs @@ -58,6 +58,7 @@ pub fn run(target: InceptTarget) -> Result<()> { input, project, output, + force: false, }) } InceptTarget::Cursor { diff --git a/crates/path-cli/src/cmd_project.rs b/crates/path-cli/src/cmd_project.rs index 81d5ef92..f96db7a8 100644 --- a/crates/path-cli/src/cmd_project.rs +++ b/crates/path-cli/src/cmd_project.rs @@ -31,6 +31,7 @@ pub fn run(target: ProjectTarget) -> Result<()> { input, project: None, output, + force: false, }) } } diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 163634fe..4b0b6e3d 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -460,7 +460,15 @@ pub(crate) fn project_into_harness( cwd: &std::path::Path, ) -> Result { 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), From 01b193dc7f62e54c47b881a2e6b0c2eb7460665f Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Thu, 30 Jul 2026 16:32:09 -0400 Subject: [PATCH 2/2] feat(plugin): /path:resume and /path:link-pr; plugin 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /path:resume fetches a shared session (Pathbase URL, owner/repo/slug, file, or cache id) and projects it into the current project via p import pathbase + p export claude, then hands the user the exact resume step — /resume in the running UI, or claude -r from a terminal. Claude Code has no mechanism for a plugin to switch the running TUI's session (verified against current docs; deep links only open new windows), so the handoff is the floor. The already-local case leans on the CLI guard: the export's refusal carries the session id, and the command turns it into a direct /resume handoff instead of an overwrite — verified headless, where the model previously ignored a prose-only guard and re-exported anyway. /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; description phrased so "share this conversation to the PR" invokes it. Idempotent per URL; distinct sessions stack as separate lines. Verified headless against a real PR (append + second-session line), body restored after; implicit invocation later verified live from the phrase "share this session with my pr". Both live end-to-end tests passed: a projected thinking-bearing session now resumes cleanly (summarized its own history) and link-pr produced working PR + Pathbase links. --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 31 +++++++++ CLAUDE.md | 3 +- .../claude-code/.claude-plugin/plugin.json | 2 +- plugins/claude-code/README.md | 2 + plugins/claude-code/commands/link-pr.md | 63 +++++++++++++++++++ plugins/claude-code/commands/resume.md | 52 +++++++++++++++ scripts/test-plugin.sh | 4 +- 8 files changed, 154 insertions(+), 5 deletions(-) create mode 100644 plugins/claude-code/commands/link-pr.md create mode 100644 plugins/claude-code/commands/resume.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 48306d22..335ee3c6 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index ae897c27..07e05ab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` fetches a shared session and projects + it into the current project, then hands the user the exact resume + step (`/resume ` in the running UI, or `claude -r `). 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 ` 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 diff --git a/CLAUDE.md b/CLAUDE.md index 759c5e1f..3d618796 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ` — 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. diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 6c298770..9cac6301 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -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" diff --git a/plugins/claude-code/README.md b/plugins/claude-code/README.md index 7982d5e6..db34e704 100644 --- a/plugins/claude-code/README.md +++ b/plugins/claude-code/README.md @@ -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 ` 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 ` here, or `claude -r ` 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 diff --git a/plugins/claude-code/commands/link-pr.md b/plugins/claude-code/commands/link-pr.md new file mode 100644 index 00000000..3f943174 --- /dev/null +++ b/plugins/claude-code/commands/link-pr.md @@ -0,0 +1,63 @@ +--- +description: Share the session and link it in a PR description — use when the user asks to share or attach this conversation to a PR +argument-hint: "[pr number or url]" +allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/ensure-path.sh:*), Bash(gh pr view:*), Bash(gh pr edit:*) +--- + +## Context + +- Toolpath CLI: !`"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-path.sh"` +- Auth: !`"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-path.sh" exec auth status` +- Current session id: !`"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-path.sh" current-session` + +## Your task + +Share an agent session to Pathbase, then add the resulting link to a GitHub PR description. + +User arguments: $ARGUMENTS + +Always invoke the CLI through the wrapper, with literal absolute paths (never `$PWD` or other variables — they fail the permission check): + +``` +"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-path.sh" exec +``` + +### Target PR + +- A PR number or URL in the arguments wins. +- Otherwise the current branch's PR: `gh pr view --json number,url,body`. +- If the conversation just opened or discussed a specific PR, that's the one the user means. +- No PR found → ask which PR. + +### Share + +Same rules as `/path:share`: + +- Share the current conversation — the "Current session id" from the context above (fall back to the newest row of `"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-path.sh" sessions` if it reads `unknown`). +- If the Auth context shows no login and the user didn't pass `--anon`, stop and ask: anonymous upload, or `path auth login` in their own terminal first (never run it yourself)? +- Run, passing through any of `--anon`, `--public`, `--repo`, `--name`, `--url` from the arguments: + + ``` + ... exec share --harness claude --project --session + ``` + +Note the Pathbase URL it prints. + +### Link it in the PR + +1. Fetch the current body: `gh pr view --json body -q .body`. +2. If the body already contains this Pathbase URL, don't add it again — report that it's already linked and stop. +3. Otherwise append (using the Write tool for a temp file, then `gh pr edit --body-file ` — don't try to inline a multi-line body in shell): + + ``` + + --- + + Agent session: []() + ``` + + If an `Agent session:` line already exists for a different session, add a new line under it rather than replacing it. + +### Report + +Give the user both links: the PR and the Pathbase session. On share failure, apply `/path:share`'s guidance (auth, `--anon`, server); on `gh` failure, show the error — likely not logged in (`gh auth login`) or no PR for the branch. diff --git a/plugins/claude-code/commands/resume.md b/plugins/claude-code/commands/resume.md new file mode 100644 index 00000000..1605d1ce --- /dev/null +++ b/plugins/claude-code/commands/resume.md @@ -0,0 +1,52 @@ +--- +description: Resume a shared agent session in Claude Code +argument-hint: "pathbase-url" +allowed-tools: Bash(${CLAUDE_PLUGIN_ROOT}/scripts/ensure-path.sh:*) +--- + +## Context + +- Toolpath CLI: !`"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-path.sh"` + +## Your task + +Bring a shared agent session into this project so the user can resume it in Claude Code. You cannot switch the running session yourself — the deliverable is the projected session plus the exact resume step. + +User arguments: $ARGUMENTS + +The input is a Pathbase URL (`https://host/owner/repo/slug`), an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id. If no input was given, ask for one. + +Always invoke the CLI through the wrapper, and write paths as literal absolute strings — never `$PWD` or other variables (they fail the permission check): + +``` +"${CLAUDE_PLUGIN_ROOT}/scripts/ensure-path.sh" exec +``` + +### Steps + +1. **Fetch** (Pathbase URL or shorthand only — skip for a cache id or local file): + + ``` + ... exec p import pathbase --force + ``` + + Note the cache id from the output. + +2. **Project** the document into this project: + + ``` + ... exec p export claude --input --project + ``` + + - Success: the output ends with the resume recipe and the full session id. + - Error saying the session **already exists in this project**: that's not a failure — the session is already local (and may be newer than the shared copy). Take the session id from the error message and go to step 3. Never retry with `--force` unless the user explicitly asks to overwrite their local session. + +3. **Hand off.** Tell the user both options, with the real session id filled in: + - `/resume ` — right here, no restart (the built-in resume takes an id and re-scans this project's sessions). + - `claude -r ` — from a terminal in this directory. + +### Notes + +- The document must be a single agent session (what `path share` produces). If the export reports it isn't, say so — graphs and multi-path documents can't be resumed. +- Sessions shared from other harnesses (Codex, Gemini, ...) project into Claude Code fine — tool calls are remapped. +- Reasoning blocks from the original session are not replayed to the model after resume (they lack API signatures); the conversation itself is intact. diff --git a/scripts/test-plugin.sh b/scripts/test-plugin.sh index 3294d080..940e3071 100755 --- a/scripts/test-plugin.sh +++ b/scripts/test-plugin.sh @@ -42,12 +42,12 @@ PY ok "manifests parse and agree (plugin 'path', versions match)" bash -n "$ENSURE" || fail "ensure-path.sh does not parse" -for cmd in share query; do +for cmd in share query resume link-pr; do [ -f "$PLUGIN/commands/$cmd.md" ] || fail "missing command $cmd.md" grep -q "ensure-path.sh" "$PLUGIN/commands/$cmd.md" \ || fail "$cmd.md does not invoke the ensure-path.sh wrapper" done -ok "scripts parse; both commands exist and use the wrapper" +ok "scripts parse; all four commands exist and use the wrapper" # --- ensure-path.sh behavior ----------------------------------------------