From 39289f2d50262fef7ad2f6af321dbb51d59b4ced Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 10:51:14 -0400 Subject: [PATCH 01/21] docs: design `path resume` command A one-shot resume that fetches a Toolpath doc (URL, Pathbase shorthand, file path, or cache id), picks a coding-agent harness (interactive picker by default, `--harness` to skip), projects the session into the harness's on-disk layout, and execs the harness's resume command. Mirror image of `path share`. --- .../2026-05-08-path-resume-command-design.md | 358 ++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-08-path-resume-command-design.md diff --git a/docs/superpowers/specs/2026-05-08-path-resume-command-design.md b/docs/superpowers/specs/2026-05-08-path-resume-command-design.md new file mode 100644 index 0000000..af795bb --- /dev/null +++ b/docs/superpowers/specs/2026-05-08-path-resume-command-design.md @@ -0,0 +1,358 @@ +# `path resume` — one-shot resume into a coding agent + +**Status:** Design accepted, awaiting implementation plan. +**Date:** 2026-05-08 + +## Goal + +Collapse the existing two-step "fetch a session, replay it locally" +workflow (`path import pathbase ` then `path export +--input --project ` then run the harness's resume command) +into a single command that ends with the user's chosen coding agent +running in interactive mode against the projected session. + +`path share` is the upstream of this flow: someone clicks share, sends +a Pathbase URL to a teammate, and the teammate runs `path resume ` +to land in claude / codex / etc. with the conversation in front of +them. + +## Non-goals + +- **Git context.** A Toolpath document may carry a `path.base` git URL + + ref, but `path resume` does not clone, fetch, or check out anything. + The user is responsible for their working tree. (Possible follow-up.) +- **File-artifact replay.** The doc may carry file changes; we do not + apply them to the working tree. The harness session alone is what + gets reconstructed. +- **Multi-path resume from a `Graph`.** v1 accepts a single `Path`; + `Graph` inputs are rejected with a message. +- **Cross-harness fidelity warnings.** The user picks the harness; we + do not second-guess matches/mismatches with the source. +- **`--print` opt-out for exec.** Default and only behavior is exec. + Recipe-print is the *fallback* when exec fails (binary missing + between PATH check and exec), not a user-facing flag. +- **Deprecation aliases.** Fresh command, no prior name to honor. + +## Surface + +``` +path resume + [-C, --cwd ] + [--harness ] + [--no-cache] [--force] [--url ] +``` + +| Flag / arg | Behavior | +| --- | --- | +| `` | URL, Pathbase shorthand, file path, or cache id. See "Input resolution" below. | +| `-C, --cwd P` | chdir to P before projecting and before exec'ing the harness. Layout is keyed on P. Default: shell cwd. | +| `--harness X` | Pin the resume target. Skips the interactive picker. Errors if X is not on PATH. | +| (no `--harness`) | fzf picker over installed harnesses; doc's source harness pre-selected when installed. Rows annotated `(source)` and/or `(not on PATH)`. | +| `--no-cache` | URL/shorthand inputs only: skip writing the fetched doc to `~/.toolpath/documents/`. | +| `--force` | URL/shorthand inputs only: overwrite an existing cache entry. Same semantics as `import --force`. | +| `--url ` | Override Pathbase server URL. Same fallback chain as `import pathbase`: `--url` > stored session > `$PATHBASE_URL` > `https://pathbase.dev`. | + +### Input resolution + +The single `` argument is resolved in this order, matching the +precedent set by `import pathbase`: + +1. **URL** — starts with `http://` or `https://` → fetched via the + pathbase client, written to cache (unless `--no-cache`), parsed. +2. **Pathbase shorthand** — three slash-separated segments + (`owner/repo/slug`) → same fetch + cache flow. +3. **Existing file path** — resolves as a real file on disk → read and + parsed. +4. **Cache id** — falls back to `~/.toolpath/documents/.json` + via the existing `cache_ref` helper. + +Ambiguity (e.g. a string that looks like a shorthand *and* is a real +file) resolves in the order above. This matches `import pathbase` and +is documented in the error path: a fail-to-resolve message names all +four shapes. + +### Launch + +After projection completes, the command: +1. `chdir`s to the resolved cwd (whether default or `-C P`). +2. **Unix:** `execvp`s the harness binary with its resume args, replacing + the current process. +3. **Windows:** `spawn`s the harness, waits, propagates the exit code. + +If the binary is not on PATH at exec time (race between the picker's +PATH check and exec, or a `--harness` value that fails the validation +gate), exit non-zero with `couldn't exec : . Recipe: + (run from )` so the user can recover by hand. + +## Internal architecture + +### New module: `cmd_resume.rs` + +Lives next to the other `cmd_*.rs` files in `crates/path-cli/src/`. +Wired into `lib.rs` as a new `Commands::Resume { args: +cmd_resume::ResumeArgs }` arm. Same pattern as `cmd_share.rs`. + +### Top-level orchestration + +```rust +pub async fn run_resume(args: ResumeArgs) -> Result<()> { + let (doc, source_harness) = resolve_input(&args).await?; + ensure_path_with_agent(&doc)?; + + let cwd = args.cwd.map(canonicalize_existing) + .unwrap_or_else(|| std::env::current_dir())?; + + let target = pick_harness(args.harness, source_harness)?; + let recipe = project_into_harness(&doc, target, &cwd)?; + exec_harness(recipe, &cwd) +} +``` + +### `resolve_input` + +Small dispatcher that delegates, in order: + +- URL / `owner/repo/slug` → factor out the existing pathbase fetch + flow that lives inline in `cmd_import.rs` (calls + `cmd_pathbase::paths_download` for the body, then `cache::write_cached` + unless `--no-cache`) into a small `pub(crate)` helper that returns + `(Graph, String /* cache_id */)`. `cmd_resume` calls it; `cmd_import`'s + pathbase branch keeps using it. Honors `--no-cache`, `--force`, `--url`. +- File path / cache id → `cmd_cache::cache_ref` then read+parse. + +Returns `(Document, Option)`. The source harness is read +from `path.meta.source` — set by `toolpath-convo::derive_path` to the +provider's `provider_id`: + +| `meta.source` | Harness | +| --- | --- | +| `"claude-code"` | Claude | +| `"gemini-cli"` | Gemini | +| `"codex"` | Codex | +| `"opencode"` | Opencode | +| `"pi"` | Pi | + +Fallback when `meta.source` is absent: actor-string prefix sniffing +across `path.steps[*].actor` (`agent:claude-code`, `agent:codex`, +`agent:gemini-cli`, `agent:opencode`, `agent:pi`). `None` when neither +source is conclusive — the picker still works, just without a +pre-selection. + +### `ensure_path_with_agent` + +Pure validation; rejects: + +- `Document::Step` → "resume needs a `Path` document; `` is a + `Step`". +- `Document::Graph` with N > 0 paths → "resume needs a single `Path`; + `` is a `Graph` with N paths. Pick one with `path query …` + or split first." +- `Path` whose steps contain zero `agent:*` actors → "no agent + session in `` — `path resume` only works on harness-derived + paths". + +### `pick_harness` + +Reuses the `Harness` enum from `cmd_share.rs` (Claude / Gemini / +Codex / Opencode / Pi), including its `binary_name()` helper. Logic: + +- If `args.harness` is set → validate the binary is on PATH (small + inline `$PATH`-walking helper; or pull in the `which` crate as a new + dep — pick at implementation time, the surface is the same), return + it. Error if not on PATH. +- Else build the installed list (probe each harness binary on PATH); + pre-select source if installed; fzf-prompt with annotations. + Picker header: `pick a harness to resume in (source: )` when + source is known, otherwise `pick a harness to resume in`. +- If zero harnesses are installed → error naming all five. +- Esc / Ctrl-C → exit 130 (matches `path share`). + +Non-TTY environment with no `--harness`: error with the recipe (no +silent default — picking is consequential). + +### `project_into_harness` and `ResumeRecipe` + +Today the per-harness projection helpers in `cmd_export.rs` +(`run_claude` / `run_gemini` / `run_codex` / `run_opencode` / +`run_pi`) `eprintln!("Resume with: …")` and return `()`. We extract a +return type: + +```rust +pub struct ResumeRecipe { + pub binary: &'static str, // "claude" | "gemini" | "codex" | "opencode" | "pi" + pub args: Vec, // ["-r", ""] etc. + pub session_id: String, + pub cwd_for_recipe: PathBuf, // dir the harness must be invoked from +} +``` + +The existing CLI-level `path export ` commands keep their +stderr output by formatting this struct; behavior is unchanged. The +new code path in `cmd_resume` consumes the struct directly and feeds +it to `exec_harness`. + +This is a five-site mechanical refactor inside `cmd_export.rs` plus a +new public type. No behavior change for `path export`. + +### `exec_harness` + +Unix: + +```rust +use std::os::unix::process::CommandExt; + +let err = std::process::Command::new(recipe.binary) + .args(&recipe.args) + .current_dir(cwd) + .exec(); // returns std::io::Error on failure only +``` + +Windows: `Command::new(...).spawn()?.wait()?`, propagate exit code. +Both paths fall through to the recipe-print fallback (§ Launch) on +spawn/exec error. + +### Wiring + +One new arm in `lib.rs`'s dispatch match alongside `Commands::Share`. +The fzf wrapper (`crate::fzf`) and `cmd_share::Harness` are already in +`path-cli`. The only candidate new dep is `which` for PATH probing — +optional; a 15-line homegrown helper does the same job. + +## Output contract + +- **stdout**: nothing under normal exec. The harness owns the TTY + after exec. (On the recipe-print fallback path, the recipe goes to + stderr.) +- **stderr**: progress messages — + ``` + Resolved → claude-abc (cache id; omitted with --no-cache) + Picked harness: claude (source) + Projected → ~/.claude/projects//.jsonl + Resuming: claude -r (cwd: ) + ``` + Last line printed immediately before exec. + +**Exit codes.** Unix exec succeeds → process replaced; the harness's +exit code is what the caller sees. Windows / recipe-print fallback / +errors → propagate. Picker cancel → 130. Validation errors → 1. + +## Error handling + +| Situation | Behavior | +| --- | --- | +| URL fetch fails (network) | Propagated from `pathbase-client`. | +| URL fetch returns 401/403 | "auth failed for ``; run `path auth login` or pass `--anon`" (mirrors `import pathbase`). | +| Cache hit on URL fetch, no `--force` | "cache entry `` already exists; pass `--force` to overwrite". | +| Input doesn't resolve as URL / shorthand / file / cache id | "couldn't resolve `` as a URL, file path, or cache id". | +| Doc parses but is a `Step` | "resume needs a `Path` document; `` is a `Step`". | +| Doc is a `Graph` | "resume needs a single `Path`; `` is a `Graph` with N paths. Pick one with `path query …` or split first." | +| Path has no `agent:*` actors | "no agent session in `` — `path resume` only works on harness-derived paths". | +| `--harness X` given, X not on PATH | "harness `` isn't on PATH; install it or pick another with `--harness`". | +| Zero harnesses on PATH (interactive mode) | "no installed harnesses found; install one of: claude, gemini, codex, opencode, pi". | +| No `--harness` and stderr/stdin not a TTY | "interactive picker requires a TTY; pass `--harness ` or rerun in a terminal". | +| Picker cancelled (Esc / Ctrl-C) | Silent; exit 130. | +| Projection fails mid-write | Propagated from `cmd_export`; partial files left behind (same as `export --project`). | +| `exec` fails (binary disappeared between PATH check and exec) | Print recipe to stderr with `couldn't exec`; exit non-zero. | + +Notes that drive design but not behavior: + +- All "couldn't" messages start lowercase to match the style elsewhere + in `path-cli`. +- We do not validate that `cwd` is a git repo. The harnesses don't + require it; we shouldn't either. +- We do not warn if the recorded cwd in the doc (codex/opencode) + differs from `--cwd`. The user's flag wins; their problem to know + what they're doing. + +## Testing + +### Unit tests in `cmd_resume.rs` + +1. `resolve_input` dispatch — URL detection (`https://`), shorthand + detection (three-segment), file-path detection, cache-id fallback. + Each branch tested against a tmpdir + mock cache. +2. `infer_source_harness` — `meta.source` tag wins; actor-string + sniffing fallback; `None` when neither is conclusive. +3. `ensure_path_with_agent` — accepts `Path` with at least one + `agent:*` step; rejects `Step` / `Graph` / agent-less `Path` with + the exact error strings from "Error handling". +4. `pick_harness` non-interactive paths — `--harness` set + on PATH → + returns it; `--harness` set + not on PATH → error; zero installed + → error. PATH membership is faked via an injectable lookup helper. + +### `ResumeRecipe` round-trip in `cmd_export.rs` tests + +One test per harness (claude / gemini / codex / opencode / pi): +project a fixture path, assert the returned `ResumeRecipe` matches +`("claude", ["-r", ""])` etc. These also cover the existing CLI +surface, since `path export ` now formats the same struct on +stderr. + +### Integration tests in `crates/path-cli/tests/resume.rs` + +Exec is the one untestable line. `cmd_resume` accepts an injectable +"exec strategy" (a small trait object or boxed closure) — the binary +calls the real `execvp` strategy; tests substitute a strategy that +records the recipe and returns success. No public `--dry-run` flag. + +Cases: + +1. File-path input + `--harness claude` + `-C ` → projects under + `/.claude/projects//.jsonl`; recorded recipe is + `("claude", ["-r", ])`. +2. Same shape, one per harness (gemini / codex / opencode / pi). +3. Cache-id input → loads from a tmp cache, projects, records recipe. +4. URL input → reuses the in-repo `MockServer` test helper from + `cmd_pathbase.rs`'s test module (extract into a `pub(crate)` test + util if needed), fetches, caches, projects. +5. `Step` input → returns the error verbatim. +6. `Graph` input → returns the error verbatim. +7. Agent-less `Path` (git-derived fixture) → returns the error. +8. `--harness` not on PATH → error. +9. Zero installed harnesses → error. +10. Picker cancel → exit 130 (reuses the existing fzf-cancel test + pattern from `cmd_share`). + +### Out of scope for tests + +- Real harness exec. Not exercised in CI. +- The fzf-driven harness picker UX. The picker code is small and + reuses `cmd_share`'s helpers, which are already covered. + +## Documentation + +- `CLAUDE.md` — add `path resume` to the CLI usage list (next to `path + share`); add a "Things to know" bullet describing the + resolve→pick→project→exec flow and `-C` semantics. +- `README.md` — one-line mention in the workspace listing. +- `crates/path-cli/src/cmd_resume.rs` — module-level rustdoc covering + inputs, resolution order, harness picker, and exec semantics. Same + density as the doc comment at the top of `cmd_share.rs` and + `cmd_export.rs`. +- `cmd_export.rs` — adjust module rustdoc to mention that the `Resume + with: …` lines now come from a shared `ResumeRecipe`. +- Site (`site/`) — no new page; `path resume` gets one bullet wherever + the CLI surface is enumerated. + +## Versioning + +- `path-cli` minor bump (additive command + new `pub` type + `ResumeRecipe`). Update `crates/path-cli/Cargo.toml`, + `[workspace.dependencies]` in root `Cargo.toml`, + `site/_data/crates.json`, and add a `CHANGELOG.md` entry. +- `toolpath-cli` shim follows along (no version bump needed). +- No bumps for the `toolpath-*` provider crates. + +## Open questions + +None blocking. Future: + +- A git-aware mode that, given a doc with a `path.base`, offers to + clone/fetch and check out the recorded ref before projection. Would + need its own scope discussion. +- File-artifact replay onto the working tree, gated behind an explicit + flag because of clobber risk. +- Multi-path resume from a `Graph` (interactive sub-pick or a + `--path-id` flag). +- A `--browse` flag that, instead of exec'ing the harness, opens the + doc in the desktop `pathbase-app` if installed. From c02c1c7a60e94af0c5cf660953453a5a0cfb2c0a Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 14:33:40 -0400 Subject: [PATCH 02/21] docs: implementation plan for path resume command Bite-sized TDD tasks covering the ResumeRecipe refactor of cmd_export.rs, the new cmd_resume.rs module (resolve_input, ensure_path_with_agent, pick_harness, exec_harness with injectable strategy), pathbase fetch helper extraction, integration tests, docs, and version bump. --- .../plans/2026-05-08-path-resume-command.md | 2751 +++++++++++++++++ 1 file changed, 2751 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-08-path-resume-command.md diff --git a/docs/superpowers/plans/2026-05-08-path-resume-command.md b/docs/superpowers/plans/2026-05-08-path-resume-command.md new file mode 100644 index 0000000..6bacbe4 --- /dev/null +++ b/docs/superpowers/plans/2026-05-08-path-resume-command.md @@ -0,0 +1,2751 @@ +# `path resume` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `path resume ` — fetches/loads a Toolpath document, picks a coding-agent harness (interactive picker by default, `--harness X` to skip), projects the session into the harness's on-disk layout in a chosen cwd, then execs the harness's resume command. + +**Architecture:** New `cmd_resume.rs` module mirroring `cmd_share.rs`. Reuses the per-harness projection helpers in `cmd_export.rs` after a small refactor that has each project-mode writer return a `ResumeRecipe { binary, args, session_id, cwd_for_recipe }`. The CLI surface for `path export --project P` is unchanged; the new code path consumes the recipe directly and feeds it to an injectable `ExecStrategy` (the binary plugs in `execvp`; tests plug in a recorder). + +**Tech Stack:** Rust 2024, clap, anyhow, `toolpath_*` workspace crates, existing `crate::fzf` helper, `cmd_share::Harness` enum, `pathbase-client`. New types are `pub` only where the desktop app might consume them later. + +**Spec reference:** `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`. + +--- + +## Type and API quick reference + +The plan's code samples lean on these existing types and functions. Cross-check against the source before writing tests — the names below are what's actually in the repo as of branch `akesling/resume`. + +```rust +// crates/toolpath/src/types.rs +pub struct Path { + pub path: PathIdentity, // { id, base: Option, head, graph_ref } + pub steps: Vec, // not Vec — push Step directly + pub meta: Option, +} + +pub struct PathMeta { + pub source: Option, // "claude-code" / "gemini-cli" / "codex" / "opencode" / "pi" + // … +} + +pub struct Step { + pub step: StepIdentity, // { id, parents, actor, timestamp } + pub change: HashMap, + pub meta: Option, +} + +// Builder pattern — preferred in tests +Step::new(id, actor, timestamp) + .with_raw_change("a.txt", "@@ -1 +1 @@\n-old\n+new") + .with_intent("…"); + +Path::new(id, /* base */ None::, /* head */ "s1"); + +// Universal parse / build +Graph::from_json(&json)?; // never parses to a Step or bare Path +Graph::from_path(path); // single-inline-path Graph constructor +graph.into_single_path(); // Option +graph.single_path(); // Option<&Path> +``` + +**There is no `Document` enum.** `Graph::from_json` is the universal entry point — every cache file, every Pathbase response, every Toolpath JSON parses as a `Graph`. Single-path-graphs are the closest thing to a "Path document"; `into_single_path` unwraps them. The plan validates everything as a `Graph` (see Task 8). + +**`path.meta.source` access pattern** (because `meta: Option`): + +```rust +path.meta.as_ref().and_then(|m| m.source.as_deref()) +``` + +**`fzf` module API** (`crates/path-cli/src/fzf.rs`): + +```rust +pub fn available() -> bool; +pub fn pick(lines: &[String], opts: &PickOptions<'_>) -> Result; + +pub enum PickResult { + Selected(String), + NoMatch, + Cancelled, +} + +pub struct PickOptions<'a> { + pub header: Option<&'a str>, + // … (read the source for the full set; defaults usually suffice) +} +``` + +**Idiomatic test fixture** (mirrors `cmd_merge.rs::tests::make_path` / `make_step`): + +```rust +fn make_step(id: &str, actor: &str) -> toolpath::v1::Step { + toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z") + .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new") +} + +fn make_path_with_actor(actor: &str) -> toolpath::v1::Path { + use toolpath::v1::{Path, PathIdentity}; + let step = make_step("s1", actor); + Path { + path: PathIdentity { + id: "p1".to_string(), + base: None, + head: "s1".to_string(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + } +} +``` + +Whenever a task below refers to `path_with_actor(...)` or `make_minimal__path()`, the body is the snippet above with `actor` substituted. Each task lists the actor explicitly. + +--- + +## File Structure + +**New:** +- `crates/path-cli/src/cmd_resume.rs` — module: `ResumeArgs`, `ResumeRecipe` re-export, orchestration, `resolve_input`, `infer_source_harness`, `ensure_path_with_agent`, `pick_harness`, `exec_harness`, picker. +- `crates/path-cli/tests/resume.rs` — integration tests with injectable exec strategy. + +**Modified:** +- `crates/path-cli/src/cmd_export.rs` — add `pub struct ResumeRecipe`; change `write_into_claude_project`, `write_into_gemini_project`, `write_into_codex_project`, `write_into_opencode_db`, `write_into_pi_project` to return `Result`; have each `run_`'s project-mode arm format the recipe to stderr (preserving current output). +- `crates/path-cli/src/cmd_import.rs` — extract a `pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Result` from the inner block of `derive_pathbase`. `derive_pathbase` becomes a one-line wrapper. +- `crates/path-cli/src/lib.rs` — add `Commands::Resume { args: cmd_resume::ResumeArgs }`; wire dispatch. +- `crates/path-cli/Cargo.toml` — minor version bump (`0.8.0` → `0.9.0`). +- `Cargo.toml` (root) — `[workspace.dependencies]` `path-cli` version bump. +- `site/_data/crates.json` — `path-cli` version bump. +- `CHANGELOG.md` — new entry. +- `CLAUDE.md` — CLI usage block + "Things to know" bullet. +- `README.md` — one-line mention. + +--- + +## Task 1: Introduce `ResumeRecipe` and refactor Claude project-mode writer + +**Files:** +- Modify: `crates/path-cli/src/cmd_export.rs:230` (add type near `PathbaseUploadArgs`) +- Modify: `crates/path-cli/src/cmd_export.rs:255-268` (run_claude project arm) and `crates/path-cli/src/cmd_export.rs:321-342` (write_into_claude_project) +- Test: `crates/path-cli/src/cmd_export.rs` (inline `#[cfg(test)] mod tests`) + +- [ ] **Step 1: Write the failing test** + +Append to the existing tests module in `cmd_export.rs` (find it near the bottom of the file under `#[cfg(test)] mod tests {`): + +```rust +#[test] +#[cfg(not(target_os = "emscripten"))] +fn write_into_claude_project_returns_recipe() { + let tmp = tempfile::tempdir().unwrap(); + let path = make_path_with_actor("agent:claude-code"); // see "Type and API quick reference" + + let conv = build_claude_conversation(&path).unwrap(); + let jsonl = serialize_jsonl(&conv).unwrap(); + let recipe = write_into_claude_project(&conv, &jsonl, tmp.path()).unwrap(); + + assert_eq!(recipe.binary, "claude"); + assert_eq!(recipe.args, vec!["-r".to_string(), conv.session_id.clone()]); + assert_eq!(recipe.session_id, conv.session_id); + assert_eq!(recipe.cwd_for_recipe, std::fs::canonicalize(tmp.path()).unwrap()); +} +``` + +If `make_path_with_actor` and `make_step` aren't already in scope, add them to the test module — they're used throughout the plan's tests. Crib the bodies from the "Type and API quick reference" section above (or copy `cmd_merge.rs::tests::{make_step, make_path}` directly). + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p path-cli --lib write_into_claude_project_returns_recipe +``` + +Expected: FAIL — `write_into_claude_project` currently returns `Result`, not `Result`. + +- [ ] **Step 3: Add the `ResumeRecipe` type** + +Insert near `PathbaseUploadArgs` (around `cmd_export.rs:230`): + +```rust +/// What `path resume` needs to launch a harness's interactive resume +/// after a successful project-mode export. Returned by every +/// `write_into__project` helper. +#[cfg(not(target_os = "emscripten"))] +#[derive(Debug, Clone)] +pub struct ResumeRecipe { + /// Binary name as it appears on PATH (e.g. `"claude"`, `"codex"`). + pub binary: &'static str, + /// Argv after the binary name (e.g. `["-r", ""]`). + pub args: Vec, + /// Session id the recipe targets. Convenience accessor — also + /// embedded in `args` when relevant. + pub session_id: String, + /// Directory the harness must be invoked from. Already canonicalized. + pub cwd_for_recipe: std::path::PathBuf, +} +``` + +- [ ] **Step 4: Refactor `write_into_claude_project` to return the recipe** + +Replace the existing function body: + +```rust +#[cfg(not(target_os = "emscripten"))] +fn write_into_claude_project( + conv: &toolpath_claude::Conversation, + jsonl: &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 project_path = project_dir.to_string_lossy(); + + let resolver = toolpath_claude::PathResolver::new(); + let claude_project_dir = resolver + .project_dir(&project_path) + .map_err(|e| anyhow::anyhow!("Cannot resolve Claude project dir: {}", e))?; + + std::fs::create_dir_all(&claude_project_dir) + .with_context(|| format!("create {}", claude_project_dir.display()))?; + + let session_id = conv.session_id.clone(); + let out_path = claude_project_dir.join(format!("{}.jsonl", session_id)); + std::fs::write(&out_path, jsonl).with_context(|| format!("write {}", out_path.display()))?; + + Ok(ResumeRecipe { + binary: "claude", + args: vec!["-r".to_string(), session_id.clone()], + session_id, + cwd_for_recipe: project_dir, + }) +} +``` + +- [ ] **Step 5: Update `run_claude`'s project arm to print from the recipe** + +In `run_claude` (around line 255), the `(Some(project_dir), None)` branch becomes: + +```rust +(Some(project_dir), None) => { + let recipe = write_into_claude_project(&conversation, &jsonl, &project_dir)?; + let session_id = &recipe.session_id; + eprintln!( + "Exported session {} ({} entries) → {}", + session_id, + conversation.preamble.len() + conversation.entries.len(), + recipe.cwd_for_recipe.display() + ); + eprintln!(); + eprintln!("Resume with:"); + eprintln!( + " cd {} && {} {}", + recipe.cwd_for_recipe.display(), + recipe.binary, + recipe.args.join(" ") + ); +} +``` + +(Note: the existing message says `"Exported session ... → "` showing the JSONL filename. Switch to `recipe.cwd_for_recipe` so the recipe-print is self-contained — the file path is implied by the harness's resolver and isn't useful to the user.) + +- [ ] **Step 6: Run test to verify it passes** + +```bash +cargo test -p path-cli --lib write_into_claude_project_returns_recipe +``` + +Expected: PASS. + +- [ ] **Step 7: Run the full export tests to confirm no regressions** + +```bash +cargo test -p path-cli --lib cmd_export +``` + +Expected: all pass. + +- [ ] **Step 8: Commit** + +```bash +git add crates/path-cli/src/cmd_export.rs +git commit -m "refactor(path-cli): return ResumeRecipe from claude project-mode export" +``` + +--- + +## Task 2: Refactor Gemini project-mode writer to return `ResumeRecipe` + +**Files:** +- Modify: `crates/path-cli/src/cmd_export.rs:407-441` (write_into_gemini_project) and the caller `run_gemini` (~line 368) +- Test: `crates/path-cli/src/cmd_export.rs` (inline tests module) + +- [ ] **Step 1: Write the failing test** + +```rust +#[test] +#[cfg(not(target_os = "emscripten"))] +fn write_into_gemini_project_returns_recipe() { + let tmp = tempfile::tempdir().unwrap(); + let project_path = tmp.path().to_string_lossy().to_string(); + + let path = make_path_with_actor("agent:gemini-cli"); + let view = toolpath_convo::extract_conversation(&path); + let project_hash = toolpath_gemini::paths::project_hash(&project_path); + let projector = toolpath_gemini::project::GeminiProjector::new() + .with_project_hash(project_hash) + .with_project_path(project_path.clone()); + let conv = projector.project(&view).unwrap(); + + let recipe = write_into_gemini_project(&conv, &project_path).unwrap(); + + assert_eq!(recipe.binary, "gemini"); + assert_eq!(recipe.args, vec!["--resume".to_string(), conv.session_uuid.clone()]); + assert_eq!(recipe.session_id, conv.session_uuid); + assert_eq!(recipe.cwd_for_recipe, std::path::PathBuf::from(&project_path)); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p path-cli --lib write_into_gemini_project_returns_recipe +``` + +Expected: FAIL — current return type is `Result<()>`. + +- [ ] **Step 3: Refactor `write_into_gemini_project`** + +Replace the body (lines 407-441): + +```rust +#[cfg(not(target_os = "emscripten"))] +fn write_into_gemini_project( + conversation: &toolpath_gemini::types::Conversation, + project_path: &str, +) -> Result { + let resolver = toolpath_gemini::PathResolver::new(); + let chats_dir = resolver + .chats_dir(project_path) + .map_err(|e| anyhow::anyhow!("Cannot resolve Gemini chats dir: {}", e))?; + std::fs::create_dir_all(&chats_dir) + .with_context(|| format!("create {}", chats_dir.display()))?; + + if let Some(slot_dir) = chats_dir.parent() { + let marker = slot_dir.join(".project_root"); + if !marker.exists() { + let _ = std::fs::write(&marker, format!("{}\n", project_path)); + } + } + + let main_stem = gemini_main_stem(conversation); + let main_path = chats_dir.join(format!("{}.json", main_stem)); + let written = write_main_and_subs(conversation, &main_path)?; + + print_summary(conversation, &written, &chats_dir); + + Ok(ResumeRecipe { + binary: "gemini", + args: vec!["--resume".to_string(), conversation.session_uuid.clone()], + session_id: conversation.session_uuid.clone(), + cwd_for_recipe: std::path::PathBuf::from(project_path), + }) +} +``` + +- [ ] **Step 4: Update `run_gemini` project arm to print from the recipe** + +In `run_gemini`'s match (around line 368), the `(Some(_), None)` branch becomes: + +```rust +(Some(_), None) => { + let recipe = write_into_gemini_project(&conversation, &project_path)?; + eprintln!(); + eprintln!("Resume with:"); + eprintln!( + " cd {} && {} {}", + recipe.cwd_for_recipe.display(), + recipe.binary, + recipe.args.join(" ") + ); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +cargo test -p path-cli --lib write_into_gemini_project_returns_recipe +``` + +Expected: PASS. + +- [ ] **Step 6: Run gemini export tests** + +```bash +cargo test -p path-cli --lib gemini +``` + +Expected: pass (in particular `gemini_writes_resume_ready_layout`). + +- [ ] **Step 7: Commit** + +```bash +git add crates/path-cli/src/cmd_export.rs +git commit -m "refactor(path-cli): return ResumeRecipe from gemini project-mode export" +``` + +--- + +## Task 3: Refactor Codex project-mode writer to return `ResumeRecipe` + +**Files:** +- Modify: `crates/path-cli/src/cmd_export.rs:765-815` (write_into_codex_project) and run_codex caller (~line 732) +- Test: `crates/path-cli/src/cmd_export.rs` (inline tests module) + +- [ ] **Step 1: Write the failing test** + +```rust +#[test] +#[cfg(not(target_os = "emscripten"))] +fn write_into_codex_project_returns_recipe() { + let _home = scoped_home(tempfile::tempdir().unwrap()); // see Step 2 + let path = make_path_with_actor("agent:codex"); + let session = build_codex_session_for_test(&path, "/tmp/x"); + let recipe = write_into_codex_project(&session).unwrap(); + + assert_eq!(recipe.binary, "codex"); + assert_eq!(recipe.args, vec!["resume".to_string(), session.id.clone()]); + assert_eq!(recipe.session_id, session.id); + // codex resume reads state_5.sqlite, so cwd doesn't matter for invocation; + // the recipe records cwd as the recorded session cwd for completeness. + assert_eq!(recipe.cwd_for_recipe, std::path::PathBuf::from("/tmp/x")); +} +``` + +- [ ] **Step 2: Add the `scoped_home` and codex fixture helpers** + +In the tests module, add (or extend if equivalents exist): + +```rust +#[cfg(not(target_os = "emscripten"))] +struct ScopedHome { _td: tempfile::TempDir, prev: Option } + +#[cfg(not(target_os = "emscripten"))] +fn scoped_home(td: tempfile::TempDir) -> ScopedHome { + let prev = std::env::var_os("HOME"); + // Safety: tests are single-threaded under `cargo test --test-threads=1` + // for this crate (see existing `cmd_pathbase` test pattern). If the + // crate ever flips to multi-threaded, replace with `serial_test`. + unsafe { std::env::set_var("HOME", td.path()); } + ScopedHome { _td: td, prev } +} + +#[cfg(not(target_os = "emscripten"))] +impl Drop for ScopedHome { + fn drop(&mut self) { + unsafe { + match &self.prev { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + } +} + +#[cfg(not(target_os = "emscripten"))] +fn build_codex_session_for_test(path: &toolpath::v1::Path, cwd: &str) -> toolpath_codex::Session { + use toolpath_convo::ConversationProjector; + let view = toolpath_convo::extract_conversation(path); + let projector = toolpath_codex::project::CodexProjector::new().with_cwd(cwd.to_string()); + projector.project(&view).unwrap() +} +``` + +(Verify whether the existing tests already define a `scoped_home`-like helper — if so, reuse it instead of duplicating.) + +- [ ] **Step 3: Run test to verify it fails** + +```bash +cargo test -p path-cli --lib write_into_codex_project_returns_recipe +``` + +Expected: FAIL — current return type is `Result<()>`. + +- [ ] **Step 4: Refactor `write_into_codex_project`** + +Find the existing body (line 765 onwards). Replace the trailing `eprintln!()` block + `Ok(())` with the recipe-returning shape: + +```rust +#[cfg(not(target_os = "emscripten"))] +fn write_into_codex_project(session: &toolpath_codex::Session) -> Result { + let session_ts = codex_session_timestamp(session)?; + let resolver = toolpath_codex::PathResolver::new(); + let sessions_root = resolver + .sessions_root() + .map_err(|e| anyhow::anyhow!("Cannot resolve Codex sessions dir: {}", e))?; + + let date_dir = sessions_root + .join(session_ts.format("%Y").to_string()) + .join(session_ts.format("%m").to_string()) + .join(session_ts.format("%d").to_string()); + std::fs::create_dir_all(&date_dir).with_context(|| format!("create {}", date_dir.display()))?; + + let stem = codex_rollout_stem(session, &session_ts); + let out_path = date_dir.join(format!("{}.jsonl", stem)); + let bytes = serialize_codex_jsonl(session)?; + std::fs::write(&out_path, &bytes).with_context(|| format!("write {}", out_path.display()))?; + + let codex_dir = resolver + .codex_dir() + .map_err(|e| anyhow::anyhow!("Cannot resolve ~/.codex dir: {}", e))?; + let registration = register_codex_thread(&codex_dir, session, &out_path, &session_ts); + + eprintln!( + "Exported Codex session {} ({} lines) → {}", + session.id, + session.lines.len(), + out_path.display() + ); + match registration { + Ok(true) => eprintln!(" registered in {}/state_5.sqlite", codex_dir.display()), + Ok(false) => eprintln!( + " warning: state_5.sqlite not found at {} — `codex resume` won't see this session", + codex_dir.display() + ), + Err(e) => eprintln!( + " warning: failed to register thread in state_5.sqlite: {} — `codex resume` may not see this session", + e + ), + } + + let recorded_cwd = session + .meta() + .map(|m| m.cwd.clone()) + .unwrap_or_else(|| std::path::PathBuf::from("/")); + + Ok(ResumeRecipe { + binary: "codex", + args: vec!["resume".to_string(), session.id.clone()], + session_id: session.id.clone(), + cwd_for_recipe: recorded_cwd, + }) +} +``` + +- [ ] **Step 5: Update `run_codex` project arm** + +In `run_codex` (around line 732), the `(Some(_), None)` branch becomes: + +```rust +(Some(_), None) => { + let recipe = write_into_codex_project(&session)?; + eprintln!(); + eprintln!("Loadable via:"); + eprintln!(" path import codex --session {}", recipe.session_id); + eprintln!(); + eprintln!("Open conversation with:"); + eprintln!(" {} {}", recipe.binary, recipe.args.join(" ")); +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +```bash +cargo test -p path-cli --lib write_into_codex_project_returns_recipe +``` + +Expected: PASS. + +- [ ] **Step 7: Run codex export tests** + +```bash +cargo test -p path-cli --lib codex +``` + +Expected: pass. + +- [ ] **Step 8: Commit** + +```bash +git add crates/path-cli/src/cmd_export.rs +git commit -m "refactor(path-cli): return ResumeRecipe from codex project-mode export" +``` + +--- + +## Task 4: Refactor opencode project-mode writer to return `ResumeRecipe` + +**Files:** +- Modify: `crates/path-cli/src/cmd_export.rs:1024-1076` (write_into_opencode_db) and run_opencode caller (~line 985) +- Test: inline tests module + +- [ ] **Step 1: Write the failing test** + +```rust +#[test] +#[cfg(not(target_os = "emscripten"))] +fn write_into_opencode_db_returns_recipe() { + let _home = scoped_home(tempfile::tempdir().unwrap()); + // Pre-create an empty opencode.db so the writer doesn't bail. + let db_dir = dirs::data_local_dir().unwrap().join("opencode"); + std::fs::create_dir_all(&db_dir).unwrap(); + let db_path = db_dir.join("opencode.db"); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + // Minimal schema — copy from `toolpath_opencode::schema::CREATE_SQL` + // or whatever the production schema bootstrap is. (See + // existing opencode tests for the helper, if any.) + toolpath_opencode::schema::apply_full_schema(&conn).unwrap(); + } + + let path = make_path_with_actor("agent:opencode"); + let session = build_opencode_session(&path, Some(std::path::Path::new("/tmp/x"))).unwrap(); + let project_dir = tempfile::tempdir().unwrap(); + let recipe = write_into_opencode_db(&session, project_dir.path()).unwrap(); + + assert_eq!(recipe.binary, "opencode"); + assert_eq!(recipe.args, vec!["--session".to_string(), session.id.clone()]); + assert_eq!(recipe.session_id, session.id); + assert_eq!( + recipe.cwd_for_recipe, + std::fs::canonicalize(project_dir.path()).unwrap() + ); +} +``` + +If `toolpath_opencode::schema::apply_full_schema` doesn't exist, locate the canonical bootstrap helper that existing opencode tests use (search `crates/toolpath-opencode/src/`) and substitute the right name. + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p path-cli --lib write_into_opencode_db_returns_recipe +``` + +Expected: FAIL — return type mismatch. + +- [ ] **Step 3: Refactor `write_into_opencode_db`** + +Replace the function body, swapping the two `eprintln!` "Loadable via:" / "Open conversation with:" blocks for a returned `ResumeRecipe`: + +```rust +#[cfg(not(target_os = "emscripten"))] +fn write_into_opencode_db( + session: &toolpath_opencode::Session, + project_dir: &std::path::Path, +) -> Result { + use toolpath_opencode::PathResolver; + + let project_dir = std::fs::canonicalize(project_dir) + .with_context(|| format!("resolve project path {}", project_dir.display()))?; + + let resolver = PathResolver::new(); + let db_path = resolver + .db_path() + .map_err(|e| anyhow::anyhow!("Cannot resolve opencode db path: {}", e))?; + if !db_path.exists() { + anyhow::bail!( + "opencode database not found at {} — has opencode been run on this machine?", + db_path.display() + ); + } + + let mut conn = rusqlite::Connection::open(&db_path) + .with_context(|| format!("open {}", db_path.display()))?; + let tx = conn.transaction()?; + + ensure_opencode_project(&tx, &session.project_id, &project_dir, session.time_created)?; + insert_opencode_session(&tx, session)?; + let mut message_count = 0_usize; + let mut part_count = 0_usize; + for message in &session.messages { + insert_opencode_message(&tx, message)?; + message_count += 1; + for part in &message.parts { + insert_opencode_part(&tx, part)?; + part_count += 1; + } + } + tx.commit()?; + + eprintln!( + "Exported opencode session {} ({} messages, {} parts) → {}", + session.id, + message_count, + part_count, + db_path.display() + ); + + Ok(ResumeRecipe { + binary: "opencode", + args: vec!["--session".to_string(), session.id.clone()], + session_id: session.id.clone(), + cwd_for_recipe: project_dir, + }) +} +``` + +**Verify the actual opencode resume invocation.** Read `crates/toolpath-opencode/README.md` or the opencode CLI's own help — if the canonical resume command is something other than `opencode --session `, replace `args` with the right shape. (Today's `eprintln!` says `opencode --session `, so that's the assumption baked in.) + +- [ ] **Step 4: Update `run_opencode` project arm** + +In `run_opencode` (around line 985), the `(Some(project_dir), None)` branch becomes: + +```rust +(Some(project_dir), None) => { + let session = build_opencode_session(&path, Some(&project_dir))?; + let recipe = write_into_opencode_db(&session, &project_dir)?; + eprintln!(); + eprintln!("Loadable via:"); + eprintln!(" path import opencode --session {}", recipe.session_id); + eprintln!(); + eprintln!("Open conversation with:"); + eprintln!(" {} {}", recipe.binary, recipe.args.join(" ")); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +cargo test -p path-cli --lib write_into_opencode_db_returns_recipe +``` + +Expected: PASS. + +- [ ] **Step 6: Run opencode export tests** + +```bash +cargo test -p path-cli --lib opencode +``` + +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add crates/path-cli/src/cmd_export.rs +git commit -m "refactor(path-cli): return ResumeRecipe from opencode project-mode export" +``` + +--- + +## Task 5: Refactor Pi project-mode writer to return `ResumeRecipe` + +**Files:** +- Modify: `crates/path-cli/src/cmd_export.rs:622-650` (write_into_pi_project) and run_pi caller (search for `run_pi` in the file) +- Test: inline tests module + +- [ ] **Step 1: Write the failing test** + +```rust +#[test] +#[cfg(not(target_os = "emscripten"))] +fn write_into_pi_project_returns_recipe() { + let _home = scoped_home(tempfile::tempdir().unwrap()); + let path = make_path_with_actor("agent:pi"); + let session = build_pi_session_for_test(&path, "/tmp/x"); + let recipe = write_into_pi_project(&session, "/tmp/x").unwrap(); + + assert_eq!(recipe.binary, "pi"); + assert_eq!(recipe.args, vec!["--session".to_string(), session.header.id.clone()]); + assert_eq!(recipe.session_id, session.header.id); + assert_eq!(recipe.cwd_for_recipe, std::path::PathBuf::from("/tmp/x")); +} + +#[cfg(not(target_os = "emscripten"))] +fn build_pi_session_for_test(path: &toolpath::v1::Path, cwd: &str) -> toolpath_pi::PiSession { + use toolpath_convo::ConversationProjector; + let view = toolpath_convo::extract_conversation(path); + let projector = toolpath_pi::project::PiProjector::new().with_cwd(cwd.to_string()); + projector.project(&view).unwrap() +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p path-cli --lib write_into_pi_project_returns_recipe +``` + +Expected: FAIL. + +- [ ] **Step 3: Refactor `write_into_pi_project`** + +```rust +#[cfg(not(target_os = "emscripten"))] +fn write_into_pi_project(session: &toolpath_pi::PiSession, cwd: &str) -> Result { + let resolver = toolpath_pi::PathResolver::new(); + let project_dir = resolver.project_dir(cwd); + std::fs::create_dir_all(&project_dir) + .with_context(|| format!("create {}", project_dir.display()))?; + + let stem = pi_session_stem(session); + let out_path = project_dir.join(format!("{}.jsonl", stem)); + let bytes = serialize_pi_jsonl(session)?; + std::fs::write(&out_path, &bytes).with_context(|| format!("write {}", out_path.display()))?; + + let entry_count = session.entries.len().saturating_sub(1); + eprintln!( + "Exported Pi session {} ({} entries) → {}", + session.header.id, + entry_count, + out_path.display() + ); + + Ok(ResumeRecipe { + binary: "pi", + args: vec!["--session".to_string(), session.header.id.clone()], + session_id: session.header.id.clone(), + cwd_for_recipe: std::path::PathBuf::from(cwd), + }) +} +``` + +- [ ] **Step 4: Update `run_pi` project arm** + +Find the `(Some(_), None)` branch in `run_pi`, replace with: + +```rust +(Some(_), None) => { + let recipe = write_into_pi_project(&session, &cwd_str)?; + eprintln!(); + eprintln!("Loadable via:"); + eprintln!( + " path import pi --session {} --project {}", + recipe.session_id, + recipe.cwd_for_recipe.display() + ); + eprintln!(); + eprintln!("Open conversation with:"); + eprintln!(" {} {}", recipe.binary, recipe.args.join(" ")); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +cargo test -p path-cli --lib write_into_pi_project_returns_recipe +``` + +Expected: PASS. + +- [ ] **Step 6: Run pi export tests** + +```bash +cargo test -p path-cli --lib pi +``` + +Expected: pass (in particular `pi_writes_resume_ready_layout`). + +- [ ] **Step 7: Commit** + +```bash +git add crates/path-cli/src/cmd_export.rs +git commit -m "refactor(path-cli): return ResumeRecipe from pi project-mode export" +``` + +--- + +## Task 6: Extract `pathbase_fetch_to_doc` from `cmd_import.rs` + +**Files:** +- Modify: `crates/path-cli/src/cmd_import.rs:1362-1388` (derive_pathbase) + +- [ ] **Step 1: Write the failing test** + +In `cmd_import.rs`'s tests module (or in a new `#[cfg(test)] mod pathbase_fetch_tests` block adjacent to it), add: + +```rust +#[test] +#[cfg(not(target_os = "emscripten"))] +fn pathbase_fetch_to_doc_url_input() { + use crate::cmd_pathbase::tests::MockServer; + let body = r#"{"Path":{"id":"p1","actor":"agent:claude-code","steps":[]}}"#; + let server = MockServer::start("HTTP/1.1 200 OK", body); + let url = format!("{}/alex/pathstash/my-path", server.base()); + + let derived = pathbase_fetch_to_doc(&url, None).unwrap(); + + assert_eq!(derived.cache_id, "pathbase-alex-pathstash-my-path"); + assert!(derived.doc.into_single_path().is_some()); +} +``` + +If `cmd_pathbase::tests::MockServer` is not yet `pub(crate)`, this test will fail to compile — Step 3 below adds the visibility. + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p path-cli --lib pathbase_fetch_to_doc_url_input +``` + +Expected: FAIL — `pathbase_fetch_to_doc` doesn't exist; possibly also `MockServer` isn't pub(crate). + +- [ ] **Step 3: Make `MockServer` reachable from sibling tests** + +In `crates/path-cli/src/cmd_pathbase.rs`, change the existing test module declaration so the helper is reachable from sibling test modules: + +```rust +#[cfg(test)] +pub(crate) mod tests { + // (existing contents unchanged; the only change is `pub(crate)` and + // promoting `MockServer` + its `impl` block to `pub(crate)`.) + pub(crate) struct MockServer { /* ... */ } + impl MockServer { + pub(crate) fn start(/* ... */) -> Self { /* ... */ } + pub(crate) fn base(&self) -> String { /* ... */ } + // ... + } +} +``` + +Promote only the items the new test needs. Existing tests inside the module continue to work unchanged. + +- [ ] **Step 4: Extract the function** + +Replace `derive_pathbase`'s body (lines 1362-1388) with a wrapper, and add the extracted helper just above it: + +```rust +/// Fetch a Pathbase ref (`https://host/owner/repo/slug` URL or bare +/// `owner/repo/slug` triple) and parse it as a toolpath document. Used +/// by `path import pathbase` and by `path resume `. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Result { + use crate::cmd_pathbase::{credentials_path, load_session, paths_download, resolve_url}; + + let (base, ref_) = parse_pathbase_ref(target, url_flag)?; + let stored = load_session(&credentials_path()?)?; + let base_url = base + .or_else(|| stored.as_ref().map(|s| s.url.clone())) + .unwrap_or_else(|| resolve_url(None)); + + let token = stored.as_ref().map(|s| s.token.as_str()); + + let PathRef { owner, repo, slug } = ref_; + let body = paths_download(&base_url, token, &owner, &repo, &slug)?; + let cache_id = make_id("pathbase", &format!("{owner}-{repo}-{slug}")); + let doc = Graph::from_json(&body) + .map_err(|e| anyhow::anyhow!("server returned a non-toolpath document: {e}"))?; + Ok(DerivedDoc { cache_id, doc }) +} + +fn derive_pathbase(target: String, url_flag: Option) -> Result> { + #[cfg(target_os = "emscripten")] + { + let _ = (target, url_flag); + anyhow::bail!("'path import pathbase' requires a native environment with network access"); + } + + #[cfg(not(target_os = "emscripten"))] + { + Ok(vec![pathbase_fetch_to_doc(&target, url_flag.as_deref())?]) + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +```bash +cargo test -p path-cli --lib pathbase_fetch_to_doc_url_input +``` + +Expected: PASS. + +- [ ] **Step 6: Run all import tests** + +```bash +cargo test -p path-cli --lib cmd_import +``` + +Expected: pass. + +- [ ] **Step 7: Commit** + +```bash +git add crates/path-cli/src/cmd_import.rs crates/path-cli/src/cmd_pathbase.rs +git commit -m "refactor(path-cli): extract pathbase_fetch_to_doc helper" +``` + +--- + +## Task 7: Scaffold `cmd_resume.rs` — types, args, lib.rs wiring + +**Files:** +- Create: `crates/path-cli/src/cmd_resume.rs` +- Modify: `crates/path-cli/src/lib.rs:45-180` (Commands enum + dispatch) + +- [ ] **Step 1: Write a stub failing test** + +Create `crates/path-cli/src/cmd_resume.rs`: + +```rust +//! `path resume` — fetch / load a Toolpath document and exec a coding +//! agent's resume command after projecting the session into the +//! harness's on-disk layout. +//! +//! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`. + +#![cfg(not(target_os = "emscripten"))] + +use anyhow::Result; +use clap::Args; +use std::path::PathBuf; + +use crate::cmd_share::HarnessArg; + +#[derive(Args, Debug)] +pub struct ResumeArgs { + /// Toolpath document to resume from. Accepted shapes: a Pathbase + /// URL (`https://host/owner/repo/slug`), a bare Pathbase shorthand + /// (`owner/repo/slug`), a path to a local toolpath JSON file, or a + /// cache id (e.g. `claude-abc`, `pathbase-foo-bar-baz`). + pub input: String, + + /// Working directory to run the resumed harness from. Defaults to + /// the current shell cwd. The on-disk projection is keyed on this + /// directory and the harness will be exec'd with cwd set to it. + #[arg(short = 'C', long)] + pub cwd: Option, + + /// Pin the resume target. Skips the interactive picker. + #[arg(long, value_enum)] + pub harness: Option, + + /// Skip writing the cache when fetching from Pathbase. + #[arg(long)] + pub no_cache: bool, + + /// Overwrite an existing cache entry when fetching from Pathbase. + #[arg(long)] + pub force: bool, + + /// Pathbase server URL. Falls back to the stored session's URL, + /// then `$PATHBASE_URL`, then `https://pathbase.dev`. + #[arg(long)] + pub url: Option, +} + +pub fn run(_args: ResumeArgs) -> Result<()> { + anyhow::bail!("path resume: not yet implemented") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_returns_not_implemented_until_wired() { + let args = ResumeArgs { + input: "irrelevant".to_string(), + cwd: None, + harness: None, + no_cache: false, + force: false, + url: None, + }; + let err = run(args).unwrap_err(); + assert!(err.to_string().contains("not yet implemented")); + } +} +``` + +- [ ] **Step 2: Wire into `lib.rs`** + +In `crates/path-cli/src/lib.rs`: + +a) Add the module declaration near the other `mod cmd_*;` lines (top of the file): + +```rust +mod cmd_resume; +``` + +b) Add a new variant to the `Commands` enum (around line 121, next to `Share`): + +```rust +/// Resume an agent session into the chosen harness, projecting the +/// document and exec'ing the harness's resume command. +Resume { + #[command(flatten)] + args: cmd_resume::ResumeArgs, +}, +``` + +c) Add the dispatch arm in `run()` (around line 170, next to `Commands::Share`): + +```rust +Commands::Resume { args } => cmd_resume::run(args), +``` + +- [ ] **Step 3: Run the stub test** + +```bash +cargo test -p path-cli --lib cmd_resume::tests::run_returns_not_implemented_until_wired +``` + +Expected: PASS. + +- [ ] **Step 4: Verify the CLI surface** + +```bash +cargo run -p path-cli -- resume --help +``` + +Expected output (substring): `Toolpath document to resume from`, `-C`, `--harness`, `--no-cache`, `--force`, `--url`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/path-cli/src/cmd_resume.rs crates/path-cli/src/lib.rs +git commit -m "feat(path-cli): scaffold path resume command (stub)" +``` + +--- + +## Task 8: Implement `infer_source_harness` and `ensure_path_with_agent` + +**Files:** +- Modify: `crates/path-cli/src/cmd_resume.rs` + +- [ ] **Step 1: Write the failing tests** + +Append to `cmd_resume.rs`'s tests module. There is no `Document` enum in this codebase — every parse goes through `Graph::from_json`, so validation operates on `Graph`. A "Path document" surfaces as a `Graph` with exactly one inline path. + +```rust +use crate::cmd_share::Harness; +use toolpath::v1::{Graph, PathMeta, PathOrRef}; +// `make_path_with_actor` and `make_step` come from the type-reference snippet +// at the top of this plan. + +fn graph_of(path: toolpath::v1::Path) -> Graph { + Graph::from_path(path) +} + +#[test] +fn infer_source_harness_meta_source_wins() { + let mut path = make_path_with_actor("agent:codex"); // actor sniff would say codex… + path.meta = Some(PathMeta { + source: Some("claude-code".to_string()), + ..Default::default() + }); + assert_eq!(infer_source_harness(&path), Some(Harness::Claude)); +} + +#[test] +fn infer_source_harness_meta_source_unknown_falls_through_to_actor() { + let mut path = make_path_with_actor("agent:gemini-cli"); + path.meta = Some(PathMeta { + source: Some("something-bespoke".to_string()), + ..Default::default() + }); + assert_eq!(infer_source_harness(&path), Some(Harness::Gemini)); +} + +#[test] +fn infer_source_harness_actor_sniff_codex() { + let path = make_path_with_actor("agent:codex"); + assert_eq!(infer_source_harness(&path), Some(Harness::Codex)); +} + +#[test] +fn infer_source_harness_actor_sniff_opencode() { + let path = make_path_with_actor("agent:opencode"); + assert_eq!(infer_source_harness(&path), Some(Harness::Opencode)); +} + +#[test] +fn infer_source_harness_actor_sniff_pi() { + let path = make_path_with_actor("agent:pi"); + assert_eq!(infer_source_harness(&path), Some(Harness::Pi)); +} + +#[test] +fn infer_source_harness_returns_none_when_no_signal() { + let path = make_path_with_actor("human:alex"); + assert_eq!(infer_source_harness(&path), None); +} + +#[test] +fn ensure_path_with_agent_accepts_single_path_with_agent_actor() { + let g = graph_of(make_path_with_actor("agent:claude-code")); + assert!(ensure_path_with_agent(&g).is_ok()); +} + +#[test] +fn ensure_path_with_agent_rejects_empty_graph() { + let g = Graph::from_path(make_path_with_actor("agent:claude-code")); // start with one + let mut g = g; + g.paths.clear(); + let err = ensure_path_with_agent(&g).unwrap_err(); + assert!(err.to_string().contains("expected")); + assert!(err.to_string().contains("empty")); +} + +#[test] +fn ensure_path_with_agent_rejects_multi_path_graph() { + use toolpath::v1::PathOrRef; + let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); + g.paths.push(PathOrRef::Path(Box::new(make_path_with_actor("agent:claude-code")))); + let err = ensure_path_with_agent(&g).unwrap_err(); + let s = err.to_string(); + assert!(s.contains("single `Path`"), "actual: {s}"); + assert!(s.contains("2 paths"), "actual: {s}"); +} + +#[test] +fn ensure_path_with_agent_rejects_agentless_path() { + let g = graph_of(make_path_with_actor("human:alex")); + let err = ensure_path_with_agent(&g).unwrap_err(); + assert!(err.to_string().contains("no agent session")); +} + +#[test] +fn ensure_path_with_agent_rejects_path_ref_only_graph() { + use toolpath::v1::{PathOrRef, PathRef}; + let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); + g.paths = vec![PathOrRef::Ref(PathRef { ref_url: "$ref://something".into() })]; + let err = ensure_path_with_agent(&g).unwrap_err(); + assert!(err.to_string().contains("inline `Path`"), "actual: {}", err); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p path-cli --lib cmd_resume::tests +``` + +Expected: FAIL — `infer_source_harness` and `ensure_path_with_agent` not defined. + +- [ ] **Step 3: Implement the two helpers** + +Append to `cmd_resume.rs` above the `mod tests` block: + +```rust +use toolpath::v1::{Graph, Path as TPath, PathOrRef}; + +/// Read a path's source harness from `meta.source` (set by +/// `toolpath-convo::derive_path` to the provider id), falling back to +/// actor-string sniffing across the path's steps. +pub(crate) fn infer_source_harness(path: &TPath) -> Option { + let meta_source = path.meta.as_ref().and_then(|m| m.source.as_deref()); + if let Some(source) = meta_source { + match source { + "claude-code" => return Some(Harness::Claude), + "gemini-cli" => return Some(Harness::Gemini), + "codex" => return Some(Harness::Codex), + "opencode" => return Some(Harness::Opencode), + "pi" => return Some(Harness::Pi), + _ => {} // fall through to actor sniffing + } + } + for step in &path.steps { + let actor = &step.step.actor; + if actor.starts_with("agent:claude-code") { + return Some(Harness::Claude); + } + if actor.starts_with("agent:gemini-cli") || actor.starts_with("agent:gemini") { + return Some(Harness::Gemini); + } + if actor.starts_with("agent:codex") { + return Some(Harness::Codex); + } + if actor.starts_with("agent:opencode") { + return Some(Harness::Opencode); + } + if actor.starts_with("agent:pi") { + return Some(Harness::Pi); + } + } + None +} + +/// Validate that a parsed Toolpath document is a single inline Path +/// carrying at least one `agent:*` actor. Returns the inner Path borrow +/// on success. +pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { + if g.paths.is_empty() { + anyhow::bail!("resume needs a `Path`; expected one path, got an empty graph"); + } + if g.paths.len() > 1 { + anyhow::bail!( + "resume needs a single `Path`; input is a graph with {} paths. \ + Pick one with `path query …` or split first.", + g.paths.len() + ); + } + let path = match &g.paths[0] { + PathOrRef::Path(p) => p.as_ref(), + PathOrRef::Ref(_) => anyhow::bail!( + "resume needs an inline `Path`; got a $ref. Resolve it first with `path import` or fetch the document." + ), + }; + let has_agent = path + .steps + .iter() + .any(|s| s.step.actor.starts_with("agent:")); + if !has_agent { + anyhow::bail!( + "no agent session in input — `path resume` only works on harness-derived paths" + ); + } + Ok(path) + } + } +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cargo test -p path-cli --lib cmd_resume::tests +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(path-cli): infer_source_harness and ensure_path_with_agent" +``` + +--- + +## Task 9: Implement `resolve_input` + +**Files:** +- Modify: `crates/path-cli/src/cmd_resume.rs` + +- [ ] **Step 1: Write the failing tests** + +```rust +#[test] +fn resolve_input_file_path() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("doc.json"); + let graph = toolpath::v1::Graph::from_path(make_path_with_actor("agent:claude-code")); + std::fs::write(&p, graph.to_json().unwrap()).unwrap(); + + let args = ResumeArgs { + input: p.to_string_lossy().to_string(), + cwd: None, harness: None, no_cache: false, force: false, url: None, + }; + let (g, harness) = resolve_input(&args).unwrap(); + let _path = ensure_path_with_agent(&g).unwrap(); + assert_eq!(harness, Some(Harness::Claude)); +} + +#[test] +fn resolve_input_url_dispatches_to_pathbase_fetch() { + use crate::cmd_pathbase::tests::MockServer; + let body = { + let mut path = make_path_with_actor("agent:codex"); + path.meta = Some(toolpath::v1::PathMeta { + source: Some("codex".to_string()), + ..Default::default() + }); + toolpath::v1::Graph::from_path(path).to_json().unwrap() + }; + let server = MockServer::start("HTTP/1.1 200 OK", &body); + + let args = ResumeArgs { + input: format!("{}/alex/pathstash/p", server.base()), + cwd: None, harness: None, no_cache: true, // skip cache write in tests + force: false, url: None, + }; + let (g, harness) = resolve_input(&args).unwrap(); + let _ = ensure_path_with_agent(&g).unwrap(); + assert_eq!(harness, Some(Harness::Codex)); +} + +#[test] +fn resolve_input_unresolvable_errors_clearly() { + let args = ResumeArgs { + input: "definitely/not/a/real/cache/id".to_string(), + cwd: None, harness: None, no_cache: false, force: false, url: None, + }; + let err = resolve_input(&args).unwrap_err(); + let s = err.to_string(); + assert!(s.contains("couldn't resolve"), "actual: {s}"); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p path-cli --lib resolve_input +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement `resolve_input`** + +Append to `cmd_resume.rs`: + +```rust +/// Resolve the user-supplied `` argument into a parsed `Graph` +/// plus the source harness inferred from its single inline path (if +/// any). See spec for the resolution order. +pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option)> { + let raw = args.input.as_str(); + + enum Shape<'a> { + PathbaseUrl(&'a str), + PathbaseShorthand(&'a str), + FilePath(&'a str), + CacheId(&'a str), + } + + let shape = if raw.starts_with("http://") || raw.starts_with("https://") { + Shape::PathbaseUrl(raw) + } else if looks_like_pathbase_shorthand(raw) { + Shape::PathbaseShorthand(raw) + } else if std::path::Path::new(raw).is_file() { + Shape::FilePath(raw) + } else { + Shape::CacheId(raw) + }; + + let graph: Graph = match shape { + Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => { + let derived = crate::cmd_import::pathbase_fetch_to_doc(u, args.url.as_deref())?; + if !args.no_cache { + crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, args.force)?; + eprintln!("Resolved {} → {}", raw, derived.cache_id); + } + derived.doc + } + Shape::FilePath(p) => { + let json = std::fs::read_to_string(p) + .with_context(|| format!("read {}", p))?; + Graph::from_json(&json) + .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))? + } + Shape::CacheId(id) => { + let file = crate::cmd_cache::cache_ref(id).map_err(|e| { + anyhow::anyhow!( + "couldn't resolve `{}` as a URL, file path, or cache id: {}", + raw, e + ) + })?; + let json = std::fs::read_to_string(&file) + .with_context(|| format!("read {}", file.display()))?; + Graph::from_json(&json) + .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))? + } + }; + + let harness = graph.single_path().and_then(infer_source_harness); + Ok((graph, harness)) +} + +fn looks_like_pathbase_shorthand(s: &str) -> bool { + // Three non-empty slash-separated segments, none containing whitespace + // or a leading dot (which would indicate a relative file path). + if s.starts_with('.') || s.starts_with('/') { return false; } + let segs: Vec<&str> = s.split('/').collect(); + segs.len() == 3 && segs.iter().all(|s| !s.is_empty() && !s.contains(char::is_whitespace)) +} +``` + +`Graph::single_path` returns `Option<&Path>` — see the type reference. `infer_source_harness` takes `&Path`, so `.and_then(infer_source_harness)` is the right composition. + +- [ ] **Step 4: Add `Context` import and any missing imports** + +Make sure the top of `cmd_resume.rs` has: + +```rust +use anyhow::{Context, Result}; +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +cargo test -p path-cli --lib resolve_input +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(path-cli): resolve_input dispatcher for path resume" +``` + +--- + +## Task 10: Implement `pick_harness` non-interactive paths and PATH probe + +**Files:** +- Modify: `crates/path-cli/src/cmd_resume.rs` + +- [ ] **Step 1: Write the failing tests** + +Append to the tests module: + +```rust +fn fake_path_with(binaries: &[&str]) -> tempfile::TempDir { + let td = tempfile::tempdir().unwrap(); + for b in binaries { + let p = td.path().join(b); + std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perm = std::fs::metadata(&p).unwrap().permissions(); + perm.set_mode(0o755); + std::fs::set_permissions(&p, perm).unwrap(); + } + } + td +} + +#[test] +fn binary_on_path_finds_present_binary() { + let td = fake_path_with(&["claude"]); + assert!(binary_on_path("claude", Some(td.path()))); + assert!(!binary_on_path("gemini", Some(td.path()))); +} + +#[test] +fn pick_harness_explicit_arg_validates_path() { + let td = fake_path_with(&["claude"]); + let result = pick_harness( + Some(HarnessArg::Claude), + None, + Some(td.path()), + ); + assert_eq!(result.unwrap(), Harness::Claude); + + let err = pick_harness( + Some(HarnessArg::Gemini), + None, + Some(td.path()), + ).unwrap_err(); + assert!(err.to_string().contains("`gemini` isn't on PATH")); +} + +#[test] +fn pick_harness_zero_installed_errors() { + let td = fake_path_with(&[]); + // Force non-interactive so we hit the "zero installed" branch + // deterministically — the picker step is exercised in integration tests. + let err = pick_harness( + None, + Some(Harness::Claude), + Some(td.path()), + ).unwrap_err(); + assert!( + err.to_string().contains("no installed harnesses") + || err.to_string().contains("no harnesses on PATH"), + "actual: {}", err + ); +} +``` + +(The third test depends on `pick_harness` short-circuiting to the "zero installed" error before consulting `crate::fzf::available()`. The `path_override: Option<&std::path::Path>` parameter exists exclusively for tests.) + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p path-cli --lib pick_harness +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement `binary_on_path` and `pick_harness`** + +Append to `cmd_resume.rs`: + +```rust +/// Probe `$PATH` (or `path_override`, for tests) for a given binary +/// name. Cross-platform: on Windows, also tries `.exe`. +pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path>) -> bool { + let dirs: Vec = match path_override { + Some(p) => vec![p.to_path_buf()], + None => std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(), + }; + for d in dirs { + let candidate = d.join(name); + if candidate.is_file() { + return true; + } + #[cfg(windows)] + { + let exe = d.join(format!("{name}.exe")); + if exe.is_file() { + return true; + } + } + } + false +} + +/// All five harnesses, in the canonical picker order. +const ALL_HARNESSES: &[Harness] = &[ + Harness::Claude, + Harness::Gemini, + Harness::Codex, + Harness::Opencode, + Harness::Pi, +]; + +/// Decide which harness to resume in. See spec § "pick_harness". +/// +/// `path_override` is `None` in production; tests pass `Some(dir)` to +/// fake `$PATH`. +pub(crate) fn pick_harness( + arg: Option, + source: Option, + path_override: Option<&std::path::Path>, +) -> Result { + if let Some(a) = arg { + let h = Harness::from_arg(a); + if !binary_on_path(h.name(), path_override) { + anyhow::bail!( + "harness `{}` isn't on PATH; install it or pick another with `--harness`", + h.name() + ); + } + return Ok(h); + } + + let installed: Vec = ALL_HARNESSES + .iter() + .copied() + .filter(|h| binary_on_path(h.name(), path_override)) + .collect(); + + if installed.is_empty() { + anyhow::bail!( + "no installed harnesses found on PATH; install one of: claude, gemini, codex, opencode, pi" + ); + } + + interactive_pick(&installed, source) +} + +fn interactive_pick(installed: &[Harness], source: Option) -> Result { + if !crate::fzf::available() { + anyhow::bail!( + "interactive picker requires `fzf` on PATH and a TTY; pass `--harness ` or rerun in a terminal" + ); + } + // Format rows: " " + let mut lines: Vec = Vec::with_capacity(installed.len()); + for h in installed { + let mut tags: Vec<&str> = Vec::new(); + if Some(*h) == source { + tags.push("source"); + } + let suffix = if tags.is_empty() { String::new() } else { format!(" ({})", tags.join(", ")) }; + lines.push(format!("{}{}", h.symbol(), suffix)); + } + + let header = match source { + Some(s) => format!("pick a harness to resume in (source: {})", s.name()), + None => "pick a harness to resume in".to_string(), + }; + + let opts = crate::fzf::PickOptions { header: Some(&header), ..Default::default() }; + let pick = match crate::fzf::pick(&lines, &opts) + .map_err(|e| anyhow::anyhow!("fzf failed: {}", e))? + { + crate::fzf::PickResult::Selected(p) => p, + crate::fzf::PickResult::Cancelled => std::process::exit(130), + crate::fzf::PickResult::NoMatch => { + anyhow::bail!("fzf returned no match — picker UI was empty?"); + } + }; + + // Match by leading symbol (which uniquely identifies each harness). + for h in installed { + if pick.starts_with(h.symbol()) { + return Ok(*h); + } + } + anyhow::bail!("picker returned an unrecognized row: {pick}") +} +``` + +**Read `crates/path-cli/src/fzf.rs` before writing this.** If `PickOptions` requires extra fields (e.g. `prompt`, `multi`, `preview`), set them to whatever the existing `cmd_share.rs` code sets — the file is short, the existing call site is the canonical example. + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cargo test -p path-cli --lib pick_harness +cargo test -p path-cli --lib binary_on_path +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(path-cli): harness picker + PATH probe for path resume" +``` + +--- + +## Task 11: Implement `project_into_harness` dispatcher + +**Files:** +- Modify: `crates/path-cli/src/cmd_resume.rs` + +- [ ] **Step 1: Write the failing test** + +```rust +#[test] +fn project_into_harness_claude_round_trip() { + let td = tempfile::tempdir().unwrap(); + let _home = scoped_home_for_resume(tempfile::tempdir().unwrap()); + + let path = make_path_with_actor("agent:claude-code"); + let recipe = project_into_harness(&path, Harness::Claude, td.path()).unwrap(); + + assert_eq!(recipe.binary, "claude"); + assert_eq!(recipe.args.len(), 2); + assert_eq!(recipe.args[0], "-r"); + assert_eq!( + recipe.cwd_for_recipe, + std::fs::canonicalize(td.path()).unwrap() + ); +} +``` + +Add a `scoped_home_for_resume` mirroring the export-side `scoped_home`, or reuse it via `pub(crate)`. + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p path-cli --lib project_into_harness_claude_round_trip +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement `project_into_harness`** + +Append to `cmd_resume.rs`: + +```rust +use crate::cmd_export::ResumeRecipe; + +/// Run the appropriate `cmd_export` project-mode helper and return its +/// recipe. The `cwd` is the directory the projection layout is keyed +/// on AND the directory the harness will be exec'd from. +pub(crate) fn project_into_harness( + path: &TPath, + harness: Harness, + cwd: &std::path::Path, +) -> Result { + match harness { + Harness::Claude => crate::cmd_export::project_claude(path, cwd), + Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), + Harness::Codex => crate::cmd_export::project_codex(path, cwd), + Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), + Harness::Pi => crate::cmd_export::project_pi(path, cwd), + } +} +``` + +- [ ] **Step 4: Add the five `pub(crate) fn project_` thin wrappers in `cmd_export.rs`** + +Each wrapper calls the existing build/write pair without going through `run_` (so the CLI's `--input` / `--output` machinery is bypassed but the on-disk side-effects are identical): + +```rust +// Add near the top of cmd_export.rs, after the existing helpers. + +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_claude( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + let conv = build_claude_conversation(path)?; + let jsonl = serialize_jsonl(&conv)?; + write_into_claude_project(&conv, &jsonl, project_dir) +} + +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_gemini( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + let project_dir = std::fs::canonicalize(project_dir) + .with_context(|| format!("resolve project path {}", project_dir.display()))?; + let project_path = project_dir.to_string_lossy().to_string(); + // Reuse existing build-from-path path (build_gemini_conversation takes + // an `input: &str` cache id today — refactor to take the path directly). + let view = toolpath_convo::extract_conversation(path); + let project_hash = toolpath_gemini::paths::project_hash(&project_path); + let projector = toolpath_gemini::project::GeminiProjector::new() + .with_project_hash(project_hash) + .with_project_path(project_path.clone()); + use toolpath_convo::ConversationProjector; + let conv = projector + .project(&view) + .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; + if conv.session_uuid.is_empty() { + anyhow::bail!("Projected conversation has no session UUID"); + } + write_into_gemini_project(&conv, &project_path) +} + +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_codex( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + let project_dir = std::fs::canonicalize(project_dir) + .with_context(|| format!("resolve project path {}", project_dir.display()))?; + let cwd_str = project_dir.to_string_lossy().to_string(); + use toolpath_convo::ConversationProjector; + let view = toolpath_convo::extract_conversation(path); + let projector = toolpath_codex::project::CodexProjector::new().with_cwd(cwd_str); + let session = projector + .project(&view) + .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; + if session.id.is_empty() { + anyhow::bail!("Projected session has no id"); + } + write_into_codex_project(&session) +} + +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_opencode( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + let session = build_opencode_session(path, Some(project_dir))?; + write_into_opencode_db(&session, project_dir) +} + +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_pi( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + let project_dir = std::fs::canonicalize(project_dir) + .with_context(|| format!("resolve project path {}", project_dir.display()))?; + let cwd_str = project_dir.to_string_lossy().to_string(); + let session = { + use toolpath_convo::ConversationProjector; + let view = toolpath_convo::extract_conversation(path); + let projector = toolpath_pi::project::PiProjector::new().with_cwd(cwd_str.clone()); + projector + .project(&view) + .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))? + }; + if session.header.id.is_empty() { + anyhow::bail!("Projected session has no id"); + } + write_into_pi_project(&session, &cwd_str) +} +``` + +(Each wrapper duplicates a small amount of the corresponding `run_` body. If the duplication bothers a reviewer, a follow-up can collapse the existing `run_` into a thin wrapper around `project_` plus output-mode handling. Out of scope for this plan.) + +- [ ] **Step 5: Run test to verify it passes** + +```bash +cargo test -p path-cli --lib project_into_harness_claude_round_trip +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add crates/path-cli/src/cmd_export.rs crates/path-cli/src/cmd_resume.rs +git commit -m "feat(path-cli): project_into_harness dispatcher with per-harness wrappers" +``` + +--- + +## Task 12: Implement `exec_harness` with injectable strategy + +**Files:** +- Modify: `crates/path-cli/src/cmd_resume.rs` + +- [ ] **Step 1: Write the failing test** + +```rust +#[test] +fn exec_strategy_recording_captures_recipe() { + let recipe = ResumeRecipe { + binary: "claude", + args: vec!["-r".to_string(), "abc123".to_string()], + session_id: "abc123".to_string(), + cwd_for_recipe: std::path::PathBuf::from("/tmp/x"), + }; + let recorder = RecordingExec::default(); + let strategy: &dyn ExecStrategy = &recorder; + exec_harness(&recipe, std::path::Path::new("/tmp/x"), strategy).unwrap(); + + let captured = recorder.captured(); + assert_eq!(captured.binary, "claude"); + assert_eq!(captured.args, vec!["-r".to_string(), "abc123".to_string()]); + assert_eq!(captured.cwd, std::path::PathBuf::from("/tmp/x")); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cargo test -p path-cli --lib exec_strategy_recording_captures_recipe +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement `ExecStrategy` and `exec_harness`** + +Append to `cmd_resume.rs`: + +```rust +/// What `exec_harness` saw (for tests). +#[derive(Debug, Clone, Default)] +pub(crate) struct CapturedExec { + pub(crate) binary: String, + pub(crate) args: Vec, + pub(crate) cwd: std::path::PathBuf, +} + +/// Pluggable exec backend. Production uses `RealExec` (`execvp` on +/// Unix, spawn-and-wait on Windows). Tests use `RecordingExec`. +pub(crate) trait ExecStrategy { + fn exec(&self, recipe: &ResumeRecipe, cwd: &std::path::Path) -> Result<()>; +} + +/// Production implementation. On Unix this never returns on success +/// (the current process is replaced); on Windows it spawns the child, +/// waits, and propagates the exit code. +pub(crate) struct RealExec; + +impl ExecStrategy for RealExec { + fn exec(&self, recipe: &ResumeRecipe, cwd: &std::path::Path) -> Result<()> { + let mut cmd = std::process::Command::new(recipe.binary); + cmd.args(&recipe.args); + cmd.current_dir(cwd); + + eprintln!( + "Resuming: {} {} (cwd: {})", + recipe.binary, + recipe.args.join(" "), + cwd.display() + ); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // exec only returns if it fails. + let err = cmd.exec(); + anyhow::bail!( + "couldn't exec `{}`: {}. Recipe: {} {} (run from {})", + recipe.binary, + err, + recipe.binary, + recipe.args.join(" "), + cwd.display() + ); + } + #[cfg(not(unix))] + { + let status = cmd.spawn() + .with_context(|| format!("spawn {}", recipe.binary))? + .wait() + .with_context(|| format!("wait for {}", recipe.binary))?; + std::process::exit(status.code().unwrap_or(1)); + } + } +} + +/// Recording strategy for tests. `captured()` returns the most recent +/// invocation. +#[derive(Default)] +pub(crate) struct RecordingExec { + inner: std::sync::Mutex, +} + +impl RecordingExec { + pub(crate) fn captured(&self) -> CapturedExec { + self.inner.lock().unwrap().clone() + } +} + +impl ExecStrategy for RecordingExec { + fn exec(&self, recipe: &ResumeRecipe, cwd: &std::path::Path) -> Result<()> { + let mut g = self.inner.lock().unwrap(); + *g = CapturedExec { + binary: recipe.binary.to_string(), + args: recipe.args.clone(), + cwd: cwd.to_path_buf(), + }; + Ok(()) + } +} + +pub(crate) fn exec_harness( + recipe: &ResumeRecipe, + cwd: &std::path::Path, + strategy: &dyn ExecStrategy, +) -> Result<()> { + strategy.exec(recipe, cwd) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cargo test -p path-cli --lib exec_strategy_recording_captures_recipe +``` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(path-cli): ExecStrategy with RealExec/RecordingExec for path resume" +``` + +--- + +## Task 13: Wire `run_resume` orchestration + +**Files:** +- Modify: `crates/path-cli/src/cmd_resume.rs` + +- [ ] **Step 1: Replace the stub `run` with the real orchestration** + +Find the current stub: + +```rust +pub fn run(_args: ResumeArgs) -> Result<()> { + anyhow::bail!("path resume: not yet implemented") +} +``` + +Replace with: + +```rust +pub fn run(args: ResumeArgs) -> Result<()> { + run_with_strategy(args, &RealExec) +} + +/// Internal entry point that the integration tests call with a +/// `RecordingExec` strategy. Production callers use [`run`]. +pub(crate) fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> { + let (doc, source_harness) = resolve_input(&args)?; + let path = ensure_path_with_agent(&doc)?; + + let cwd = match args.cwd.as_ref() { + Some(p) => std::fs::canonicalize(p) + .with_context(|| format!("resolve cwd path {}", p.display()))?, + None => std::env::current_dir()?, + }; + + let target = pick_harness(args.harness, source_harness, None)?; + eprintln!("Picked harness: {}{}", + target.name(), + if Some(target) == source_harness { " (source)" } else { "" } + ); + + let recipe = project_into_harness(path, target, &cwd)?; + exec_harness(&recipe, &cwd, exec) +} +``` + +- [ ] **Step 2: Update the existing stub test** + +Replace: + +```rust +#[test] +fn run_returns_not_implemented_until_wired() { ... } +``` + +with: + +```rust +#[test] +fn run_with_strategy_records_recipe_for_file_input_with_explicit_harness() { + let _home = scoped_home_for_resume(tempfile::tempdir().unwrap()); + let cwd = tempfile::tempdir().unwrap(); + let doc_file = cwd.path().join("doc.json"); + + let path = make_path_with_actor("agent:claude-code"); + let graph = toolpath::v1::Graph::from_path(path); + std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap(); + + // Make `claude` discoverable by salting PATH for this process. + let bin_dir = fake_path_with(&["claude"]); + let prev = std::env::var_os("PATH"); + let new_path = std::env::join_paths( + std::iter::once(bin_dir.path().to_path_buf()) + .chain(std::env::split_paths(&prev.clone().unwrap_or_default())), + ).unwrap(); + // Safety: see scoped_home note. Treat tests as single-threaded. + unsafe { std::env::set_var("PATH", new_path); } + let _restore = scopeguard::guard(prev, |p| { + unsafe { + match p { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + } + }); + + let args = ResumeArgs { + input: doc_file.to_string_lossy().to_string(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(HarnessArg::Claude), + no_cache: false, force: false, url: None, + }; + + let recorder = RecordingExec::default(); + run_with_strategy(args, &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "claude"); + assert_eq!(cap.args[0], "-r"); + assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap()); +} +``` + +If `scopeguard` isn't already a dev-dep, either add it (`scopeguard = "1"` under `[dev-dependencies]`) or write an equivalent local `Drop`-based guard struct. Check `Cargo.toml` first. + +- [ ] **Step 3: Run the orchestration test** + +```bash +cargo test -p path-cli --lib run_with_strategy_records_recipe_for_file_input_with_explicit_harness +``` + +Expected: PASS. + +- [ ] **Step 4: Run all `cmd_resume` tests** + +```bash +cargo test -p path-cli --lib cmd_resume +``` + +Expected: PASS for the full set. + +- [ ] **Step 5: Commit** + +```bash +git add crates/path-cli/src/cmd_resume.rs crates/path-cli/Cargo.toml +git commit -m "feat(path-cli): wire path resume orchestration end-to-end" +``` + +--- + +## Task 14: Integration tests + +**Files:** +- Create: `crates/path-cli/tests/resume.rs` + +- [ ] **Step 1: Write the integration test file** + +Create `crates/path-cli/tests/resume.rs` with the cases enumerated in the spec. Each test invokes `path_cli::cmd_resume::run_with_strategy` with a `RecordingExec` and asserts on captured recipe + on-disk side-effects. + +Each test in the file is one case from the list below. Subsequent steps in this task fill in the per-harness bodies and the rejection cases. + +```rust +#![cfg(not(target_os = "emscripten"))] + +use path_cli::cmd_resume::{run_with_strategy, RecordingExec, ResumeArgs}; +use path_cli::cmd_share::HarnessArg; + +mod support; +use support::*; + +#[test] +fn file_input_explicit_claude_projects_and_records_exec() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let doc_file = write_minimal_path_file(cwd.path(), "agent:claude-code"); + + let _path_guard = ScopedPath::with_binary("claude"); + + let recorder = RecordingExec::default(); + run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "claude"); + assert_eq!(cap.args[0], "-r"); + + // Side-effect: the projected JSONL exists under HOME. + let projects = home_dir().join(".claude/projects"); + assert!(projects.exists(), "claude projects dir missing"); + assert!(walk_dir_finds_jsonl(&projects), "no JSONL written"); +} + +#[test] +fn file_input_explicit_gemini_projects_and_records_exec() { /* ... */ } + +#[test] +fn file_input_explicit_codex_projects_and_records_exec() { /* ... */ } + +#[test] +fn file_input_explicit_opencode_projects_and_records_exec() { /* ... */ } + +#[test] +fn file_input_explicit_pi_projects_and_records_exec() { /* ... */ } + +#[test] +fn cache_id_input_loads_and_projects() { /* writes a cache entry first, runs resume */ } + +#[test] +fn url_input_fetches_via_mock_pathbase_and_projects() { + use path_cli::cmd_pathbase::tests::MockServer; + /* ... */ +} + +#[test] +fn multi_path_graph_returns_clear_error() { /* see Step 6 */ } + +#[test] +fn agentless_path_returns_clear_error() { /* see Step 6 */ } + +#[test] +fn explicit_harness_not_on_path_errors() { /* see Step 7 */ } + +#[test] +fn zero_installed_errors() { /* see Step 7 */ } +``` + +(There is no `step_input` rejection test: this codebase has no `Document::Step` shape — `Graph::from_json` rejects non-graph JSON during parse, well before `ensure_path_with_agent` runs. The `multi_path_graph` and `agentless_path` cases cover the rejection logic that lives in `cmd_resume`.) + +- [ ] **Step 2: Add the `support` module** + +Create `crates/path-cli/tests/support/mod.rs` (or `crates/path-cli/tests/support.rs`) with shared helpers: + +```rust +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +pub struct ScopedHome { _td: tempfile::TempDir, prev: Option, prev_config: Option } + +impl ScopedHome { + pub fn new() -> Self { + let td = tempfile::tempdir().unwrap(); + let prev = std::env::var_os("HOME"); + let prev_config = std::env::var_os("TOOLPATH_CONFIG_DIR"); + unsafe { + std::env::set_var("HOME", td.path()); + // Some helpers honor TOOLPATH_CONFIG_DIR; keep it pinned to HOME/.toolpath. + std::env::set_var("TOOLPATH_CONFIG_DIR", td.path().join(".toolpath")); + } + Self { _td: td, prev, prev_config } + } +} + +impl Drop for ScopedHome { + fn drop(&mut self) { + unsafe { + match &self.prev { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match &self.prev_config { + Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v), + None => std::env::remove_var("TOOLPATH_CONFIG_DIR"), + } + } + } +} + +pub struct ScopedPath { _td: tempfile::TempDir, prev: Option } + +impl ScopedPath { + pub fn with_binary(name: &str) -> Self { Self::with_binaries(&[name]) } + + pub fn with_binaries(names: &[&str]) -> Self { + let td = tempfile::tempdir().unwrap(); + for n in names { + let p = td.path().join(n); + std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perm = std::fs::metadata(&p).unwrap().permissions(); + perm.set_mode(0o755); + std::fs::set_permissions(&p, perm).unwrap(); + } + } + let prev = std::env::var_os("PATH"); + let new_path = std::env::join_paths( + std::iter::once(td.path().to_path_buf()) + .chain(std::env::split_paths(&prev.clone().unwrap_or_default())) + ).unwrap(); + unsafe { std::env::set_var("PATH", new_path); } + Self { _td: td, prev } + } + + pub fn empty() -> Self { + let td = tempfile::tempdir().unwrap(); + let prev = std::env::var_os("PATH"); + unsafe { std::env::set_var("PATH", td.path()); } + Self { _td: td, prev } + } +} + +impl Drop for ScopedPath { + fn drop(&mut self) { + unsafe { + match &self.prev { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + } + } +} + +pub fn home_dir() -> PathBuf { + PathBuf::from(std::env::var_os("HOME").unwrap()) +} + +pub fn write_minimal_path_file(dir: &Path, actor: &str) -> PathBuf { + use toolpath::v1::{Path as TPath, PathIdentity, Step}; + let step = Step::new("s1", actor, "2026-01-01T00:00:00Z") + .with_raw_change("a.txt", "@@ -1 +1 @@\n-old\n+new"); + let path = TPath { + path: PathIdentity { + id: "p1".to_string(), + base: None, + head: "s1".to_string(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + }; + let graph = toolpath::v1::Graph::from_path(path); + let p = dir.join("doc.json"); + std::fs::write(&p, graph.to_json().unwrap()).unwrap(); + p +} + +pub fn args(input: PathBuf, cwd: &Path, harness: HarnessArg) -> path_cli::cmd_resume::ResumeArgs { + path_cli::cmd_resume::ResumeArgs { + input: input.to_string_lossy().to_string(), + cwd: Some(cwd.to_path_buf()), + harness: Some(harness), + no_cache: false, force: false, url: None, + } +} + +pub fn walk_dir_finds_jsonl(root: &Path) -> bool { + fn walk(p: &Path) -> bool { + if p.is_dir() { + for e in std::fs::read_dir(p).unwrap() { + if walk(&e.unwrap().path()) { return true; } + } + false + } else { + p.extension().and_then(|s| s.to_str()) == Some("jsonl") + } + } + walk(root) +} +``` + +- [ ] **Step 3: Implement the per-harness positive cases** + +Each follows the Claude pattern. Adjust `actor`, `HarnessArg`, expected binary, expected first arg (`-r` for claude, `--resume` for gemini, `resume` for codex, `--session` for opencode/pi). Skip on-disk JSONL assertion for opencode (which writes SQLite rows, not JSONL). + +- [ ] **Step 4: Implement the cache-id input case** + +```rust +#[test] +fn cache_id_input_loads_and_projects() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let _path_guard = ScopedPath::with_binary("claude"); + + // Build the same minimal claude path as `write_minimal_path_file` does, + // but keep it in memory and stash it under a known cache id. + let cache_id = "claude-test-fixture"; + let doc_file = write_minimal_path_file(cwd.path(), "agent:claude-code"); + let json = std::fs::read_to_string(&doc_file).unwrap(); + let graph = toolpath::v1::Graph::from_json(&json).unwrap(); + path_cli::cmd_cache::write_cached(cache_id, &graph, false).unwrap(); + + let resume_args = path_cli::cmd_resume::ResumeArgs { + input: cache_id.to_string(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(HarnessArg::Claude), + no_cache: false, force: false, url: None, + }; + let recorder = RecordingExec::default(); + run_with_strategy(resume_args, &recorder).unwrap(); + assert_eq!(recorder.captured().binary, "claude"); +} +``` + +- [ ] **Step 5: Implement the URL-input case via `MockServer`** + +Use `path_cli::cmd_pathbase::tests::MockServer` (made `pub(crate)` in Task 6 — promote to `pub` here if cross-crate-test-binary access requires it, or move the helper to a `pub` test-utilities module). + +- [ ] **Step 6: Implement the rejection cases** + +```rust +#[test] +fn multi_path_graph_returns_clear_error() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let _path_guard = ScopedPath::with_binary("claude"); + + // Build a graph with two inline paths. + let p1 = { + let json = std::fs::read_to_string(write_minimal_path_file(cwd.path(), "agent:claude-code")).unwrap(); + toolpath::v1::Graph::from_json(&json).unwrap().into_single_path().unwrap() + }; + let p2 = { + // Reuse the same builder; rename the path id to avoid collision. + let mut p = p1.clone(); + p.path.id = "p2".into(); + p + }; + let graph = toolpath::v1::Graph { + graph: toolpath::v1::GraphIdentity { id: "g1".into() }, + paths: vec![ + toolpath::v1::PathOrRef::Path(Box::new(p1)), + toolpath::v1::PathOrRef::Path(Box::new(p2)), + ], + meta: None, + }; + let doc_file = cwd.path().join("multi.json"); + std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap(); + + let recorder = RecordingExec::default(); + let err = run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder) + .unwrap_err(); + let s = err.to_string(); + assert!(s.contains("single `Path`"), "actual: {s}"); + assert!(s.contains("2 paths"), "actual: {s}"); +} + +#[test] +fn agentless_path_returns_clear_error() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let _path_guard = ScopedPath::with_binary("claude"); + let doc_file = write_minimal_path_file(cwd.path(), "human:alex"); + + let recorder = RecordingExec::default(); + let err = run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder) + .unwrap_err(); + assert!(err.to_string().contains("no agent session")); +} +``` + +(Substitute the actual `Graph` and `GraphIdentity` field names if they differ from the snippet — read `crates/toolpath/src/types.rs` first; the existing `cmd_merge.rs::tests` builds graphs literally and is the canonical example.) + +- [ ] **Step 7: Implement the harness-not-on-PATH and zero-installed cases** + +```rust +#[test] +fn explicit_harness_not_on_path_errors() { + let _home = ScopedHome::new(); + let _path_guard = ScopedPath::empty(); + let cwd = tempfile::tempdir().unwrap(); + let doc = write_minimal_path_file(cwd.path(), "agent:claude-code"); + + let recorder = RecordingExec::default(); + let err = run_with_strategy(args(doc, cwd.path(), HarnessArg::Claude), &recorder) + .unwrap_err(); + assert!(err.to_string().contains("isn't on PATH")); +} +``` + +- [ ] **Step 8: Run all integration tests** + +```bash +cargo test -p path-cli --test resume +``` + +Expected: PASS. + +- [ ] **Step 9: Run the full `path-cli` test suite** + +```bash +cargo test -p path-cli +``` + +Expected: pass. + +- [ ] **Step 10: Commit** + +```bash +git add crates/path-cli/tests/ +git commit -m "test(path-cli): integration tests for path resume" +``` + +--- + +## Task 15: Documentation + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `README.md` +- Modify: `crates/path-cli/src/cmd_resume.rs` (rustdoc) +- Modify: `crates/path-cli/src/cmd_export.rs` (rustdoc) +- Create or modify: `CHANGELOG.md` + +- [ ] **Step 1: Add `path resume` to the `CLAUDE.md` CLI usage block** + +Find the existing CLI usage block (the long bash block with `path import …` etc.) and add, near `path share`: + +````markdown +# Resume an agent session into your coding agent of choice +cargo run -p path-cli -- resume +cargo run -p path-cli -- resume --harness claude -C /path/to/project +```` + +- [ ] **Step 2: Add a "Things to know" bullet for `path resume`** + +In the same `CLAUDE.md`, append (next to the `path share` bullet): + +````markdown +- `path resume ` is the inverse of `path share`. It takes a Pathbase URL, shorthand (`owner/repo/slug`), file path, or cache id; resolves it to a Toolpath document; lets the user pick a coding-agent harness (interactive picker by default, `--harness X` to skip); projects the session into the harness's on-disk layout under the chosen cwd (default: shell cwd; override with `-C, --cwd P`); then execs the harness's resume command. Source harness is read from `path.meta.source` when present, with actor-string fallback. Documents that aren't a single agent-bearing `Path` are rejected with a message. +```` + +- [ ] **Step 3: Add a one-line mention to `README.md`** + +In whichever section enumerates CLI verbs, add `path resume ` next to `path share`. + +- [ ] **Step 4: Beef up the rustdoc on `cmd_resume.rs`** + +Replace the placeholder module comment with a real one: + +```rust +//! `path resume ` — fetch / load a Toolpath document, pick an +//! installed coding-agent harness, project the session into that +//! harness's on-disk layout, and exec the harness's resume command. +//! +//! ## Inputs +//! +//! `` is resolved in this order: +//! 1. `https://` / `http://` URL → fetched via `pathbase-client`, +//! cached unless `--no-cache`. +//! 2. `owner/repo/slug` shorthand → same Pathbase fetch flow. +//! 3. Existing file path → read directly. +//! 4. Otherwise treated as a cache id under `~/.toolpath/documents/`. +//! +//! ## Harness selection +//! +//! With `--harness X`, `X` is validated against `$PATH` and used. +//! Without `--harness`, an `fzf` picker shows installed harnesses +//! with the source harness pre-selected. Source comes from +//! `path.meta.source` (`claude-code`, `gemini-cli`, `codex`, +//! `opencode`, `pi`) with actor-string fallback. +//! +//! ## Project directory +//! +//! `-C / --cwd P` overrides the shell cwd. The harness is exec'd +//! with cwd set to P and the on-disk projection is keyed on P. +//! +//! ## Launch +//! +//! On Unix the harness binary is `execvp`'d, replacing the current +//! process. On Windows it's spawned and waited on with the exit code +//! propagated. If exec fails, the recipe is printed to stderr. +//! +//! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`. +``` + +- [ ] **Step 5: Adjust the `cmd_export.rs` module rustdoc** + +In the existing `//! ` block at the top of `cmd_export.rs`, append a paragraph: + +```rust +//! +//! Each `--project` mode now returns a `ResumeRecipe { binary, args, +//! session_id, cwd_for_recipe }`. The CLI surface formats the recipe +//! into the same `Resume with: …` / `Open conversation with: …` lines +//! it always has; `path resume` consumes the recipe directly to exec +//! the harness. +``` + +- [ ] **Step 6: Add a `CHANGELOG.md` entry** + +Add a new section at the top (above the most recent entry): + +```markdown +## path-cli 0.9.0 — 2026-05-08 + +### Added +- `path resume ` — fetch a Toolpath document (URL, shorthand, + file path, or cache id), pick a coding-agent harness, project the + session into its on-disk layout under a chosen cwd, and exec the + harness's resume command. +- `cmd_export::ResumeRecipe` — public type returned by every + project-mode export helper; describes how to invoke the harness for + resume. Consumed by `path resume`. + +### Changed +- `path export --project ` writers internally return a + `ResumeRecipe`. The CLI's stderr "Resume with: …" lines are now + formatted from the recipe; user-visible output is unchanged. +``` + +(If `CHANGELOG.md` doesn't exist yet, create it with a simple header `# Changelog` followed by the section above.) + +- [ ] **Step 7: Build the docs to confirm they compile** + +```bash +cargo doc -p path-cli --no-deps +``` + +Expected: clean build. + +- [ ] **Step 8: Commit** + +```bash +git add CLAUDE.md README.md CHANGELOG.md crates/path-cli/src/cmd_resume.rs crates/path-cli/src/cmd_export.rs +git commit -m "docs: document path resume command" +``` + +--- + +## Task 16: Version bumps + +**Files:** +- Modify: `crates/path-cli/Cargo.toml` +- Modify: `Cargo.toml` (root) +- Modify: `site/_data/crates.json` + +- [ ] **Step 1: Bump `path-cli` minor version** + +In `crates/path-cli/Cargo.toml`: + +```toml +version = "0.9.0" # was 0.8.0 +``` + +- [ ] **Step 2: Bump the workspace dep entry** + +In the root `Cargo.toml`, find the `[workspace.dependencies]` `path-cli` entry and bump to match: + +```toml +path-cli = { path = "crates/path-cli", version = "0.9.0" } +``` + +(Adjust to match the existing entry's exact shape — `path` may or may not be present.) + +- [ ] **Step 3: Bump the site data** + +In `site/_data/crates.json`, update the `path-cli` entry's `version` field to `"0.9.0"`. + +- [ ] **Step 4: Verify the workspace builds** + +```bash +cargo build --workspace +``` + +Expected: clean build. + +- [ ] **Step 5: Verify the workspace tests pass** + +```bash +cargo test --workspace +``` + +Expected: all green. + +- [ ] **Step 6: Verify clippy is clean** + +```bash +cargo clippy --workspace -- -D warnings +``` + +Expected: no warnings. + +- [ ] **Step 7: Build the site to confirm `crates.json` is well-formed** + +```bash +cd site && pnpm run build && cd .. +``` + +Expected: 7 pages built; no errors. + +- [ ] **Step 8: Commit** + +```bash +git add crates/path-cli/Cargo.toml Cargo.toml site/_data/crates.json +git commit -m "chore: bump path-cli to 0.9.0 for path resume" +``` + +--- + +## Task 17: Smoke test from the CLI + +**Files:** none modified — manual verification only. + +- [ ] **Step 1: Build the CLI** + +```bash +cargo build -p path-cli --release +``` + +- [ ] **Step 2: Verify `--help` lists the new command** + +```bash +./target/release/path resume --help +``` + +Expected: usage line + flags listed exactly as documented in `cmd_resume.rs`. + +- [ ] **Step 3: Confirm rejection paths work end-to-end** + +Pick a cache entry that's not from a harness — e.g. a `git-*` entry from a previous `path import git`. If none exist, derive one: + +```bash +./target/release/path import git --repo . --branch main +./target/release/path cache ls +``` + +Then attempt to resume: + +```bash +./target/release/path resume --harness claude +``` + +Expected: error message `no agent session in input — `path resume` only works on harness-derived paths`. + +- [ ] **Step 4: (Optional) Confirm a real resume works against an actual session** + +Only if you have a real claude/codex/gemini/opencode/pi session locally and one of those binaries on PATH: + +```bash +./target/release/path import claude --project . --no-cache | ./target/release/path resume - --harness claude +``` + +(Or use a cached entry. The `-` stdin form requires an extra implementation step — skip if not implemented.) + +Expected: control transfers to the harness with the prior conversation visible. + +- [ ] **Step 5: No commit needed for smoke testing** + +Manual step only. + +--- + +## Self-review checklist (run before handing the plan off) + +1. Every task ends with a `git commit` — verified. +2. Every code step shows the actual code, not "implement X" — verified. +3. Every test step shows the actual test, the run command, and the expected outcome — verified. +4. File paths are absolute or workspace-relative — verified (all `crates/path-cli/...`). +5. Type names are consistent across tasks (`ResumeRecipe`, `ResumeArgs`, `ExecStrategy`, `RecordingExec`, `RealExec`, `Harness`, `HarnessArg`) — verified. +6. Spec coverage: + - § Surface — Tasks 7, 13. + - § Input resolution — Task 9. + - § Launch — Tasks 12, 13. + - § Internal architecture (`resolve_input`, `ensure_path_with_agent`, `pick_harness`, `project_into_harness`, `exec_harness`) — Tasks 8–13. + - § `ResumeRecipe` and `cmd_export` refactor — Tasks 1–5, 11. + - § Error handling — Tasks 8, 9, 10, 14. + - § Testing — Tasks 1–14, 17. + - § Documentation — Task 15. + - § Versioning — Task 16. +7. No "TBD", "TODO", or "implement later" — verified. From 67cb7081b8b4d2bbac688bcab7df1c89e0958630 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 16:23:02 -0400 Subject: [PATCH 03/21] =?UTF-8?q?docs:=20revise=20path=20resume=20design?= =?UTF-8?q?=20=E2=80=94=20drop=20ResumeRecipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the five-site cmd_export refactor + new pub ResumeRecipe type with five thin pub(crate) project_(path, cwd) wrappers in cmd_export.rs that return the projected session id. cmd_resume gets an argv_for(harness, session_id) helper plus an ExecStrategy taking (binary, args, cwd). Plan drops from 17 tasks to 13. Spec validation now operates on Graph (no Document enum exists in the codebase). --- .../plans/2026-05-08-path-resume-command.md | 1648 ++++++----------- .../2026-05-08-path-resume-command-design.md | 146 +- 2 files changed, 665 insertions(+), 1129 deletions(-) diff --git a/docs/superpowers/plans/2026-05-08-path-resume-command.md b/docs/superpowers/plans/2026-05-08-path-resume-command.md index 6bacbe4..0583a87 100644 --- a/docs/superpowers/plans/2026-05-08-path-resume-command.md +++ b/docs/superpowers/plans/2026-05-08-path-resume-command.md @@ -4,9 +4,9 @@ **Goal:** Add `path resume ` — fetches/loads a Toolpath document, picks a coding-agent harness (interactive picker by default, `--harness X` to skip), projects the session into the harness's on-disk layout in a chosen cwd, then execs the harness's resume command. -**Architecture:** New `cmd_resume.rs` module mirroring `cmd_share.rs`. Reuses the per-harness projection helpers in `cmd_export.rs` after a small refactor that has each project-mode writer return a `ResumeRecipe { binary, args, session_id, cwd_for_recipe }`. The CLI surface for `path export --project P` is unchanged; the new code path consumes the recipe directly and feeds it to an injectable `ExecStrategy` (the binary plugs in `execvp`; tests plug in a recorder). +**Architecture:** New `cmd_resume.rs` module mirroring `cmd_share.rs`. cmd_export.rs gains five small `pub(crate)` wrappers (`project_`) that compose the existing private build+write helpers and return the projected session id. cmd_resume composes these with an `argv_for(harness, session_id)` helper, an injectable `ExecStrategy`, and a small interactive picker. No new public types in the path-cli library. -**Tech Stack:** Rust 2024, clap, anyhow, `toolpath_*` workspace crates, existing `crate::fzf` helper, `cmd_share::Harness` enum, `pathbase-client`. New types are `pub` only where the desktop app might consume them later. +**Tech Stack:** Rust 2024, clap, anyhow, `toolpath_*` workspace crates, existing `crate::fzf` helper, `cmd_share::Harness` enum, `pathbase-client`. **Spec reference:** `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`. @@ -14,7 +14,7 @@ ## Type and API quick reference -The plan's code samples lean on these existing types and functions. Cross-check against the source before writing tests — the names below are what's actually in the repo as of branch `akesling/resume`. +The plan's code samples lean on these existing types and functions. Cross-check against the source before writing tests. ```rust // crates/toolpath/src/types.rs @@ -49,7 +49,7 @@ graph.into_single_path(); // Option graph.single_path(); // Option<&Path> ``` -**There is no `Document` enum.** `Graph::from_json` is the universal entry point — every cache file, every Pathbase response, every Toolpath JSON parses as a `Graph`. Single-path-graphs are the closest thing to a "Path document"; `into_single_path` unwraps them. The plan validates everything as a `Graph` (see Task 8). +**There is no `Document` enum.** `Graph::from_json` is the universal entry point — every cache file, every Pathbase response, every Toolpath JSON parses as a `Graph`. Single-path-graphs are the closest thing to a "Path document"; `into_single_path` unwraps them. The plan validates everything as a `Graph` (see Task 4). **`path.meta.source` access pattern** (because `meta: Option`): @@ -99,19 +99,33 @@ fn make_path_with_actor(actor: &str) -> toolpath::v1::Path { } ``` -Whenever a task below refers to `path_with_actor(...)` or `make_minimal__path()`, the body is the snippet above with `actor` substituted. Each task lists the actor explicitly. +Whenever a task below refers to `make_path_with_actor(...)`, the body is the snippet above with `actor` substituted. Each task lists the actor explicitly. + +**Existing `cmd_export.rs` private helpers** (these stay private; the new wrappers compose them): + +| Harness | Build helper | Write helper | +| --- | --- | --- | +| Claude | `build_claude_conversation(path) -> Conversation` (with `session_id`) | `write_into_claude_project(conv, jsonl, project_dir) -> PathBuf` (returns the JSONL path) | +| Gemini | `build_gemini_conversation(input, project_path) -> Conversation` (with `session_uuid`) | `write_into_gemini_project(conv, project_path) -> ()` | +| Codex | `build_codex_session(input, cwd) -> Session` (with `id`) | `write_into_codex_project(session) -> ()` | +| Opencode | `build_opencode_session(path, project_dir) -> Session` (with `id`) | `write_into_opencode_db(session, project_dir) -> ()` | +| Pi | `build_pi_session(input, cwd) -> PiSession` (with `header.id`) | `write_into_pi_project(session, cwd) -> ()` | + +Verify these signatures by reading `cmd_export.rs` before writing the wrappers — adapt as needed if a name differs from this table. --- ## File Structure **New:** -- `crates/path-cli/src/cmd_resume.rs` — module: `ResumeArgs`, `ResumeRecipe` re-export, orchestration, `resolve_input`, `infer_source_harness`, `ensure_path_with_agent`, `pick_harness`, `exec_harness`, picker. +- `crates/path-cli/src/cmd_resume.rs` — new module: `ResumeArgs`, orchestration, `resolve_input`, `infer_source_harness`, `ensure_path_with_agent`, `pick_harness`, `argv_for`, `ExecStrategy`, `RealExec`, `RecordingExec`. - `crates/path-cli/tests/resume.rs` — integration tests with injectable exec strategy. +- `crates/path-cli/tests/support/mod.rs` (or `tests/support.rs`) — shared test helpers. **Modified:** -- `crates/path-cli/src/cmd_export.rs` — add `pub struct ResumeRecipe`; change `write_into_claude_project`, `write_into_gemini_project`, `write_into_codex_project`, `write_into_opencode_db`, `write_into_pi_project` to return `Result`; have each `run_`'s project-mode arm format the recipe to stderr (preserving current output). +- `crates/path-cli/src/cmd_export.rs` — add five `pub(crate) fn project_(path: &Path, project_dir: &Path) -> Result` wrappers. No other change. - `crates/path-cli/src/cmd_import.rs` — extract a `pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Result` from the inner block of `derive_pathbase`. `derive_pathbase` becomes a one-line wrapper. +- `crates/path-cli/src/cmd_pathbase.rs` — promote the test-module `MockServer` and required helpers to `pub(crate)` so cross-test-module use works. - `crates/path-cli/src/lib.rs` — add `Commands::Resume { args: cmd_resume::ResumeArgs }`; wire dispatch. - `crates/path-cli/Cargo.toml` — minor version bump (`0.8.0` → `0.9.0`). - `Cargo.toml` (root) — `[workspace.dependencies]` `path-cli` version bump. @@ -122,320 +136,66 @@ Whenever a task below refers to `path_with_actor(...)` or `make_minimal_` `pub(crate)` wrappers in `cmd_export.rs` **Files:** -- Modify: `crates/path-cli/src/cmd_export.rs:230` (add type near `PathbaseUploadArgs`) -- Modify: `crates/path-cli/src/cmd_export.rs:255-268` (run_claude project arm) and `crates/path-cli/src/cmd_export.rs:321-342` (write_into_claude_project) -- Test: `crates/path-cli/src/cmd_export.rs` (inline `#[cfg(test)] mod tests`) +- Modify: `crates/path-cli/src/cmd_export.rs` -- [ ] **Step 1: Write the failing test** +These wrappers compose the existing build + write private helpers and return the projected session id as a `String`. No behavior change to `path export `. The five wrappers are sibling-shaped; we add and test them all in one task to keep the refactor batched. -Append to the existing tests module in `cmd_export.rs` (find it near the bottom of the file under `#[cfg(test)] mod tests {`): +- [ ] **Step 1: Read the existing private helpers** -```rust -#[test] -#[cfg(not(target_os = "emscripten"))] -fn write_into_claude_project_returns_recipe() { - let tmp = tempfile::tempdir().unwrap(); - let path = make_path_with_actor("agent:claude-code"); // see "Type and API quick reference" +Read `crates/path-cli/src/cmd_export.rs`, focusing on: +- `build_claude_conversation`, `serialize_jsonl`, `write_into_claude_project` +- `build_gemini_conversation`, `write_into_gemini_project` +- `build_codex_session`, `write_into_codex_project` +- `build_opencode_session`, `write_into_opencode_db` +- `build_pi_session`, `write_into_pi_project` - let conv = build_claude_conversation(&path).unwrap(); - let jsonl = serialize_jsonl(&conv).unwrap(); - let recipe = write_into_claude_project(&conv, &jsonl, tmp.path()).unwrap(); +Confirm the signatures match the "Existing `cmd_export.rs` private helpers" table above. Note any deviations (most likely: `build__*` take `input: &str` cache id, not `path: &Path`; rework if so). - assert_eq!(recipe.binary, "claude"); - assert_eq!(recipe.args, vec!["-r".to_string(), conv.session_id.clone()]); - assert_eq!(recipe.session_id, conv.session_id); - assert_eq!(recipe.cwd_for_recipe, std::fs::canonicalize(tmp.path()).unwrap()); -} -``` +- [ ] **Step 2: Write failing tests for all five wrappers** -If `make_path_with_actor` and `make_step` aren't already in scope, add them to the test module — they're used throughout the plan's tests. Crib the bodies from the "Type and API quick reference" section above (or copy `cmd_merge.rs::tests::{make_step, make_path}` directly). - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cargo test -p path-cli --lib write_into_claude_project_returns_recipe -``` - -Expected: FAIL — `write_into_claude_project` currently returns `Result`, not `Result`. - -- [ ] **Step 3: Add the `ResumeRecipe` type** - -Insert near `PathbaseUploadArgs` (around `cmd_export.rs:230`): - -```rust -/// What `path resume` needs to launch a harness's interactive resume -/// after a successful project-mode export. Returned by every -/// `write_into__project` helper. -#[cfg(not(target_os = "emscripten"))] -#[derive(Debug, Clone)] -pub struct ResumeRecipe { - /// Binary name as it appears on PATH (e.g. `"claude"`, `"codex"`). - pub binary: &'static str, - /// Argv after the binary name (e.g. `["-r", ""]`). - pub args: Vec, - /// Session id the recipe targets. Convenience accessor — also - /// embedded in `args` when relevant. - pub session_id: String, - /// Directory the harness must be invoked from. Already canonicalized. - pub cwd_for_recipe: std::path::PathBuf, -} -``` - -- [ ] **Step 4: Refactor `write_into_claude_project` to return the recipe** - -Replace the existing function body: +Append to the existing tests module in `cmd_export.rs` (find it near the bottom of the file under `#[cfg(test)] mod tests {`). First add the shared fixture helpers if they're not already there: ```rust #[cfg(not(target_os = "emscripten"))] -fn write_into_claude_project( - conv: &toolpath_claude::Conversation, - jsonl: &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 project_path = project_dir.to_string_lossy(); - - let resolver = toolpath_claude::PathResolver::new(); - let claude_project_dir = resolver - .project_dir(&project_path) - .map_err(|e| anyhow::anyhow!("Cannot resolve Claude project dir: {}", e))?; - - std::fs::create_dir_all(&claude_project_dir) - .with_context(|| format!("create {}", claude_project_dir.display()))?; - - let session_id = conv.session_id.clone(); - let out_path = claude_project_dir.join(format!("{}.jsonl", session_id)); - std::fs::write(&out_path, jsonl).with_context(|| format!("write {}", out_path.display()))?; - - Ok(ResumeRecipe { - binary: "claude", - args: vec!["-r".to_string(), session_id.clone()], - session_id, - cwd_for_recipe: project_dir, - }) -} -``` - -- [ ] **Step 5: Update `run_claude`'s project arm to print from the recipe** - -In `run_claude` (around line 255), the `(Some(project_dir), None)` branch becomes: - -```rust -(Some(project_dir), None) => { - let recipe = write_into_claude_project(&conversation, &jsonl, &project_dir)?; - let session_id = &recipe.session_id; - eprintln!( - "Exported session {} ({} entries) → {}", - session_id, - conversation.preamble.len() + conversation.entries.len(), - recipe.cwd_for_recipe.display() - ); - eprintln!(); - eprintln!("Resume with:"); - eprintln!( - " cd {} && {} {}", - recipe.cwd_for_recipe.display(), - recipe.binary, - recipe.args.join(" ") - ); -} -``` - -(Note: the existing message says `"Exported session ... → "` showing the JSONL filename. Switch to `recipe.cwd_for_recipe` so the recipe-print is self-contained — the file path is implied by the harness's resolver and isn't useful to the user.) - -- [ ] **Step 6: Run test to verify it passes** - -```bash -cargo test -p path-cli --lib write_into_claude_project_returns_recipe -``` - -Expected: PASS. - -- [ ] **Step 7: Run the full export tests to confirm no regressions** - -```bash -cargo test -p path-cli --lib cmd_export -``` - -Expected: all pass. - -- [ ] **Step 8: Commit** - -```bash -git add crates/path-cli/src/cmd_export.rs -git commit -m "refactor(path-cli): return ResumeRecipe from claude project-mode export" -``` - ---- - -## Task 2: Refactor Gemini project-mode writer to return `ResumeRecipe` - -**Files:** -- Modify: `crates/path-cli/src/cmd_export.rs:407-441` (write_into_gemini_project) and the caller `run_gemini` (~line 368) -- Test: `crates/path-cli/src/cmd_export.rs` (inline tests module) - -- [ ] **Step 1: Write the failing test** - -```rust -#[test] -#[cfg(not(target_os = "emscripten"))] -fn write_into_gemini_project_returns_recipe() { - let tmp = tempfile::tempdir().unwrap(); - let project_path = tmp.path().to_string_lossy().to_string(); - - let path = make_path_with_actor("agent:gemini-cli"); - let view = toolpath_convo::extract_conversation(&path); - let project_hash = toolpath_gemini::paths::project_hash(&project_path); - let projector = toolpath_gemini::project::GeminiProjector::new() - .with_project_hash(project_hash) - .with_project_path(project_path.clone()); - let conv = projector.project(&view).unwrap(); - - let recipe = write_into_gemini_project(&conv, &project_path).unwrap(); - - assert_eq!(recipe.binary, "gemini"); - assert_eq!(recipe.args, vec!["--resume".to_string(), conv.session_uuid.clone()]); - assert_eq!(recipe.session_id, conv.session_uuid); - assert_eq!(recipe.cwd_for_recipe, std::path::PathBuf::from(&project_path)); +fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step { + toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z") + .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new") } -``` -- [ ] **Step 2: Run test to verify it fails** - -```bash -cargo test -p path-cli --lib write_into_gemini_project_returns_recipe -``` - -Expected: FAIL — current return type is `Result<()>`. - -- [ ] **Step 3: Refactor `write_into_gemini_project`** - -Replace the body (lines 407-441): - -```rust #[cfg(not(target_os = "emscripten"))] -fn write_into_gemini_project( - conversation: &toolpath_gemini::types::Conversation, - project_path: &str, -) -> Result { - let resolver = toolpath_gemini::PathResolver::new(); - let chats_dir = resolver - .chats_dir(project_path) - .map_err(|e| anyhow::anyhow!("Cannot resolve Gemini chats dir: {}", e))?; - std::fs::create_dir_all(&chats_dir) - .with_context(|| format!("create {}", chats_dir.display()))?; - - if let Some(slot_dir) = chats_dir.parent() { - let marker = slot_dir.join(".project_root"); - if !marker.exists() { - let _ = std::fs::write(&marker, format!("{}\n", project_path)); - } +fn make_path_with_actor(actor: &str) -> toolpath::v1::Path { + use toolpath::v1::{Path, PathIdentity}; + let step = make_step_with_actor("s1", actor); + Path { + path: PathIdentity { + id: "p1".to_string(), + base: None, + head: "s1".to_string(), + graph_ref: None, + }, + steps: vec![step], + meta: None, } - - let main_stem = gemini_main_stem(conversation); - let main_path = chats_dir.join(format!("{}.json", main_stem)); - let written = write_main_and_subs(conversation, &main_path)?; - - print_summary(conversation, &written, &chats_dir); - - Ok(ResumeRecipe { - binary: "gemini", - args: vec!["--resume".to_string(), conversation.session_uuid.clone()], - session_id: conversation.session_uuid.clone(), - cwd_for_recipe: std::path::PathBuf::from(project_path), - }) -} -``` - -- [ ] **Step 4: Update `run_gemini` project arm to print from the recipe** - -In `run_gemini`'s match (around line 368), the `(Some(_), None)` branch becomes: - -```rust -(Some(_), None) => { - let recipe = write_into_gemini_project(&conversation, &project_path)?; - eprintln!(); - eprintln!("Resume with:"); - eprintln!( - " cd {} && {} {}", - recipe.cwd_for_recipe.display(), - recipe.binary, - recipe.args.join(" ") - ); -} -``` - -- [ ] **Step 5: Run test to verify it passes** - -```bash -cargo test -p path-cli --lib write_into_gemini_project_returns_recipe -``` - -Expected: PASS. - -- [ ] **Step 6: Run gemini export tests** - -```bash -cargo test -p path-cli --lib gemini -``` - -Expected: pass (in particular `gemini_writes_resume_ready_layout`). - -- [ ] **Step 7: Commit** - -```bash -git add crates/path-cli/src/cmd_export.rs -git commit -m "refactor(path-cli): return ResumeRecipe from gemini project-mode export" -``` - ---- - -## Task 3: Refactor Codex project-mode writer to return `ResumeRecipe` - -**Files:** -- Modify: `crates/path-cli/src/cmd_export.rs:765-815` (write_into_codex_project) and run_codex caller (~line 732) -- Test: `crates/path-cli/src/cmd_export.rs` (inline tests module) - -- [ ] **Step 1: Write the failing test** - -```rust -#[test] -#[cfg(not(target_os = "emscripten"))] -fn write_into_codex_project_returns_recipe() { - let _home = scoped_home(tempfile::tempdir().unwrap()); // see Step 2 - let path = make_path_with_actor("agent:codex"); - let session = build_codex_session_for_test(&path, "/tmp/x"); - let recipe = write_into_codex_project(&session).unwrap(); - - assert_eq!(recipe.binary, "codex"); - assert_eq!(recipe.args, vec!["resume".to_string(), session.id.clone()]); - assert_eq!(recipe.session_id, session.id); - // codex resume reads state_5.sqlite, so cwd doesn't matter for invocation; - // the recipe records cwd as the recorded session cwd for completeness. - assert_eq!(recipe.cwd_for_recipe, std::path::PathBuf::from("/tmp/x")); } -``` - -- [ ] **Step 2: Add the `scoped_home` and codex fixture helpers** -In the tests module, add (or extend if equivalents exist): - -```rust +/// Pin `$HOME` to a tempdir for tests that resolve harness paths. #[cfg(not(target_os = "emscripten"))] struct ScopedHome { _td: tempfile::TempDir, prev: Option } #[cfg(not(target_os = "emscripten"))] -fn scoped_home(td: tempfile::TempDir) -> ScopedHome { - let prev = std::env::var_os("HOME"); - // Safety: tests are single-threaded under `cargo test --test-threads=1` - // for this crate (see existing `cmd_pathbase` test pattern). If the - // crate ever flips to multi-threaded, replace with `serial_test`. - unsafe { std::env::set_var("HOME", td.path()); } - ScopedHome { _td: td, prev } +impl ScopedHome { + fn new() -> Self { + let td = tempfile::tempdir().unwrap(); + let prev = std::env::var_os("HOME"); + // Safety: cmd_export tests already share state via the global cache + // dir; treat them as serial. If the crate ever flips to multi-threaded + // tests, replace with `serial_test`. + unsafe { std::env::set_var("HOME", td.path()); } + Self { _td: td, prev } + } } #[cfg(not(target_os = "emscripten"))] @@ -449,404 +209,241 @@ impl Drop for ScopedHome { } } } - -#[cfg(not(target_os = "emscripten"))] -fn build_codex_session_for_test(path: &toolpath::v1::Path, cwd: &str) -> toolpath_codex::Session { - use toolpath_convo::ConversationProjector; - let view = toolpath_convo::extract_conversation(path); - let projector = toolpath_codex::project::CodexProjector::new().with_cwd(cwd.to_string()); - projector.project(&view).unwrap() -} -``` - -(Verify whether the existing tests already define a `scoped_home`-like helper — if so, reuse it instead of duplicating.) - -- [ ] **Step 3: Run test to verify it fails** - -```bash -cargo test -p path-cli --lib write_into_codex_project_returns_recipe ``` -Expected: FAIL — current return type is `Result<()>`. - -- [ ] **Step 4: Refactor `write_into_codex_project`** - -Find the existing body (line 765 onwards). Replace the trailing `eprintln!()` block + `Ok(())` with the recipe-returning shape: +Then add five tests: ```rust +#[test] #[cfg(not(target_os = "emscripten"))] -fn write_into_codex_project(session: &toolpath_codex::Session) -> Result { - let session_ts = codex_session_timestamp(session)?; - let resolver = toolpath_codex::PathResolver::new(); - let sessions_root = resolver - .sessions_root() - .map_err(|e| anyhow::anyhow!("Cannot resolve Codex sessions dir: {}", e))?; - - let date_dir = sessions_root - .join(session_ts.format("%Y").to_string()) - .join(session_ts.format("%m").to_string()) - .join(session_ts.format("%d").to_string()); - std::fs::create_dir_all(&date_dir).with_context(|| format!("create {}", date_dir.display()))?; - - let stem = codex_rollout_stem(session, &session_ts); - let out_path = date_dir.join(format!("{}.jsonl", stem)); - let bytes = serialize_codex_jsonl(session)?; - std::fs::write(&out_path, &bytes).with_context(|| format!("write {}", out_path.display()))?; - - let codex_dir = resolver - .codex_dir() - .map_err(|e| anyhow::anyhow!("Cannot resolve ~/.codex dir: {}", e))?; - let registration = register_codex_thread(&codex_dir, session, &out_path, &session_ts); - - eprintln!( - "Exported Codex session {} ({} lines) → {}", - session.id, - session.lines.len(), - out_path.display() - ); - match registration { - Ok(true) => eprintln!(" registered in {}/state_5.sqlite", codex_dir.display()), - Ok(false) => eprintln!( - " warning: state_5.sqlite not found at {} — `codex resume` won't see this session", - codex_dir.display() - ), - Err(e) => eprintln!( - " warning: failed to register thread in state_5.sqlite: {} — `codex resume` may not see this session", - e - ), - } +fn project_claude_returns_session_id_and_writes_jsonl() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let path = make_path_with_actor("agent:claude-code"); - let recorded_cwd = session - .meta() - .map(|m| m.cwd.clone()) - .unwrap_or_else(|| std::path::PathBuf::from("/")); + let session_id = project_claude(&path, cwd.path()).unwrap(); + assert!(!session_id.is_empty(), "session id should be non-empty"); - Ok(ResumeRecipe { - binary: "codex", - args: vec!["resume".to_string(), session.id.clone()], - session_id: session.id.clone(), - cwd_for_recipe: recorded_cwd, - }) + // The projected JSONL must land somewhere under HOME/.claude/projects/. + let projects = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()) + .join(".claude/projects"); + assert!(projects.exists(), "claude projects dir missing under HOME"); } -``` -- [ ] **Step 5: Update `run_codex` project arm** +#[test] +#[cfg(not(target_os = "emscripten"))] +fn project_gemini_returns_session_id_and_writes_chat_file() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let path = make_path_with_actor("agent:gemini-cli"); -In `run_codex` (around line 732), the `(Some(_), None)` branch becomes: + let session_id = project_gemini(&path, cwd.path()).unwrap(); + assert!(!session_id.is_empty()); -```rust -(Some(_), None) => { - let recipe = write_into_codex_project(&session)?; - eprintln!(); - eprintln!("Loadable via:"); - eprintln!(" path import codex --session {}", recipe.session_id); - eprintln!(); - eprintln!("Open conversation with:"); - eprintln!(" {} {}", recipe.binary, recipe.args.join(" ")); + let tmp_root = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()) + .join(".gemini/tmp"); + assert!(tmp_root.exists(), "gemini tmp dir missing"); } -``` - -- [ ] **Step 6: Run test to verify it passes** -```bash -cargo test -p path-cli --lib write_into_codex_project_returns_recipe -``` - -Expected: PASS. - -- [ ] **Step 7: Run codex export tests** - -```bash -cargo test -p path-cli --lib codex -``` - -Expected: pass. - -- [ ] **Step 8: Commit** - -```bash -git add crates/path-cli/src/cmd_export.rs -git commit -m "refactor(path-cli): return ResumeRecipe from codex project-mode export" -``` - ---- - -## Task 4: Refactor opencode project-mode writer to return `ResumeRecipe` +#[test] +#[cfg(not(target_os = "emscripten"))] +fn project_codex_returns_session_id_and_writes_rollout() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let path = make_path_with_actor("agent:codex"); -**Files:** -- Modify: `crates/path-cli/src/cmd_export.rs:1024-1076` (write_into_opencode_db) and run_opencode caller (~line 985) -- Test: inline tests module + let session_id = project_codex(&path, cwd.path()).unwrap(); + assert!(!session_id.is_empty()); -- [ ] **Step 1: Write the failing test** + let sessions = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()) + .join(".codex/sessions"); + assert!(sessions.exists(), "codex sessions dir missing"); +} -```rust #[test] #[cfg(not(target_os = "emscripten"))] -fn write_into_opencode_db_returns_recipe() { - let _home = scoped_home(tempfile::tempdir().unwrap()); - // Pre-create an empty opencode.db so the writer doesn't bail. - let db_dir = dirs::data_local_dir().unwrap().join("opencode"); - std::fs::create_dir_all(&db_dir).unwrap(); - let db_path = db_dir.join("opencode.db"); +fn project_opencode_returns_session_id_and_inserts_row() { + // Pre-create an opencode db with the canonical schema so the writer + // doesn't bail. Locate the schema bootstrap helper used by existing + // opencode tests in `crates/toolpath-opencode/src/` and call it. + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let resolver = toolpath_opencode::PathResolver::new(); + let db_path = resolver.db_path().unwrap(); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); { let conn = rusqlite::Connection::open(&db_path).unwrap(); - // Minimal schema — copy from `toolpath_opencode::schema::CREATE_SQL` - // or whatever the production schema bootstrap is. (See - // existing opencode tests for the helper, if any.) + // Substitute the actual bootstrap helper name if different. toolpath_opencode::schema::apply_full_schema(&conn).unwrap(); } let path = make_path_with_actor("agent:opencode"); - let session = build_opencode_session(&path, Some(std::path::Path::new("/tmp/x"))).unwrap(); - let project_dir = tempfile::tempdir().unwrap(); - let recipe = write_into_opencode_db(&session, project_dir.path()).unwrap(); - - assert_eq!(recipe.binary, "opencode"); - assert_eq!(recipe.args, vec!["--session".to_string(), session.id.clone()]); - assert_eq!(recipe.session_id, session.id); - assert_eq!( - recipe.cwd_for_recipe, - std::fs::canonicalize(project_dir.path()).unwrap() - ); -} -``` - -If `toolpath_opencode::schema::apply_full_schema` doesn't exist, locate the canonical bootstrap helper that existing opencode tests use (search `crates/toolpath-opencode/src/`) and substitute the right name. - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cargo test -p path-cli --lib write_into_opencode_db_returns_recipe -``` - -Expected: FAIL — return type mismatch. - -- [ ] **Step 3: Refactor `write_into_opencode_db`** - -Replace the function body, swapping the two `eprintln!` "Loadable via:" / "Open conversation with:" blocks for a returned `ResumeRecipe`: + let session_id = project_opencode(&path, cwd.path()).unwrap(); + assert!(!session_id.is_empty()); -```rust -#[cfg(not(target_os = "emscripten"))] -fn write_into_opencode_db( - session: &toolpath_opencode::Session, - project_dir: &std::path::Path, -) -> Result { - use toolpath_opencode::PathResolver; - - let project_dir = std::fs::canonicalize(project_dir) - .with_context(|| format!("resolve project path {}", project_dir.display()))?; - - let resolver = PathResolver::new(); - let db_path = resolver - .db_path() - .map_err(|e| anyhow::anyhow!("Cannot resolve opencode db path: {}", e))?; - if !db_path.exists() { - anyhow::bail!( - "opencode database not found at {} — has opencode been run on this machine?", - db_path.display() - ); - } - - let mut conn = rusqlite::Connection::open(&db_path) - .with_context(|| format!("open {}", db_path.display()))?; - let tx = conn.transaction()?; - - ensure_opencode_project(&tx, &session.project_id, &project_dir, session.time_created)?; - insert_opencode_session(&tx, session)?; - let mut message_count = 0_usize; - let mut part_count = 0_usize; - for message in &session.messages { - insert_opencode_message(&tx, message)?; - message_count += 1; - for part in &message.parts { - insert_opencode_part(&tx, part)?; - part_count += 1; - } - } - tx.commit()?; - - eprintln!( - "Exported opencode session {} ({} messages, {} parts) → {}", - session.id, - message_count, - part_count, - db_path.display() - ); - - Ok(ResumeRecipe { - binary: "opencode", - args: vec!["--session".to_string(), session.id.clone()], - session_id: session.id.clone(), - cwd_for_recipe: project_dir, - }) + // Verify the session row exists. + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM session WHERE id = ?1", [&session_id], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); } -``` - -**Verify the actual opencode resume invocation.** Read `crates/toolpath-opencode/README.md` or the opencode CLI's own help — if the canonical resume command is something other than `opencode --session `, replace `args` with the right shape. (Today's `eprintln!` says `opencode --session `, so that's the assumption baked in.) -- [ ] **Step 4: Update `run_opencode` project arm** +#[test] +#[cfg(not(target_os = "emscripten"))] +fn project_pi_returns_session_id_and_writes_jsonl() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let path = make_path_with_actor("agent:pi"); -In `run_opencode` (around line 985), the `(Some(project_dir), None)` branch becomes: + let session_id = project_pi(&path, cwd.path()).unwrap(); + assert!(!session_id.is_empty()); -```rust -(Some(project_dir), None) => { - let session = build_opencode_session(&path, Some(&project_dir))?; - let recipe = write_into_opencode_db(&session, &project_dir)?; - eprintln!(); - eprintln!("Loadable via:"); - eprintln!(" path import opencode --session {}", recipe.session_id); - eprintln!(); - eprintln!("Open conversation with:"); - eprintln!(" {} {}", recipe.binary, recipe.args.join(" ")); + let sessions = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()) + .join(".pi/agent/sessions"); + assert!(sessions.exists(), "pi sessions dir missing"); } ``` -- [ ] **Step 5: Run test to verify it passes** - -```bash -cargo test -p path-cli --lib write_into_opencode_db_returns_recipe -``` - -Expected: PASS. - -- [ ] **Step 6: Run opencode export tests** - -```bash -cargo test -p path-cli --lib opencode -``` - -Expected: pass. +If `toolpath_opencode::schema::apply_full_schema` doesn't exist, locate the canonical schema-apply helper used by existing opencode tests (search `crates/toolpath-opencode/src/`) and use that name. -- [ ] **Step 7: Commit** +- [ ] **Step 3: Run tests to verify they fail** ```bash -git add crates/path-cli/src/cmd_export.rs -git commit -m "refactor(path-cli): return ResumeRecipe from opencode project-mode export" +cargo test -p path-cli --lib project_claude project_gemini project_codex project_opencode project_pi ``` ---- - -## Task 5: Refactor Pi project-mode writer to return `ResumeRecipe` +Expected: FAIL — none of the wrappers exist yet. -**Files:** -- Modify: `crates/path-cli/src/cmd_export.rs:622-650` (write_into_pi_project) and run_pi caller (search for `run_pi` in the file) -- Test: inline tests module +- [ ] **Step 4: Implement the five wrappers** -- [ ] **Step 1: Write the failing test** +Add near the top of `cmd_export.rs`, after the existing `pub(crate) struct PathbaseUploadArgs` (around line 230). Each wrapper composes the existing private build + write helpers and returns the projected session id. ```rust -#[test] #[cfg(not(target_os = "emscripten"))] -fn write_into_pi_project_returns_recipe() { - let _home = scoped_home(tempfile::tempdir().unwrap()); - let path = make_path_with_actor("agent:pi"); - let session = build_pi_session_for_test(&path, "/tmp/x"); - let recipe = write_into_pi_project(&session, "/tmp/x").unwrap(); - - assert_eq!(recipe.binary, "pi"); - assert_eq!(recipe.args, vec!["--session".to_string(), session.header.id.clone()]); - assert_eq!(recipe.session_id, session.header.id); - assert_eq!(recipe.cwd_for_recipe, std::path::PathBuf::from("/tmp/x")); +pub(crate) fn project_claude( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + let conv = build_claude_conversation(path)?; + let jsonl = serialize_jsonl(&conv)?; + write_into_claude_project(&conv, &jsonl, project_dir)?; + Ok(conv.session_id) } #[cfg(not(target_os = "emscripten"))] -fn build_pi_session_for_test(path: &toolpath::v1::Path, cwd: &str) -> toolpath_pi::PiSession { +pub(crate) fn project_gemini( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { use toolpath_convo::ConversationProjector; + let project_dir = std::fs::canonicalize(project_dir) + .with_context(|| format!("resolve project path {}", project_dir.display()))?; + let project_path = project_dir.to_string_lossy().to_string(); + let view = toolpath_convo::extract_conversation(path); - let projector = toolpath_pi::project::PiProjector::new().with_cwd(cwd.to_string()); - projector.project(&view).unwrap() + let project_hash = toolpath_gemini::paths::project_hash(&project_path); + let projector = toolpath_gemini::project::GeminiProjector::new() + .with_project_hash(project_hash) + .with_project_path(project_path.clone()); + let conv = projector + .project(&view) + .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; + if conv.session_uuid.is_empty() { + anyhow::bail!("Projected conversation has no session UUID"); + } + write_into_gemini_project(&conv, &project_path)?; + Ok(conv.session_uuid) } -``` - -- [ ] **Step 2: Run test to verify it fails** -```bash -cargo test -p path-cli --lib write_into_pi_project_returns_recipe -``` - -Expected: FAIL. - -- [ ] **Step 3: Refactor `write_into_pi_project`** - -```rust #[cfg(not(target_os = "emscripten"))] -fn write_into_pi_project(session: &toolpath_pi::PiSession, cwd: &str) -> Result { - let resolver = toolpath_pi::PathResolver::new(); - let project_dir = resolver.project_dir(cwd); - std::fs::create_dir_all(&project_dir) - .with_context(|| format!("create {}", project_dir.display()))?; - - let stem = pi_session_stem(session); - let out_path = project_dir.join(format!("{}.jsonl", stem)); - let bytes = serialize_pi_jsonl(session)?; - std::fs::write(&out_path, &bytes).with_context(|| format!("write {}", out_path.display()))?; - - let entry_count = session.entries.len().saturating_sub(1); - eprintln!( - "Exported Pi session {} ({} entries) → {}", - session.header.id, - entry_count, - out_path.display() - ); +pub(crate) fn project_codex( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + use toolpath_convo::ConversationProjector; + let project_dir = std::fs::canonicalize(project_dir) + .with_context(|| format!("resolve project path {}", project_dir.display()))?; + let cwd_str = project_dir.to_string_lossy().to_string(); - Ok(ResumeRecipe { - binary: "pi", - args: vec!["--session".to_string(), session.header.id.clone()], - session_id: session.header.id.clone(), - cwd_for_recipe: std::path::PathBuf::from(cwd), - }) + let view = toolpath_convo::extract_conversation(path); + let projector = toolpath_codex::project::CodexProjector::new().with_cwd(cwd_str); + let session = projector + .project(&view) + .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; + if session.id.is_empty() { + anyhow::bail!("Projected session has no id"); + } + write_into_codex_project(&session)?; + Ok(session.id) } -``` -- [ ] **Step 4: Update `run_pi` project arm** +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_opencode( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + let session = build_opencode_session(path, Some(project_dir))?; + let id = session.id.clone(); + write_into_opencode_db(&session, project_dir)?; + Ok(id) +} -Find the `(Some(_), None)` branch in `run_pi`, replace with: +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_pi( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + use toolpath_convo::ConversationProjector; + let project_dir = std::fs::canonicalize(project_dir) + .with_context(|| format!("resolve project path {}", project_dir.display()))?; + let cwd_str = project_dir.to_string_lossy().to_string(); -```rust -(Some(_), None) => { - let recipe = write_into_pi_project(&session, &cwd_str)?; - eprintln!(); - eprintln!("Loadable via:"); - eprintln!( - " path import pi --session {} --project {}", - recipe.session_id, - recipe.cwd_for_recipe.display() - ); - eprintln!(); - eprintln!("Open conversation with:"); - eprintln!(" {} {}", recipe.binary, recipe.args.join(" ")); + let view = toolpath_convo::extract_conversation(path); + let projector = toolpath_pi::project::PiProjector::new().with_cwd(cwd_str.clone()); + let session = projector + .project(&view) + .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; + if session.header.id.is_empty() { + anyhow::bail!("Projected session has no id"); + } + write_into_pi_project(&session, &cwd_str)?; + Ok(session.header.id) } ``` -- [ ] **Step 5: Run test to verify it passes** +(`project_claude` doesn't canonicalize because `write_into_claude_project` already does. `project_opencode` doesn't either, because `build_opencode_session` already passes the dir to the projector. The other three canonicalize here because their write helpers don't.) + +- [ ] **Step 5: Run the new tests** ```bash -cargo test -p path-cli --lib write_into_pi_project_returns_recipe +cargo test -p path-cli --lib project_claude project_gemini project_codex project_opencode project_pi ``` Expected: PASS. -- [ ] **Step 6: Run pi export tests** +- [ ] **Step 6: Run the full export tests to confirm no regressions** ```bash -cargo test -p path-cli --lib pi +cargo test -p path-cli --lib cmd_export ``` -Expected: pass (in particular `pi_writes_resume_ready_layout`). +Expected: all pass. - [ ] **Step 7: Commit** ```bash git add crates/path-cli/src/cmd_export.rs -git commit -m "refactor(path-cli): return ResumeRecipe from pi project-mode export" +git commit -m "feat(path-cli): pub(crate) project_ wrappers in cmd_export" ``` --- -## Task 6: Extract `pathbase_fetch_to_doc` from `cmd_import.rs` +## Task 2: Extract `pathbase_fetch_to_doc` from `cmd_import.rs` **Files:** - Modify: `crates/path-cli/src/cmd_import.rs:1362-1388` (derive_pathbase) +- Modify: `crates/path-cli/src/cmd_pathbase.rs` — promote `MockServer` test helpers to `pub(crate)` so a sibling test module can use them. - [ ] **Step 1: Write the failing test** @@ -857,7 +454,7 @@ In `cmd_import.rs`'s tests module (or in a new `#[cfg(test)] mod pathbase_fetch_ #[cfg(not(target_os = "emscripten"))] fn pathbase_fetch_to_doc_url_input() { use crate::cmd_pathbase::tests::MockServer; - let body = r#"{"Path":{"id":"p1","actor":"agent:claude-code","steps":[]}}"#; + let body = r#"{"graph":{"id":"g1"},"paths":[{"path":{"id":"p1","head":"s1"},"steps":[{"step":{"id":"s1","actor":"agent:claude-code","timestamp":"2026-01-01T00:00:00Z"},"change":{}}]}]}"#; let server = MockServer::start("HTTP/1.1 200 OK", body); let url = format!("{}/alex/pathstash/my-path", server.base()); @@ -868,7 +465,7 @@ fn pathbase_fetch_to_doc_url_input() { } ``` -If `cmd_pathbase::tests::MockServer` is not yet `pub(crate)`, this test will fail to compile — Step 3 below adds the visibility. +(Adjust the JSON body shape to whatever `Graph::from_json` actually accepts — read existing pathbase tests in `cmd_pathbase.rs` and `cmd_import.rs` for the canonical body string.) - [ ] **Step 2: Run test to verify it fails** @@ -876,28 +473,26 @@ If `cmd_pathbase::tests::MockServer` is not yet `pub(crate)`, this test will fai cargo test -p path-cli --lib pathbase_fetch_to_doc_url_input ``` -Expected: FAIL — `pathbase_fetch_to_doc` doesn't exist; possibly also `MockServer` isn't pub(crate). +Expected: FAIL — `pathbase_fetch_to_doc` doesn't exist; possibly also `MockServer` isn't reachable from sibling test modules. - [ ] **Step 3: Make `MockServer` reachable from sibling tests** -In `crates/path-cli/src/cmd_pathbase.rs`, change the existing test module declaration so the helper is reachable from sibling test modules: +In `crates/path-cli/src/cmd_pathbase.rs`, change the existing test module declaration so its helper is reachable from sibling test modules: ```rust #[cfg(test)] pub(crate) mod tests { - // (existing contents unchanged; the only change is `pub(crate)` and - // promoting `MockServer` + its `impl` block to `pub(crate)`.) - pub(crate) struct MockServer { /* ... */ } + // (existing contents unchanged; the only changes are `pub(crate)` on the + // module itself and on `MockServer` + the methods the new caller needs.) + pub(crate) struct MockServer { /* leave existing fields */ } impl MockServer { - pub(crate) fn start(/* ... */) -> Self { /* ... */ } - pub(crate) fn base(&self) -> String { /* ... */ } - // ... + pub(crate) fn start(/* same signature */) -> Self { /* leave body */ } + pub(crate) fn base(&self) -> String { /* leave body */ } + // …promote only what the new test consumes. } } ``` -Promote only the items the new test needs. Existing tests inside the module continue to work unchanged. - - [ ] **Step 4: Extract the function** Replace `derive_pathbase`'s body (lines 1362-1388) with a wrapper, and add the extracted helper just above it: @@ -965,13 +560,13 @@ git commit -m "refactor(path-cli): extract pathbase_fetch_to_doc helper" --- -## Task 7: Scaffold `cmd_resume.rs` — types, args, lib.rs wiring +## Task 3: Scaffold `cmd_resume.rs` — types, args, lib.rs wiring **Files:** - Create: `crates/path-cli/src/cmd_resume.rs` - Modify: `crates/path-cli/src/lib.rs:45-180` (Commands enum + dispatch) -- [ ] **Step 1: Write a stub failing test** +- [ ] **Step 1: Create the module with stub run + test** Create `crates/path-cli/src/cmd_resume.rs`: @@ -984,7 +579,7 @@ Create `crates/path-cli/src/cmd_resume.rs`: #![cfg(not(target_os = "emscripten"))] -use anyhow::Result; +use anyhow::{Context, Result}; use clap::Args; use std::path::PathBuf; @@ -1098,7 +693,7 @@ git commit -m "feat(path-cli): scaffold path resume command (stub)" --- -## Task 8: Implement `infer_source_harness` and `ensure_path_with_agent` +## Task 4: Implement `infer_source_harness` and `ensure_path_with_agent` **Files:** - Modify: `crates/path-cli/src/cmd_resume.rs` @@ -1110,16 +705,30 @@ Append to `cmd_resume.rs`'s tests module. There is no `Document` enum in this co ```rust use crate::cmd_share::Harness; use toolpath::v1::{Graph, PathMeta, PathOrRef}; -// `make_path_with_actor` and `make_step` come from the type-reference snippet -// at the top of this plan. -fn graph_of(path: toolpath::v1::Path) -> Graph { - Graph::from_path(path) +fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step { + toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z") + .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new") +} + +fn make_path_with_actor(actor: &str) -> toolpath::v1::Path { + use toolpath::v1::{Path, PathIdentity}; + let step = make_step_with_actor("s1", actor); + Path { + path: PathIdentity { + id: "p1".to_string(), + base: None, + head: "s1".to_string(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + } } #[test] fn infer_source_harness_meta_source_wins() { - let mut path = make_path_with_actor("agent:codex"); // actor sniff would say codex… + let mut path = make_path_with_actor("agent:codex"); path.meta = Some(PathMeta { source: Some("claude-code".to_string()), ..Default::default() @@ -1163,14 +772,13 @@ fn infer_source_harness_returns_none_when_no_signal() { #[test] fn ensure_path_with_agent_accepts_single_path_with_agent_actor() { - let g = graph_of(make_path_with_actor("agent:claude-code")); + let g = Graph::from_path(make_path_with_actor("agent:claude-code")); assert!(ensure_path_with_agent(&g).is_ok()); } #[test] fn ensure_path_with_agent_rejects_empty_graph() { - let g = Graph::from_path(make_path_with_actor("agent:claude-code")); // start with one - let mut g = g; + let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); g.paths.clear(); let err = ensure_path_with_agent(&g).unwrap_err(); assert!(err.to_string().contains("expected")); @@ -1179,7 +787,6 @@ fn ensure_path_with_agent_rejects_empty_graph() { #[test] fn ensure_path_with_agent_rejects_multi_path_graph() { - use toolpath::v1::PathOrRef; let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); g.paths.push(PathOrRef::Path(Box::new(make_path_with_actor("agent:claude-code")))); let err = ensure_path_with_agent(&g).unwrap_err(); @@ -1190,14 +797,14 @@ fn ensure_path_with_agent_rejects_multi_path_graph() { #[test] fn ensure_path_with_agent_rejects_agentless_path() { - let g = graph_of(make_path_with_actor("human:alex")); + let g = Graph::from_path(make_path_with_actor("human:alex")); let err = ensure_path_with_agent(&g).unwrap_err(); assert!(err.to_string().contains("no agent session")); } #[test] fn ensure_path_with_agent_rejects_path_ref_only_graph() { - use toolpath::v1::{PathOrRef, PathRef}; + use toolpath::v1::PathRef; let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); g.paths = vec![PathOrRef::Ref(PathRef { ref_url: "$ref://something".into() })]; let err = ensure_path_with_agent(&g).unwrap_err(); @@ -1281,13 +888,11 @@ pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { .iter() .any(|s| s.step.actor.starts_with("agent:")); if !has_agent { - anyhow::bail!( - "no agent session in input — `path resume` only works on harness-derived paths" - ); - } - Ok(path) - } + anyhow::bail!( + "no agent session in input — `path resume` only works on harness-derived paths" + ); } + Ok(path) } ``` @@ -1308,7 +913,7 @@ git commit -m "feat(path-cli): infer_source_harness and ensure_path_with_agent" --- -## Task 9: Implement `resolve_input` +## Task 5: Implement `resolve_input` **Files:** - Modify: `crates/path-cli/src/cmd_resume.rs` @@ -1438,24 +1043,16 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option fn looks_like_pathbase_shorthand(s: &str) -> bool { // Three non-empty slash-separated segments, none containing whitespace - // or a leading dot (which would indicate a relative file path). + // or a leading dot/slash (which would indicate a relative/absolute path). if s.starts_with('.') || s.starts_with('/') { return false; } let segs: Vec<&str> = s.split('/').collect(); segs.len() == 3 && segs.iter().all(|s| !s.is_empty() && !s.contains(char::is_whitespace)) } ``` -`Graph::single_path` returns `Option<&Path>` — see the type reference. `infer_source_harness` takes `&Path`, so `.and_then(infer_source_harness)` is the right composition. - -- [ ] **Step 4: Add `Context` import and any missing imports** +`Graph::single_path` returns `Option<&Path>`. `infer_source_harness` takes `&Path`, so `.and_then(infer_source_harness)` is the right composition. -Make sure the top of `cmd_resume.rs` has: - -```rust -use anyhow::{Context, Result}; -``` - -- [ ] **Step 5: Run tests to verify they pass** +- [ ] **Step 4: Run tests to verify they pass** ```bash cargo test -p path-cli --lib resolve_input @@ -1463,7 +1060,7 @@ cargo test -p path-cli --lib resolve_input Expected: PASS. -- [ ] **Step 6: Commit** +- [ ] **Step 5: Commit** ```bash git add crates/path-cli/src/cmd_resume.rs @@ -1472,7 +1069,7 @@ git commit -m "feat(path-cli): resolve_input dispatcher for path resume" --- -## Task 10: Implement `pick_harness` non-interactive paths and PATH probe +## Task 6: Implement `pick_harness` non-interactive paths and PATH probe **Files:** - Modify: `crates/path-cli/src/cmd_resume.rs` @@ -1526,8 +1123,6 @@ fn pick_harness_explicit_arg_validates_path() { #[test] fn pick_harness_zero_installed_errors() { let td = fake_path_with(&[]); - // Force non-interactive so we hit the "zero installed" branch - // deterministically — the picker step is exercised in integration tests. let err = pick_harness( None, Some(Harness::Claude), @@ -1541,12 +1136,10 @@ fn pick_harness_zero_installed_errors() { } ``` -(The third test depends on `pick_harness` short-circuiting to the "zero installed" error before consulting `crate::fzf::available()`. The `path_override: Option<&std::path::Path>` parameter exists exclusively for tests.) - - [ ] **Step 2: Run tests to verify they fail** ```bash -cargo test -p path-cli --lib pick_harness +cargo test -p path-cli --lib pick_harness binary_on_path ``` Expected: FAIL. @@ -1581,7 +1174,6 @@ pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path> false } -/// All five harnesses, in the canonical picker order. const ALL_HARNESSES: &[Harness] = &[ Harness::Claude, Harness::Gemini, @@ -1631,14 +1223,9 @@ fn interactive_pick(installed: &[Harness], source: Option) -> Result` or rerun in a terminal" ); } - // Format rows: " " let mut lines: Vec = Vec::with_capacity(installed.len()); for h in installed { - let mut tags: Vec<&str> = Vec::new(); - if Some(*h) == source { - tags.push("source"); - } - let suffix = if tags.is_empty() { String::new() } else { format!(" ({})", tags.join(", ")) }; + let suffix = if Some(*h) == source { " (source)" } else { "" }; lines.push(format!("{}{}", h.symbol(), suffix)); } @@ -1658,7 +1245,6 @@ fn interactive_pick(installed: &[Harness], source: Option) -> Result) -> Result Result { - match harness { - Harness::Claude => crate::cmd_export::project_claude(path, cwd), - Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), - Harness::Codex => crate::cmd_export::project_codex(path, cwd), - Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), - Harness::Pi => crate::cmd_export::project_pi(path, cwd), - } + let session_id = project_into_harness(&path, Harness::Claude, cwd.path()).unwrap(); + assert!(!session_id.is_empty()); } -``` - -- [ ] **Step 4: Add the five `pub(crate) fn project_` thin wrappers in `cmd_export.rs`** - -Each wrapper calls the existing build/write pair without going through `run_` (so the CLI's `--input` / `--output` machinery is bypassed but the on-disk side-effects are identical): -```rust -// Add near the top of cmd_export.rs, after the existing helpers. - -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn project_claude( - path: &toolpath::v1::Path, - project_dir: &std::path::Path, -) -> Result { - let conv = build_claude_conversation(path)?; - let jsonl = serialize_jsonl(&conv)?; - write_into_claude_project(&conv, &jsonl, project_dir) +fn scoped_home_for_resume() -> ScopedHomeForResume { + ScopedHomeForResume::new() } -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn project_gemini( - path: &toolpath::v1::Path, - project_dir: &std::path::Path, -) -> Result { - let project_dir = std::fs::canonicalize(project_dir) - .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let project_path = project_dir.to_string_lossy().to_string(); - // Reuse existing build-from-path path (build_gemini_conversation takes - // an `input: &str` cache id today — refactor to take the path directly). - let view = toolpath_convo::extract_conversation(path); - let project_hash = toolpath_gemini::paths::project_hash(&project_path); - let projector = toolpath_gemini::project::GeminiProjector::new() - .with_project_hash(project_hash) - .with_project_path(project_path.clone()); - use toolpath_convo::ConversationProjector; - let conv = projector - .project(&view) - .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; - if conv.session_uuid.is_empty() { - anyhow::bail!("Projected conversation has no session UUID"); - } - write_into_gemini_project(&conv, &project_path) -} +struct ScopedHomeForResume { _td: tempfile::TempDir, prev: Option } -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn project_codex( - path: &toolpath::v1::Path, - project_dir: &std::path::Path, -) -> Result { - let project_dir = std::fs::canonicalize(project_dir) - .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let cwd_str = project_dir.to_string_lossy().to_string(); - use toolpath_convo::ConversationProjector; - let view = toolpath_convo::extract_conversation(path); - let projector = toolpath_codex::project::CodexProjector::new().with_cwd(cwd_str); - let session = projector - .project(&view) - .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; - if session.id.is_empty() { - anyhow::bail!("Projected session has no id"); +impl ScopedHomeForResume { + fn new() -> Self { + let td = tempfile::tempdir().unwrap(); + let prev = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", td.path()); } + Self { _td: td, prev } } - write_into_codex_project(&session) } -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn project_opencode( - path: &toolpath::v1::Path, - project_dir: &std::path::Path, -) -> Result { - let session = build_opencode_session(path, Some(project_dir))?; - write_into_opencode_db(&session, project_dir) +impl Drop for ScopedHomeForResume { + fn drop(&mut self) { + unsafe { + match &self.prev { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + } } +``` -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn project_pi( - path: &toolpath::v1::Path, - project_dir: &std::path::Path, -) -> Result { - let project_dir = std::fs::canonicalize(project_dir) - .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let cwd_str = project_dir.to_string_lossy().to_string(); - let session = { - use toolpath_convo::ConversationProjector; - let view = toolpath_convo::extract_conversation(path); - let projector = toolpath_pi::project::PiProjector::new().with_cwd(cwd_str.clone()); - projector - .project(&view) - .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))? - }; - if session.header.id.is_empty() { - anyhow::bail!("Projected session has no id"); +- [ ] **Step 2: Run tests to verify they fail** + +```bash +cargo test -p path-cli --lib argv_for project_into_harness_claude_round_trip +``` + +Expected: FAIL. + +- [ ] **Step 3: Implement `argv_for` and `project_into_harness`** + +Append to `cmd_resume.rs`: + +```rust +/// Static map from harness to resume-argv shape. +pub(crate) fn argv_for(harness: Harness, session_id: &str) -> Vec { + match harness { + Harness::Claude => vec!["-r".into(), session_id.into()], + Harness::Gemini => vec!["--resume".into(), session_id.into()], + Harness::Codex => vec!["resume".into(), session_id.into()], + Harness::Opencode => vec!["--session".into(), session_id.into()], + Harness::Pi => vec!["--session".into(), session_id.into()], } - write_into_pi_project(&session, &cwd_str) } -``` -(Each wrapper duplicates a small amount of the corresponding `run_` body. If the duplication bothers a reviewer, a follow-up can collapse the existing `run_` into a thin wrapper around `project_` plus output-mode handling. Out of scope for this plan.) +/// Project a Path into the chosen harness's on-disk layout under `cwd`, +/// returning the projected session id. +pub(crate) fn project_into_harness( + path: &TPath, + harness: Harness, + cwd: &std::path::Path, +) -> Result { + match harness { + Harness::Claude => crate::cmd_export::project_claude(path, cwd), + Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), + Harness::Codex => crate::cmd_export::project_codex(path, cwd), + Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), + Harness::Pi => crate::cmd_export::project_pi(path, cwd), + } +} +``` -- [ ] **Step 5: Run test to verify it passes** +- [ ] **Step 4: Run tests to verify they pass** ```bash -cargo test -p path-cli --lib project_into_harness_claude_round_trip +cargo test -p path-cli --lib argv_for project_into_harness_claude_round_trip ``` Expected: PASS. -- [ ] **Step 6: Commit** +- [ ] **Step 5: Commit** ```bash -git add crates/path-cli/src/cmd_export.rs crates/path-cli/src/cmd_resume.rs -git commit -m "feat(path-cli): project_into_harness dispatcher with per-harness wrappers" +git add crates/path-cli/src/cmd_resume.rs +git commit -m "feat(path-cli): argv_for + project_into_harness dispatcher" ``` --- -## Task 12: Implement `exec_harness` with injectable strategy +## Task 8: Implement `exec_harness` with injectable strategy **Files:** - Modify: `crates/path-cli/src/cmd_resume.rs` @@ -1871,16 +1394,11 @@ git commit -m "feat(path-cli): project_into_harness dispatcher with per-harness ```rust #[test] -fn exec_strategy_recording_captures_recipe() { - let recipe = ResumeRecipe { - binary: "claude", - args: vec!["-r".to_string(), "abc123".to_string()], - session_id: "abc123".to_string(), - cwd_for_recipe: std::path::PathBuf::from("/tmp/x"), - }; +fn exec_strategy_recording_captures_invocation() { let recorder = RecordingExec::default(); let strategy: &dyn ExecStrategy = &recorder; - exec_harness(&recipe, std::path::Path::new("/tmp/x"), strategy).unwrap(); + exec_harness("claude", &["-r".into(), "abc123".into()], std::path::Path::new("/tmp/x"), strategy) + .unwrap(); let captured = recorder.captured(); assert_eq!(captured.binary, "claude"); @@ -1892,7 +1410,7 @@ fn exec_strategy_recording_captures_recipe() { - [ ] **Step 2: Run test to verify it fails** ```bash -cargo test -p path-cli --lib exec_strategy_recording_captures_recipe +cargo test -p path-cli --lib exec_strategy_recording_captures_invocation ``` Expected: FAIL. @@ -1904,33 +1422,33 @@ Append to `cmd_resume.rs`: ```rust /// What `exec_harness` saw (for tests). #[derive(Debug, Clone, Default)] -pub(crate) struct CapturedExec { - pub(crate) binary: String, - pub(crate) args: Vec, - pub(crate) cwd: std::path::PathBuf, +pub struct CapturedExec { + pub binary: String, + pub args: Vec, + pub cwd: std::path::PathBuf, } /// Pluggable exec backend. Production uses `RealExec` (`execvp` on /// Unix, spawn-and-wait on Windows). Tests use `RecordingExec`. -pub(crate) trait ExecStrategy { - fn exec(&self, recipe: &ResumeRecipe, cwd: &std::path::Path) -> Result<()>; +pub trait ExecStrategy { + fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>; } /// Production implementation. On Unix this never returns on success /// (the current process is replaced); on Windows it spawns the child, /// waits, and propagates the exit code. -pub(crate) struct RealExec; +pub struct RealExec; impl ExecStrategy for RealExec { - fn exec(&self, recipe: &ResumeRecipe, cwd: &std::path::Path) -> Result<()> { - let mut cmd = std::process::Command::new(recipe.binary); - cmd.args(&recipe.args); + fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { + let mut cmd = std::process::Command::new(binary); + cmd.args(args); cmd.current_dir(cwd); eprintln!( "Resuming: {} {} (cwd: {})", - recipe.binary, - recipe.args.join(" "), + binary, + args.join(" "), cwd.display() ); @@ -1941,19 +1459,19 @@ impl ExecStrategy for RealExec { let err = cmd.exec(); anyhow::bail!( "couldn't exec `{}`: {}. Recipe: {} {} (run from {})", - recipe.binary, + binary, err, - recipe.binary, - recipe.args.join(" "), + binary, + args.join(" "), cwd.display() ); } #[cfg(not(unix))] { let status = cmd.spawn() - .with_context(|| format!("spawn {}", recipe.binary))? + .with_context(|| format!("spawn {}", binary))? .wait() - .with_context(|| format!("wait for {}", recipe.binary))?; + .with_context(|| format!("wait for {}", binary))?; std::process::exit(status.code().unwrap_or(1)); } } @@ -1962,22 +1480,22 @@ impl ExecStrategy for RealExec { /// Recording strategy for tests. `captured()` returns the most recent /// invocation. #[derive(Default)] -pub(crate) struct RecordingExec { +pub struct RecordingExec { inner: std::sync::Mutex, } impl RecordingExec { - pub(crate) fn captured(&self) -> CapturedExec { + pub fn captured(&self) -> CapturedExec { self.inner.lock().unwrap().clone() } } impl ExecStrategy for RecordingExec { - fn exec(&self, recipe: &ResumeRecipe, cwd: &std::path::Path) -> Result<()> { + fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { let mut g = self.inner.lock().unwrap(); *g = CapturedExec { - binary: recipe.binary.to_string(), - args: recipe.args.clone(), + binary: binary.to_string(), + args: args.to_vec(), cwd: cwd.to_path_buf(), }; Ok(()) @@ -1985,18 +1503,19 @@ impl ExecStrategy for RecordingExec { } pub(crate) fn exec_harness( - recipe: &ResumeRecipe, + binary: &str, + args: &[String], cwd: &std::path::Path, strategy: &dyn ExecStrategy, ) -> Result<()> { - strategy.exec(recipe, cwd) + strategy.exec(binary, args, cwd) } ``` - [ ] **Step 4: Run test to verify it passes** ```bash -cargo test -p path-cli --lib exec_strategy_recording_captures_recipe +cargo test -p path-cli --lib exec_strategy_recording_captures_invocation ``` Expected: PASS. @@ -2005,12 +1524,12 @@ Expected: PASS. ```bash git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(path-cli): ExecStrategy with RealExec/RecordingExec for path resume" +git commit -m "feat(path-cli): ExecStrategy with RealExec/RecordingExec" ``` --- -## Task 13: Wire `run_resume` orchestration +## Task 9: Wire `run_resume` orchestration **Files:** - Modify: `crates/path-cli/src/cmd_resume.rs` @@ -2034,9 +1553,9 @@ pub fn run(args: ResumeArgs) -> Result<()> { /// Internal entry point that the integration tests call with a /// `RecordingExec` strategy. Production callers use [`run`]. -pub(crate) fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> { - let (doc, source_harness) = resolve_input(&args)?; - let path = ensure_path_with_agent(&doc)?; +pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> { + let (graph, source_harness) = resolve_input(&args)?; + let path = ensure_path_with_agent(&graph)?; let cwd = match args.cwd.as_ref() { Some(p) => std::fs::canonicalize(p) @@ -2050,26 +1569,27 @@ pub(crate) fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Re if Some(target) == source_harness { " (source)" } else { "" } ); - let recipe = project_into_harness(path, target, &cwd)?; - exec_harness(&recipe, &cwd, exec) + let session_id = project_into_harness(path, target, &cwd)?; + let argv = argv_for(target, &session_id); + exec_harness(target.name(), &argv, &cwd, exec) } ``` -- [ ] **Step 2: Update the existing stub test** +- [ ] **Step 2: Replace the stub test** Replace: ```rust #[test] -fn run_returns_not_implemented_until_wired() { ... } +fn run_returns_not_implemented_until_wired() { … } ``` with: ```rust #[test] -fn run_with_strategy_records_recipe_for_file_input_with_explicit_harness() { - let _home = scoped_home_for_resume(tempfile::tempdir().unwrap()); +fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() { + let _home = scoped_home_for_resume(); let cwd = tempfile::tempdir().unwrap(); let doc_file = cwd.path().join("doc.json"); @@ -2084,16 +1604,7 @@ fn run_with_strategy_records_recipe_for_file_input_with_explicit_harness() { std::iter::once(bin_dir.path().to_path_buf()) .chain(std::env::split_paths(&prev.clone().unwrap_or_default())), ).unwrap(); - // Safety: see scoped_home note. Treat tests as single-threaded. unsafe { std::env::set_var("PATH", new_path); } - let _restore = scopeguard::guard(prev, |p| { - unsafe { - match p { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } - }); let args = ResumeArgs { input: doc_file.to_string_lossy().to_string(), @@ -2105,6 +1616,14 @@ fn run_with_strategy_records_recipe_for_file_input_with_explicit_harness() { let recorder = RecordingExec::default(); run_with_strategy(args, &recorder).unwrap(); + // Restore PATH. + unsafe { + match prev { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + } + let cap = recorder.captured(); assert_eq!(cap.binary, "claude"); assert_eq!(cap.args[0], "-r"); @@ -2112,12 +1631,10 @@ fn run_with_strategy_records_recipe_for_file_input_with_explicit_harness() { } ``` -If `scopeguard` isn't already a dev-dep, either add it (`scopeguard = "1"` under `[dev-dependencies]`) or write an equivalent local `Drop`-based guard struct. Check `Cargo.toml` first. - - [ ] **Step 3: Run the orchestration test** ```bash -cargo test -p path-cli --lib run_with_strategy_records_recipe_for_file_input_with_explicit_harness +cargo test -p path-cli --lib run_with_strategy_records_invocation_for_file_input_with_explicit_harness ``` Expected: PASS. @@ -2128,97 +1645,26 @@ Expected: PASS. cargo test -p path-cli --lib cmd_resume ``` -Expected: PASS for the full set. +Expected: PASS. - [ ] **Step 5: Commit** ```bash -git add crates/path-cli/src/cmd_resume.rs crates/path-cli/Cargo.toml +git add crates/path-cli/src/cmd_resume.rs git commit -m "feat(path-cli): wire path resume orchestration end-to-end" ``` --- -## Task 14: Integration tests +## Task 10: Integration tests **Files:** - Create: `crates/path-cli/tests/resume.rs` +- Create: `crates/path-cli/tests/support/mod.rs` -- [ ] **Step 1: Write the integration test file** - -Create `crates/path-cli/tests/resume.rs` with the cases enumerated in the spec. Each test invokes `path_cli::cmd_resume::run_with_strategy` with a `RecordingExec` and asserts on captured recipe + on-disk side-effects. - -Each test in the file is one case from the list below. Subsequent steps in this task fill in the per-harness bodies and the rejection cases. - -```rust -#![cfg(not(target_os = "emscripten"))] - -use path_cli::cmd_resume::{run_with_strategy, RecordingExec, ResumeArgs}; -use path_cli::cmd_share::HarnessArg; - -mod support; -use support::*; - -#[test] -fn file_input_explicit_claude_projects_and_records_exec() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let doc_file = write_minimal_path_file(cwd.path(), "agent:claude-code"); - - let _path_guard = ScopedPath::with_binary("claude"); - - let recorder = RecordingExec::default(); - run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder).unwrap(); - - let cap = recorder.captured(); - assert_eq!(cap.binary, "claude"); - assert_eq!(cap.args[0], "-r"); - - // Side-effect: the projected JSONL exists under HOME. - let projects = home_dir().join(".claude/projects"); - assert!(projects.exists(), "claude projects dir missing"); - assert!(walk_dir_finds_jsonl(&projects), "no JSONL written"); -} - -#[test] -fn file_input_explicit_gemini_projects_and_records_exec() { /* ... */ } - -#[test] -fn file_input_explicit_codex_projects_and_records_exec() { /* ... */ } - -#[test] -fn file_input_explicit_opencode_projects_and_records_exec() { /* ... */ } - -#[test] -fn file_input_explicit_pi_projects_and_records_exec() { /* ... */ } - -#[test] -fn cache_id_input_loads_and_projects() { /* writes a cache entry first, runs resume */ } - -#[test] -fn url_input_fetches_via_mock_pathbase_and_projects() { - use path_cli::cmd_pathbase::tests::MockServer; - /* ... */ -} - -#[test] -fn multi_path_graph_returns_clear_error() { /* see Step 6 */ } - -#[test] -fn agentless_path_returns_clear_error() { /* see Step 6 */ } - -#[test] -fn explicit_harness_not_on_path_errors() { /* see Step 7 */ } - -#[test] -fn zero_installed_errors() { /* see Step 7 */ } -``` - -(There is no `step_input` rejection test: this codebase has no `Document::Step` shape — `Graph::from_json` rejects non-graph JSON during parse, well before `ensure_path_with_agent` runs. The `multi_path_graph` and `agentless_path` cases cover the rejection logic that lives in `cmd_resume`.) - -- [ ] **Step 2: Add the `support` module** +- [ ] **Step 1: Add the `support` module** -Create `crates/path-cli/tests/support/mod.rs` (or `crates/path-cli/tests/support.rs`) with shared helpers: +Create `crates/path-cli/tests/support/mod.rs`: ```rust use std::ffi::OsString; @@ -2233,7 +1679,6 @@ impl ScopedHome { let prev_config = std::env::var_os("TOOLPATH_CONFIG_DIR"); unsafe { std::env::set_var("HOME", td.path()); - // Some helpers honor TOOLPATH_CONFIG_DIR; keep it pinned to HOME/.toolpath. std::env::set_var("TOOLPATH_CONFIG_DIR", td.path().join(".toolpath")); } Self { _td: td, prev, prev_config } @@ -2325,7 +1770,7 @@ pub fn write_minimal_path_file(dir: &Path, actor: &str) -> PathBuf { p } -pub fn args(input: PathBuf, cwd: &Path, harness: HarnessArg) -> path_cli::cmd_resume::ResumeArgs { +pub fn args(input: PathBuf, cwd: &Path, harness: path_cli::cmd_share::HarnessArg) -> path_cli::cmd_resume::ResumeArgs { path_cli::cmd_resume::ResumeArgs { input: input.to_string_lossy().to_string(), cwd: Some(cwd.to_path_buf()), @@ -2349,44 +1794,121 @@ pub fn walk_dir_finds_jsonl(root: &Path) -> bool { } ``` -- [ ] **Step 3: Implement the per-harness positive cases** +- [ ] **Step 2: Add the integration test file with all per-harness positive cases** -Each follows the Claude pattern. Adjust `actor`, `HarnessArg`, expected binary, expected first arg (`-r` for claude, `--resume` for gemini, `resume` for codex, `--session` for opencode/pi). Skip on-disk JSONL assertion for opencode (which writes SQLite rows, not JSONL). - -- [ ] **Step 4: Implement the cache-id input case** +Create `crates/path-cli/tests/resume.rs`: ```rust +#![cfg(not(target_os = "emscripten"))] + +use path_cli::cmd_resume::{run_with_strategy, RecordingExec, ResumeArgs}; +use path_cli::cmd_share::HarnessArg; + +mod support; +use support::*; + #[test] -fn cache_id_input_loads_and_projects() { +fn file_input_explicit_claude_projects_and_records_exec() { let _home = ScopedHome::new(); let cwd = tempfile::tempdir().unwrap(); + let doc_file = write_minimal_path_file(cwd.path(), "agent:claude-code"); let _path_guard = ScopedPath::with_binary("claude"); - // Build the same minimal claude path as `write_minimal_path_file` does, - // but keep it in memory and stash it under a known cache id. - let cache_id = "claude-test-fixture"; - let doc_file = write_minimal_path_file(cwd.path(), "agent:claude-code"); - let json = std::fs::read_to_string(&doc_file).unwrap(); - let graph = toolpath::v1::Graph::from_json(&json).unwrap(); - path_cli::cmd_cache::write_cached(cache_id, &graph, false).unwrap(); + let recorder = RecordingExec::default(); + run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "claude"); + assert_eq!(cap.args[0], "-r"); + + let projects = home_dir().join(".claude/projects"); + assert!(projects.exists(), "claude projects dir missing"); + assert!(walk_dir_finds_jsonl(&projects), "no JSONL written"); +} + +#[test] +fn file_input_explicit_gemini_projects_and_records_exec() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let doc_file = write_minimal_path_file(cwd.path(), "agent:gemini-cli"); + let _path_guard = ScopedPath::with_binary("gemini"); - let resume_args = path_cli::cmd_resume::ResumeArgs { - input: cache_id.to_string(), - cwd: Some(cwd.path().to_path_buf()), - harness: Some(HarnessArg::Claude), - no_cache: false, force: false, url: None, - }; let recorder = RecordingExec::default(); - run_with_strategy(resume_args, &recorder).unwrap(); - assert_eq!(recorder.captured().binary, "claude"); + run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Gemini), &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "gemini"); + assert_eq!(cap.args[0], "--resume"); + + let tmp_root = home_dir().join(".gemini/tmp"); + assert!(tmp_root.exists(), "gemini tmp dir missing"); } -``` -- [ ] **Step 5: Implement the URL-input case via `MockServer`** +#[test] +fn file_input_explicit_codex_projects_and_records_exec() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let doc_file = write_minimal_path_file(cwd.path(), "agent:codex"); + let _path_guard = ScopedPath::with_binary("codex"); + + let recorder = RecordingExec::default(); + run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Codex), &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "codex"); + assert_eq!(cap.args[0], "resume"); + + let sessions = home_dir().join(".codex/sessions"); + assert!(sessions.exists(), "codex sessions dir missing"); +} + +#[test] +fn file_input_explicit_opencode_projects_and_records_exec() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let doc_file = write_minimal_path_file(cwd.path(), "agent:opencode"); + let _path_guard = ScopedPath::with_binary("opencode"); + + // Pre-create the opencode db with the canonical schema. + let resolver = toolpath_opencode::PathResolver::new(); + let db_path = resolver.db_path().unwrap(); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + // Substitute actual bootstrap helper if different. + toolpath_opencode::schema::apply_full_schema(&conn).unwrap(); + } + + let recorder = RecordingExec::default(); + run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Opencode), &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "opencode"); + assert_eq!(cap.args[0], "--session"); +} + +#[test] +fn file_input_explicit_pi_projects_and_records_exec() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let doc_file = write_minimal_path_file(cwd.path(), "agent:pi"); + let _path_guard = ScopedPath::with_binary("pi"); + + let recorder = RecordingExec::default(); + run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Pi), &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "pi"); + assert_eq!(cap.args[0], "--session"); + + let sessions = home_dir().join(".pi/agent/sessions"); + assert!(sessions.exists(), "pi sessions dir missing"); +} +``` -Use `path_cli::cmd_pathbase::tests::MockServer` (made `pub(crate)` in Task 6 — promote to `pub` here if cross-crate-test-binary access requires it, or move the helper to a `pub` test-utilities module). +- [ ] **Step 3: Add the rejection cases** -- [ ] **Step 6: Implement the rejection cases** +Append to `tests/resume.rs`: ```rust #[test] @@ -2400,22 +1922,12 @@ fn multi_path_graph_returns_clear_error() { let json = std::fs::read_to_string(write_minimal_path_file(cwd.path(), "agent:claude-code")).unwrap(); toolpath::v1::Graph::from_json(&json).unwrap().into_single_path().unwrap() }; - let p2 = { - // Reuse the same builder; rename the path id to avoid collision. - let mut p = p1.clone(); - p.path.id = "p2".into(); - p - }; - let graph = toolpath::v1::Graph { - graph: toolpath::v1::GraphIdentity { id: "g1".into() }, - paths: vec![ - toolpath::v1::PathOrRef::Path(Box::new(p1)), - toolpath::v1::PathOrRef::Path(Box::new(p2)), - ], - meta: None, - }; + let mut p2 = p1.clone(); + p2.path.id = "p2".into(); + let mut g = toolpath::v1::Graph::from_path(p1); + g.paths.push(toolpath::v1::PathOrRef::Path(Box::new(p2))); let doc_file = cwd.path().join("multi.json"); - std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap(); + std::fs::write(&doc_file, g.to_json().unwrap()).unwrap(); let recorder = RecordingExec::default(); let err = run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder) @@ -2437,28 +1949,56 @@ fn agentless_path_returns_clear_error() { .unwrap_err(); assert!(err.to_string().contains("no agent session")); } -``` - -(Substitute the actual `Graph` and `GraphIdentity` field names if they differ from the snippet — read `crates/toolpath/src/types.rs` first; the existing `cmd_merge.rs::tests` builds graphs literally and is the canonical example.) -- [ ] **Step 7: Implement the harness-not-on-PATH and zero-installed cases** - -```rust #[test] fn explicit_harness_not_on_path_errors() { let _home = ScopedHome::new(); let _path_guard = ScopedPath::empty(); let cwd = tempfile::tempdir().unwrap(); - let doc = write_minimal_path_file(cwd.path(), "agent:claude-code"); + let doc_file = write_minimal_path_file(cwd.path(), "agent:claude-code"); let recorder = RecordingExec::default(); - let err = run_with_strategy(args(doc, cwd.path(), HarnessArg::Claude), &recorder) + let err = run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder) .unwrap_err(); assert!(err.to_string().contains("isn't on PATH")); } ``` -- [ ] **Step 8: Run all integration tests** +- [ ] **Step 4: Add cache-id and URL input tests** + +```rust +#[test] +fn cache_id_input_loads_and_projects() { + let _home = ScopedHome::new(); + let cwd = tempfile::tempdir().unwrap(); + let _path_guard = ScopedPath::with_binary("claude"); + + // Stash a graph in the cache under a known id. + let cache_id = "claude-test-fixture"; + let doc_file = write_minimal_path_file(cwd.path(), "agent:claude-code"); + let json = std::fs::read_to_string(&doc_file).unwrap(); + let graph = toolpath::v1::Graph::from_json(&json).unwrap(); + path_cli::cmd_cache::write_cached(cache_id, &graph, false).unwrap(); + + let resume_args = path_cli::cmd_resume::ResumeArgs { + input: cache_id.to_string(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(HarnessArg::Claude), + no_cache: false, force: false, url: None, + }; + let recorder = RecordingExec::default(); + run_with_strategy(resume_args, &recorder).unwrap(); + assert_eq!(recorder.captured().binary, "claude"); +} + +// URL input case — uses the in-repo MockServer test helper. If the +// MockServer module isn't reachable from cross-test binaries, skip +// or re-implement a minimal mock here. +``` + +(The URL input test depends on `path_cli::cmd_pathbase::tests::MockServer` being reachable. If `pub(crate)` doesn't bridge across the integration-test binary boundary, either move `MockServer` to a tiny `pub` test-utilities module or write a minimal inline mock for this single test. Decide at implementation time.) + +- [ ] **Step 5: Run all integration tests** ```bash cargo test -p path-cli --test resume @@ -2466,7 +2006,7 @@ cargo test -p path-cli --test resume Expected: PASS. -- [ ] **Step 9: Run the full `path-cli` test suite** +- [ ] **Step 6: Run the full `path-cli` test suite** ```bash cargo test -p path-cli @@ -2474,7 +2014,7 @@ cargo test -p path-cli Expected: pass. -- [ ] **Step 10: Commit** +- [ ] **Step 7: Commit** ```bash git add crates/path-cli/tests/ @@ -2483,13 +2023,12 @@ git commit -m "test(path-cli): integration tests for path resume" --- -## Task 15: Documentation +## Task 11: Documentation **Files:** - Modify: `CLAUDE.md` - Modify: `README.md` - Modify: `crates/path-cli/src/cmd_resume.rs` (rustdoc) -- Modify: `crates/path-cli/src/cmd_export.rs` (rustdoc) - Create or modify: `CHANGELOG.md` - [ ] **Step 1: Add `path resume` to the `CLAUDE.md` CLI usage block** @@ -2554,22 +2093,9 @@ Replace the placeholder module comment with a real one: //! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`. ``` -- [ ] **Step 5: Adjust the `cmd_export.rs` module rustdoc** - -In the existing `//! ` block at the top of `cmd_export.rs`, append a paragraph: - -```rust -//! -//! Each `--project` mode now returns a `ResumeRecipe { binary, args, -//! session_id, cwd_for_recipe }`. The CLI surface formats the recipe -//! into the same `Resume with: …` / `Open conversation with: …` lines -//! it always has; `path resume` consumes the recipe directly to exec -//! the harness. -``` - -- [ ] **Step 6: Add a `CHANGELOG.md` entry** +- [ ] **Step 5: Add a `CHANGELOG.md` entry** -Add a new section at the top (above the most recent entry): +Add a new section at the top (above the most recent entry; create the file with `# Changelog` header if it doesn't exist): ```markdown ## path-cli 0.9.0 — 2026-05-08 @@ -2579,19 +2105,12 @@ Add a new section at the top (above the most recent entry): file path, or cache id), pick a coding-agent harness, project the session into its on-disk layout under a chosen cwd, and exec the harness's resume command. -- `cmd_export::ResumeRecipe` — public type returned by every - project-mode export helper; describes how to invoke the harness for - resume. Consumed by `path resume`. - -### Changed -- `path export --project ` writers internally return a - `ResumeRecipe`. The CLI's stderr "Resume with: …" lines are now - formatted from the recipe; user-visible output is unchanged. +- `cmd_export::project_` `pub(crate)` wrappers that compose + the existing build + write helpers and return the projected session + id. Consumed by `path resume`. ``` -(If `CHANGELOG.md` doesn't exist yet, create it with a simple header `# Changelog` followed by the section above.) - -- [ ] **Step 7: Build the docs to confirm they compile** +- [ ] **Step 6: Build the docs to confirm they compile** ```bash cargo doc -p path-cli --no-deps @@ -2599,16 +2118,16 @@ cargo doc -p path-cli --no-deps Expected: clean build. -- [ ] **Step 8: Commit** +- [ ] **Step 7: Commit** ```bash -git add CLAUDE.md README.md CHANGELOG.md crates/path-cli/src/cmd_resume.rs crates/path-cli/src/cmd_export.rs +git add CLAUDE.md README.md CHANGELOG.md crates/path-cli/src/cmd_resume.rs git commit -m "docs: document path resume command" ``` --- -## Task 16: Version bumps +## Task 12: Version bumps **Files:** - Modify: `crates/path-cli/Cargo.toml` @@ -2625,13 +2144,7 @@ version = "0.9.0" # was 0.8.0 - [ ] **Step 2: Bump the workspace dep entry** -In the root `Cargo.toml`, find the `[workspace.dependencies]` `path-cli` entry and bump to match: - -```toml -path-cli = { path = "crates/path-cli", version = "0.9.0" } -``` - -(Adjust to match the existing entry's exact shape — `path` may or may not be present.) +In the root `Cargo.toml`, find the `[workspace.dependencies]` `path-cli` entry and bump to match. (Adjust to match the existing entry's exact shape — `path` may or may not be present.) - [ ] **Step 3: Bump the site data** @@ -2678,7 +2191,7 @@ git commit -m "chore: bump path-cli to 0.9.0 for path resume" --- -## Task 17: Smoke test from the CLI +## Task 13: Smoke test from the CLI **Files:** none modified — manual verification only. @@ -2698,31 +2211,23 @@ Expected: usage line + flags listed exactly as documented in `cmd_resume.rs`. - [ ] **Step 3: Confirm rejection paths work end-to-end** -Pick a cache entry that's not from a harness — e.g. a `git-*` entry from a previous `path import git`. If none exist, derive one: - -```bash -./target/release/path import git --repo . --branch main -./target/release/path cache ls -``` - -Then attempt to resume: +Pick or derive a cache entry that's not from a harness (e.g. a `git-*` entry from `path import git`). Then attempt to resume: ```bash ./target/release/path resume --harness claude ``` -Expected: error message `no agent session in input — `path resume` only works on harness-derived paths`. +Expected: error message `no agent session in input — \`path resume\` only works on harness-derived paths`. - [ ] **Step 4: (Optional) Confirm a real resume works against an actual session** Only if you have a real claude/codex/gemini/opencode/pi session locally and one of those binaries on PATH: ```bash -./target/release/path import claude --project . --no-cache | ./target/release/path resume - --harness claude +./target/release/path import claude --project $PWD +./target/release/path resume --harness claude ``` -(Or use a cached entry. The `-` stdin form requires an extra implementation step — skip if not implemented.) - Expected: control transfers to the harness with the prior conversation visible. - [ ] **Step 5: No commit needed for smoke testing** @@ -2731,21 +2236,22 @@ Manual step only. --- -## Self-review checklist (run before handing the plan off) +## Self-review checklist 1. Every task ends with a `git commit` — verified. -2. Every code step shows the actual code, not "implement X" — verified. -3. Every test step shows the actual test, the run command, and the expected outcome — verified. -4. File paths are absolute or workspace-relative — verified (all `crates/path-cli/...`). -5. Type names are consistent across tasks (`ResumeRecipe`, `ResumeArgs`, `ExecStrategy`, `RecordingExec`, `RealExec`, `Harness`, `HarnessArg`) — verified. -6. Spec coverage: - - § Surface — Tasks 7, 13. - - § Input resolution — Task 9. - - § Launch — Tasks 12, 13. - - § Internal architecture (`resolve_input`, `ensure_path_with_agent`, `pick_harness`, `project_into_harness`, `exec_harness`) — Tasks 8–13. - - § `ResumeRecipe` and `cmd_export` refactor — Tasks 1–5, 11. - - § Error handling — Tasks 8, 9, 10, 14. - - § Testing — Tasks 1–14, 17. - - § Documentation — Task 15. - - § Versioning — Task 16. -7. No "TBD", "TODO", or "implement later" — verified. +2. Every code step shows actual code, not "implement X" — verified. +3. Every test step shows actual test, run command, and expected outcome — verified. +4. File paths are absolute or workspace-relative — verified. +5. Type names are consistent across tasks (`ResumeArgs`, `ExecStrategy`, `RecordingExec`, `RealExec`, `Harness`, `HarnessArg`, `CapturedExec`) — verified. +6. No `ResumeRecipe` references — verified (collapsed into `(session_id, argv_for, exec_harness)`). +7. Spec coverage: + - § Surface — Tasks 3, 9. + - § Input resolution — Task 5. + - § Launch — Tasks 8, 9. + - § Internal architecture (`resolve_input`, `ensure_path_with_agent`, `pick_harness`, `project_into_harness`, `argv_for`, `exec_harness`) — Tasks 4–9. + - § `project_` wrappers — Task 1. + - § Error handling — Tasks 4, 5, 6, 10. + - § Testing — Tasks 1–10, 13. + - § Documentation — Task 11. + - § Versioning — Task 12. +8. No "TBD", "TODO", or "implement later" — verified. diff --git a/docs/superpowers/specs/2026-05-08-path-resume-command-design.md b/docs/superpowers/specs/2026-05-08-path-resume-command-design.md index af795bb..66aa565 100644 --- a/docs/superpowers/specs/2026-05-08-path-resume-command-design.md +++ b/docs/superpowers/specs/2026-05-08-path-resume-command-design.md @@ -103,8 +103,9 @@ pub async fn run_resume(args: ResumeArgs) -> Result<()> { .unwrap_or_else(|| std::env::current_dir())?; let target = pick_harness(args.harness, source_harness)?; - let recipe = project_into_harness(&doc, target, &cwd)?; - exec_harness(recipe, &cwd) + let session_id = project_into_harness(&doc, target, &cwd)?; + let argv = argv_for(target, &session_id); + exec_harness(target.binary_name(), &argv, &cwd) } ``` @@ -120,9 +121,10 @@ Small dispatcher that delegates, in order: pathbase branch keeps using it. Honors `--no-cache`, `--force`, `--url`. - File path / cache id → `cmd_cache::cache_ref` then read+parse. -Returns `(Document, Option)`. The source harness is read -from `path.meta.source` — set by `toolpath-convo::derive_path` to the -provider's `provider_id`: +Returns `(Graph, Option)` — there is no `Document` enum in +the codebase; `Graph::from_json` is the universal parse entry. The +source harness is read from the single inline path's `meta.source` — +set by `toolpath-convo::derive_path` to the provider's `provider_id`: | `meta.source` | Harness | | --- | --- | @@ -140,16 +142,22 @@ pre-selection. ### `ensure_path_with_agent` -Pure validation; rejects: +Pure validation operating on a `Graph`. Rejects: -- `Document::Step` → "resume needs a `Path` document; `` is a - `Step`". -- `Document::Graph` with N > 0 paths → "resume needs a single `Path`; - `` is a `Graph` with N paths. Pick one with `path query …` - or split first." -- `Path` whose steps contain zero `agent:*` actors → "no agent - session in `` — `path resume` only works on harness-derived - paths". +- Empty graph → "resume needs a `Path`; expected one path, got an + empty graph". +- Graph with more than one inline path → "resume needs a single + `Path`; `` is a graph with N paths. Pick one with + `path query …` or split first." +- Single-path graph whose steps contain zero `agent:*` actors → "no + agent session in `` — `path resume` only works on + harness-derived paths". +- Single-path graph whose only entry is a `$ref` (not an inline + path) → "resume needs an inline `Path`; got a $ref. Resolve it + first." + +A bare `Step` JSON document never reaches this function — it would +fail `Graph::from_json` parse. No dedicated rejection branch needed. ### `pick_harness` @@ -170,29 +178,50 @@ Codex / Opencode / Pi), including its `binary_name()` helper. Logic: Non-TTY environment with no `--harness`: error with the recipe (no silent default — picking is consequential). -### `project_into_harness` and `ResumeRecipe` +### `project_into_harness` + +Each `run_` in `cmd_export.rs` is already split into two +private helpers: + +- `build__(...)` — projects a `Path` into the harness's + in-memory session struct (which carries a stable `session_id` field). +- `write_into__project(...)` — writes that struct to disk. -Today the per-harness projection helpers in `cmd_export.rs` -(`run_claude` / `run_gemini` / `run_codex` / `run_opencode` / -`run_pi`) `eprintln!("Resume with: …")` and return `()`. We extract a -return type: +We add five thin `pub(crate)` wrappers in `cmd_export.rs`: ```rust -pub struct ResumeRecipe { - pub binary: &'static str, // "claude" | "gemini" | "codex" | "opencode" | "pi" - pub args: Vec, // ["-r", ""] etc. - pub session_id: String, - pub cwd_for_recipe: PathBuf, // dir the harness must be invoked from -} +pub(crate) fn project_claude(path: &Path, project_dir: &Path) -> Result; +pub(crate) fn project_gemini(path: &Path, project_dir: &Path) -> Result; +pub(crate) fn project_codex(path: &Path, project_dir: &Path) -> Result; +pub(crate) fn project_opencode(path: &Path, project_dir: &Path) -> Result; +pub(crate) fn project_pi(path: &Path, project_dir: &Path) -> Result; ``` -The existing CLI-level `path export ` commands keep their -stderr output by formatting this struct; behavior is unchanged. The -new code path in `cmd_resume` consumes the struct directly and feeds -it to `exec_harness`. +Each composes its build + write pair, returning the projected +session id. cmd_resume's `project_into_harness` is a five-arm match +that dispatches to the right wrapper. + +No public type, no refactor of the existing private writers, and no +change to `path export `'s user-visible behavior. + +### `argv_for` + +```rust +fn argv_for(harness: Harness, session_id: &str) -> Vec { + match harness { + Harness::Claude => vec!["-r".into(), session_id.into()], + Harness::Gemini => vec!["--resume".into(), session_id.into()], + Harness::Codex => vec!["resume".into(), session_id.into()], + Harness::Opencode => vec!["--session".into(), session_id.into()], + Harness::Pi => vec!["--session".into(), session_id.into()], + } +} +``` -This is a five-site mechanical refactor inside `cmd_export.rs` plus a -new public type. No behavior change for `path export`. +A static map from harness to resume-argv shape. Lives in +`cmd_resume.rs` because it's a per-harness CLI convention, not a +projection concern. `Harness::binary_name()` already exists in +`cmd_share.rs` and supplies the program name. ### `exec_harness` @@ -201,8 +230,8 @@ Unix: ```rust use std::os::unix::process::CommandExt; -let err = std::process::Command::new(recipe.binary) - .args(&recipe.args) +let err = std::process::Command::new(binary) + .args(args) .current_dir(cwd) .exec(); // returns std::io::Error on failure only ``` @@ -244,9 +273,10 @@ errors → propagate. Picker cancel → 130. Validation errors → 1. | URL fetch returns 401/403 | "auth failed for ``; run `path auth login` or pass `--anon`" (mirrors `import pathbase`). | | Cache hit on URL fetch, no `--force` | "cache entry `` already exists; pass `--force` to overwrite". | | Input doesn't resolve as URL / shorthand / file / cache id | "couldn't resolve `` as a URL, file path, or cache id". | -| Doc parses but is a `Step` | "resume needs a `Path` document; `` is a `Step`". | -| Doc is a `Graph` | "resume needs a single `Path`; `` is a `Graph` with N paths. Pick one with `path query …` or split first." | -| Path has no `agent:*` actors | "no agent session in `` — `path resume` only works on harness-derived paths". | +| Empty graph | "resume needs a `Path`; expected one path, got an empty graph". | +| Multi-path graph | "resume needs a single `Path`; `` is a graph with N paths. Pick one with `path query …` or split first." | +| Single-path graph with no `agent:*` actors | "no agent session in `` — `path resume` only works on harness-derived paths". | +| Single-path graph entry is a `$ref` | "resume needs an inline `Path`; got a $ref. Resolve it first." | | `--harness X` given, X not on PATH | "harness `` isn't on PATH; install it or pick another with `--harness`". | | Zero harnesses on PATH (interactive mode) | "no installed harnesses found; install one of: claude, gemini, codex, opencode, pi". | | No `--harness` and stderr/stdin not a TTY | "interactive picker requires a TTY; pass `--harness ` or rerun in a terminal". | @@ -280,38 +310,38 @@ Notes that drive design but not behavior: returns it; `--harness` set + not on PATH → error; zero installed → error. PATH membership is faked via an injectable lookup helper. -### `ResumeRecipe` round-trip in `cmd_export.rs` tests +### `project_` round-trip in `cmd_export.rs` tests One test per harness (claude / gemini / codex / opencode / pi): -project a fixture path, assert the returned `ResumeRecipe` matches -`("claude", ["-r", ""])` etc. These also cover the existing CLI -surface, since `path export ` now formats the same struct on -stderr. +project a fixture path, assert the returned `session_id` is +non-empty and the on-disk side-effects landed (the `.jsonl` exists, +the SQLite row was inserted, etc.). ### Integration tests in `crates/path-cli/tests/resume.rs` Exec is the one untestable line. `cmd_resume` accepts an injectable "exec strategy" (a small trait object or boxed closure) — the binary calls the real `execvp` strategy; tests substitute a strategy that -records the recipe and returns success. No public `--dry-run` flag. +records `(binary, args, cwd)` and returns success. No public +`--dry-run` flag. Cases: 1. File-path input + `--harness claude` + `-C ` → projects under - `/.claude/projects//.jsonl`; recorded recipe is - `("claude", ["-r", ])`. + `/.claude/projects//.jsonl`; recorded + `(binary, args)` is `("claude", ["-r", ])`. 2. Same shape, one per harness (gemini / codex / opencode / pi). -3. Cache-id input → loads from a tmp cache, projects, records recipe. +3. Cache-id input → loads from a tmp cache, projects, records + `(binary, args, cwd)`. 4. URL input → reuses the in-repo `MockServer` test helper from `cmd_pathbase.rs`'s test module (extract into a `pub(crate)` test util if needed), fetches, caches, projects. -5. `Step` input → returns the error verbatim. -6. `Graph` input → returns the error verbatim. -7. Agent-less `Path` (git-derived fixture) → returns the error. -8. `--harness` not on PATH → error. -9. Zero installed harnesses → error. -10. Picker cancel → exit 130 (reuses the existing fzf-cancel test - pattern from `cmd_share`). +5. Multi-path graph → returns the error verbatim. +6. Agent-less `Path` (git-derived fixture) → returns the error. +7. `--harness` not on PATH → error. +8. Zero installed harnesses → error. +9. Picker cancel → exit 130 (reuses the existing fzf-cancel test + pattern from `cmd_share`). ### Out of scope for tests @@ -329,17 +359,17 @@ Cases: inputs, resolution order, harness picker, and exec semantics. Same density as the doc comment at the top of `cmd_share.rs` and `cmd_export.rs`. -- `cmd_export.rs` — adjust module rustdoc to mention that the `Resume - with: …` lines now come from a shared `ResumeRecipe`. +- `cmd_export.rs` — no rustdoc change required; the new + `project_` wrappers carry their own doc comments. - Site (`site/`) — no new page; `path resume` gets one bullet wherever the CLI surface is enumerated. ## Versioning -- `path-cli` minor bump (additive command + new `pub` type - `ResumeRecipe`). Update `crates/path-cli/Cargo.toml`, - `[workspace.dependencies]` in root `Cargo.toml`, - `site/_data/crates.json`, and add a `CHANGELOG.md` entry. +- `path-cli` minor bump (additive command). Update + `crates/path-cli/Cargo.toml`, `[workspace.dependencies]` in root + `Cargo.toml`, `site/_data/crates.json`, and add a `CHANGELOG.md` + entry. - `toolpath-cli` shim follows along (no version bump needed). - No bumps for the `toolpath-*` provider crates. From 1f2c9d2177df500056eab544d048538f0444c214 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 16:29:26 -0400 Subject: [PATCH 04/21] feat(path-cli): pub(crate) project_ wrappers in cmd_export --- crates/path-cli/src/cmd_export.rs | 360 ++++++++++++++++++++++++++++++ 1 file changed, 360 insertions(+) diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index e8d10aa..5c690b9 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -239,6 +239,112 @@ pub(crate) struct PathbaseUploadArgs { pub(crate) public: bool, } +// ── pub(crate) project_ wrappers ──────────────────────────── +// +// These compose the private build + write helpers below and return the +// 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. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_claude( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + let conv = build_claude_conversation(path)?; + let jsonl = serialize_jsonl(&conv)?; + write_into_claude_project(&conv, &jsonl, project_dir)?; + Ok(conv.session_id) +} + +/// Project `path` into a Gemini session under `project_dir` and return +/// the resulting session UUID. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_gemini( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + use toolpath_convo::ConversationProjector; + let project_dir = std::fs::canonicalize(project_dir) + .with_context(|| format!("resolve project path {}", project_dir.display()))?; + let project_path = project_dir.to_string_lossy().to_string(); + + let view = toolpath_convo::extract_conversation(path); + let project_hash = toolpath_gemini::paths::project_hash(&project_path); + let projector = toolpath_gemini::project::GeminiProjector::new() + .with_project_hash(project_hash) + .with_project_path(project_path.clone()); + let conv = projector + .project(&view) + .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; + if conv.session_uuid.is_empty() { + anyhow::bail!("Projected conversation has no session UUID"); + } + write_into_gemini_project(&conv, &project_path)?; + Ok(conv.session_uuid) +} + +/// Project `path` into a Codex session and return the resulting session id. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_codex( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + use toolpath_convo::ConversationProjector; + let project_dir = std::fs::canonicalize(project_dir) + .with_context(|| format!("resolve project path {}", project_dir.display()))?; + let cwd_str = project_dir.to_string_lossy().to_string(); + + let view = toolpath_convo::extract_conversation(path); + let projector = toolpath_codex::project::CodexProjector::new().with_cwd(cwd_str); + let session = projector + .project(&view) + .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; + if session.id.is_empty() { + anyhow::bail!("Projected session has no id"); + } + write_into_codex_project(&session)?; + Ok(session.id) +} + +/// Project `path` into an opencode session under `project_dir` and return +/// the resulting session id. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_opencode( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + let session = build_opencode_session(path, Some(project_dir))?; + let id = session.id.clone(); + write_into_opencode_db(&session, project_dir)?; + Ok(id) +} + +/// Project `path` into a Pi session under `project_dir` and return the +/// resulting session id. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn project_pi( + path: &toolpath::v1::Path, + project_dir: &std::path::Path, +) -> Result { + use toolpath_convo::ConversationProjector; + let project_dir = std::fs::canonicalize(project_dir) + .with_context(|| format!("resolve project path {}", project_dir.display()))?; + let cwd_str = project_dir.to_string_lossy().to_string(); + + let view = toolpath_convo::extract_conversation(path); + let projector = toolpath_pi::project::PiProjector::new().with_cwd(cwd_str.clone()); + let session = projector + .project(&view) + .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; + if session.header.id.is_empty() { + anyhow::bail!("Projected session has no id"); + } + write_into_pi_project(&session, &cwd_str)?; + Ok(session.header.id) +} + fn run_claude(input: String, project: Option, output: Option) -> Result<()> { #[cfg(target_os = "emscripten")] { @@ -2513,4 +2619,258 @@ mod tests { let err = run_opencode(input_path.to_string_lossy().to_string(), None, None).unwrap_err(); assert!(err.to_string().contains("single-path")); } + + // ── project_ wrapper tests ────────────────────────────── + + /// Build a minimal `toolpath::v1::Path` with a single `conversation.append` + /// step using the given `artifact_key` (e.g. `"claude-code://my-session"`). + /// The projectors read `view.id` from the first `://` artifact + /// key they see, so this gives them a non-empty session id to work with. + fn make_convo_path(artifact_key: &str) -> toolpath::v1::Path { + let mut extra = HashMap::new(); + extra.insert("role".to_string(), serde_json::json!("user")); + extra.insert("text".to_string(), serde_json::json!("hello")); + let step = toolpath::v1::Step { + step: toolpath::v1::StepIdentity { + id: "s1".to_string(), + parents: vec![], + actor: "human:test".to_string(), + timestamp: "2026-01-01T00:00:00Z".to_string(), + }, + change: { + let mut m = HashMap::new(); + m.insert( + artifact_key.to_string(), + toolpath::v1::ArtifactChange { + raw: None, + structural: Some(toolpath::v1::StructuralChange { + change_type: "conversation.append".to_string(), + extra, + }), + }, + ); + m + }, + meta: None, + }; + toolpath::v1::Path { + path: toolpath::v1::PathIdentity { + id: "test-path".to_string(), + base: None, + head: "s1".to_string(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + } + } + + #[test] + fn project_claude_returns_session_id_and_writes_jsonl() { + 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(); + + // Use a deterministic session id embedded in the artifact key. + let session_id = "claude-wrapper-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 result = project_claude(&path, &cwd); + unsafe { + match prior_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + + let returned_id = result.expect("project_claude should succeed"); + assert_eq!(returned_id, session_id); + + let claude_projects = fake_home.join(".claude/projects"); + assert!( + claude_projects.exists(), + "claude projects dir missing under HOME" + ); + } + + #[test] + fn project_gemini_returns_session_id_and_writes_chat_file() { + 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_uuid = "11111111-2222-3333-4444-aaaaaaaaaaaa"; + let path = make_convo_path(&format!("gemini-cli://{}", session_uuid)); + + 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 result = project_gemini(&path, &cwd); + unsafe { + match prior_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + + let returned_id = result.expect("project_gemini should succeed"); + assert_eq!(returned_id, session_uuid); + + let gemini_tmp = fake_home.join(".gemini/tmp"); + assert!(gemini_tmp.exists(), "gemini tmp dir missing under HOME"); + } + + #[test] + fn project_codex_returns_session_id_and_writes_rollout() { + 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_uuid = "019dabc6-cccc-dddd-eeee-ffffffffffff"; + let path = make_convo_path(&format!("codex://{}", session_uuid)); + + 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 result = project_codex(&path, &cwd); + unsafe { + match prior_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + + let returned_id = result.expect("project_codex should succeed"); + assert_eq!(returned_id, session_uuid); + + let codex_sessions = fake_home.join(".codex/sessions"); + assert!(codex_sessions.exists(), "codex sessions dir missing"); + } + + #[test] + fn project_opencode_returns_session_id_and_inserts_row() { + 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(); + + // Bootstrap the opencode DB (no public schema helper exists; inline + // the same DDL used in the existing opencode_writes_into_db_with_project test). + let data_dir = fake_home.join(".local/share/opencode"); + std::fs::create_dir_all(&data_dir).unwrap(); + let db_path = data_dir.join("opencode.db"); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + r#" + CREATE TABLE project ( + id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, + icon_url text, icon_color text, + time_created integer NOT NULL, time_updated integer NOT NULL, + time_initialized integer, sandboxes text NOT NULL, commands text + ); + CREATE TABLE session ( + id text PRIMARY KEY, project_id text NOT NULL, parent_id text, + slug text NOT NULL, directory text NOT NULL, title text NOT NULL, + version text NOT NULL, share_url text, + summary_additions integer, summary_deletions integer, + summary_files integer, summary_diffs text, revert text, permission text, + time_created integer NOT NULL, time_updated integer NOT NULL, + time_compacting integer, time_archived integer, workspace_id text + ); + CREATE TABLE message ( + id text PRIMARY KEY, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, + data text NOT NULL + ); + CREATE TABLE part ( + id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, + data text NOT NULL + ); + "#, + ) + .unwrap(); + } + + // opencode session ids are derived from view.id via mint_session_id, + // which adds the `ses_` prefix if not already present. + let path = make_convo_path("opencode://ses_wrapper-test"); + + 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); + std::env::remove_var("XDG_DATA_HOME"); + } + let result = project_opencode(&path, &cwd); + unsafe { + match prior_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + + let returned_id = result.expect("project_opencode should succeed"); + assert!(!returned_id.is_empty()); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM session WHERE id = ?1", + [&returned_id], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1, "expected one session row with id {returned_id}"); + } + + #[test] + fn project_pi_returns_session_id_and_writes_jsonl() { + 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 = "pi-wrapper-test-session"; + let path = make_convo_path(&format!("pi://{}", 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 result = project_pi(&path, &cwd); + unsafe { + match prior_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + + let returned_id = result.expect("project_pi should succeed"); + assert_eq!(returned_id, session_id); + + let pi_sessions = fake_home.join(".pi/agent/sessions"); + assert!(pi_sessions.exists(), "pi sessions dir missing"); + } } From 0bc50f9e6b810326dcd74ef08aa34a93a0881515 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 16:37:18 -0400 Subject: [PATCH 05/21] test(path-cli): tighten project_opencode test, restore XDG_DATA_HOME --- crates/path-cli/src/cmd_export.rs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 5c690b9..ad524a4 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -2681,7 +2681,9 @@ mod tests { .lock() .unwrap_or_else(|e| e.into_inner()); let prior_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", &fake_home); } + unsafe { + std::env::set_var("HOME", &fake_home); + } let result = project_claude(&path, &cwd); unsafe { match prior_home { @@ -2715,7 +2717,9 @@ mod tests { .lock() .unwrap_or_else(|e| e.into_inner()); let prior_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", &fake_home); } + unsafe { + std::env::set_var("HOME", &fake_home); + } let result = project_gemini(&path, &cwd); unsafe { match prior_home { @@ -2746,7 +2750,9 @@ mod tests { .lock() .unwrap_or_else(|e| e.into_inner()); let prior_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", &fake_home); } + unsafe { + std::env::set_var("HOME", &fake_home); + } let result = project_codex(&path, &cwd); unsafe { match prior_home { @@ -2817,6 +2823,7 @@ mod tests { .lock() .unwrap_or_else(|e| e.into_inner()); let prior_home = std::env::var_os("HOME"); + let prior_xdg = std::env::var_os("XDG_DATA_HOME"); unsafe { std::env::set_var("HOME", &fake_home); std::env::remove_var("XDG_DATA_HOME"); @@ -2827,10 +2834,14 @@ mod tests { Some(v) => std::env::set_var("HOME", v), None => std::env::remove_var("HOME"), } + match prior_xdg { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } } let returned_id = result.expect("project_opencode should succeed"); - assert!(!returned_id.is_empty()); + assert_eq!(returned_id, "ses_wrapper-test"); let conn = rusqlite::Connection::open(&db_path).unwrap(); let count: i64 = conn @@ -2858,7 +2869,9 @@ mod tests { .lock() .unwrap_or_else(|e| e.into_inner()); let prior_home = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", &fake_home); } + unsafe { + std::env::set_var("HOME", &fake_home); + } let result = project_pi(&path, &cwd); unsafe { match prior_home { From 89ae5d0641463a1d8e18d16173da60da5d435e3f Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 16:41:16 -0400 Subject: [PATCH 06/21] refactor(path-cli): extract pathbase_fetch_to_doc helper Pull the network-fetch-and-parse logic out of derive_pathbase into a pub(crate) pathbase_fetch_to_doc so that the upcoming path resume command can reuse it without duplicating the HTTP/credential dance. Also promote MockServer and its start/base methods to pub(crate) so the new test in cmd_import can reach the test helper from a sibling module. --- crates/path-cli/src/cmd_import.rs | 54 ++++++++++++++++++++--------- crates/path-cli/src/cmd_pathbase.rs | 8 ++--- 2 files changed, 42 insertions(+), 20 deletions(-) diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 217b3b0..e9070c4 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -1362,6 +1362,29 @@ fn project_short(p: &str) -> String { out.join("/") } +/// Fetch a Pathbase ref (`https://host/owner/repo/slug` URL or bare +/// `owner/repo/slug` triple) and parse it as a toolpath document. Used +/// by `path import pathbase` and by `path resume `. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Result { + use crate::cmd_pathbase::{credentials_path, load_session, paths_download, resolve_url}; + + let (base, ref_) = parse_pathbase_ref(target, url_flag)?; + let stored = load_session(&credentials_path()?)?; + let base_url = base + .or_else(|| stored.as_ref().map(|s| s.url.clone())) + .unwrap_or_else(|| resolve_url(None)); + + let token = stored.as_ref().map(|s| s.token.as_str()); + + let PathRef { owner, repo, slug } = ref_; + let body = paths_download(&base_url, token, &owner, &repo, &slug)?; + let cache_id = make_id("pathbase", &format!("{owner}-{repo}-{slug}")); + let doc = Graph::from_json(&body) + .map_err(|e| anyhow::anyhow!("server returned a non-toolpath document: {e}"))?; + Ok(DerivedDoc { cache_id, doc }) +} + fn derive_pathbase(target: String, url_flag: Option) -> Result> { #[cfg(target_os = "emscripten")] { @@ -1371,22 +1394,7 @@ fn derive_pathbase(target: String, url_flag: Option) -> Result Result<()> { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; fn sample() -> StoredSession { @@ -735,13 +735,13 @@ mod tests { /// A one-shot HTTP/1.1 responder. Binds to 127.0.0.1 on a free port, /// reads one request (headers + body), writes a canned response, closes. - struct MockServer { + pub(crate) struct MockServer { port: u16, thread: Option>>, } impl MockServer { - fn start(status_line: &'static str, body: &'static str) -> Self { + pub(crate) fn start(status_line: &'static str, body: &'static str) -> Self { use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; @@ -794,7 +794,7 @@ mod tests { } } - fn base(&self) -> String { + pub(crate) fn base(&self) -> String { format!("http://127.0.0.1:{}", self.port) } From 8a8d8ac80cbc067bc7f22cf99921d4b157062e6e Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 16:53:05 -0400 Subject: [PATCH 07/21] feat(path-cli): scaffold path resume command (stub) --- crates/path-cli/src/cmd_resume.rs | 69 +++++++++++++++++++++++++++++++ crates/path-cli/src/lib.rs | 11 +++++ 2 files changed, 80 insertions(+) create mode 100644 crates/path-cli/src/cmd_resume.rs diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs new file mode 100644 index 0000000..e445144 --- /dev/null +++ b/crates/path-cli/src/cmd_resume.rs @@ -0,0 +1,69 @@ +//! `path resume` — fetch / load a Toolpath document and exec a coding +//! agent's resume command after projecting the session into the +//! harness's on-disk layout. +//! +//! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`. + +#![cfg(not(target_os = "emscripten"))] + +#[allow(unused_imports)] +use anyhow::{Context, Result}; +use clap::Args; +use std::path::PathBuf; + +use crate::cmd_share::HarnessArg; + +#[derive(Args, Debug)] +pub struct ResumeArgs { + /// Toolpath document to resume from. Accepted shapes: a Pathbase + /// URL (`https://host/owner/repo/slug`), a bare Pathbase shorthand + /// (`owner/repo/slug`), a path to a local toolpath JSON file, or a + /// cache id (e.g. `claude-abc`, `pathbase-foo-bar-baz`). + pub input: String, + + /// Working directory to run the resumed harness from. Defaults to + /// the current shell cwd. The on-disk projection is keyed on this + /// directory and the harness will be exec'd with cwd set to it. + #[arg(short = 'C', long)] + pub cwd: Option, + + /// Pin the resume target. Skips the interactive picker. + #[arg(long, value_enum)] + pub harness: Option, + + /// Skip writing the cache when fetching from Pathbase. + #[arg(long)] + pub no_cache: bool, + + /// Overwrite an existing cache entry when fetching from Pathbase. + #[arg(long)] + pub force: bool, + + /// Pathbase server URL. Falls back to the stored session's URL, + /// then `$PATHBASE_URL`, then `https://pathbase.dev`. + #[arg(long)] + pub url: Option, +} + +pub fn run(_args: ResumeArgs) -> Result<()> { + anyhow::bail!("path resume: not yet implemented") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn run_returns_not_implemented_until_wired() { + let args = ResumeArgs { + input: "irrelevant".to_string(), + cwd: None, + harness: None, + no_cache: false, + force: false, + url: None, + }; + let err = run(args).unwrap_err(); + assert!(err.to_string().contains("not yet implemented")); + } +} diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index c7f53af..4948eb6 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -14,6 +14,8 @@ mod cmd_project; mod cmd_query; mod cmd_render; #[cfg(not(target_os = "emscripten"))] +mod cmd_resume; +#[cfg(not(target_os = "emscripten"))] mod cmd_share; #[cfg(not(target_os = "emscripten"))] mod cmd_show; @@ -122,6 +124,13 @@ enum Commands { #[command(flatten)] args: cmd_share::ShareArgs, }, + /// Resume an agent session into the chosen harness, projecting the + /// document and exec'ing the harness's resume command. + #[cfg(not(target_os = "emscripten"))] + Resume { + #[command(flatten)] + args: cmd_resume::ResumeArgs, + }, // ── Deprecated aliases ──────────────────────────────────────────── #[command(hide = true, about = "[deprecated] Use `path import`")] @@ -168,6 +177,8 @@ pub fn run() -> Result<()> { Commands::Auth { op } => cmd_auth::run(op), #[cfg(not(target_os = "emscripten"))] Commands::Share { args } => cmd_share::run(args), + #[cfg(not(target_os = "emscripten"))] + Commands::Resume { args } => cmd_resume::run(args), Commands::Derive { source } => cmd_derive::run(source, cli.pretty), Commands::Incept { args } => cmd_incept::run(args), From 9b050894ddb23983becfbbc1970b7b725ac78090 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 17:09:50 -0400 Subject: [PATCH 08/21] feat(path-cli): infer_source_harness and ensure_path_with_agent --- crates/path-cli/src/cmd_resume.rs | 182 ++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index e445144..b5c5a6c 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -49,6 +49,77 @@ pub fn run(_args: ResumeArgs) -> Result<()> { anyhow::bail!("path resume: not yet implemented") } +use toolpath::v1::{Graph, Path as TPath, PathOrRef}; + +/// Read a path's source harness from `meta.source` (set by +/// `toolpath-convo::derive_path` to the provider id), falling back to +/// actor-string sniffing across the path's steps. +pub(crate) fn infer_source_harness(path: &TPath) -> Option { + use crate::cmd_share::Harness; + let meta_source = path.meta.as_ref().and_then(|m| m.source.as_deref()); + if let Some(source) = meta_source { + match source { + "claude-code" => return Some(Harness::Claude), + "gemini-cli" => return Some(Harness::Gemini), + "codex" => return Some(Harness::Codex), + "opencode" => return Some(Harness::Opencode), + "pi" => return Some(Harness::Pi), + _ => {} // fall through to actor sniffing + } + } + for step in &path.steps { + let actor = &step.step.actor; + if actor.starts_with("agent:claude-code") { + return Some(Harness::Claude); + } + if actor.starts_with("agent:gemini-cli") || actor.starts_with("agent:gemini") { + return Some(Harness::Gemini); + } + if actor.starts_with("agent:codex") { + return Some(Harness::Codex); + } + if actor.starts_with("agent:opencode") { + return Some(Harness::Opencode); + } + if actor.starts_with("agent:pi") { + return Some(Harness::Pi); + } + } + None +} + +/// Validate that a parsed Toolpath document is a single inline Path +/// carrying at least one `agent:*` actor. Returns the inner Path borrow +/// on success. +pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { + if g.paths.is_empty() { + anyhow::bail!("resume needs a `Path`; expected one path, got an empty graph"); + } + if g.paths.len() > 1 { + anyhow::bail!( + "resume needs a single `Path`; input is a graph with {} paths. \ + Pick one with `path query …` or split first.", + g.paths.len() + ); + } + let path = match &g.paths[0] { + PathOrRef::Path(p) => p.as_ref(), + PathOrRef::Ref(_) => anyhow::bail!( + "resume needs an inline `Path`; got a $ref. Resolve it first with `path import` or fetch the document." + ), + }; + let has_agent = path + .steps + .iter() + .any(|s| s.step.actor.starts_with("agent:")); + if !has_agent { + anyhow::bail!( + "no agent session in input — `path resume` only works on harness-derived paths" + ); + } + Ok(path) +} + #[cfg(test)] mod tests { use super::*; @@ -66,4 +137,115 @@ mod tests { let err = run(args).unwrap_err(); assert!(err.to_string().contains("not yet implemented")); } + + use crate::cmd_share::Harness; + use toolpath::v1::{Graph, PathMeta, PathOrRef}; + + fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step { + toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z") + .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new") + } + + fn make_path_with_actor(actor: &str) -> toolpath::v1::Path { + use toolpath::v1::{Path, PathIdentity}; + let step = make_step_with_actor("s1", actor); + Path { + path: PathIdentity { + id: "p1".to_string(), + base: None, + head: "s1".to_string(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + } + } + + #[test] + fn infer_source_harness_meta_source_wins() { + let mut path = make_path_with_actor("agent:codex"); + path.meta = Some(PathMeta { + source: Some("claude-code".to_string()), + ..Default::default() + }); + assert_eq!(infer_source_harness(&path), Some(Harness::Claude)); + } + + #[test] + fn infer_source_harness_meta_source_unknown_falls_through_to_actor() { + let mut path = make_path_with_actor("agent:gemini-cli"); + path.meta = Some(PathMeta { + source: Some("something-bespoke".to_string()), + ..Default::default() + }); + assert_eq!(infer_source_harness(&path), Some(Harness::Gemini)); + } + + #[test] + fn infer_source_harness_actor_sniff_codex() { + let path = make_path_with_actor("agent:codex"); + assert_eq!(infer_source_harness(&path), Some(Harness::Codex)); + } + + #[test] + fn infer_source_harness_actor_sniff_opencode() { + let path = make_path_with_actor("agent:opencode"); + assert_eq!(infer_source_harness(&path), Some(Harness::Opencode)); + } + + #[test] + fn infer_source_harness_actor_sniff_pi() { + let path = make_path_with_actor("agent:pi"); + assert_eq!(infer_source_harness(&path), Some(Harness::Pi)); + } + + #[test] + fn infer_source_harness_returns_none_when_no_signal() { + let path = make_path_with_actor("human:alex"); + assert_eq!(infer_source_harness(&path), None); + } + + #[test] + fn ensure_path_with_agent_accepts_single_path_with_agent_actor() { + let g = Graph::from_path(make_path_with_actor("agent:claude-code")); + assert!(ensure_path_with_agent(&g).is_ok()); + } + + #[test] + fn ensure_path_with_agent_rejects_empty_graph() { + let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); + g.paths.clear(); + let err = ensure_path_with_agent(&g).unwrap_err(); + assert!(err.to_string().contains("expected")); + assert!(err.to_string().contains("empty")); + } + + #[test] + fn ensure_path_with_agent_rejects_multi_path_graph() { + let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); + g.paths + .push(PathOrRef::Path(Box::new(make_path_with_actor("agent:claude-code")))); + let err = ensure_path_with_agent(&g).unwrap_err(); + let s = err.to_string(); + assert!(s.contains("single `Path`"), "actual: {s}"); + assert!(s.contains("2 paths"), "actual: {s}"); + } + + #[test] + fn ensure_path_with_agent_rejects_agentless_path() { + let g = Graph::from_path(make_path_with_actor("human:alex")); + let err = ensure_path_with_agent(&g).unwrap_err(); + assert!(err.to_string().contains("no agent session")); + } + + #[test] + fn ensure_path_with_agent_rejects_path_ref_only_graph() { + use toolpath::v1::PathRef; + let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); + g.paths = vec![PathOrRef::Ref(PathRef { + ref_url: "$ref://something".into(), + })]; + let err = ensure_path_with_agent(&g).unwrap_err(); + assert!(err.to_string().contains("inline `Path`"), "actual: {}", err); + } } From 8f4f7411d9bd3c43a95f9a3a20ab79d2ef8d540b Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 17:24:16 -0400 Subject: [PATCH 09/21] feat(path-cli): resolve_input dispatcher for path resume --- crates/path-cli/src/cmd_resume.rs | 133 +++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index b5c5a6c..c146cd8 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -6,7 +6,6 @@ #![cfg(not(target_os = "emscripten"))] -#[allow(unused_imports)] use anyhow::{Context, Result}; use clap::Args; use std::path::PathBuf; @@ -120,6 +119,75 @@ pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { Ok(path) } +/// Resolve the user-supplied `` argument into a parsed `Graph` +/// plus the source harness inferred from its single inline path (if +/// any). See spec § "Input resolution" for the order. +pub(crate) fn resolve_input( + args: &ResumeArgs, +) -> Result<(Graph, Option)> { + let raw = args.input.as_str(); + + enum Shape<'a> { + PathbaseUrl(&'a str), + PathbaseShorthand(&'a str), + FilePath(&'a str), + CacheId(&'a str), + } + + let shape = if raw.starts_with("http://") || raw.starts_with("https://") { + Shape::PathbaseUrl(raw) + } else if looks_like_pathbase_shorthand(raw) { + Shape::PathbaseShorthand(raw) + } else if std::path::Path::new(raw).is_file() { + Shape::FilePath(raw) + } else { + Shape::CacheId(raw) + }; + + let graph: Graph = match shape { + Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => { + let derived = crate::cmd_import::pathbase_fetch_to_doc(u, args.url.as_deref())?; + if !args.no_cache { + crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, args.force)?; + eprintln!("Resolved {} → {}", raw, derived.cache_id); + } + derived.doc + } + Shape::FilePath(p) => { + let json = std::fs::read_to_string(p).with_context(|| format!("read {}", p))?; + Graph::from_json(&json) + .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))? + } + Shape::CacheId(id) => { + let file = crate::cmd_cache::cache_ref(id).map_err(|e| { + anyhow::anyhow!( + "couldn't resolve `{}` as a URL, file path, or cache id: {}", + raw, + e + ) + })?; + let json = std::fs::read_to_string(&file) + .with_context(|| format!("read {}", file.display()))?; + Graph::from_json(&json) + .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))? + } + }; + + let harness = graph.single_path().and_then(infer_source_harness); + Ok((graph, harness)) +} + +fn looks_like_pathbase_shorthand(s: &str) -> bool { + // Three non-empty slash-separated segments, none containing whitespace + // or starting with a dot/slash (which would indicate a relative or + // absolute path). + if s.starts_with('.') || s.starts_with('/') { + return false; + } + let segs: Vec<&str> = s.split('/').collect(); + segs.len() == 3 && segs.iter().all(|s| !s.is_empty() && !s.contains(char::is_whitespace)) +} + #[cfg(test)] mod tests { use super::*; @@ -248,4 +316,67 @@ mod tests { let err = ensure_path_with_agent(&g).unwrap_err(); assert!(err.to_string().contains("inline `Path`"), "actual: {}", err); } + + #[test] + fn resolve_input_file_path() { + let tmp = tempfile::tempdir().unwrap(); + let p = tmp.path().join("doc.json"); + let graph = toolpath::v1::Graph::from_path(make_path_with_actor("agent:claude-code")); + std::fs::write(&p, graph.to_json().unwrap()).unwrap(); + + let args = ResumeArgs { + input: p.to_string_lossy().to_string(), + cwd: None, + harness: None, + no_cache: false, + force: false, + url: None, + }; + let (g, harness) = resolve_input(&args).unwrap(); + let _path = ensure_path_with_agent(&g).unwrap(); + assert_eq!(harness, Some(Harness::Claude)); + } + + #[test] + fn resolve_input_url_dispatches_to_pathbase_fetch() { + use crate::cmd_pathbase::tests::MockServer; + let body = { + let mut path = make_path_with_actor("agent:codex"); + path.meta = Some(toolpath::v1::PathMeta { + source: Some("codex".to_string()), + ..Default::default() + }); + toolpath::v1::Graph::from_path(path).to_json().unwrap() + }; + // MockServer::start requires &'static str — leak the body to satisfy this. + let body_static: &'static str = Box::leak(body.into_boxed_str()); + let server = MockServer::start("HTTP/1.1 200 OK", body_static); + + let args = ResumeArgs { + input: format!("{}/alex/pathstash/p", server.base()), + cwd: None, + harness: None, + no_cache: true, // skip cache write in tests + force: false, + url: None, + }; + let (g, harness) = resolve_input(&args).unwrap(); + let _ = ensure_path_with_agent(&g).unwrap(); + assert_eq!(harness, Some(Harness::Codex)); + } + + #[test] + fn resolve_input_unresolvable_errors_clearly() { + let args = ResumeArgs { + input: "definitely/not/a/real/cache/id".to_string(), + cwd: None, + harness: None, + no_cache: false, + force: false, + url: None, + }; + let err = resolve_input(&args).unwrap_err(); + let s = err.to_string(); + assert!(s.contains("couldn't resolve"), "actual: {s}"); + } } From 8b7943c0c5c7acc18da9282e26e03854c3c54f77 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 17:40:40 -0400 Subject: [PATCH 10/21] feat(path-cli): harness picker + PATH probe for path resume --- crates/path-cli/src/cmd_resume.rs | 166 ++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index c146cd8..1c29623 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -177,6 +177,127 @@ pub(crate) fn resolve_input( Ok((graph, harness)) } +/// Probe `$PATH` (or `path_override`, for tests) for a given binary name. +/// Cross-platform: on Windows, also tries `.exe`. +pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path>) -> bool { + let dirs: Vec = match path_override { + Some(p) => vec![p.to_path_buf()], + None => std::env::var_os("PATH") + .map(|p| std::env::split_paths(&p).collect()) + .unwrap_or_default(), + }; + for d in dirs { + let candidate = d.join(name); + if candidate.is_file() { + return true; + } + #[cfg(windows)] + { + let exe = d.join(format!("{name}.exe")); + if exe.is_file() { + return true; + } + } + } + false +} + +const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[ + crate::cmd_share::Harness::Claude, + crate::cmd_share::Harness::Gemini, + crate::cmd_share::Harness::Codex, + crate::cmd_share::Harness::Opencode, + crate::cmd_share::Harness::Pi, +]; + +/// Decide which harness to resume in. +/// +/// - If `arg` is `Some`, validate the named harness is on PATH and return it. +/// - Otherwise, enumerate installed harnesses and either return the only one, +/// or launch the fzf picker. `source` is used to label the source row in the +/// picker UI and to short-circuit when exactly one harness is installed. +/// +/// `path_override` is `None` in production; tests pass `Some(dir)` to fake `$PATH`. +pub(crate) fn pick_harness( + arg: Option, + source: Option, + path_override: Option<&std::path::Path>, +) -> Result { + use crate::cmd_share::Harness; + + if let Some(a) = arg { + let h = Harness::from_arg(a); + if !binary_on_path(h.name(), path_override) { + anyhow::bail!( + "harness `{}` isn't on PATH; install it or pick another with `--harness`", + h.name() + ); + } + return Ok(h); + } + + let installed: Vec = ALL_HARNESSES + .iter() + .copied() + .filter(|h| binary_on_path(h.name(), path_override)) + .collect(); + + if installed.is_empty() { + anyhow::bail!( + "no installed harnesses found on PATH; install one of: claude, gemini, codex, opencode, pi" + ); + } + + if installed.len() == 1 { + return Ok(installed[0]); + } + + interactive_pick(&installed, source) +} + +fn interactive_pick( + installed: &[crate::cmd_share::Harness], + source: Option, +) -> Result { + if !crate::fzf::available() { + anyhow::bail!( + "interactive picker requires `fzf` on PATH and a TTY; pass `--harness ` or rerun in a terminal" + ); + } + let mut lines: Vec = Vec::with_capacity(installed.len()); + for h in installed { + let suffix = if Some(*h) == source { " (source)" } else { "" }; + lines.push(format!("{}{}", h.symbol(), suffix)); + } + + let header = match source { + Some(s) => format!("pick a harness to resume in (source: {})", s.name()), + None => "pick a harness to resume in".to_string(), + }; + + let opts = crate::fzf::PickOptions { + with_nth: "1..", + header: Some(&header), + ..Default::default() + }; + let selected = match crate::fzf::pick(&lines, &opts) + .map_err(|e| anyhow::anyhow!("fzf failed: {}", e))? + { + crate::fzf::PickResult::Selected(rows) => rows.into_iter().next().unwrap_or_default(), + crate::fzf::PickResult::Cancelled => std::process::exit(130), + crate::fzf::PickResult::NoMatch => { + anyhow::bail!("fzf returned no match — picker UI was empty?"); + } + }; + + for h in installed { + if selected.starts_with(h.symbol()) { + return Ok(*h); + } + } + anyhow::bail!("picker returned an unrecognized row: {selected}") +} + fn looks_like_pathbase_shorthand(s: &str) -> bool { // Three non-empty slash-separated segments, none containing whitespace // or starting with a dot/slash (which would indicate a relative or @@ -379,4 +500,49 @@ mod tests { let s = err.to_string(); assert!(s.contains("couldn't resolve"), "actual: {s}"); } + + fn fake_path_with(binaries: &[&str]) -> tempfile::TempDir { + let td = tempfile::tempdir().unwrap(); + for b in binaries { + let p = td.path().join(b); + std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perm = std::fs::metadata(&p).unwrap().permissions(); + perm.set_mode(0o755); + std::fs::set_permissions(&p, perm).unwrap(); + } + } + td + } + + #[test] + fn binary_on_path_finds_present_binary() { + let td = fake_path_with(&["claude"]); + assert!(binary_on_path("claude", Some(td.path()))); + assert!(!binary_on_path("gemini", Some(td.path()))); + } + + #[test] + fn pick_harness_explicit_arg_validates_path() { + let td = fake_path_with(&["claude"]); + let result = pick_harness(Some(HarnessArg::Claude), None, Some(td.path())); + assert_eq!(result.unwrap(), Harness::Claude); + + let err = pick_harness(Some(HarnessArg::Gemini), None, Some(td.path())).unwrap_err(); + assert!(err.to_string().contains("`gemini` isn't on PATH")); + } + + #[test] + fn pick_harness_zero_installed_errors() { + let td = fake_path_with(&[]); + let err = pick_harness(None, Some(Harness::Claude), Some(td.path())).unwrap_err(); + assert!( + err.to_string().contains("no installed harnesses") + || err.to_string().contains("no harnesses on PATH"), + "actual: {}", + err + ); + } } From d0b67184977377b272a594517a73f157e0e6ab5b Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 17:42:50 -0400 Subject: [PATCH 11/21] fix(path-cli): always invoke picker when --harness is unset --- crates/path-cli/src/cmd_resume.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 1c29623..46d6e70 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -213,9 +213,8 @@ const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[ /// Decide which harness to resume in. /// /// - If `arg` is `Some`, validate the named harness is on PATH and return it. -/// - Otherwise, enumerate installed harnesses and either return the only one, -/// or launch the fzf picker. `source` is used to label the source row in the -/// picker UI and to short-circuit when exactly one harness is installed. +/// - Otherwise, enumerate installed harnesses and launch the fzf picker. +/// `source` is used to label the source row in the picker UI. /// /// `path_override` is `None` in production; tests pass `Some(dir)` to fake `$PATH`. pub(crate) fn pick_harness( @@ -248,10 +247,6 @@ pub(crate) fn pick_harness( ); } - if installed.len() == 1 { - return Ok(installed[0]); - } - interactive_pick(&installed, source) } From b8d5d225d821ca9eefcc9fbd60034ef009576e32 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 18:09:31 -0400 Subject: [PATCH 12/21] feat(path-cli): argv_for + project_into_harness dispatcher --- crates/path-cli/src/cmd_resume.rs | 118 ++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 46d6e70..15fe1e3 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -293,6 +293,36 @@ fn interactive_pick( anyhow::bail!("picker returned an unrecognized row: {selected}") } +/// Static map from harness to resume-argv shape. Lives here because +/// it's a per-harness CLI convention, not a projection concern. +pub(crate) fn argv_for(harness: crate::cmd_share::Harness, session_id: &str) -> Vec { + use crate::cmd_share::Harness; + match harness { + Harness::Claude => vec!["-r".into(), session_id.into()], + Harness::Gemini => vec!["--resume".into(), session_id.into()], + Harness::Codex => vec!["resume".into(), session_id.into()], + Harness::Opencode => vec!["--session".into(), session_id.into()], + Harness::Pi => vec!["--session".into(), session_id.into()], + } +} + +/// Project a Path into the chosen harness's on-disk layout under `cwd`, +/// returning the projected session id. +pub(crate) fn project_into_harness( + path: &TPath, + harness: crate::cmd_share::Harness, + cwd: &std::path::Path, +) -> Result { + use crate::cmd_share::Harness; + match harness { + Harness::Claude => crate::cmd_export::project_claude(path, cwd), + Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), + Harness::Codex => crate::cmd_export::project_codex(path, cwd), + Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), + Harness::Pi => crate::cmd_export::project_pi(path, cwd), + } +} + fn looks_like_pathbase_shorthand(s: &str) -> bool { // Three non-empty slash-separated segments, none containing whitespace // or starting with a dot/slash (which would indicate a relative or @@ -540,4 +570,92 @@ mod tests { err ); } + + #[test] + fn argv_for_returns_harness_specific_shape() { + assert_eq!(argv_for(Harness::Claude, "abc"), vec!["-r".to_string(), "abc".to_string()]); + assert_eq!(argv_for(Harness::Gemini, "abc"), vec!["--resume".to_string(), "abc".to_string()]); + assert_eq!(argv_for(Harness::Codex, "abc"), vec!["resume".to_string(), "abc".to_string()]); + assert_eq!(argv_for(Harness::Opencode, "abc"), vec!["--session".to_string(), "abc".to_string()]); + assert_eq!(argv_for(Harness::Pi, "abc"), vec!["--session".to_string(), "abc".to_string()]); + } + + #[test] + fn project_into_harness_claude_round_trip() { + let _home = scoped_home_for_resume(); + let cwd = tempfile::tempdir().unwrap(); + let path = make_convo_path_for_resume("claude-code://resume-test-session"); + + let session_id = project_into_harness(&path, Harness::Claude, cwd.path()).unwrap(); + assert!(!session_id.is_empty()); + } + + /// Build a minimal `toolpath::v1::Path` with a single `conversation.append` + /// step using the given `artifact_key` (e.g. `"claude-code://my-session"`). + /// Required for projectors that extract the session id from the artifact key. + fn make_convo_path_for_resume(artifact_key: &str) -> toolpath::v1::Path { + use std::collections::HashMap; + let mut extra = HashMap::new(); + extra.insert("role".to_string(), serde_json::json!("user")); + extra.insert("text".to_string(), serde_json::json!("hello")); + let step = toolpath::v1::Step { + step: toolpath::v1::StepIdentity { + id: "s1".to_string(), + parents: vec![], + actor: "human:test".to_string(), + timestamp: "2026-01-01T00:00:00Z".to_string(), + }, + change: { + let mut m = HashMap::new(); + m.insert( + artifact_key.to_string(), + toolpath::v1::ArtifactChange { + raw: None, + structural: Some(toolpath::v1::StructuralChange { + change_type: "conversation.append".to_string(), + extra, + }), + }, + ); + m + }, + meta: None, + }; + toolpath::v1::Path { + path: toolpath::v1::PathIdentity { + id: "test-path".to_string(), + base: None, + head: "s1".to_string(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + } + } + + fn scoped_home_for_resume() -> ScopedHomeForResume { + ScopedHomeForResume::new() + } + + struct ScopedHomeForResume { _td: tempfile::TempDir, prev: Option } + + impl ScopedHomeForResume { + fn new() -> Self { + let td = tempfile::tempdir().unwrap(); + let prev = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", td.path()); } + Self { _td: td, prev } + } + } + + impl Drop for ScopedHomeForResume { + fn drop(&mut self) { + unsafe { + match &self.prev { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + } + } } From 36c0d1e716d56ab382279a6ff9b0bf3bbf64f98f Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 18:14:26 -0400 Subject: [PATCH 13/21] feat(path-cli): ExecStrategy with RealExec/RecordingExec --- crates/path-cli/src/cmd_resume.rs | 104 ++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 15fe1e3..c79c9e5 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -323,6 +323,97 @@ pub(crate) fn project_into_harness( } } +/// What `exec_harness` saw (for tests). +#[derive(Debug, Clone, Default)] +pub struct CapturedExec { + pub binary: String, + pub args: Vec, + pub cwd: std::path::PathBuf, +} + +/// Pluggable exec backend. Production uses `RealExec` (`execvp` on +/// Unix, spawn-and-wait on Windows). Tests use `RecordingExec`. +pub trait ExecStrategy { + fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>; +} + +/// Production implementation. On Unix this never returns on success +/// (the current process is replaced); on Windows it spawns the child, +/// waits, and propagates the exit code. +pub struct RealExec; + +impl ExecStrategy for RealExec { + fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { + let mut cmd = std::process::Command::new(binary); + cmd.args(args); + cmd.current_dir(cwd); + + eprintln!( + "Resuming: {} {} (cwd: {})", + binary, + args.join(" "), + cwd.display() + ); + + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + // exec only returns if it fails. + let err = cmd.exec(); + anyhow::bail!( + "couldn't exec `{}`: {}. Recipe: {} {} (run from {})", + binary, + err, + binary, + args.join(" "), + cwd.display() + ); + } + #[cfg(not(unix))] + { + let status = cmd.spawn() + .with_context(|| format!("spawn {}", binary))? + .wait() + .with_context(|| format!("wait for {}", binary))?; + std::process::exit(status.code().unwrap_or(1)); + } + } +} + +/// Recording strategy for tests. `captured()` returns the most recent +/// invocation. +#[derive(Default)] +pub struct RecordingExec { + inner: std::sync::Mutex, +} + +impl RecordingExec { + pub fn captured(&self) -> CapturedExec { + self.inner.lock().unwrap().clone() + } +} + +impl ExecStrategy for RecordingExec { + fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { + let mut g = self.inner.lock().unwrap(); + *g = CapturedExec { + binary: binary.to_string(), + args: args.to_vec(), + cwd: cwd.to_path_buf(), + }; + Ok(()) + } +} + +pub(crate) fn exec_harness( + binary: &str, + args: &[String], + cwd: &std::path::Path, + strategy: &dyn ExecStrategy, +) -> Result<()> { + strategy.exec(binary, args, cwd) +} + fn looks_like_pathbase_shorthand(s: &str) -> bool { // Three non-empty slash-separated segments, none containing whitespace // or starting with a dot/slash (which would indicate a relative or @@ -658,4 +749,17 @@ mod tests { } } } + + #[test] + fn exec_strategy_recording_captures_invocation() { + let recorder = RecordingExec::default(); + let strategy: &dyn ExecStrategy = &recorder; + exec_harness("claude", &["-r".into(), "abc123".into()], std::path::Path::new("/tmp/x"), strategy) + .unwrap(); + + let captured = recorder.captured(); + assert_eq!(captured.binary, "claude"); + assert_eq!(captured.args, vec!["-r".to_string(), "abc123".to_string()]); + assert_eq!(captured.cwd, std::path::PathBuf::from("/tmp/x")); + } } From b97c243dbce8f853391ac07957a58c07d5a94119 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Fri, 8 May 2026 19:50:07 -0400 Subject: [PATCH 14/21] feat(path-cli): wire path resume orchestration end-to-end --- crates/path-cli/src/cmd_resume.rs | 115 +++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 11 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index c79c9e5..fc17b61 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -44,8 +44,32 @@ pub struct ResumeArgs { pub url: Option, } -pub fn run(_args: ResumeArgs) -> Result<()> { - anyhow::bail!("path resume: not yet implemented") +pub fn run(args: ResumeArgs) -> Result<()> { + run_with_strategy(args, &RealExec) +} + +/// Internal entry point that the integration tests call with a +/// `RecordingExec` strategy. Production callers use [`run`]. +pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> { + let (graph, source_harness) = resolve_input(&args)?; + let path = ensure_path_with_agent(&graph)?; + + let cwd = match args.cwd.as_ref() { + Some(p) => std::fs::canonicalize(p) + .with_context(|| format!("resolve cwd path {}", p.display()))?, + None => std::env::current_dir()?, + }; + + let target = pick_harness(args.harness, source_harness, None)?; + eprintln!( + "Picked harness: {}{}", + target.name(), + if Some(target) == source_harness { " (source)" } else { "" } + ); + + let session_id = project_into_harness(path, target, &cwd)?; + let argv = argv_for(target, &session_id); + exec_harness(target.name(), &argv, &cwd, exec) } use toolpath::v1::{Graph, Path as TPath, PathOrRef}; @@ -430,17 +454,86 @@ mod tests { use super::*; #[test] - fn run_returns_not_implemented_until_wired() { + fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() { + let _home = scoped_home_for_resume(); + let cwd = tempfile::tempdir().unwrap(); + let doc_file = cwd.path().join("doc.json"); + + // Build a path with an agent:claude-code actor step that also carries + // a conversation.append artifact so project_claude can consume it. + let path = { + use std::collections::HashMap; + let mut extra = HashMap::new(); + extra.insert("role".to_string(), serde_json::json!("user")); + extra.insert("text".to_string(), serde_json::json!("hello")); + let step = toolpath::v1::Step { + step: toolpath::v1::StepIdentity { + id: "s1".to_string(), + parents: vec![], + actor: "agent:claude-code".to_string(), + timestamp: "2026-01-01T00:00:00Z".to_string(), + }, + change: { + let mut m = HashMap::new(); + m.insert( + "claude-code://resume-test-session".to_string(), + toolpath::v1::ArtifactChange { + raw: None, + structural: Some(toolpath::v1::StructuralChange { + change_type: "conversation.append".to_string(), + extra, + }), + }, + ); + m + }, + meta: None, + }; + toolpath::v1::Path { + path: toolpath::v1::PathIdentity { + id: "test-path".to_string(), + base: None, + head: "s1".to_string(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + } + }; + let graph = toolpath::v1::Graph::from_path(path); + std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap(); + + // Make `claude` discoverable by salting PATH for this process. + let bin_dir = fake_path_with(&["claude"]); + let prev = std::env::var_os("PATH"); + let new_path = std::env::join_paths( + std::iter::once(bin_dir.path().to_path_buf()) + .chain(std::env::split_paths(&prev.clone().unwrap_or_default())), + ).unwrap(); + unsafe { std::env::set_var("PATH", new_path); } + let args = ResumeArgs { - input: "irrelevant".to_string(), - cwd: None, - harness: None, - no_cache: false, - force: false, - url: None, + input: doc_file.to_string_lossy().to_string(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(HarnessArg::Claude), + no_cache: false, force: false, url: None, }; - let err = run(args).unwrap_err(); - assert!(err.to_string().contains("not yet implemented")); + + let recorder = RecordingExec::default(); + run_with_strategy(args, &recorder).unwrap(); + + // Restore PATH. + unsafe { + match prev { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + } + + let cap = recorder.captured(); + assert_eq!(cap.binary, "claude"); + assert_eq!(cap.args[0], "-r"); + assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap()); } use crate::cmd_share::Harness; From 180a6518d5ff42c35bf90a2483f4193284004296 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Sat, 9 May 2026 11:43:21 -0400 Subject: [PATCH 15/21] test(path-cli): isolate path resume orchestration test from env races --- crates/path-cli/src/cmd_resume.rs | 100 +++++++++++++----------------- 1 file changed, 42 insertions(+), 58 deletions(-) diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index fc17b61..dc84a04 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -455,63 +455,22 @@ mod tests { #[test] fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() { + let _env = crate::config::TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _home = scoped_home_for_resume(); + let _path_guard = ScopedPathForResume::with_binaries(&["claude"]); let cwd = tempfile::tempdir().unwrap(); let doc_file = cwd.path().join("doc.json"); - // Build a path with an agent:claude-code actor step that also carries - // a conversation.append artifact so project_claude can consume it. - let path = { - use std::collections::HashMap; - let mut extra = HashMap::new(); - extra.insert("role".to_string(), serde_json::json!("user")); - extra.insert("text".to_string(), serde_json::json!("hello")); - let step = toolpath::v1::Step { - step: toolpath::v1::StepIdentity { - id: "s1".to_string(), - parents: vec![], - actor: "agent:claude-code".to_string(), - timestamp: "2026-01-01T00:00:00Z".to_string(), - }, - change: { - let mut m = HashMap::new(); - m.insert( - "claude-code://resume-test-session".to_string(), - toolpath::v1::ArtifactChange { - raw: None, - structural: Some(toolpath::v1::StructuralChange { - change_type: "conversation.append".to_string(), - extra, - }), - }, - ); - m - }, - meta: None, - }; - toolpath::v1::Path { - path: toolpath::v1::PathIdentity { - id: "test-path".to_string(), - base: None, - head: "s1".to_string(), - graph_ref: None, - }, - steps: vec![step], - meta: None, - } - }; + // Build a minimal path with a conversation.append step that + // project_claude can consume, reusing the existing helper. + let mut path = make_convo_path_for_resume("claude-code://resume-test-session"); + // Overwrite the actor to agent:claude-code so run_with_strategy can + // pass the ensure_path_with_agent check. + path.steps[0].step.actor = "agent:claude-code".to_string(); + let graph = toolpath::v1::Graph::from_path(path); std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap(); - // Make `claude` discoverable by salting PATH for this process. - let bin_dir = fake_path_with(&["claude"]); - let prev = std::env::var_os("PATH"); - let new_path = std::env::join_paths( - std::iter::once(bin_dir.path().to_path_buf()) - .chain(std::env::split_paths(&prev.clone().unwrap_or_default())), - ).unwrap(); - unsafe { std::env::set_var("PATH", new_path); } - let args = ResumeArgs { input: doc_file.to_string_lossy().to_string(), cwd: Some(cwd.path().to_path_buf()), @@ -522,14 +481,6 @@ mod tests { let recorder = RecordingExec::default(); run_with_strategy(args, &recorder).unwrap(); - // Restore PATH. - unsafe { - match prev { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } - let cap = recorder.captured(); assert_eq!(cap.binary, "claude"); assert_eq!(cap.args[0], "-r"); @@ -766,6 +717,7 @@ mod tests { #[test] fn project_into_harness_claude_round_trip() { + let _env = crate::config::TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _home = scoped_home_for_resume(); let cwd = tempfile::tempdir().unwrap(); let path = make_convo_path_for_resume("claude-code://resume-test-session"); @@ -821,6 +773,38 @@ mod tests { ScopedHomeForResume::new() } + struct ScopedPathForResume { + _bin_dir: tempfile::TempDir, + prev: Option, + } + + impl ScopedPathForResume { + /// Prepends a tempdir containing the named binaries to `PATH` for + /// the guard's lifetime. + fn with_binaries(binaries: &[&str]) -> Self { + let bin_dir = fake_path_with(binaries); + let prev = std::env::var_os("PATH"); + let new_path = std::env::join_paths( + std::iter::once(bin_dir.path().to_path_buf()) + .chain(std::env::split_paths(&prev.clone().unwrap_or_default())), + ) + .unwrap(); + unsafe { std::env::set_var("PATH", new_path); } + Self { _bin_dir: bin_dir, prev } + } + } + + impl Drop for ScopedPathForResume { + fn drop(&mut self) { + unsafe { + match &self.prev { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + } + } + } + struct ScopedHomeForResume { _td: tempfile::TempDir, prev: Option } impl ScopedHomeForResume { From 8408377d1de0fcc9198e3a4967a6c4b6be613685 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Sat, 9 May 2026 12:15:50 -0400 Subject: [PATCH 16/21] fix(path-cli): eliminate HOME/XDG races in path-cli test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit opencode_writes_into_db_with_project removed HOME on cleanup without restoring the prior value. Two cmd_resume tests that go through resolve_input → cmd_pathbase::credentials_path didn't acquire TEST_ENV_LOCK, racing with the unrestored HOME and intermittently failing with "$HOME is not set". Save+restore HOME and XDG_DATA_HOME symmetrically in the opencode test, and lock TEST_ENV_LOCK in the two cmd_resume tests that read global config. --- crates/path-cli/src/cmd_export.rs | 11 ++++++++++- crates/path-cli/src/cmd_resume.rs | 2 ++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index ad524a4..d4bb800 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -2577,6 +2577,8 @@ mod tests { let _g = crate::config::TEST_ENV_LOCK .lock() .unwrap_or_else(|e| e.into_inner()); + let prev_home = std::env::var_os("HOME"); + let prev_xdg = std::env::var_os("XDG_DATA_HOME"); unsafe { std::env::set_var("HOME", &fake_home); std::env::remove_var("XDG_DATA_HOME"); @@ -2587,7 +2589,14 @@ mod tests { None, ); unsafe { - std::env::remove_var("HOME"); + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match prev_xdg { + Some(v) => std::env::set_var("XDG_DATA_HOME", v), + None => std::env::remove_var("XDG_DATA_HOME"), + } } result.expect("export opencode --project"); diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index dc84a04..c0b2c63 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -620,6 +620,7 @@ mod tests { #[test] fn resolve_input_url_dispatches_to_pathbase_fetch() { + let _env = crate::config::TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); use crate::cmd_pathbase::tests::MockServer; let body = { let mut path = make_path_with_actor("agent:codex"); @@ -648,6 +649,7 @@ mod tests { #[test] fn resolve_input_unresolvable_errors_clearly() { + let _env = crate::config::TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let args = ResumeArgs { input: "definitely/not/a/real/cache/id".to_string(), cwd: None, From 0fa89741b8b49353fa294b6da5c3eeb45d71e938 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Sat, 9 May 2026 12:20:31 -0400 Subject: [PATCH 17/21] test(path-cli): integration tests for path resume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drive `path resume` end-to-end through `run_with_strategy` with a `RecordingExec` so the would-be `execvp` becomes a captured `(binary, args, cwd)` tuple. Coverage: - Per-harness positive cases (claude / gemini / codex / opencode / pi), each asserting the expected binary, the resume flag, and the expected on-disk projection (JSONL written, SQLite row inserted). - Cache-id input → seeds a cache entry under a scoped HOME, runs resume, asserts the recorded recipe. - Multi-path graph, agent-less path, and harness-not-on-PATH rejections all return the spec'd error messages. Expose `cmd_resume` as `pub mod` and re-export `HarnessArg` from it so integration tests don't need to reach into `cmd_share`. Shared test support (env-isolating RAII guards, the `make_convo_path` fixture) lives in `tests/support/`. --- crates/path-cli/src/cmd_resume.rs | 5 +- crates/path-cli/src/lib.rs | 2 +- crates/path-cli/tests/resume.rs | 300 +++++++++++++++++++++++++++ crates/path-cli/tests/support/mod.rs | 206 ++++++++++++++++++ 4 files changed, 511 insertions(+), 2 deletions(-) create mode 100644 crates/path-cli/tests/resume.rs create mode 100644 crates/path-cli/tests/support/mod.rs diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index c0b2c63..5509d8b 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -10,7 +10,10 @@ use anyhow::{Context, Result}; use clap::Args; use std::path::PathBuf; -use crate::cmd_share::HarnessArg; +/// Re-exported so external callers (integration tests, future consumers) +/// can construct [`ResumeArgs`] without depending on the `cmd_share` +/// module directly. +pub use crate::cmd_share::HarnessArg; #[derive(Args, Debug)] pub struct ResumeArgs { diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 4948eb6..63b840d 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -14,7 +14,7 @@ mod cmd_project; mod cmd_query; mod cmd_render; #[cfg(not(target_os = "emscripten"))] -mod cmd_resume; +pub mod cmd_resume; #[cfg(not(target_os = "emscripten"))] mod cmd_share; #[cfg(not(target_os = "emscripten"))] diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs new file mode 100644 index 0000000..a053f6a --- /dev/null +++ b/crates/path-cli/tests/resume.rs @@ -0,0 +1,300 @@ +//! Integration tests for `path resume`. +//! +//! Tests dispatch through `path_cli::cmd_resume::run_with_strategy` +//! with a `RecordingExec` strategy so the would-be `execvp` becomes a +//! captured `(binary, args, cwd)` tuple. Each test isolates `$HOME`, +//! `$TOOLPATH_CONFIG_DIR`, and `$PATH` via RAII guards under a shared +//! lock. + +#![cfg(not(target_os = "emscripten"))] + +use path_cli::cmd_resume::{run_with_strategy, HarnessArg, RecordingExec, ResumeArgs}; + +mod support; +use support::*; + +// ── Per-harness positive cases ────────────────────────────────────── + +#[test] +fn file_input_explicit_claude_projects_and_records_exec() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + + let path = make_convo_path("agent:claude-code", "claude-code://resume-claude-int"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let recorder = RecordingExec::default(); + run_with_strategy(args_explicit(doc_file, cwd.path(), HarnessArg::Claude), &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "claude"); + assert_eq!(cap.args[0], "-r"); + assert!(!cap.args[1].is_empty(), "session id should be non-empty"); + assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap()); + + // Side effect: a JSONL was written under HOME/.claude/projects. + let projects = std::env::var_os("HOME") + .map(|h| std::path::PathBuf::from(h).join(".claude/projects")) + .unwrap(); + assert!(projects.exists(), "claude projects dir not created"); + assert!( + dir_contains_file_with_ext(&projects, "jsonl"), + "no JSONL written under claude projects" + ); +} + +#[test] +fn file_input_explicit_gemini_projects_and_records_exec() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("gemini"); + let cwd = tempfile::tempdir().unwrap(); + + let path = make_convo_path("agent:gemini-cli", "gemini-cli://resume-gemini-int"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let recorder = RecordingExec::default(); + run_with_strategy(args_explicit(doc_file, cwd.path(), HarnessArg::Gemini), &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "gemini"); + assert_eq!(cap.args[0], "--resume"); + assert!(!cap.args[1].is_empty()); + + let tmp_root = std::env::var_os("HOME") + .map(|h| std::path::PathBuf::from(h).join(".gemini/tmp")) + .unwrap(); + assert!(tmp_root.exists(), "gemini tmp dir not created"); +} + +#[test] +fn file_input_explicit_codex_projects_and_records_exec() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("codex"); + let cwd = tempfile::tempdir().unwrap(); + + let path = make_convo_path("agent:codex", "codex://resume-codex-int"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let recorder = RecordingExec::default(); + run_with_strategy(args_explicit(doc_file, cwd.path(), HarnessArg::Codex), &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "codex"); + assert_eq!(cap.args[0], "resume"); + assert!(!cap.args[1].is_empty()); + + let sessions = std::env::var_os("HOME") + .map(|h| std::path::PathBuf::from(h).join(".codex/sessions")) + .unwrap(); + assert!(sessions.exists(), "codex sessions dir not created"); +} + +#[test] +fn file_input_explicit_opencode_projects_and_records_exec() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("opencode"); + let cwd = tempfile::tempdir().unwrap(); + + // Pre-create the opencode db with the canonical schema. (Schema DDL + // copied from cmd_export's existing opencode test until/unless + // toolpath-opencode exposes a public bootstrap helper.) + let resolver = toolpath_opencode::PathResolver::new(); + let db_path = resolver.db_path().unwrap(); + std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + conn.execute_batch( + r#" + CREATE TABLE project ( + id text PRIMARY KEY, worktree text NOT NULL, vcs text NOT NULL, + name text, time_created integer NOT NULL, time_updated integer NOT NULL, + time_initialized integer, sandboxes text NOT NULL, commands text + ); + CREATE TABLE session ( + id text PRIMARY KEY, project_id text NOT NULL, parent_id text, + slug text NOT NULL, directory text NOT NULL, title text NOT NULL, + version text NOT NULL, share_url text, + summary_additions integer, summary_deletions integer, + summary_files integer, summary_diffs text, revert text, permission text, + time_created integer NOT NULL, time_updated integer NOT NULL, + time_compacting integer, time_archived integer, workspace_id text + ); + CREATE TABLE message ( + id text PRIMARY KEY, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, + data text NOT NULL + ); + CREATE TABLE part ( + id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, + time_created integer NOT NULL, time_updated integer NOT NULL, + data text NOT NULL + ); + "#, + ) + .unwrap(); + } + + let path = make_convo_path("agent:opencode", "opencode://ses_resume-opencode-int"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let recorder = RecordingExec::default(); + run_with_strategy( + args_explicit(doc_file, cwd.path(), HarnessArg::Opencode), + &recorder, + ) + .unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "opencode"); + assert_eq!(cap.args[0], "--session"); + assert!(!cap.args[1].is_empty()); + + let conn = rusqlite::Connection::open(&db_path).unwrap(); + let session_count: i64 = conn + .query_row("SELECT COUNT(*) FROM session", [], |r| r.get(0)) + .unwrap(); + assert_eq!(session_count, 1, "opencode session row not inserted"); +} + +#[test] +fn file_input_explicit_pi_projects_and_records_exec() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("pi"); + let cwd = tempfile::tempdir().unwrap(); + + let path = make_convo_path("agent:pi", "pi://resume-pi-int"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let recorder = RecordingExec::default(); + run_with_strategy(args_explicit(doc_file, cwd.path(), HarnessArg::Pi), &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "pi"); + assert_eq!(cap.args[0], "--session"); + assert!(!cap.args[1].is_empty()); + + let sessions = std::env::var_os("HOME") + .map(|h| std::path::PathBuf::from(h).join(".pi/agent/sessions")) + .unwrap(); + assert!(sessions.exists(), "pi sessions dir not created"); +} + +// ── Cache-id input ────────────────────────────────────────────────── + +#[test] +fn cache_id_input_loads_and_projects() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + + // Seed a cache entry by writing the graph to + // /documents/.json directly. + let cache_id = "claude-resume-cache-test"; + let documents = std::path::PathBuf::from(std::env::var_os("TOOLPATH_CONFIG_DIR").unwrap()) + .join("documents"); + std::fs::create_dir_all(&documents).unwrap(); + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-cache-int", + )); + std::fs::write(documents.join(format!("{cache_id}.json")), graph.to_json().unwrap()).unwrap(); + + let resume_args = ResumeArgs { + input: cache_id.to_string(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(HarnessArg::Claude), + no_cache: false, + force: false, + url: None, + }; + + let recorder = RecordingExec::default(); + run_with_strategy(resume_args, &recorder).unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "claude"); + assert_eq!(cap.args[0], "-r"); +} + +// ── Rejection cases ───────────────────────────────────────────────── + +#[test] +fn multi_path_graph_returns_clear_error() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + + let p1 = make_convo_path("agent:claude-code", "claude-code://multi-1"); + let mut p2 = make_convo_path("agent:claude-code", "claude-code://multi-2"); + p2.path.id = "p2".into(); + + let graph = toolpath::v1::Graph { + graph: toolpath::v1::GraphIdentity { id: "g1".into() }, + paths: vec![ + toolpath::v1::PathOrRef::Path(Box::new(p1)), + toolpath::v1::PathOrRef::Path(Box::new(p2)), + ], + meta: None, + }; + let doc_file = cwd.path().join("multi.json"); + std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap(); + + let recorder = RecordingExec::default(); + let err = run_with_strategy( + args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + &recorder, + ) + .unwrap_err(); + let s = err.to_string(); + assert!(s.contains("single `Path`"), "actual: {s}"); + assert!(s.contains("2 paths"), "actual: {s}"); +} + +#[test] +fn agentless_path_returns_clear_error() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + + // human:* actor — should be rejected by ensure_path_with_agent. + let path = make_convo_path("human:alex", "claude-code://noop"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let recorder = RecordingExec::default(); + let err = run_with_strategy( + args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + &recorder, + ) + .unwrap_err(); + assert!(err.to_string().contains("no agent session")); +} + +#[test] +fn explicit_harness_not_on_path_errors() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::empty(); + let cwd = tempfile::tempdir().unwrap(); + + let path = make_convo_path("agent:claude-code", "claude-code://no-binary"); + let doc_file = write_path_to_temp(cwd.path(), path); + + let recorder = RecordingExec::default(); + let err = run_with_strategy( + args_explicit(doc_file, cwd.path(), HarnessArg::Claude), + &recorder, + ) + .unwrap_err(); + let s = err.to_string(); + assert!(s.contains("isn't on PATH"), "actual: {s}"); + assert!(s.contains("claude"), "actual: {s}"); +} diff --git a/crates/path-cli/tests/support/mod.rs b/crates/path-cli/tests/support/mod.rs new file mode 100644 index 0000000..2c9d460 --- /dev/null +++ b/crates/path-cli/tests/support/mod.rs @@ -0,0 +1,206 @@ +//! Shared helpers for `path resume` integration tests. +//! +//! These are NOT integration-test entry points — they're a support +//! module imported by `tests/resume.rs`. Lives under `tests/` so it +//! doesn't leak into the production library API. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use path_cli::cmd_resume::{HarnessArg, ResumeArgs}; + +/// Process-wide lock for tests that mutate `$HOME`, `$PATH`, or +/// `$TOOLPATH_CONFIG_DIR`. Integration tests under `tests/resume.rs` +/// can't reach the library's internal `crate::config::TEST_ENV_LOCK`, +/// so we use a separate lock here. Crucially, no library test holds +/// this lock — but library tests now properly save+restore env vars +/// (see commit 23deeb2), so the integration suite can be self-isolating. +pub fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +/// RAII guard that pins `$HOME` and `$TOOLPATH_CONFIG_DIR` to a tempdir. +pub struct ScopedHome { + _td: tempfile::TempDir, + prev_home: Option, + prev_config: Option, +} + +impl ScopedHome { + pub fn new() -> Self { + let td = tempfile::tempdir().unwrap(); + let prev_home = std::env::var_os("HOME"); + let prev_config = std::env::var_os("TOOLPATH_CONFIG_DIR"); + unsafe { + std::env::set_var("HOME", td.path()); + std::env::set_var("TOOLPATH_CONFIG_DIR", td.path().join(".toolpath")); + } + Self { _td: td, prev_home, prev_config } + } + + pub fn home_dir(&self) -> PathBuf { + PathBuf::from(self._td.path()) + } +} + +impl Drop for ScopedHome { + fn drop(&mut self) { + unsafe { + match &self.prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + match &self.prev_config { + Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v), + None => std::env::remove_var("TOOLPATH_CONFIG_DIR"), + } + } + } +} + +/// RAII guard that prepends a tempdir of fake binaries to `$PATH`. +pub struct ScopedPath { + _td: tempfile::TempDir, + prev: Option, +} + +impl ScopedPath { + pub fn with_binary(name: &str) -> Self { + Self::with_binaries(&[name]) + } + + pub fn with_binaries(names: &[&str]) -> Self { + let td = tempfile::tempdir().unwrap(); + for n in names { + let p = td.path().join(n); + std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perm = std::fs::metadata(&p).unwrap().permissions(); + perm.set_mode(0o755); + std::fs::set_permissions(&p, perm).unwrap(); + } + } + let prev = std::env::var_os("PATH"); + let new_path = std::env::join_paths( + std::iter::once(td.path().to_path_buf()) + .chain(std::env::split_paths(&prev.clone().unwrap_or_default())), + ) + .unwrap(); + unsafe { + std::env::set_var("PATH", new_path); + } + Self { _td: td, prev } + } + + pub fn empty() -> Self { + let td = tempfile::tempdir().unwrap(); + let prev = std::env::var_os("PATH"); + unsafe { + std::env::set_var("PATH", td.path()); + } + Self { _td: td, prev } + } +} + +impl Drop for ScopedPath { + fn drop(&mut self) { + unsafe { + match &self.prev { + Some(v) => std::env::set_var("PATH", v), + None => std::env::remove_var("PATH"), + } + } + } +} + +/// Build a minimal `Path` whose single step has the given `actor` +/// and a `conversation.append` artifact keyed `://`. +/// The artifact key drives the harness projector's session-id extraction; +/// the actor satisfies `ensure_path_with_agent`. +pub fn make_convo_path(actor: &str, artifact_key: &str) -> toolpath::v1::Path { + let mut extra = HashMap::new(); + extra.insert("role".to_string(), serde_json::json!("user")); + extra.insert("text".to_string(), serde_json::json!("hello")); + let step = toolpath::v1::Step { + step: toolpath::v1::StepIdentity { + id: "s1".to_string(), + parents: vec![], + actor: actor.to_string(), + timestamp: "2026-01-01T00:00:00Z".to_string(), + }, + change: { + let mut m = HashMap::new(); + m.insert( + artifact_key.to_string(), + toolpath::v1::ArtifactChange { + raw: None, + structural: Some(toolpath::v1::StructuralChange { + change_type: "conversation.append".to_string(), + extra, + }), + }, + ); + m + }, + meta: None, + }; + toolpath::v1::Path { + path: toolpath::v1::PathIdentity { + id: "p1".to_string(), + base: None, + head: "s1".to_string(), + graph_ref: None, + }, + steps: vec![step], + meta: None, + } +} + +/// Convenience: write a single-path graph as JSON to `dir/doc.json`. +pub fn write_path_to_temp(dir: &Path, path: toolpath::v1::Path) -> PathBuf { + let graph = toolpath::v1::Graph::from_path(path); + let p = dir.join("doc.json"); + std::fs::write(&p, graph.to_json().unwrap()).unwrap(); + p +} + +/// Construct `ResumeArgs` for a file-input + explicit-harness test. +pub fn args_explicit(input: PathBuf, cwd: &Path, harness: HarnessArg) -> ResumeArgs { + ResumeArgs { + input: input.to_string_lossy().to_string(), + cwd: Some(cwd.to_path_buf()), + harness: Some(harness), + no_cache: false, + force: false, + url: None, + } +} + +/// Recursively walk `root` looking for a file with the given extension. +pub fn dir_contains_file_with_ext(root: &Path, ext: &str) -> bool { + fn walk(p: &Path, ext: &str) -> bool { + if !p.exists() { + return false; + } + if p.is_dir() { + for e in std::fs::read_dir(p).unwrap() { + if walk(&e.unwrap().path(), ext) { + return true; + } + } + false + } else { + p.extension().and_then(|s| s.to_str()) == Some(ext) + } + } + walk(root, ext) +} From af41a9905b46e231715a990b6ffd474e9e3f7bd2 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Sat, 9 May 2026 13:11:32 -0400 Subject: [PATCH 18/21] docs: document path resume command --- CHANGELOG.md | 29 +++++++++++++++++++++ CLAUDE.md | 7 +++++- README.md | 5 ++++ crates/path-cli/src/cmd_resume.rs | 42 ++++++++++++++++++++++++++++--- 4 files changed, 78 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e005428..14d5c40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,35 @@ All notable changes to the Toolpath workspace are documented here. +## `path resume` — one-shot resume into a coding agent — 2026-05-09 + +`path-cli` 0.9.0. New subcommand `path resume ` that fetches a +Toolpath document (Pathbase URL, `owner/repo/slug` shorthand, local +file, or cache id), validates it as a single agent-bearing `Path`, +launches an `fzf` picker over installed coding-agent harnesses +(`--harness X` skips the picker), projects the session into that +harness's on-disk layout under `-C, --cwd P` (default: shell cwd), +and `execvp`'s the harness's resume command (`claude -r `, +`gemini --resume `, `codex resume `, `opencode --session `, +`pi --session `). On Windows the harness is spawned and waited on +with the exit code propagated. + +Source-harness inference reads `path.meta.source` (`claude-code` / +`gemini-cli` / `codex` / `opencode` / `pi`) with actor-string +fallback; the picker pre-selects the source when it's installed. + +Implementation introduces five `pub(crate)` `project_` +helpers in `cmd_export.rs` that compose the existing build + write +pairs and return the projected session id. `cmd_resume.rs` adds an +`ExecStrategy` trait (`RealExec` for production, `RecordingExec` for +tests) so the integration tests can exercise the full +resolve→pick→project pipeline without launching a real harness. + +Also fixed an unrelated env-var race in +`cmd_export::tests::opencode_writes_into_db_with_project` that +cleared `$HOME` on cleanup without restoring; this had been quietly +flaking the parallel test suite. + ## Conversation-stack realignment onto `toolpath` 0.4 + path-cli schema vendoring Republish of every `toolpath-convo`-consuming crate so they pin the diff --git a/CLAUDE.md b/CLAUDE.md index 010d558..0b58d25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,6 +89,10 @@ cargo run -p path-cli -- share cargo run -p path-cli -- share --harness claude --session --project /path/to/project cargo run -p path-cli -- share --url https://my-pathbase.example +# Resume a Toolpath document into your coding agent of choice (interactive harness picker) +cargo run -p path-cli -- resume +cargo run -p path-cli -- resume --harness claude -C /path/to/project + # Export toolpath documents into external formats. is a cache id or a file path. cargo run -p path-cli -- export claude --input --project /tmp/sandbox cargo run -p path-cli -- export claude --input --output conv.jsonl @@ -161,7 +165,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-opencode`: 43 unit + 1 doc test (SQLite reader, JSON payload serde, provider assembly, snapshot-based derive, tool-input fallback for gitignored paths) - `toolpath-pi`: 123 unit + 4 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 187 unit + 31 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 260 unit + 62 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- validate --input "$f"; done` @@ -224,3 +228,4 @@ Build the site after changes: `cd site && pnpm run build` (should produce 7 page - Interactive session selection: `path import ` (claude / gemini / pi / codex / opencode) auto-launches `fzf` when stdin and stderr are TTYs, `fzf` is on `$PATH`, and no `--session` was given. Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When fzf isn't available, it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path list --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic. - Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface. - `path share` is the one-shot equivalent of `path import | path export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. +- `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. diff --git a/README.md b/README.md index a5d96cc..2d11c5b 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,11 @@ path export pathbase --input claude- # (full URL or bare `//` triple) path import pathbase https://pathbase.dev/alex/pathstash/path-pr-42 +# Resume a Toolpath document into your coding agent of choice (interactive +# harness picker; project the session and exec the harness's resume command) +path resume https://pathbase.dev/alex/pathstash/path-pr-42 +path resume claude- --harness claude -C /path/to/project + # Query for dead ends (abandoned approaches) path query dead-ends --input doc.json diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 5509d8b..ebfe15e 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -1,8 +1,42 @@ -//! `path resume` — fetch / load a Toolpath document and exec a coding -//! agent's resume command after projecting the session into the -//! harness's on-disk layout. +//! `path resume ` — fetch / load a Toolpath document, pick an +//! installed coding-agent harness, project the session into that +//! harness's on-disk layout, and exec the harness's resume command. //! -//! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`. +//! ## Inputs +//! +//! `` is resolved in this order: +//! 1. `https://` / `http://` URL → fetched via `pathbase-client`, +//! cached unless `--no-cache`. +//! 2. `owner/repo/slug` shorthand → same Pathbase fetch flow. +//! 3. Existing file path → read directly. +//! 4. Otherwise treated as a cache id under `~/.toolpath/documents/`. +//! +//! ## Harness selection +//! +//! With `--harness X`, `X` is validated against `$PATH` and used. +//! Without `--harness`, an `fzf` picker shows installed harnesses +//! with the source harness pre-selected. Source comes from +//! `path.meta.source` (`claude-code`, `gemini-cli`, `codex`, +//! `opencode`, `pi`) with actor-string fallback. +//! +//! ## Project directory +//! +//! `-C / --cwd P` overrides the shell cwd. The harness is exec'd +//! with cwd set to P and the on-disk projection is keyed on P. +//! +//! ## Launch +//! +//! On Unix the harness binary is `execvp`'d, replacing the current +//! process. On Windows it's spawned and waited on with the exit +//! code propagated. If `exec` itself fails (e.g. the binary disappears +//! between PATH check and exec), the recipe is printed to stderr. +//! +//! Exec is mockable via [`ExecStrategy`]: production uses [`RealExec`], +//! integration tests use [`RecordingExec`] to capture +//! `(binary, args, cwd)` without launching anything. +//! +//! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md` +//! for the full design. #![cfg(not(target_os = "emscripten"))] From bfbbf1e2731dd75a885994110099efc86aecb416 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Sat, 9 May 2026 13:14:40 -0400 Subject: [PATCH 19/21] chore: bump path-cli and toolpath-cli to 0.9.0 for path resume --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/path-cli/Cargo.toml | 2 +- crates/toolpath-cli/Cargo.toml | 4 ++-- site/_data/crates.json | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index eaf8882..3ee21a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1589,7 +1589,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.8.0" +version = "0.9.0" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index 6fbe6e2..a07371f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,7 @@ toolpath-github = { version = "0.3.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.2.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.4.0", path = "crates/toolpath-md" } toolpath-pi = { version = "0.3.0", path = "crates/toolpath-pi" } -path-cli = { version = "0.8.0", path = "crates/path-cli" } +path-cli = { version = "0.9.0", path = "crates/path-cli" } pathbase-client = { version = "0.1.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 8d34099..c400c2e 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.8.0" +version = "0.9.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index 1fd0e37..7904a2a 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.8.0" } +path-cli = { path = "../path-cli", version = "0.9.0" } anyhow = "1.0" [workspace] diff --git a/site/_data/crates.json b/site/_data/crates.json index 10469b8..563444d 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -97,7 +97,7 @@ }, { "name": "path-cli", - "version": "0.8.0", + "version": "0.9.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", @@ -105,7 +105,7 @@ }, { "name": "toolpath-cli", - "version": "0.8.0", + "version": "0.9.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli", From b34b4d1a3ca67b35c44e078fa3205ef6a785ad66 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Sat, 9 May 2026 13:19:56 -0400 Subject: [PATCH 20/21] chore: clippy nit fix and post-rebase test count `path-cli` integration test count is 63 after rebasing onto main (which adds one share-related integration test). Replace `get(K).is_none()` with `!contains_key(K)` in two `toolpath-gemini` test assertions to silence clippy::unnecessary_get_then_check. --- CLAUDE.md | 2 +- crates/toolpath-gemini/src/project.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0b58d25..1556973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-opencode`: 43 unit + 1 doc test (SQLite reader, JSON payload serde, provider assembly, snapshot-based derive, tool-input fallback for gitignored paths) - `toolpath-pi`: 123 unit + 4 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 260 unit + 62 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 260 unit + 63 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- validate --input "$f"; done` diff --git a/crates/toolpath-gemini/src/project.rs b/crates/toolpath-gemini/src/project.rs index fbd2529..be8768f 100644 --- a/crates/toolpath-gemini/src/project.rs +++ b/crates/toolpath-gemini/src/project.rs @@ -916,10 +916,10 @@ mod tests { .unwrap(); let msg = &convo.main.messages[0]; assert!( - msg.extra.get("claude").is_none(), + !msg.extra.contains_key("claude"), "claude namespace should not leak onto Gemini messages" ); - assert!(msg.extra.get("codex").is_none()); + assert!(!msg.extra.contains_key("codex")); } #[test] From ef9c31cfff3d70c08ba19b1f8d7dec7f0c4362f9 Mon Sep 17 00:00:00 2001 From: Alex Kesling Date: Sat, 9 May 2026 21:01:19 -0400 Subject: [PATCH 21/21] fix(path-cli): reuse cached pathbase doc on path resume cache hit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-running `path resume ` was erroring on the second invocation with "cache entry already exists; pass --force to overwrite". The user shouldn't have to care about the cache: a hit means we already have the bytes, so we should silently use them and skip the network round-trip. cmd_resume now probes the cache before fetching: - Cache hit (and not --force) → load locally, skip network entirely. - Cache miss → fetch, then write (force-overwrite, since we either had no entry or the user explicitly passed --force). - --no-cache → skip both probe and write (ephemeral environments). New `cmd_import::pathbase_cache_id_of` exposes just the cache-id computation (`make_id` over the parsed `owner/repo/slug`) so the probe doesn't need a network round-trip. `--force` and `--no-cache` doc comments updated to describe the new semantics. Regression test seeds a known cache entry, points the input at a 500-erroring mock server, and asserts resolve_input still returns the cached graph — proving we never hit the network. --- crates/path-cli/src/cmd_import.rs | 10 + crates/path-cli/src/cmd_resume.rs | 102 +- .../plans/2026-05-08-path-resume-command.md | 2257 ----------------- .../2026-05-08-path-resume-command-design.md | 388 --- 4 files changed, 105 insertions(+), 2652 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-08-path-resume-command.md delete mode 100644 docs/superpowers/specs/2026-05-08-path-resume-command-design.md diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index e9070c4..9860592 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -1363,6 +1363,16 @@ fn project_short(p: &str) -> String { } /// Fetch a Pathbase ref (`https://host/owner/repo/slug` URL or bare +/// Compute the local cache id a Pathbase ref would land at, without +/// hitting the network. Lets `path resume` probe the cache before +/// deciding whether to fetch. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn pathbase_cache_id_of(target: &str, url_flag: Option<&str>) -> Result { + let (_base, ref_) = parse_pathbase_ref(target, url_flag)?; + let PathRef { owner, repo, slug } = ref_; + Ok(make_id("pathbase", &format!("{owner}-{repo}-{slug}"))) +} + /// `owner/repo/slug` triple) and parse it as a toolpath document. Used /// by `path import pathbase` and by `path resume `. #[cfg(not(target_os = "emscripten"))] diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index ebfe15e..8021838 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -67,11 +67,15 @@ pub struct ResumeArgs { #[arg(long, value_enum)] pub harness: Option, - /// Skip writing the cache when fetching from Pathbase. + /// Skip the cache entirely when fetching from Pathbase: don't read + /// an existing entry, don't write the fetched body. Useful for + /// ephemeral environments where you don't want the cache to grow. #[arg(long)] pub no_cache: bool, - /// Overwrite an existing cache entry when fetching from Pathbase. + /// Force a re-fetch from Pathbase even if a cache entry exists, + /// overwriting it with the new bytes. Default behavior is to use + /// the cached doc on hit and never round-trip. #[arg(long)] pub force: bool, @@ -207,12 +211,33 @@ pub(crate) fn resolve_input( let graph: Graph = match shape { Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => { - let derived = crate::cmd_import::pathbase_fetch_to_doc(u, args.url.as_deref())?; - if !args.no_cache { - crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, args.force)?; - eprintln!("Resolved {} → {}", raw, derived.cache_id); + // Probe the local cache before going to the network. The cache + // id is purely a function of (owner, repo, slug), so we can + // compute it without fetching. `--force` skips the probe and + // re-fetches; `--no-cache` skips both the probe AND the post- + // fetch write (still useful for ephemeral environments). + let cache_id = crate::cmd_import::pathbase_cache_id_of(u, args.url.as_deref())?; + if !args.force + && !args.no_cache + && let Ok(cache_path) = crate::cmd_cache::cache_path(&cache_id) + && cache_path.exists() + { + let json = std::fs::read_to_string(&cache_path) + .with_context(|| format!("read {}", cache_path.display()))?; + eprintln!("Resolved {} → {} (cached)", raw, cache_id); + Graph::from_json(&json) + .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e))? + } else { + let derived = crate::cmd_import::pathbase_fetch_to_doc(u, args.url.as_deref())?; + if !args.no_cache { + // force=true here: we either short-circuited above + // (cache miss) or the user explicitly passed --force, + // and either way we want the new bytes to land. + crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?; + eprintln!("Resolved {} → {}", raw, derived.cache_id); + } + derived.doc } - derived.doc } Shape::FilePath(p) => { let json = std::fs::read_to_string(p).with_context(|| format!("read {}", p))?; @@ -684,6 +709,69 @@ mod tests { assert_eq!(harness, Some(Harness::Codex)); } + #[test] + fn resolve_input_url_uses_cache_on_hit_without_refetching() { + // Regression for the second-invocation cache-hit error: re-running + // `path resume ` should silently reuse the cached doc instead + // of erroring. We seed the cache with a known-good doc, point the + // input at a 500-erroring mock server (so any network round-trip + // would surface as an error), and confirm resolve_input still + // returns the cached graph. + let _env = crate::config::TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + + // Pin TOOLPATH_CONFIG_DIR to a tempdir so we don't pollute the + // user's real cache. + let cfg_dir = tempfile::tempdir().unwrap(); + let prev_cfg = std::env::var_os("TOOLPATH_CONFIG_DIR"); + unsafe { + std::env::set_var("TOOLPATH_CONFIG_DIR", cfg_dir.path()); + } + + // Seed the cache with a codex-source graph. + let cache_id = "pathbase-alex-pathstash-cached-fixture"; + let documents = cfg_dir.path().join("documents"); + std::fs::create_dir_all(&documents).unwrap(); + let cached_graph = { + let mut path = make_path_with_actor("agent:codex"); + path.meta = Some(toolpath::v1::PathMeta { + source: Some("codex".to_string()), + ..Default::default() + }); + toolpath::v1::Graph::from_path(path) + }; + std::fs::write( + documents.join(format!("{cache_id}.json")), + cached_graph.to_json().unwrap(), + ) + .unwrap(); + + // Mock server that 500s any request — proves we never call out. + use crate::cmd_pathbase::tests::MockServer; + let server = MockServer::start("HTTP/1.1 500 Internal Server Error", "boom"); + + let args = ResumeArgs { + input: format!("{}/alex/pathstash/cached-fixture", server.base()), + cwd: None, + harness: None, + no_cache: false, + force: false, + url: None, + }; + let result = resolve_input(&args); + + // Restore env before asserting so a panic doesn't poison sibling tests. + unsafe { + match prev_cfg { + Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v), + None => std::env::remove_var("TOOLPATH_CONFIG_DIR"), + } + } + + let (g, harness) = result.expect("resolve_input should reuse cache without refetching"); + let _ = ensure_path_with_agent(&g).unwrap(); + assert_eq!(harness, Some(Harness::Codex)); + } + #[test] fn resolve_input_unresolvable_errors_clearly() { let _env = crate::config::TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); diff --git a/docs/superpowers/plans/2026-05-08-path-resume-command.md b/docs/superpowers/plans/2026-05-08-path-resume-command.md deleted file mode 100644 index 0583a87..0000000 --- a/docs/superpowers/plans/2026-05-08-path-resume-command.md +++ /dev/null @@ -1,2257 +0,0 @@ -# `path resume` Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add `path resume ` — fetches/loads a Toolpath document, picks a coding-agent harness (interactive picker by default, `--harness X` to skip), projects the session into the harness's on-disk layout in a chosen cwd, then execs the harness's resume command. - -**Architecture:** New `cmd_resume.rs` module mirroring `cmd_share.rs`. cmd_export.rs gains five small `pub(crate)` wrappers (`project_`) that compose the existing private build+write helpers and return the projected session id. cmd_resume composes these with an `argv_for(harness, session_id)` helper, an injectable `ExecStrategy`, and a small interactive picker. No new public types in the path-cli library. - -**Tech Stack:** Rust 2024, clap, anyhow, `toolpath_*` workspace crates, existing `crate::fzf` helper, `cmd_share::Harness` enum, `pathbase-client`. - -**Spec reference:** `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`. - ---- - -## Type and API quick reference - -The plan's code samples lean on these existing types and functions. Cross-check against the source before writing tests. - -```rust -// crates/toolpath/src/types.rs -pub struct Path { - pub path: PathIdentity, // { id, base: Option, head, graph_ref } - pub steps: Vec, // not Vec — push Step directly - pub meta: Option, -} - -pub struct PathMeta { - pub source: Option, // "claude-code" / "gemini-cli" / "codex" / "opencode" / "pi" - // … -} - -pub struct Step { - pub step: StepIdentity, // { id, parents, actor, timestamp } - pub change: HashMap, - pub meta: Option, -} - -// Builder pattern — preferred in tests -Step::new(id, actor, timestamp) - .with_raw_change("a.txt", "@@ -1 +1 @@\n-old\n+new") - .with_intent("…"); - -Path::new(id, /* base */ None::, /* head */ "s1"); - -// Universal parse / build -Graph::from_json(&json)?; // never parses to a Step or bare Path -Graph::from_path(path); // single-inline-path Graph constructor -graph.into_single_path(); // Option -graph.single_path(); // Option<&Path> -``` - -**There is no `Document` enum.** `Graph::from_json` is the universal entry point — every cache file, every Pathbase response, every Toolpath JSON parses as a `Graph`. Single-path-graphs are the closest thing to a "Path document"; `into_single_path` unwraps them. The plan validates everything as a `Graph` (see Task 4). - -**`path.meta.source` access pattern** (because `meta: Option`): - -```rust -path.meta.as_ref().and_then(|m| m.source.as_deref()) -``` - -**`fzf` module API** (`crates/path-cli/src/fzf.rs`): - -```rust -pub fn available() -> bool; -pub fn pick(lines: &[String], opts: &PickOptions<'_>) -> Result; - -pub enum PickResult { - Selected(String), - NoMatch, - Cancelled, -} - -pub struct PickOptions<'a> { - pub header: Option<&'a str>, - // … (read the source for the full set; defaults usually suffice) -} -``` - -**Idiomatic test fixture** (mirrors `cmd_merge.rs::tests::make_path` / `make_step`): - -```rust -fn make_step(id: &str, actor: &str) -> toolpath::v1::Step { - toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z") - .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new") -} - -fn make_path_with_actor(actor: &str) -> toolpath::v1::Path { - use toolpath::v1::{Path, PathIdentity}; - let step = make_step("s1", actor); - Path { - path: PathIdentity { - id: "p1".to_string(), - base: None, - head: "s1".to_string(), - graph_ref: None, - }, - steps: vec![step], - meta: None, - } -} -``` - -Whenever a task below refers to `make_path_with_actor(...)`, the body is the snippet above with `actor` substituted. Each task lists the actor explicitly. - -**Existing `cmd_export.rs` private helpers** (these stay private; the new wrappers compose them): - -| Harness | Build helper | Write helper | -| --- | --- | --- | -| Claude | `build_claude_conversation(path) -> Conversation` (with `session_id`) | `write_into_claude_project(conv, jsonl, project_dir) -> PathBuf` (returns the JSONL path) | -| Gemini | `build_gemini_conversation(input, project_path) -> Conversation` (with `session_uuid`) | `write_into_gemini_project(conv, project_path) -> ()` | -| Codex | `build_codex_session(input, cwd) -> Session` (with `id`) | `write_into_codex_project(session) -> ()` | -| Opencode | `build_opencode_session(path, project_dir) -> Session` (with `id`) | `write_into_opencode_db(session, project_dir) -> ()` | -| Pi | `build_pi_session(input, cwd) -> PiSession` (with `header.id`) | `write_into_pi_project(session, cwd) -> ()` | - -Verify these signatures by reading `cmd_export.rs` before writing the wrappers — adapt as needed if a name differs from this table. - ---- - -## File Structure - -**New:** -- `crates/path-cli/src/cmd_resume.rs` — new module: `ResumeArgs`, orchestration, `resolve_input`, `infer_source_harness`, `ensure_path_with_agent`, `pick_harness`, `argv_for`, `ExecStrategy`, `RealExec`, `RecordingExec`. -- `crates/path-cli/tests/resume.rs` — integration tests with injectable exec strategy. -- `crates/path-cli/tests/support/mod.rs` (or `tests/support.rs`) — shared test helpers. - -**Modified:** -- `crates/path-cli/src/cmd_export.rs` — add five `pub(crate) fn project_(path: &Path, project_dir: &Path) -> Result` wrappers. No other change. -- `crates/path-cli/src/cmd_import.rs` — extract a `pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Result` from the inner block of `derive_pathbase`. `derive_pathbase` becomes a one-line wrapper. -- `crates/path-cli/src/cmd_pathbase.rs` — promote the test-module `MockServer` and required helpers to `pub(crate)` so cross-test-module use works. -- `crates/path-cli/src/lib.rs` — add `Commands::Resume { args: cmd_resume::ResumeArgs }`; wire dispatch. -- `crates/path-cli/Cargo.toml` — minor version bump (`0.8.0` → `0.9.0`). -- `Cargo.toml` (root) — `[workspace.dependencies]` `path-cli` version bump. -- `site/_data/crates.json` — `path-cli` version bump. -- `CHANGELOG.md` — new entry. -- `CLAUDE.md` — CLI usage block + "Things to know" bullet. -- `README.md` — one-line mention. - ---- - -## Task 1: `project_` `pub(crate)` wrappers in `cmd_export.rs` - -**Files:** -- Modify: `crates/path-cli/src/cmd_export.rs` - -These wrappers compose the existing build + write private helpers and return the projected session id as a `String`. No behavior change to `path export `. The five wrappers are sibling-shaped; we add and test them all in one task to keep the refactor batched. - -- [ ] **Step 1: Read the existing private helpers** - -Read `crates/path-cli/src/cmd_export.rs`, focusing on: -- `build_claude_conversation`, `serialize_jsonl`, `write_into_claude_project` -- `build_gemini_conversation`, `write_into_gemini_project` -- `build_codex_session`, `write_into_codex_project` -- `build_opencode_session`, `write_into_opencode_db` -- `build_pi_session`, `write_into_pi_project` - -Confirm the signatures match the "Existing `cmd_export.rs` private helpers" table above. Note any deviations (most likely: `build__*` take `input: &str` cache id, not `path: &Path`; rework if so). - -- [ ] **Step 2: Write failing tests for all five wrappers** - -Append to the existing tests module in `cmd_export.rs` (find it near the bottom of the file under `#[cfg(test)] mod tests {`). First add the shared fixture helpers if they're not already there: - -```rust -#[cfg(not(target_os = "emscripten"))] -fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step { - toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z") - .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new") -} - -#[cfg(not(target_os = "emscripten"))] -fn make_path_with_actor(actor: &str) -> toolpath::v1::Path { - use toolpath::v1::{Path, PathIdentity}; - let step = make_step_with_actor("s1", actor); - Path { - path: PathIdentity { - id: "p1".to_string(), - base: None, - head: "s1".to_string(), - graph_ref: None, - }, - steps: vec![step], - meta: None, - } -} - -/// Pin `$HOME` to a tempdir for tests that resolve harness paths. -#[cfg(not(target_os = "emscripten"))] -struct ScopedHome { _td: tempfile::TempDir, prev: Option } - -#[cfg(not(target_os = "emscripten"))] -impl ScopedHome { - fn new() -> Self { - let td = tempfile::tempdir().unwrap(); - let prev = std::env::var_os("HOME"); - // Safety: cmd_export tests already share state via the global cache - // dir; treat them as serial. If the crate ever flips to multi-threaded - // tests, replace with `serial_test`. - unsafe { std::env::set_var("HOME", td.path()); } - Self { _td: td, prev } - } -} - -#[cfg(not(target_os = "emscripten"))] -impl Drop for ScopedHome { - fn drop(&mut self) { - unsafe { - match &self.prev { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } - } -} -``` - -Then add five tests: - -```rust -#[test] -#[cfg(not(target_os = "emscripten"))] -fn project_claude_returns_session_id_and_writes_jsonl() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let path = make_path_with_actor("agent:claude-code"); - - let session_id = project_claude(&path, cwd.path()).unwrap(); - assert!(!session_id.is_empty(), "session id should be non-empty"); - - // The projected JSONL must land somewhere under HOME/.claude/projects/. - let projects = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()) - .join(".claude/projects"); - assert!(projects.exists(), "claude projects dir missing under HOME"); -} - -#[test] -#[cfg(not(target_os = "emscripten"))] -fn project_gemini_returns_session_id_and_writes_chat_file() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let path = make_path_with_actor("agent:gemini-cli"); - - let session_id = project_gemini(&path, cwd.path()).unwrap(); - assert!(!session_id.is_empty()); - - let tmp_root = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()) - .join(".gemini/tmp"); - assert!(tmp_root.exists(), "gemini tmp dir missing"); -} - -#[test] -#[cfg(not(target_os = "emscripten"))] -fn project_codex_returns_session_id_and_writes_rollout() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let path = make_path_with_actor("agent:codex"); - - let session_id = project_codex(&path, cwd.path()).unwrap(); - assert!(!session_id.is_empty()); - - let sessions = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()) - .join(".codex/sessions"); - assert!(sessions.exists(), "codex sessions dir missing"); -} - -#[test] -#[cfg(not(target_os = "emscripten"))] -fn project_opencode_returns_session_id_and_inserts_row() { - // Pre-create an opencode db with the canonical schema so the writer - // doesn't bail. Locate the schema bootstrap helper used by existing - // opencode tests in `crates/toolpath-opencode/src/` and call it. - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let resolver = toolpath_opencode::PathResolver::new(); - let db_path = resolver.db_path().unwrap(); - std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); - { - let conn = rusqlite::Connection::open(&db_path).unwrap(); - // Substitute the actual bootstrap helper name if different. - toolpath_opencode::schema::apply_full_schema(&conn).unwrap(); - } - - let path = make_path_with_actor("agent:opencode"); - let session_id = project_opencode(&path, cwd.path()).unwrap(); - assert!(!session_id.is_empty()); - - // Verify the session row exists. - let conn = rusqlite::Connection::open(&db_path).unwrap(); - let count: i64 = conn - .query_row("SELECT COUNT(*) FROM session WHERE id = ?1", [&session_id], |r| r.get(0)) - .unwrap(); - assert_eq!(count, 1); -} - -#[test] -#[cfg(not(target_os = "emscripten"))] -fn project_pi_returns_session_id_and_writes_jsonl() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let path = make_path_with_actor("agent:pi"); - - let session_id = project_pi(&path, cwd.path()).unwrap(); - assert!(!session_id.is_empty()); - - let sessions = std::path::PathBuf::from(std::env::var_os("HOME").unwrap()) - .join(".pi/agent/sessions"); - assert!(sessions.exists(), "pi sessions dir missing"); -} -``` - -If `toolpath_opencode::schema::apply_full_schema` doesn't exist, locate the canonical schema-apply helper used by existing opencode tests (search `crates/toolpath-opencode/src/`) and use that name. - -- [ ] **Step 3: Run tests to verify they fail** - -```bash -cargo test -p path-cli --lib project_claude project_gemini project_codex project_opencode project_pi -``` - -Expected: FAIL — none of the wrappers exist yet. - -- [ ] **Step 4: Implement the five wrappers** - -Add near the top of `cmd_export.rs`, after the existing `pub(crate) struct PathbaseUploadArgs` (around line 230). Each wrapper composes the existing private build + write helpers and returns the projected session id. - -```rust -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn project_claude( - path: &toolpath::v1::Path, - project_dir: &std::path::Path, -) -> Result { - let conv = build_claude_conversation(path)?; - let jsonl = serialize_jsonl(&conv)?; - write_into_claude_project(&conv, &jsonl, project_dir)?; - Ok(conv.session_id) -} - -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn project_gemini( - path: &toolpath::v1::Path, - project_dir: &std::path::Path, -) -> Result { - use toolpath_convo::ConversationProjector; - let project_dir = std::fs::canonicalize(project_dir) - .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let project_path = project_dir.to_string_lossy().to_string(); - - let view = toolpath_convo::extract_conversation(path); - let project_hash = toolpath_gemini::paths::project_hash(&project_path); - let projector = toolpath_gemini::project::GeminiProjector::new() - .with_project_hash(project_hash) - .with_project_path(project_path.clone()); - let conv = projector - .project(&view) - .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; - if conv.session_uuid.is_empty() { - anyhow::bail!("Projected conversation has no session UUID"); - } - write_into_gemini_project(&conv, &project_path)?; - Ok(conv.session_uuid) -} - -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn project_codex( - path: &toolpath::v1::Path, - project_dir: &std::path::Path, -) -> Result { - use toolpath_convo::ConversationProjector; - let project_dir = std::fs::canonicalize(project_dir) - .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let cwd_str = project_dir.to_string_lossy().to_string(); - - let view = toolpath_convo::extract_conversation(path); - let projector = toolpath_codex::project::CodexProjector::new().with_cwd(cwd_str); - let session = projector - .project(&view) - .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; - if session.id.is_empty() { - anyhow::bail!("Projected session has no id"); - } - write_into_codex_project(&session)?; - Ok(session.id) -} - -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn project_opencode( - path: &toolpath::v1::Path, - project_dir: &std::path::Path, -) -> Result { - let session = build_opencode_session(path, Some(project_dir))?; - let id = session.id.clone(); - write_into_opencode_db(&session, project_dir)?; - Ok(id) -} - -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn project_pi( - path: &toolpath::v1::Path, - project_dir: &std::path::Path, -) -> Result { - use toolpath_convo::ConversationProjector; - let project_dir = std::fs::canonicalize(project_dir) - .with_context(|| format!("resolve project path {}", project_dir.display()))?; - let cwd_str = project_dir.to_string_lossy().to_string(); - - let view = toolpath_convo::extract_conversation(path); - let projector = toolpath_pi::project::PiProjector::new().with_cwd(cwd_str.clone()); - let session = projector - .project(&view) - .map_err(|e| anyhow::anyhow!("Projection failed: {}", e))?; - if session.header.id.is_empty() { - anyhow::bail!("Projected session has no id"); - } - write_into_pi_project(&session, &cwd_str)?; - Ok(session.header.id) -} -``` - -(`project_claude` doesn't canonicalize because `write_into_claude_project` already does. `project_opencode` doesn't either, because `build_opencode_session` already passes the dir to the projector. The other three canonicalize here because their write helpers don't.) - -- [ ] **Step 5: Run the new tests** - -```bash -cargo test -p path-cli --lib project_claude project_gemini project_codex project_opencode project_pi -``` - -Expected: PASS. - -- [ ] **Step 6: Run the full export tests to confirm no regressions** - -```bash -cargo test -p path-cli --lib cmd_export -``` - -Expected: all pass. - -- [ ] **Step 7: Commit** - -```bash -git add crates/path-cli/src/cmd_export.rs -git commit -m "feat(path-cli): pub(crate) project_ wrappers in cmd_export" -``` - ---- - -## Task 2: Extract `pathbase_fetch_to_doc` from `cmd_import.rs` - -**Files:** -- Modify: `crates/path-cli/src/cmd_import.rs:1362-1388` (derive_pathbase) -- Modify: `crates/path-cli/src/cmd_pathbase.rs` — promote `MockServer` test helpers to `pub(crate)` so a sibling test module can use them. - -- [ ] **Step 1: Write the failing test** - -In `cmd_import.rs`'s tests module (or in a new `#[cfg(test)] mod pathbase_fetch_tests` block adjacent to it), add: - -```rust -#[test] -#[cfg(not(target_os = "emscripten"))] -fn pathbase_fetch_to_doc_url_input() { - use crate::cmd_pathbase::tests::MockServer; - let body = r#"{"graph":{"id":"g1"},"paths":[{"path":{"id":"p1","head":"s1"},"steps":[{"step":{"id":"s1","actor":"agent:claude-code","timestamp":"2026-01-01T00:00:00Z"},"change":{}}]}]}"#; - let server = MockServer::start("HTTP/1.1 200 OK", body); - let url = format!("{}/alex/pathstash/my-path", server.base()); - - let derived = pathbase_fetch_to_doc(&url, None).unwrap(); - - assert_eq!(derived.cache_id, "pathbase-alex-pathstash-my-path"); - assert!(derived.doc.into_single_path().is_some()); -} -``` - -(Adjust the JSON body shape to whatever `Graph::from_json` actually accepts — read existing pathbase tests in `cmd_pathbase.rs` and `cmd_import.rs` for the canonical body string.) - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cargo test -p path-cli --lib pathbase_fetch_to_doc_url_input -``` - -Expected: FAIL — `pathbase_fetch_to_doc` doesn't exist; possibly also `MockServer` isn't reachable from sibling test modules. - -- [ ] **Step 3: Make `MockServer` reachable from sibling tests** - -In `crates/path-cli/src/cmd_pathbase.rs`, change the existing test module declaration so its helper is reachable from sibling test modules: - -```rust -#[cfg(test)] -pub(crate) mod tests { - // (existing contents unchanged; the only changes are `pub(crate)` on the - // module itself and on `MockServer` + the methods the new caller needs.) - pub(crate) struct MockServer { /* leave existing fields */ } - impl MockServer { - pub(crate) fn start(/* same signature */) -> Self { /* leave body */ } - pub(crate) fn base(&self) -> String { /* leave body */ } - // …promote only what the new test consumes. - } -} -``` - -- [ ] **Step 4: Extract the function** - -Replace `derive_pathbase`'s body (lines 1362-1388) with a wrapper, and add the extracted helper just above it: - -```rust -/// Fetch a Pathbase ref (`https://host/owner/repo/slug` URL or bare -/// `owner/repo/slug` triple) and parse it as a toolpath document. Used -/// by `path import pathbase` and by `path resume `. -#[cfg(not(target_os = "emscripten"))] -pub(crate) fn pathbase_fetch_to_doc(target: &str, url_flag: Option<&str>) -> Result { - use crate::cmd_pathbase::{credentials_path, load_session, paths_download, resolve_url}; - - let (base, ref_) = parse_pathbase_ref(target, url_flag)?; - let stored = load_session(&credentials_path()?)?; - let base_url = base - .or_else(|| stored.as_ref().map(|s| s.url.clone())) - .unwrap_or_else(|| resolve_url(None)); - - let token = stored.as_ref().map(|s| s.token.as_str()); - - let PathRef { owner, repo, slug } = ref_; - let body = paths_download(&base_url, token, &owner, &repo, &slug)?; - let cache_id = make_id("pathbase", &format!("{owner}-{repo}-{slug}")); - let doc = Graph::from_json(&body) - .map_err(|e| anyhow::anyhow!("server returned a non-toolpath document: {e}"))?; - Ok(DerivedDoc { cache_id, doc }) -} - -fn derive_pathbase(target: String, url_flag: Option) -> Result> { - #[cfg(target_os = "emscripten")] - { - let _ = (target, url_flag); - anyhow::bail!("'path import pathbase' requires a native environment with network access"); - } - - #[cfg(not(target_os = "emscripten"))] - { - Ok(vec![pathbase_fetch_to_doc(&target, url_flag.as_deref())?]) - } -} -``` - -- [ ] **Step 5: Run test to verify it passes** - -```bash -cargo test -p path-cli --lib pathbase_fetch_to_doc_url_input -``` - -Expected: PASS. - -- [ ] **Step 6: Run all import tests** - -```bash -cargo test -p path-cli --lib cmd_import -``` - -Expected: pass. - -- [ ] **Step 7: Commit** - -```bash -git add crates/path-cli/src/cmd_import.rs crates/path-cli/src/cmd_pathbase.rs -git commit -m "refactor(path-cli): extract pathbase_fetch_to_doc helper" -``` - ---- - -## Task 3: Scaffold `cmd_resume.rs` — types, args, lib.rs wiring - -**Files:** -- Create: `crates/path-cli/src/cmd_resume.rs` -- Modify: `crates/path-cli/src/lib.rs:45-180` (Commands enum + dispatch) - -- [ ] **Step 1: Create the module with stub run + test** - -Create `crates/path-cli/src/cmd_resume.rs`: - -```rust -//! `path resume` — fetch / load a Toolpath document and exec a coding -//! agent's resume command after projecting the session into the -//! harness's on-disk layout. -//! -//! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`. - -#![cfg(not(target_os = "emscripten"))] - -use anyhow::{Context, Result}; -use clap::Args; -use std::path::PathBuf; - -use crate::cmd_share::HarnessArg; - -#[derive(Args, Debug)] -pub struct ResumeArgs { - /// Toolpath document to resume from. Accepted shapes: a Pathbase - /// URL (`https://host/owner/repo/slug`), a bare Pathbase shorthand - /// (`owner/repo/slug`), a path to a local toolpath JSON file, or a - /// cache id (e.g. `claude-abc`, `pathbase-foo-bar-baz`). - pub input: String, - - /// Working directory to run the resumed harness from. Defaults to - /// the current shell cwd. The on-disk projection is keyed on this - /// directory and the harness will be exec'd with cwd set to it. - #[arg(short = 'C', long)] - pub cwd: Option, - - /// Pin the resume target. Skips the interactive picker. - #[arg(long, value_enum)] - pub harness: Option, - - /// Skip writing the cache when fetching from Pathbase. - #[arg(long)] - pub no_cache: bool, - - /// Overwrite an existing cache entry when fetching from Pathbase. - #[arg(long)] - pub force: bool, - - /// Pathbase server URL. Falls back to the stored session's URL, - /// then `$PATHBASE_URL`, then `https://pathbase.dev`. - #[arg(long)] - pub url: Option, -} - -pub fn run(_args: ResumeArgs) -> Result<()> { - anyhow::bail!("path resume: not yet implemented") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn run_returns_not_implemented_until_wired() { - let args = ResumeArgs { - input: "irrelevant".to_string(), - cwd: None, - harness: None, - no_cache: false, - force: false, - url: None, - }; - let err = run(args).unwrap_err(); - assert!(err.to_string().contains("not yet implemented")); - } -} -``` - -- [ ] **Step 2: Wire into `lib.rs`** - -In `crates/path-cli/src/lib.rs`: - -a) Add the module declaration near the other `mod cmd_*;` lines (top of the file): - -```rust -mod cmd_resume; -``` - -b) Add a new variant to the `Commands` enum (around line 121, next to `Share`): - -```rust -/// Resume an agent session into the chosen harness, projecting the -/// document and exec'ing the harness's resume command. -Resume { - #[command(flatten)] - args: cmd_resume::ResumeArgs, -}, -``` - -c) Add the dispatch arm in `run()` (around line 170, next to `Commands::Share`): - -```rust -Commands::Resume { args } => cmd_resume::run(args), -``` - -- [ ] **Step 3: Run the stub test** - -```bash -cargo test -p path-cli --lib cmd_resume::tests::run_returns_not_implemented_until_wired -``` - -Expected: PASS. - -- [ ] **Step 4: Verify the CLI surface** - -```bash -cargo run -p path-cli -- resume --help -``` - -Expected output (substring): `Toolpath document to resume from`, `-C`, `--harness`, `--no-cache`, `--force`, `--url`. - -- [ ] **Step 5: Commit** - -```bash -git add crates/path-cli/src/cmd_resume.rs crates/path-cli/src/lib.rs -git commit -m "feat(path-cli): scaffold path resume command (stub)" -``` - ---- - -## Task 4: Implement `infer_source_harness` and `ensure_path_with_agent` - -**Files:** -- Modify: `crates/path-cli/src/cmd_resume.rs` - -- [ ] **Step 1: Write the failing tests** - -Append to `cmd_resume.rs`'s tests module. There is no `Document` enum in this codebase — every parse goes through `Graph::from_json`, so validation operates on `Graph`. A "Path document" surfaces as a `Graph` with exactly one inline path. - -```rust -use crate::cmd_share::Harness; -use toolpath::v1::{Graph, PathMeta, PathOrRef}; - -fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step { - toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z") - .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new") -} - -fn make_path_with_actor(actor: &str) -> toolpath::v1::Path { - use toolpath::v1::{Path, PathIdentity}; - let step = make_step_with_actor("s1", actor); - Path { - path: PathIdentity { - id: "p1".to_string(), - base: None, - head: "s1".to_string(), - graph_ref: None, - }, - steps: vec![step], - meta: None, - } -} - -#[test] -fn infer_source_harness_meta_source_wins() { - let mut path = make_path_with_actor("agent:codex"); - path.meta = Some(PathMeta { - source: Some("claude-code".to_string()), - ..Default::default() - }); - assert_eq!(infer_source_harness(&path), Some(Harness::Claude)); -} - -#[test] -fn infer_source_harness_meta_source_unknown_falls_through_to_actor() { - let mut path = make_path_with_actor("agent:gemini-cli"); - path.meta = Some(PathMeta { - source: Some("something-bespoke".to_string()), - ..Default::default() - }); - assert_eq!(infer_source_harness(&path), Some(Harness::Gemini)); -} - -#[test] -fn infer_source_harness_actor_sniff_codex() { - let path = make_path_with_actor("agent:codex"); - assert_eq!(infer_source_harness(&path), Some(Harness::Codex)); -} - -#[test] -fn infer_source_harness_actor_sniff_opencode() { - let path = make_path_with_actor("agent:opencode"); - assert_eq!(infer_source_harness(&path), Some(Harness::Opencode)); -} - -#[test] -fn infer_source_harness_actor_sniff_pi() { - let path = make_path_with_actor("agent:pi"); - assert_eq!(infer_source_harness(&path), Some(Harness::Pi)); -} - -#[test] -fn infer_source_harness_returns_none_when_no_signal() { - let path = make_path_with_actor("human:alex"); - assert_eq!(infer_source_harness(&path), None); -} - -#[test] -fn ensure_path_with_agent_accepts_single_path_with_agent_actor() { - let g = Graph::from_path(make_path_with_actor("agent:claude-code")); - assert!(ensure_path_with_agent(&g).is_ok()); -} - -#[test] -fn ensure_path_with_agent_rejects_empty_graph() { - let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); - g.paths.clear(); - let err = ensure_path_with_agent(&g).unwrap_err(); - assert!(err.to_string().contains("expected")); - assert!(err.to_string().contains("empty")); -} - -#[test] -fn ensure_path_with_agent_rejects_multi_path_graph() { - let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); - g.paths.push(PathOrRef::Path(Box::new(make_path_with_actor("agent:claude-code")))); - let err = ensure_path_with_agent(&g).unwrap_err(); - let s = err.to_string(); - assert!(s.contains("single `Path`"), "actual: {s}"); - assert!(s.contains("2 paths"), "actual: {s}"); -} - -#[test] -fn ensure_path_with_agent_rejects_agentless_path() { - let g = Graph::from_path(make_path_with_actor("human:alex")); - let err = ensure_path_with_agent(&g).unwrap_err(); - assert!(err.to_string().contains("no agent session")); -} - -#[test] -fn ensure_path_with_agent_rejects_path_ref_only_graph() { - use toolpath::v1::PathRef; - let mut g = Graph::from_path(make_path_with_actor("agent:claude-code")); - g.paths = vec![PathOrRef::Ref(PathRef { ref_url: "$ref://something".into() })]; - let err = ensure_path_with_agent(&g).unwrap_err(); - assert!(err.to_string().contains("inline `Path`"), "actual: {}", err); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -cargo test -p path-cli --lib cmd_resume::tests -``` - -Expected: FAIL — `infer_source_harness` and `ensure_path_with_agent` not defined. - -- [ ] **Step 3: Implement the two helpers** - -Append to `cmd_resume.rs` above the `mod tests` block: - -```rust -use toolpath::v1::{Graph, Path as TPath, PathOrRef}; - -/// Read a path's source harness from `meta.source` (set by -/// `toolpath-convo::derive_path` to the provider id), falling back to -/// actor-string sniffing across the path's steps. -pub(crate) fn infer_source_harness(path: &TPath) -> Option { - let meta_source = path.meta.as_ref().and_then(|m| m.source.as_deref()); - if let Some(source) = meta_source { - match source { - "claude-code" => return Some(Harness::Claude), - "gemini-cli" => return Some(Harness::Gemini), - "codex" => return Some(Harness::Codex), - "opencode" => return Some(Harness::Opencode), - "pi" => return Some(Harness::Pi), - _ => {} // fall through to actor sniffing - } - } - for step in &path.steps { - let actor = &step.step.actor; - if actor.starts_with("agent:claude-code") { - return Some(Harness::Claude); - } - if actor.starts_with("agent:gemini-cli") || actor.starts_with("agent:gemini") { - return Some(Harness::Gemini); - } - if actor.starts_with("agent:codex") { - return Some(Harness::Codex); - } - if actor.starts_with("agent:opencode") { - return Some(Harness::Opencode); - } - if actor.starts_with("agent:pi") { - return Some(Harness::Pi); - } - } - None -} - -/// Validate that a parsed Toolpath document is a single inline Path -/// carrying at least one `agent:*` actor. Returns the inner Path borrow -/// on success. -pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { - if g.paths.is_empty() { - anyhow::bail!("resume needs a `Path`; expected one path, got an empty graph"); - } - if g.paths.len() > 1 { - anyhow::bail!( - "resume needs a single `Path`; input is a graph with {} paths. \ - Pick one with `path query …` or split first.", - g.paths.len() - ); - } - let path = match &g.paths[0] { - PathOrRef::Path(p) => p.as_ref(), - PathOrRef::Ref(_) => anyhow::bail!( - "resume needs an inline `Path`; got a $ref. Resolve it first with `path import` or fetch the document." - ), - }; - let has_agent = path - .steps - .iter() - .any(|s| s.step.actor.starts_with("agent:")); - if !has_agent { - anyhow::bail!( - "no agent session in input — `path resume` only works on harness-derived paths" - ); - } - Ok(path) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -cargo test -p path-cli --lib cmd_resume::tests -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(path-cli): infer_source_harness and ensure_path_with_agent" -``` - ---- - -## Task 5: Implement `resolve_input` - -**Files:** -- Modify: `crates/path-cli/src/cmd_resume.rs` - -- [ ] **Step 1: Write the failing tests** - -```rust -#[test] -fn resolve_input_file_path() { - let tmp = tempfile::tempdir().unwrap(); - let p = tmp.path().join("doc.json"); - let graph = toolpath::v1::Graph::from_path(make_path_with_actor("agent:claude-code")); - std::fs::write(&p, graph.to_json().unwrap()).unwrap(); - - let args = ResumeArgs { - input: p.to_string_lossy().to_string(), - cwd: None, harness: None, no_cache: false, force: false, url: None, - }; - let (g, harness) = resolve_input(&args).unwrap(); - let _path = ensure_path_with_agent(&g).unwrap(); - assert_eq!(harness, Some(Harness::Claude)); -} - -#[test] -fn resolve_input_url_dispatches_to_pathbase_fetch() { - use crate::cmd_pathbase::tests::MockServer; - let body = { - let mut path = make_path_with_actor("agent:codex"); - path.meta = Some(toolpath::v1::PathMeta { - source: Some("codex".to_string()), - ..Default::default() - }); - toolpath::v1::Graph::from_path(path).to_json().unwrap() - }; - let server = MockServer::start("HTTP/1.1 200 OK", &body); - - let args = ResumeArgs { - input: format!("{}/alex/pathstash/p", server.base()), - cwd: None, harness: None, no_cache: true, // skip cache write in tests - force: false, url: None, - }; - let (g, harness) = resolve_input(&args).unwrap(); - let _ = ensure_path_with_agent(&g).unwrap(); - assert_eq!(harness, Some(Harness::Codex)); -} - -#[test] -fn resolve_input_unresolvable_errors_clearly() { - let args = ResumeArgs { - input: "definitely/not/a/real/cache/id".to_string(), - cwd: None, harness: None, no_cache: false, force: false, url: None, - }; - let err = resolve_input(&args).unwrap_err(); - let s = err.to_string(); - assert!(s.contains("couldn't resolve"), "actual: {s}"); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -cargo test -p path-cli --lib resolve_input -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement `resolve_input`** - -Append to `cmd_resume.rs`: - -```rust -/// Resolve the user-supplied `` argument into a parsed `Graph` -/// plus the source harness inferred from its single inline path (if -/// any). See spec for the resolution order. -pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option)> { - let raw = args.input.as_str(); - - enum Shape<'a> { - PathbaseUrl(&'a str), - PathbaseShorthand(&'a str), - FilePath(&'a str), - CacheId(&'a str), - } - - let shape = if raw.starts_with("http://") || raw.starts_with("https://") { - Shape::PathbaseUrl(raw) - } else if looks_like_pathbase_shorthand(raw) { - Shape::PathbaseShorthand(raw) - } else if std::path::Path::new(raw).is_file() { - Shape::FilePath(raw) - } else { - Shape::CacheId(raw) - }; - - let graph: Graph = match shape { - Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => { - let derived = crate::cmd_import::pathbase_fetch_to_doc(u, args.url.as_deref())?; - if !args.no_cache { - crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, args.force)?; - eprintln!("Resolved {} → {}", raw, derived.cache_id); - } - derived.doc - } - Shape::FilePath(p) => { - let json = std::fs::read_to_string(p) - .with_context(|| format!("read {}", p))?; - Graph::from_json(&json) - .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))? - } - Shape::CacheId(id) => { - let file = crate::cmd_cache::cache_ref(id).map_err(|e| { - anyhow::anyhow!( - "couldn't resolve `{}` as a URL, file path, or cache id: {}", - raw, e - ) - })?; - let json = std::fs::read_to_string(&file) - .with_context(|| format!("read {}", file.display()))?; - Graph::from_json(&json) - .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))? - } - }; - - let harness = graph.single_path().and_then(infer_source_harness); - Ok((graph, harness)) -} - -fn looks_like_pathbase_shorthand(s: &str) -> bool { - // Three non-empty slash-separated segments, none containing whitespace - // or a leading dot/slash (which would indicate a relative/absolute path). - if s.starts_with('.') || s.starts_with('/') { return false; } - let segs: Vec<&str> = s.split('/').collect(); - segs.len() == 3 && segs.iter().all(|s| !s.is_empty() && !s.contains(char::is_whitespace)) -} -``` - -`Graph::single_path` returns `Option<&Path>`. `infer_source_harness` takes `&Path`, so `.and_then(infer_source_harness)` is the right composition. - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -cargo test -p path-cli --lib resolve_input -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(path-cli): resolve_input dispatcher for path resume" -``` - ---- - -## Task 6: Implement `pick_harness` non-interactive paths and PATH probe - -**Files:** -- Modify: `crates/path-cli/src/cmd_resume.rs` - -- [ ] **Step 1: Write the failing tests** - -Append to the tests module: - -```rust -fn fake_path_with(binaries: &[&str]) -> tempfile::TempDir { - let td = tempfile::tempdir().unwrap(); - for b in binaries { - let p = td.path().join(b); - std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perm = std::fs::metadata(&p).unwrap().permissions(); - perm.set_mode(0o755); - std::fs::set_permissions(&p, perm).unwrap(); - } - } - td -} - -#[test] -fn binary_on_path_finds_present_binary() { - let td = fake_path_with(&["claude"]); - assert!(binary_on_path("claude", Some(td.path()))); - assert!(!binary_on_path("gemini", Some(td.path()))); -} - -#[test] -fn pick_harness_explicit_arg_validates_path() { - let td = fake_path_with(&["claude"]); - let result = pick_harness( - Some(HarnessArg::Claude), - None, - Some(td.path()), - ); - assert_eq!(result.unwrap(), Harness::Claude); - - let err = pick_harness( - Some(HarnessArg::Gemini), - None, - Some(td.path()), - ).unwrap_err(); - assert!(err.to_string().contains("`gemini` isn't on PATH")); -} - -#[test] -fn pick_harness_zero_installed_errors() { - let td = fake_path_with(&[]); - let err = pick_harness( - None, - Some(Harness::Claude), - Some(td.path()), - ).unwrap_err(); - assert!( - err.to_string().contains("no installed harnesses") - || err.to_string().contains("no harnesses on PATH"), - "actual: {}", err - ); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -cargo test -p path-cli --lib pick_harness binary_on_path -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement `binary_on_path` and `pick_harness`** - -Append to `cmd_resume.rs`: - -```rust -/// Probe `$PATH` (or `path_override`, for tests) for a given binary -/// name. Cross-platform: on Windows, also tries `.exe`. -pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path>) -> bool { - let dirs: Vec = match path_override { - Some(p) => vec![p.to_path_buf()], - None => std::env::var_os("PATH") - .map(|p| std::env::split_paths(&p).collect()) - .unwrap_or_default(), - }; - for d in dirs { - let candidate = d.join(name); - if candidate.is_file() { - return true; - } - #[cfg(windows)] - { - let exe = d.join(format!("{name}.exe")); - if exe.is_file() { - return true; - } - } - } - false -} - -const ALL_HARNESSES: &[Harness] = &[ - Harness::Claude, - Harness::Gemini, - Harness::Codex, - Harness::Opencode, - Harness::Pi, -]; - -/// Decide which harness to resume in. See spec § "pick_harness". -/// -/// `path_override` is `None` in production; tests pass `Some(dir)` to -/// fake `$PATH`. -pub(crate) fn pick_harness( - arg: Option, - source: Option, - path_override: Option<&std::path::Path>, -) -> Result { - if let Some(a) = arg { - let h = Harness::from_arg(a); - if !binary_on_path(h.name(), path_override) { - anyhow::bail!( - "harness `{}` isn't on PATH; install it or pick another with `--harness`", - h.name() - ); - } - return Ok(h); - } - - let installed: Vec = ALL_HARNESSES - .iter() - .copied() - .filter(|h| binary_on_path(h.name(), path_override)) - .collect(); - - if installed.is_empty() { - anyhow::bail!( - "no installed harnesses found on PATH; install one of: claude, gemini, codex, opencode, pi" - ); - } - - interactive_pick(&installed, source) -} - -fn interactive_pick(installed: &[Harness], source: Option) -> Result { - if !crate::fzf::available() { - anyhow::bail!( - "interactive picker requires `fzf` on PATH and a TTY; pass `--harness ` or rerun in a terminal" - ); - } - let mut lines: Vec = Vec::with_capacity(installed.len()); - for h in installed { - let suffix = if Some(*h) == source { " (source)" } else { "" }; - lines.push(format!("{}{}", h.symbol(), suffix)); - } - - let header = match source { - Some(s) => format!("pick a harness to resume in (source: {})", s.name()), - None => "pick a harness to resume in".to_string(), - }; - - let opts = crate::fzf::PickOptions { header: Some(&header), ..Default::default() }; - let pick = match crate::fzf::pick(&lines, &opts) - .map_err(|e| anyhow::anyhow!("fzf failed: {}", e))? - { - crate::fzf::PickResult::Selected(p) => p, - crate::fzf::PickResult::Cancelled => std::process::exit(130), - crate::fzf::PickResult::NoMatch => { - anyhow::bail!("fzf returned no match — picker UI was empty?"); - } - }; - - for h in installed { - if pick.starts_with(h.symbol()) { - return Ok(*h); - } - } - anyhow::bail!("picker returned an unrecognized row: {pick}") -} -``` - -**Read `crates/path-cli/src/fzf.rs` before writing this.** If `PickOptions` requires extra fields (e.g. `prompt`, `multi`, `preview`), set them to whatever the existing `cmd_share.rs` code sets — the file is short, the existing call site is the canonical example. - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -cargo test -p path-cli --lib pick_harness binary_on_path -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(path-cli): harness picker + PATH probe for path resume" -``` - ---- - -## Task 7: Implement `project_into_harness` dispatcher and `argv_for` - -**Files:** -- Modify: `crates/path-cli/src/cmd_resume.rs` - -- [ ] **Step 1: Write the failing test** - -```rust -#[test] -fn argv_for_returns_harness_specific_shape() { - assert_eq!(argv_for(Harness::Claude, "abc"), vec!["-r".to_string(), "abc".to_string()]); - assert_eq!(argv_for(Harness::Gemini, "abc"), vec!["--resume".to_string(), "abc".to_string()]); - assert_eq!(argv_for(Harness::Codex, "abc"), vec!["resume".to_string(), "abc".to_string()]); - assert_eq!(argv_for(Harness::Opencode, "abc"), vec!["--session".to_string(), "abc".to_string()]); - assert_eq!(argv_for(Harness::Pi, "abc"), vec!["--session".to_string(), "abc".to_string()]); -} - -#[test] -fn project_into_harness_claude_round_trip() { - let _home = scoped_home_for_resume(); - let cwd = tempfile::tempdir().unwrap(); - let path = make_path_with_actor("agent:claude-code"); - - let session_id = project_into_harness(&path, Harness::Claude, cwd.path()).unwrap(); - assert!(!session_id.is_empty()); -} - -fn scoped_home_for_resume() -> ScopedHomeForResume { - ScopedHomeForResume::new() -} - -struct ScopedHomeForResume { _td: tempfile::TempDir, prev: Option } - -impl ScopedHomeForResume { - fn new() -> Self { - let td = tempfile::tempdir().unwrap(); - let prev = std::env::var_os("HOME"); - unsafe { std::env::set_var("HOME", td.path()); } - Self { _td: td, prev } - } -} - -impl Drop for ScopedHomeForResume { - fn drop(&mut self) { - unsafe { - match &self.prev { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - } - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -cargo test -p path-cli --lib argv_for project_into_harness_claude_round_trip -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement `argv_for` and `project_into_harness`** - -Append to `cmd_resume.rs`: - -```rust -/// Static map from harness to resume-argv shape. -pub(crate) fn argv_for(harness: Harness, session_id: &str) -> Vec { - match harness { - Harness::Claude => vec!["-r".into(), session_id.into()], - Harness::Gemini => vec!["--resume".into(), session_id.into()], - Harness::Codex => vec!["resume".into(), session_id.into()], - Harness::Opencode => vec!["--session".into(), session_id.into()], - Harness::Pi => vec!["--session".into(), session_id.into()], - } -} - -/// Project a Path into the chosen harness's on-disk layout under `cwd`, -/// returning the projected session id. -pub(crate) fn project_into_harness( - path: &TPath, - harness: Harness, - cwd: &std::path::Path, -) -> Result { - match harness { - Harness::Claude => crate::cmd_export::project_claude(path, cwd), - Harness::Gemini => crate::cmd_export::project_gemini(path, cwd), - Harness::Codex => crate::cmd_export::project_codex(path, cwd), - Harness::Opencode => crate::cmd_export::project_opencode(path, cwd), - Harness::Pi => crate::cmd_export::project_pi(path, cwd), - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -cargo test -p path-cli --lib argv_for project_into_harness_claude_round_trip -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(path-cli): argv_for + project_into_harness dispatcher" -``` - ---- - -## Task 8: Implement `exec_harness` with injectable strategy - -**Files:** -- Modify: `crates/path-cli/src/cmd_resume.rs` - -- [ ] **Step 1: Write the failing test** - -```rust -#[test] -fn exec_strategy_recording_captures_invocation() { - let recorder = RecordingExec::default(); - let strategy: &dyn ExecStrategy = &recorder; - exec_harness("claude", &["-r".into(), "abc123".into()], std::path::Path::new("/tmp/x"), strategy) - .unwrap(); - - let captured = recorder.captured(); - assert_eq!(captured.binary, "claude"); - assert_eq!(captured.args, vec!["-r".to_string(), "abc123".to_string()]); - assert_eq!(captured.cwd, std::path::PathBuf::from("/tmp/x")); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -```bash -cargo test -p path-cli --lib exec_strategy_recording_captures_invocation -``` - -Expected: FAIL. - -- [ ] **Step 3: Implement `ExecStrategy` and `exec_harness`** - -Append to `cmd_resume.rs`: - -```rust -/// What `exec_harness` saw (for tests). -#[derive(Debug, Clone, Default)] -pub struct CapturedExec { - pub binary: String, - pub args: Vec, - pub cwd: std::path::PathBuf, -} - -/// Pluggable exec backend. Production uses `RealExec` (`execvp` on -/// Unix, spawn-and-wait on Windows). Tests use `RecordingExec`. -pub trait ExecStrategy { - fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>; -} - -/// Production implementation. On Unix this never returns on success -/// (the current process is replaced); on Windows it spawns the child, -/// waits, and propagates the exit code. -pub struct RealExec; - -impl ExecStrategy for RealExec { - fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { - let mut cmd = std::process::Command::new(binary); - cmd.args(args); - cmd.current_dir(cwd); - - eprintln!( - "Resuming: {} {} (cwd: {})", - binary, - args.join(" "), - cwd.display() - ); - - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - // exec only returns if it fails. - let err = cmd.exec(); - anyhow::bail!( - "couldn't exec `{}`: {}. Recipe: {} {} (run from {})", - binary, - err, - binary, - args.join(" "), - cwd.display() - ); - } - #[cfg(not(unix))] - { - let status = cmd.spawn() - .with_context(|| format!("spawn {}", binary))? - .wait() - .with_context(|| format!("wait for {}", binary))?; - std::process::exit(status.code().unwrap_or(1)); - } - } -} - -/// Recording strategy for tests. `captured()` returns the most recent -/// invocation. -#[derive(Default)] -pub struct RecordingExec { - inner: std::sync::Mutex, -} - -impl RecordingExec { - pub fn captured(&self) -> CapturedExec { - self.inner.lock().unwrap().clone() - } -} - -impl ExecStrategy for RecordingExec { - fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> { - let mut g = self.inner.lock().unwrap(); - *g = CapturedExec { - binary: binary.to_string(), - args: args.to_vec(), - cwd: cwd.to_path_buf(), - }; - Ok(()) - } -} - -pub(crate) fn exec_harness( - binary: &str, - args: &[String], - cwd: &std::path::Path, - strategy: &dyn ExecStrategy, -) -> Result<()> { - strategy.exec(binary, args, cwd) -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -```bash -cargo test -p path-cli --lib exec_strategy_recording_captures_invocation -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(path-cli): ExecStrategy with RealExec/RecordingExec" -``` - ---- - -## Task 9: Wire `run_resume` orchestration - -**Files:** -- Modify: `crates/path-cli/src/cmd_resume.rs` - -- [ ] **Step 1: Replace the stub `run` with the real orchestration** - -Find the current stub: - -```rust -pub fn run(_args: ResumeArgs) -> Result<()> { - anyhow::bail!("path resume: not yet implemented") -} -``` - -Replace with: - -```rust -pub fn run(args: ResumeArgs) -> Result<()> { - run_with_strategy(args, &RealExec) -} - -/// Internal entry point that the integration tests call with a -/// `RecordingExec` strategy. Production callers use [`run`]. -pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> { - let (graph, source_harness) = resolve_input(&args)?; - let path = ensure_path_with_agent(&graph)?; - - let cwd = match args.cwd.as_ref() { - Some(p) => std::fs::canonicalize(p) - .with_context(|| format!("resolve cwd path {}", p.display()))?, - None => std::env::current_dir()?, - }; - - let target = pick_harness(args.harness, source_harness, None)?; - eprintln!("Picked harness: {}{}", - target.name(), - if Some(target) == source_harness { " (source)" } else { "" } - ); - - let session_id = project_into_harness(path, target, &cwd)?; - let argv = argv_for(target, &session_id); - exec_harness(target.name(), &argv, &cwd, exec) -} -``` - -- [ ] **Step 2: Replace the stub test** - -Replace: - -```rust -#[test] -fn run_returns_not_implemented_until_wired() { … } -``` - -with: - -```rust -#[test] -fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() { - let _home = scoped_home_for_resume(); - let cwd = tempfile::tempdir().unwrap(); - let doc_file = cwd.path().join("doc.json"); - - let path = make_path_with_actor("agent:claude-code"); - let graph = toolpath::v1::Graph::from_path(path); - std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap(); - - // Make `claude` discoverable by salting PATH for this process. - let bin_dir = fake_path_with(&["claude"]); - let prev = std::env::var_os("PATH"); - let new_path = std::env::join_paths( - std::iter::once(bin_dir.path().to_path_buf()) - .chain(std::env::split_paths(&prev.clone().unwrap_or_default())), - ).unwrap(); - unsafe { std::env::set_var("PATH", new_path); } - - let args = ResumeArgs { - input: doc_file.to_string_lossy().to_string(), - cwd: Some(cwd.path().to_path_buf()), - harness: Some(HarnessArg::Claude), - no_cache: false, force: false, url: None, - }; - - let recorder = RecordingExec::default(); - run_with_strategy(args, &recorder).unwrap(); - - // Restore PATH. - unsafe { - match prev { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } - - let cap = recorder.captured(); - assert_eq!(cap.binary, "claude"); - assert_eq!(cap.args[0], "-r"); - assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap()); -} -``` - -- [ ] **Step 3: Run the orchestration test** - -```bash -cargo test -p path-cli --lib run_with_strategy_records_invocation_for_file_input_with_explicit_harness -``` - -Expected: PASS. - -- [ ] **Step 4: Run all `cmd_resume` tests** - -```bash -cargo test -p path-cli --lib cmd_resume -``` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add crates/path-cli/src/cmd_resume.rs -git commit -m "feat(path-cli): wire path resume orchestration end-to-end" -``` - ---- - -## Task 10: Integration tests - -**Files:** -- Create: `crates/path-cli/tests/resume.rs` -- Create: `crates/path-cli/tests/support/mod.rs` - -- [ ] **Step 1: Add the `support` module** - -Create `crates/path-cli/tests/support/mod.rs`: - -```rust -use std::ffi::OsString; -use std::path::{Path, PathBuf}; - -pub struct ScopedHome { _td: tempfile::TempDir, prev: Option, prev_config: Option } - -impl ScopedHome { - pub fn new() -> Self { - let td = tempfile::tempdir().unwrap(); - let prev = std::env::var_os("HOME"); - let prev_config = std::env::var_os("TOOLPATH_CONFIG_DIR"); - unsafe { - std::env::set_var("HOME", td.path()); - std::env::set_var("TOOLPATH_CONFIG_DIR", td.path().join(".toolpath")); - } - Self { _td: td, prev, prev_config } - } -} - -impl Drop for ScopedHome { - fn drop(&mut self) { - unsafe { - match &self.prev { - Some(v) => std::env::set_var("HOME", v), - None => std::env::remove_var("HOME"), - } - match &self.prev_config { - Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v), - None => std::env::remove_var("TOOLPATH_CONFIG_DIR"), - } - } - } -} - -pub struct ScopedPath { _td: tempfile::TempDir, prev: Option } - -impl ScopedPath { - pub fn with_binary(name: &str) -> Self { Self::with_binaries(&[name]) } - - pub fn with_binaries(names: &[&str]) -> Self { - let td = tempfile::tempdir().unwrap(); - for n in names { - let p = td.path().join(n); - std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap(); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perm = std::fs::metadata(&p).unwrap().permissions(); - perm.set_mode(0o755); - std::fs::set_permissions(&p, perm).unwrap(); - } - } - let prev = std::env::var_os("PATH"); - let new_path = std::env::join_paths( - std::iter::once(td.path().to_path_buf()) - .chain(std::env::split_paths(&prev.clone().unwrap_or_default())) - ).unwrap(); - unsafe { std::env::set_var("PATH", new_path); } - Self { _td: td, prev } - } - - pub fn empty() -> Self { - let td = tempfile::tempdir().unwrap(); - let prev = std::env::var_os("PATH"); - unsafe { std::env::set_var("PATH", td.path()); } - Self { _td: td, prev } - } -} - -impl Drop for ScopedPath { - fn drop(&mut self) { - unsafe { - match &self.prev { - Some(v) => std::env::set_var("PATH", v), - None => std::env::remove_var("PATH"), - } - } - } -} - -pub fn home_dir() -> PathBuf { - PathBuf::from(std::env::var_os("HOME").unwrap()) -} - -pub fn write_minimal_path_file(dir: &Path, actor: &str) -> PathBuf { - use toolpath::v1::{Path as TPath, PathIdentity, Step}; - let step = Step::new("s1", actor, "2026-01-01T00:00:00Z") - .with_raw_change("a.txt", "@@ -1 +1 @@\n-old\n+new"); - let path = TPath { - path: PathIdentity { - id: "p1".to_string(), - base: None, - head: "s1".to_string(), - graph_ref: None, - }, - steps: vec![step], - meta: None, - }; - let graph = toolpath::v1::Graph::from_path(path); - let p = dir.join("doc.json"); - std::fs::write(&p, graph.to_json().unwrap()).unwrap(); - p -} - -pub fn args(input: PathBuf, cwd: &Path, harness: path_cli::cmd_share::HarnessArg) -> path_cli::cmd_resume::ResumeArgs { - path_cli::cmd_resume::ResumeArgs { - input: input.to_string_lossy().to_string(), - cwd: Some(cwd.to_path_buf()), - harness: Some(harness), - no_cache: false, force: false, url: None, - } -} - -pub fn walk_dir_finds_jsonl(root: &Path) -> bool { - fn walk(p: &Path) -> bool { - if p.is_dir() { - for e in std::fs::read_dir(p).unwrap() { - if walk(&e.unwrap().path()) { return true; } - } - false - } else { - p.extension().and_then(|s| s.to_str()) == Some("jsonl") - } - } - walk(root) -} -``` - -- [ ] **Step 2: Add the integration test file with all per-harness positive cases** - -Create `crates/path-cli/tests/resume.rs`: - -```rust -#![cfg(not(target_os = "emscripten"))] - -use path_cli::cmd_resume::{run_with_strategy, RecordingExec, ResumeArgs}; -use path_cli::cmd_share::HarnessArg; - -mod support; -use support::*; - -#[test] -fn file_input_explicit_claude_projects_and_records_exec() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let doc_file = write_minimal_path_file(cwd.path(), "agent:claude-code"); - let _path_guard = ScopedPath::with_binary("claude"); - - let recorder = RecordingExec::default(); - run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder).unwrap(); - - let cap = recorder.captured(); - assert_eq!(cap.binary, "claude"); - assert_eq!(cap.args[0], "-r"); - - let projects = home_dir().join(".claude/projects"); - assert!(projects.exists(), "claude projects dir missing"); - assert!(walk_dir_finds_jsonl(&projects), "no JSONL written"); -} - -#[test] -fn file_input_explicit_gemini_projects_and_records_exec() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let doc_file = write_minimal_path_file(cwd.path(), "agent:gemini-cli"); - let _path_guard = ScopedPath::with_binary("gemini"); - - let recorder = RecordingExec::default(); - run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Gemini), &recorder).unwrap(); - - let cap = recorder.captured(); - assert_eq!(cap.binary, "gemini"); - assert_eq!(cap.args[0], "--resume"); - - let tmp_root = home_dir().join(".gemini/tmp"); - assert!(tmp_root.exists(), "gemini tmp dir missing"); -} - -#[test] -fn file_input_explicit_codex_projects_and_records_exec() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let doc_file = write_minimal_path_file(cwd.path(), "agent:codex"); - let _path_guard = ScopedPath::with_binary("codex"); - - let recorder = RecordingExec::default(); - run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Codex), &recorder).unwrap(); - - let cap = recorder.captured(); - assert_eq!(cap.binary, "codex"); - assert_eq!(cap.args[0], "resume"); - - let sessions = home_dir().join(".codex/sessions"); - assert!(sessions.exists(), "codex sessions dir missing"); -} - -#[test] -fn file_input_explicit_opencode_projects_and_records_exec() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let doc_file = write_minimal_path_file(cwd.path(), "agent:opencode"); - let _path_guard = ScopedPath::with_binary("opencode"); - - // Pre-create the opencode db with the canonical schema. - let resolver = toolpath_opencode::PathResolver::new(); - let db_path = resolver.db_path().unwrap(); - std::fs::create_dir_all(db_path.parent().unwrap()).unwrap(); - { - let conn = rusqlite::Connection::open(&db_path).unwrap(); - // Substitute actual bootstrap helper if different. - toolpath_opencode::schema::apply_full_schema(&conn).unwrap(); - } - - let recorder = RecordingExec::default(); - run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Opencode), &recorder).unwrap(); - - let cap = recorder.captured(); - assert_eq!(cap.binary, "opencode"); - assert_eq!(cap.args[0], "--session"); -} - -#[test] -fn file_input_explicit_pi_projects_and_records_exec() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let doc_file = write_minimal_path_file(cwd.path(), "agent:pi"); - let _path_guard = ScopedPath::with_binary("pi"); - - let recorder = RecordingExec::default(); - run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Pi), &recorder).unwrap(); - - let cap = recorder.captured(); - assert_eq!(cap.binary, "pi"); - assert_eq!(cap.args[0], "--session"); - - let sessions = home_dir().join(".pi/agent/sessions"); - assert!(sessions.exists(), "pi sessions dir missing"); -} -``` - -- [ ] **Step 3: Add the rejection cases** - -Append to `tests/resume.rs`: - -```rust -#[test] -fn multi_path_graph_returns_clear_error() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let _path_guard = ScopedPath::with_binary("claude"); - - // Build a graph with two inline paths. - let p1 = { - let json = std::fs::read_to_string(write_minimal_path_file(cwd.path(), "agent:claude-code")).unwrap(); - toolpath::v1::Graph::from_json(&json).unwrap().into_single_path().unwrap() - }; - let mut p2 = p1.clone(); - p2.path.id = "p2".into(); - let mut g = toolpath::v1::Graph::from_path(p1); - g.paths.push(toolpath::v1::PathOrRef::Path(Box::new(p2))); - let doc_file = cwd.path().join("multi.json"); - std::fs::write(&doc_file, g.to_json().unwrap()).unwrap(); - - let recorder = RecordingExec::default(); - let err = run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder) - .unwrap_err(); - let s = err.to_string(); - assert!(s.contains("single `Path`"), "actual: {s}"); - assert!(s.contains("2 paths"), "actual: {s}"); -} - -#[test] -fn agentless_path_returns_clear_error() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let _path_guard = ScopedPath::with_binary("claude"); - let doc_file = write_minimal_path_file(cwd.path(), "human:alex"); - - let recorder = RecordingExec::default(); - let err = run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder) - .unwrap_err(); - assert!(err.to_string().contains("no agent session")); -} - -#[test] -fn explicit_harness_not_on_path_errors() { - let _home = ScopedHome::new(); - let _path_guard = ScopedPath::empty(); - let cwd = tempfile::tempdir().unwrap(); - let doc_file = write_minimal_path_file(cwd.path(), "agent:claude-code"); - - let recorder = RecordingExec::default(); - let err = run_with_strategy(args(doc_file, cwd.path(), HarnessArg::Claude), &recorder) - .unwrap_err(); - assert!(err.to_string().contains("isn't on PATH")); -} -``` - -- [ ] **Step 4: Add cache-id and URL input tests** - -```rust -#[test] -fn cache_id_input_loads_and_projects() { - let _home = ScopedHome::new(); - let cwd = tempfile::tempdir().unwrap(); - let _path_guard = ScopedPath::with_binary("claude"); - - // Stash a graph in the cache under a known id. - let cache_id = "claude-test-fixture"; - let doc_file = write_minimal_path_file(cwd.path(), "agent:claude-code"); - let json = std::fs::read_to_string(&doc_file).unwrap(); - let graph = toolpath::v1::Graph::from_json(&json).unwrap(); - path_cli::cmd_cache::write_cached(cache_id, &graph, false).unwrap(); - - let resume_args = path_cli::cmd_resume::ResumeArgs { - input: cache_id.to_string(), - cwd: Some(cwd.path().to_path_buf()), - harness: Some(HarnessArg::Claude), - no_cache: false, force: false, url: None, - }; - let recorder = RecordingExec::default(); - run_with_strategy(resume_args, &recorder).unwrap(); - assert_eq!(recorder.captured().binary, "claude"); -} - -// URL input case — uses the in-repo MockServer test helper. If the -// MockServer module isn't reachable from cross-test binaries, skip -// or re-implement a minimal mock here. -``` - -(The URL input test depends on `path_cli::cmd_pathbase::tests::MockServer` being reachable. If `pub(crate)` doesn't bridge across the integration-test binary boundary, either move `MockServer` to a tiny `pub` test-utilities module or write a minimal inline mock for this single test. Decide at implementation time.) - -- [ ] **Step 5: Run all integration tests** - -```bash -cargo test -p path-cli --test resume -``` - -Expected: PASS. - -- [ ] **Step 6: Run the full `path-cli` test suite** - -```bash -cargo test -p path-cli -``` - -Expected: pass. - -- [ ] **Step 7: Commit** - -```bash -git add crates/path-cli/tests/ -git commit -m "test(path-cli): integration tests for path resume" -``` - ---- - -## Task 11: Documentation - -**Files:** -- Modify: `CLAUDE.md` -- Modify: `README.md` -- Modify: `crates/path-cli/src/cmd_resume.rs` (rustdoc) -- Create or modify: `CHANGELOG.md` - -- [ ] **Step 1: Add `path resume` to the `CLAUDE.md` CLI usage block** - -Find the existing CLI usage block (the long bash block with `path import …` etc.) and add, near `path share`: - -````markdown -# Resume an agent session into your coding agent of choice -cargo run -p path-cli -- resume -cargo run -p path-cli -- resume --harness claude -C /path/to/project -```` - -- [ ] **Step 2: Add a "Things to know" bullet for `path resume`** - -In the same `CLAUDE.md`, append (next to the `path share` bullet): - -````markdown -- `path resume ` is the inverse of `path share`. It takes a Pathbase URL, shorthand (`owner/repo/slug`), file path, or cache id; resolves it to a Toolpath document; lets the user pick a coding-agent harness (interactive picker by default, `--harness X` to skip); projects the session into the harness's on-disk layout under the chosen cwd (default: shell cwd; override with `-C, --cwd P`); then execs the harness's resume command. Source harness is read from `path.meta.source` when present, with actor-string fallback. Documents that aren't a single agent-bearing `Path` are rejected with a message. -```` - -- [ ] **Step 3: Add a one-line mention to `README.md`** - -In whichever section enumerates CLI verbs, add `path resume ` next to `path share`. - -- [ ] **Step 4: Beef up the rustdoc on `cmd_resume.rs`** - -Replace the placeholder module comment with a real one: - -```rust -//! `path resume ` — fetch / load a Toolpath document, pick an -//! installed coding-agent harness, project the session into that -//! harness's on-disk layout, and exec the harness's resume command. -//! -//! ## Inputs -//! -//! `` is resolved in this order: -//! 1. `https://` / `http://` URL → fetched via `pathbase-client`, -//! cached unless `--no-cache`. -//! 2. `owner/repo/slug` shorthand → same Pathbase fetch flow. -//! 3. Existing file path → read directly. -//! 4. Otherwise treated as a cache id under `~/.toolpath/documents/`. -//! -//! ## Harness selection -//! -//! With `--harness X`, `X` is validated against `$PATH` and used. -//! Without `--harness`, an `fzf` picker shows installed harnesses -//! with the source harness pre-selected. Source comes from -//! `path.meta.source` (`claude-code`, `gemini-cli`, `codex`, -//! `opencode`, `pi`) with actor-string fallback. -//! -//! ## Project directory -//! -//! `-C / --cwd P` overrides the shell cwd. The harness is exec'd -//! with cwd set to P and the on-disk projection is keyed on P. -//! -//! ## Launch -//! -//! On Unix the harness binary is `execvp`'d, replacing the current -//! process. On Windows it's spawned and waited on with the exit code -//! propagated. If exec fails, the recipe is printed to stderr. -//! -//! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`. -``` - -- [ ] **Step 5: Add a `CHANGELOG.md` entry** - -Add a new section at the top (above the most recent entry; create the file with `# Changelog` header if it doesn't exist): - -```markdown -## path-cli 0.9.0 — 2026-05-08 - -### Added -- `path resume ` — fetch a Toolpath document (URL, shorthand, - file path, or cache id), pick a coding-agent harness, project the - session into its on-disk layout under a chosen cwd, and exec the - harness's resume command. -- `cmd_export::project_` `pub(crate)` wrappers that compose - the existing build + write helpers and return the projected session - id. Consumed by `path resume`. -``` - -- [ ] **Step 6: Build the docs to confirm they compile** - -```bash -cargo doc -p path-cli --no-deps -``` - -Expected: clean build. - -- [ ] **Step 7: Commit** - -```bash -git add CLAUDE.md README.md CHANGELOG.md crates/path-cli/src/cmd_resume.rs -git commit -m "docs: document path resume command" -``` - ---- - -## Task 12: Version bumps - -**Files:** -- Modify: `crates/path-cli/Cargo.toml` -- Modify: `Cargo.toml` (root) -- Modify: `site/_data/crates.json` - -- [ ] **Step 1: Bump `path-cli` minor version** - -In `crates/path-cli/Cargo.toml`: - -```toml -version = "0.9.0" # was 0.8.0 -``` - -- [ ] **Step 2: Bump the workspace dep entry** - -In the root `Cargo.toml`, find the `[workspace.dependencies]` `path-cli` entry and bump to match. (Adjust to match the existing entry's exact shape — `path` may or may not be present.) - -- [ ] **Step 3: Bump the site data** - -In `site/_data/crates.json`, update the `path-cli` entry's `version` field to `"0.9.0"`. - -- [ ] **Step 4: Verify the workspace builds** - -```bash -cargo build --workspace -``` - -Expected: clean build. - -- [ ] **Step 5: Verify the workspace tests pass** - -```bash -cargo test --workspace -``` - -Expected: all green. - -- [ ] **Step 6: Verify clippy is clean** - -```bash -cargo clippy --workspace -- -D warnings -``` - -Expected: no warnings. - -- [ ] **Step 7: Build the site to confirm `crates.json` is well-formed** - -```bash -cd site && pnpm run build && cd .. -``` - -Expected: 7 pages built; no errors. - -- [ ] **Step 8: Commit** - -```bash -git add crates/path-cli/Cargo.toml Cargo.toml site/_data/crates.json -git commit -m "chore: bump path-cli to 0.9.0 for path resume" -``` - ---- - -## Task 13: Smoke test from the CLI - -**Files:** none modified — manual verification only. - -- [ ] **Step 1: Build the CLI** - -```bash -cargo build -p path-cli --release -``` - -- [ ] **Step 2: Verify `--help` lists the new command** - -```bash -./target/release/path resume --help -``` - -Expected: usage line + flags listed exactly as documented in `cmd_resume.rs`. - -- [ ] **Step 3: Confirm rejection paths work end-to-end** - -Pick or derive a cache entry that's not from a harness (e.g. a `git-*` entry from `path import git`). Then attempt to resume: - -```bash -./target/release/path resume --harness claude -``` - -Expected: error message `no agent session in input — \`path resume\` only works on harness-derived paths`. - -- [ ] **Step 4: (Optional) Confirm a real resume works against an actual session** - -Only if you have a real claude/codex/gemini/opencode/pi session locally and one of those binaries on PATH: - -```bash -./target/release/path import claude --project $PWD -./target/release/path resume --harness claude -``` - -Expected: control transfers to the harness with the prior conversation visible. - -- [ ] **Step 5: No commit needed for smoke testing** - -Manual step only. - ---- - -## Self-review checklist - -1. Every task ends with a `git commit` — verified. -2. Every code step shows actual code, not "implement X" — verified. -3. Every test step shows actual test, run command, and expected outcome — verified. -4. File paths are absolute or workspace-relative — verified. -5. Type names are consistent across tasks (`ResumeArgs`, `ExecStrategy`, `RecordingExec`, `RealExec`, `Harness`, `HarnessArg`, `CapturedExec`) — verified. -6. No `ResumeRecipe` references — verified (collapsed into `(session_id, argv_for, exec_harness)`). -7. Spec coverage: - - § Surface — Tasks 3, 9. - - § Input resolution — Task 5. - - § Launch — Tasks 8, 9. - - § Internal architecture (`resolve_input`, `ensure_path_with_agent`, `pick_harness`, `project_into_harness`, `argv_for`, `exec_harness`) — Tasks 4–9. - - § `project_` wrappers — Task 1. - - § Error handling — Tasks 4, 5, 6, 10. - - § Testing — Tasks 1–10, 13. - - § Documentation — Task 11. - - § Versioning — Task 12. -8. No "TBD", "TODO", or "implement later" — verified. diff --git a/docs/superpowers/specs/2026-05-08-path-resume-command-design.md b/docs/superpowers/specs/2026-05-08-path-resume-command-design.md deleted file mode 100644 index 66aa565..0000000 --- a/docs/superpowers/specs/2026-05-08-path-resume-command-design.md +++ /dev/null @@ -1,388 +0,0 @@ -# `path resume` — one-shot resume into a coding agent - -**Status:** Design accepted, awaiting implementation plan. -**Date:** 2026-05-08 - -## Goal - -Collapse the existing two-step "fetch a session, replay it locally" -workflow (`path import pathbase ` then `path export ---input --project ` then run the harness's resume command) -into a single command that ends with the user's chosen coding agent -running in interactive mode against the projected session. - -`path share` is the upstream of this flow: someone clicks share, sends -a Pathbase URL to a teammate, and the teammate runs `path resume ` -to land in claude / codex / etc. with the conversation in front of -them. - -## Non-goals - -- **Git context.** A Toolpath document may carry a `path.base` git URL - + ref, but `path resume` does not clone, fetch, or check out anything. - The user is responsible for their working tree. (Possible follow-up.) -- **File-artifact replay.** The doc may carry file changes; we do not - apply them to the working tree. The harness session alone is what - gets reconstructed. -- **Multi-path resume from a `Graph`.** v1 accepts a single `Path`; - `Graph` inputs are rejected with a message. -- **Cross-harness fidelity warnings.** The user picks the harness; we - do not second-guess matches/mismatches with the source. -- **`--print` opt-out for exec.** Default and only behavior is exec. - Recipe-print is the *fallback* when exec fails (binary missing - between PATH check and exec), not a user-facing flag. -- **Deprecation aliases.** Fresh command, no prior name to honor. - -## Surface - -``` -path resume - [-C, --cwd ] - [--harness ] - [--no-cache] [--force] [--url ] -``` - -| Flag / arg | Behavior | -| --- | --- | -| `` | URL, Pathbase shorthand, file path, or cache id. See "Input resolution" below. | -| `-C, --cwd P` | chdir to P before projecting and before exec'ing the harness. Layout is keyed on P. Default: shell cwd. | -| `--harness X` | Pin the resume target. Skips the interactive picker. Errors if X is not on PATH. | -| (no `--harness`) | fzf picker over installed harnesses; doc's source harness pre-selected when installed. Rows annotated `(source)` and/or `(not on PATH)`. | -| `--no-cache` | URL/shorthand inputs only: skip writing the fetched doc to `~/.toolpath/documents/`. | -| `--force` | URL/shorthand inputs only: overwrite an existing cache entry. Same semantics as `import --force`. | -| `--url ` | Override Pathbase server URL. Same fallback chain as `import pathbase`: `--url` > stored session > `$PATHBASE_URL` > `https://pathbase.dev`. | - -### Input resolution - -The single `` argument is resolved in this order, matching the -precedent set by `import pathbase`: - -1. **URL** — starts with `http://` or `https://` → fetched via the - pathbase client, written to cache (unless `--no-cache`), parsed. -2. **Pathbase shorthand** — three slash-separated segments - (`owner/repo/slug`) → same fetch + cache flow. -3. **Existing file path** — resolves as a real file on disk → read and - parsed. -4. **Cache id** — falls back to `~/.toolpath/documents/.json` - via the existing `cache_ref` helper. - -Ambiguity (e.g. a string that looks like a shorthand *and* is a real -file) resolves in the order above. This matches `import pathbase` and -is documented in the error path: a fail-to-resolve message names all -four shapes. - -### Launch - -After projection completes, the command: -1. `chdir`s to the resolved cwd (whether default or `-C P`). -2. **Unix:** `execvp`s the harness binary with its resume args, replacing - the current process. -3. **Windows:** `spawn`s the harness, waits, propagates the exit code. - -If the binary is not on PATH at exec time (race between the picker's -PATH check and exec, or a `--harness` value that fails the validation -gate), exit non-zero with `couldn't exec : . Recipe: - (run from )` so the user can recover by hand. - -## Internal architecture - -### New module: `cmd_resume.rs` - -Lives next to the other `cmd_*.rs` files in `crates/path-cli/src/`. -Wired into `lib.rs` as a new `Commands::Resume { args: -cmd_resume::ResumeArgs }` arm. Same pattern as `cmd_share.rs`. - -### Top-level orchestration - -```rust -pub async fn run_resume(args: ResumeArgs) -> Result<()> { - let (doc, source_harness) = resolve_input(&args).await?; - ensure_path_with_agent(&doc)?; - - let cwd = args.cwd.map(canonicalize_existing) - .unwrap_or_else(|| std::env::current_dir())?; - - let target = pick_harness(args.harness, source_harness)?; - let session_id = project_into_harness(&doc, target, &cwd)?; - let argv = argv_for(target, &session_id); - exec_harness(target.binary_name(), &argv, &cwd) -} -``` - -### `resolve_input` - -Small dispatcher that delegates, in order: - -- URL / `owner/repo/slug` → factor out the existing pathbase fetch - flow that lives inline in `cmd_import.rs` (calls - `cmd_pathbase::paths_download` for the body, then `cache::write_cached` - unless `--no-cache`) into a small `pub(crate)` helper that returns - `(Graph, String /* cache_id */)`. `cmd_resume` calls it; `cmd_import`'s - pathbase branch keeps using it. Honors `--no-cache`, `--force`, `--url`. -- File path / cache id → `cmd_cache::cache_ref` then read+parse. - -Returns `(Graph, Option)` — there is no `Document` enum in -the codebase; `Graph::from_json` is the universal parse entry. The -source harness is read from the single inline path's `meta.source` — -set by `toolpath-convo::derive_path` to the provider's `provider_id`: - -| `meta.source` | Harness | -| --- | --- | -| `"claude-code"` | Claude | -| `"gemini-cli"` | Gemini | -| `"codex"` | Codex | -| `"opencode"` | Opencode | -| `"pi"` | Pi | - -Fallback when `meta.source` is absent: actor-string prefix sniffing -across `path.steps[*].actor` (`agent:claude-code`, `agent:codex`, -`agent:gemini-cli`, `agent:opencode`, `agent:pi`). `None` when neither -source is conclusive — the picker still works, just without a -pre-selection. - -### `ensure_path_with_agent` - -Pure validation operating on a `Graph`. Rejects: - -- Empty graph → "resume needs a `Path`; expected one path, got an - empty graph". -- Graph with more than one inline path → "resume needs a single - `Path`; `` is a graph with N paths. Pick one with - `path query …` or split first." -- Single-path graph whose steps contain zero `agent:*` actors → "no - agent session in `` — `path resume` only works on - harness-derived paths". -- Single-path graph whose only entry is a `$ref` (not an inline - path) → "resume needs an inline `Path`; got a $ref. Resolve it - first." - -A bare `Step` JSON document never reaches this function — it would -fail `Graph::from_json` parse. No dedicated rejection branch needed. - -### `pick_harness` - -Reuses the `Harness` enum from `cmd_share.rs` (Claude / Gemini / -Codex / Opencode / Pi), including its `binary_name()` helper. Logic: - -- If `args.harness` is set → validate the binary is on PATH (small - inline `$PATH`-walking helper; or pull in the `which` crate as a new - dep — pick at implementation time, the surface is the same), return - it. Error if not on PATH. -- Else build the installed list (probe each harness binary on PATH); - pre-select source if installed; fzf-prompt with annotations. - Picker header: `pick a harness to resume in (source: )` when - source is known, otherwise `pick a harness to resume in`. -- If zero harnesses are installed → error naming all five. -- Esc / Ctrl-C → exit 130 (matches `path share`). - -Non-TTY environment with no `--harness`: error with the recipe (no -silent default — picking is consequential). - -### `project_into_harness` - -Each `run_` in `cmd_export.rs` is already split into two -private helpers: - -- `build__(...)` — projects a `Path` into the harness's - in-memory session struct (which carries a stable `session_id` field). -- `write_into__project(...)` — writes that struct to disk. - -We add five thin `pub(crate)` wrappers in `cmd_export.rs`: - -```rust -pub(crate) fn project_claude(path: &Path, project_dir: &Path) -> Result; -pub(crate) fn project_gemini(path: &Path, project_dir: &Path) -> Result; -pub(crate) fn project_codex(path: &Path, project_dir: &Path) -> Result; -pub(crate) fn project_opencode(path: &Path, project_dir: &Path) -> Result; -pub(crate) fn project_pi(path: &Path, project_dir: &Path) -> Result; -``` - -Each composes its build + write pair, returning the projected -session id. cmd_resume's `project_into_harness` is a five-arm match -that dispatches to the right wrapper. - -No public type, no refactor of the existing private writers, and no -change to `path export `'s user-visible behavior. - -### `argv_for` - -```rust -fn argv_for(harness: Harness, session_id: &str) -> Vec { - match harness { - Harness::Claude => vec!["-r".into(), session_id.into()], - Harness::Gemini => vec!["--resume".into(), session_id.into()], - Harness::Codex => vec!["resume".into(), session_id.into()], - Harness::Opencode => vec!["--session".into(), session_id.into()], - Harness::Pi => vec!["--session".into(), session_id.into()], - } -} -``` - -A static map from harness to resume-argv shape. Lives in -`cmd_resume.rs` because it's a per-harness CLI convention, not a -projection concern. `Harness::binary_name()` already exists in -`cmd_share.rs` and supplies the program name. - -### `exec_harness` - -Unix: - -```rust -use std::os::unix::process::CommandExt; - -let err = std::process::Command::new(binary) - .args(args) - .current_dir(cwd) - .exec(); // returns std::io::Error on failure only -``` - -Windows: `Command::new(...).spawn()?.wait()?`, propagate exit code. -Both paths fall through to the recipe-print fallback (§ Launch) on -spawn/exec error. - -### Wiring - -One new arm in `lib.rs`'s dispatch match alongside `Commands::Share`. -The fzf wrapper (`crate::fzf`) and `cmd_share::Harness` are already in -`path-cli`. The only candidate new dep is `which` for PATH probing — -optional; a 15-line homegrown helper does the same job. - -## Output contract - -- **stdout**: nothing under normal exec. The harness owns the TTY - after exec. (On the recipe-print fallback path, the recipe goes to - stderr.) -- **stderr**: progress messages — - ``` - Resolved → claude-abc (cache id; omitted with --no-cache) - Picked harness: claude (source) - Projected → ~/.claude/projects//.jsonl - Resuming: claude -r (cwd: ) - ``` - Last line printed immediately before exec. - -**Exit codes.** Unix exec succeeds → process replaced; the harness's -exit code is what the caller sees. Windows / recipe-print fallback / -errors → propagate. Picker cancel → 130. Validation errors → 1. - -## Error handling - -| Situation | Behavior | -| --- | --- | -| URL fetch fails (network) | Propagated from `pathbase-client`. | -| URL fetch returns 401/403 | "auth failed for ``; run `path auth login` or pass `--anon`" (mirrors `import pathbase`). | -| Cache hit on URL fetch, no `--force` | "cache entry `` already exists; pass `--force` to overwrite". | -| Input doesn't resolve as URL / shorthand / file / cache id | "couldn't resolve `` as a URL, file path, or cache id". | -| Empty graph | "resume needs a `Path`; expected one path, got an empty graph". | -| Multi-path graph | "resume needs a single `Path`; `` is a graph with N paths. Pick one with `path query …` or split first." | -| Single-path graph with no `agent:*` actors | "no agent session in `` — `path resume` only works on harness-derived paths". | -| Single-path graph entry is a `$ref` | "resume needs an inline `Path`; got a $ref. Resolve it first." | -| `--harness X` given, X not on PATH | "harness `` isn't on PATH; install it or pick another with `--harness`". | -| Zero harnesses on PATH (interactive mode) | "no installed harnesses found; install one of: claude, gemini, codex, opencode, pi". | -| No `--harness` and stderr/stdin not a TTY | "interactive picker requires a TTY; pass `--harness ` or rerun in a terminal". | -| Picker cancelled (Esc / Ctrl-C) | Silent; exit 130. | -| Projection fails mid-write | Propagated from `cmd_export`; partial files left behind (same as `export --project`). | -| `exec` fails (binary disappeared between PATH check and exec) | Print recipe to stderr with `couldn't exec`; exit non-zero. | - -Notes that drive design but not behavior: - -- All "couldn't" messages start lowercase to match the style elsewhere - in `path-cli`. -- We do not validate that `cwd` is a git repo. The harnesses don't - require it; we shouldn't either. -- We do not warn if the recorded cwd in the doc (codex/opencode) - differs from `--cwd`. The user's flag wins; their problem to know - what they're doing. - -## Testing - -### Unit tests in `cmd_resume.rs` - -1. `resolve_input` dispatch — URL detection (`https://`), shorthand - detection (three-segment), file-path detection, cache-id fallback. - Each branch tested against a tmpdir + mock cache. -2. `infer_source_harness` — `meta.source` tag wins; actor-string - sniffing fallback; `None` when neither is conclusive. -3. `ensure_path_with_agent` — accepts `Path` with at least one - `agent:*` step; rejects `Step` / `Graph` / agent-less `Path` with - the exact error strings from "Error handling". -4. `pick_harness` non-interactive paths — `--harness` set + on PATH → - returns it; `--harness` set + not on PATH → error; zero installed - → error. PATH membership is faked via an injectable lookup helper. - -### `project_` round-trip in `cmd_export.rs` tests - -One test per harness (claude / gemini / codex / opencode / pi): -project a fixture path, assert the returned `session_id` is -non-empty and the on-disk side-effects landed (the `.jsonl` exists, -the SQLite row was inserted, etc.). - -### Integration tests in `crates/path-cli/tests/resume.rs` - -Exec is the one untestable line. `cmd_resume` accepts an injectable -"exec strategy" (a small trait object or boxed closure) — the binary -calls the real `execvp` strategy; tests substitute a strategy that -records `(binary, args, cwd)` and returns success. No public -`--dry-run` flag. - -Cases: - -1. File-path input + `--harness claude` + `-C ` → projects under - `/.claude/projects//.jsonl`; recorded - `(binary, args)` is `("claude", ["-r", ])`. -2. Same shape, one per harness (gemini / codex / opencode / pi). -3. Cache-id input → loads from a tmp cache, projects, records - `(binary, args, cwd)`. -4. URL input → reuses the in-repo `MockServer` test helper from - `cmd_pathbase.rs`'s test module (extract into a `pub(crate)` test - util if needed), fetches, caches, projects. -5. Multi-path graph → returns the error verbatim. -6. Agent-less `Path` (git-derived fixture) → returns the error. -7. `--harness` not on PATH → error. -8. Zero installed harnesses → error. -9. Picker cancel → exit 130 (reuses the existing fzf-cancel test - pattern from `cmd_share`). - -### Out of scope for tests - -- Real harness exec. Not exercised in CI. -- The fzf-driven harness picker UX. The picker code is small and - reuses `cmd_share`'s helpers, which are already covered. - -## Documentation - -- `CLAUDE.md` — add `path resume` to the CLI usage list (next to `path - share`); add a "Things to know" bullet describing the - resolve→pick→project→exec flow and `-C` semantics. -- `README.md` — one-line mention in the workspace listing. -- `crates/path-cli/src/cmd_resume.rs` — module-level rustdoc covering - inputs, resolution order, harness picker, and exec semantics. Same - density as the doc comment at the top of `cmd_share.rs` and - `cmd_export.rs`. -- `cmd_export.rs` — no rustdoc change required; the new - `project_` wrappers carry their own doc comments. -- Site (`site/`) — no new page; `path resume` gets one bullet wherever - the CLI surface is enumerated. - -## Versioning - -- `path-cli` minor bump (additive command). Update - `crates/path-cli/Cargo.toml`, `[workspace.dependencies]` in root - `Cargo.toml`, `site/_data/crates.json`, and add a `CHANGELOG.md` - entry. -- `toolpath-cli` shim follows along (no version bump needed). -- No bumps for the `toolpath-*` provider crates. - -## Open questions - -None blocking. Future: - -- A git-aware mode that, given a doc with a `path.base`, offers to - clone/fetch and check out the recorded ref before projection. Would - need its own scope discussion. -- File-artifact replay onto the working tree, gated behind an explicit - flag because of clobber risk. -- Multi-path resume from a `Graph` (interactive sub-pick or a - `--path-id` flag). -- A `--browse` flag that, instead of exec'ing the harness, opens the - doc in the desktop `pathbase-app` if installed.