diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 48306d2..335ee3c 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 5f143f2..f7c8a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ All notable changes to the Toolpath workspace are documented here. +## 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 7fb2d74..a0cd478 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -289,5 +289,5 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. - Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/artifact.rs`: `ArtifactType` + `ArtifactRef` + the stamp helpers; `sync/engine.rs`: manifest + ingestion loop, no UI — it reports through a `SyncObserver` trait, `&mut ()` for a silent sync; `sync/sources.rs`: an `ArtifactSource` trait — enumerate / stamp / derive / peek-dir / scope-match — with one impl per provider, so the engine never matches on artifact type; `cmd_cache.rs`: the stderr progress line + summary) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactRef` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (each source calls the `derive_*_session_with` helpers in `derive.rs`). Manifest at `~/.toolpath/manifest.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`manifest.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — refresh semantics — and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--project-under` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--project-under ` on both `p cache sync` and `path query` restricts ingestion to sessions whose project directory (recorded cwd) is under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactRef` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_artifact` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. -- Claude Code plugin: `.claude-plugin/marketplace.json` (marketplace `toolpath`) + `plugins/claude-code/` (plugin `path`, so commands are `/path:share` and `/path:query`). The plugin does **not** commit binaries — both commands invoke the CLI through `plugins/claude-code/scripts/ensure-path.sh`, which prefers an existing Toolpath `path` on PATH (identity-checked via `--help`), else `~/.local/bin/path`, else `~/.toolpath/bin/path`, else downloads the latest GitHub release (sha256-verified, same logic as `scripts/install.sh`) and installs globally to `~/.local/bin` — falling back to `~/.toolpath/bin` when a foreign binary named `path` claims the name. Two hard-won constraints baked into the command docs: slash-command inline `!` 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` and `current-session` helper modes, the latter reading `$CLAUDE_CODE_SESSION_ID` so no-arg `/path:share` shares exactly the running session), and `--project` must always be an absolute path (path-cli does not canonicalize relative `--project` values; `.` silently matches nothing). Tests: `scripts/test-plugin.sh` (manifest consistency + offline bootstrap tests against a stubbed curl/release), wired in as the `plugin` quality gate; plugin shell scripts are shellchecked. Dev loop: `claude --plugin-dir ./plugins/claude-code`. Future harness integrations go under `plugins//` (only Claude Code plugins are marketplace entries; other harnesses distribute their own way). Version bumps: keep `plugins/claude-code/.claude-plugin/plugin.json` and the matching entry in `.claude-plugin/marketplace.json` in lockstep (test-plugin.sh asserts this); the binary is unpinned (latest release) with `MIN_VERSION` in ensure-path.sh naming the oldest CLI the command docs support. +- Claude Code plugin: `.claude-plugin/marketplace.json` (marketplace `toolpath`) + `plugins/claude-code/` (plugin `path`, so commands are `/path:share`, `/path:query`, `/path:resume`, and `/path:link-pr`). `/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`. The plugin does **not** commit binaries — both commands invoke the CLI through `plugins/claude-code/scripts/ensure-path.sh`, which prefers an existing Toolpath `path` on PATH (identity-checked via `--help`), else `~/.local/bin/path`, else `~/.toolpath/bin/path`, else downloads the latest GitHub release (sha256-verified, same logic as `scripts/install.sh`) and installs globally to `~/.local/bin` — falling back to `~/.toolpath/bin` when a foreign binary named `path` claims the name. Two hard-won constraints baked into the command docs: slash-command inline `!` 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` and `current-session` helper modes, the latter reading `$CLAUDE_CODE_SESSION_ID` so no-arg `/path:share` shares exactly the running session), and `--project` must always be an absolute path (path-cli does not canonicalize relative `--project` values; `.` silently matches nothing). Tests: `scripts/test-plugin.sh` (manifest consistency + offline bootstrap tests against a stubbed curl/release), wired in as the `plugin` quality gate; plugin shell scripts are shellchecked. Dev loop: `claude --plugin-dir ./plugins/claude-code`. Future harness integrations go under `plugins//` (only Claude Code plugins are marketplace entries; other harnesses distribute their own way). Version bumps: keep `plugins/claude-code/.claude-plugin/plugin.json` and the matching entry in `.claude-plugin/marketplace.json` in lockstep (test-plugin.sh asserts this); the binary is unpinned (latest release) with `MIN_VERSION` in ensure-path.sh naming the oldest CLI the command docs support. - `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`crates/path-cli/src/harness.rs`, alongside `HarnessBundle`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 42cd7e0..54e03a1 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -42,6 +42,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 { @@ -221,7 +227,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, @@ -299,17 +306,55 @@ 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 @@ -609,10 +654,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"); } @@ -624,7 +674,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) → {}", @@ -696,6 +747,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()))?; @@ -711,6 +763,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) } @@ -2116,6 +2177,7 @@ mod tests { input_path.to_string_lossy().to_string(), None, Some(output_path.clone()), + false, ) .unwrap(); @@ -2159,7 +2221,7 @@ 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")); } @@ -2168,7 +2230,7 @@ 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")); } @@ -3169,7 +3231,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"); @@ -3179,6 +3244,95 @@ 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 0650b3a..6edcc7d 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 81d5ef9..f96db7a 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 163634f..4b0b6e3 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), diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index 6c29877..9cac630 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 7982d5e..db34e70 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 0000000..3f94317 --- /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 0000000..1605d1c --- /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 3294d08..940e307 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 ----------------------------------------------