Skip to main content

path_cli/
cmd_resume.rs

1//! `path resume <input>` — fetch / load a Toolpath document, pick an
2//! installed coding-agent harness, project the session into that
3//! harness's on-disk layout, and exec the harness's resume command.
4//!
5//! ## Inputs
6//!
7//! `<input>` is resolved in this order:
8//! 1. `https://` / `http://` URL → fetched via `pathbase-client`,
9//!    cached unless `--no-cache`.
10//! 2. `owner/repo/slug` shorthand → same Pathbase fetch flow.
11//! 3. Existing file path → read directly.
12//! 4. Otherwise treated as a cache id under `~/.toolpath/documents/`.
13//!
14//! ## Harness selection
15//!
16//! With `--harness X`, `X` is validated against `$PATH` and used.
17//! Without `--harness`, an `fzf` picker shows installed harnesses
18//! with the source harness pre-selected. Source comes from
19//! `path.meta.source` (`claude-code`, `gemini-cli`, `codex`,
20//! `opencode`, `pi`) with actor-string fallback.
21//!
22//! ## Project directory
23//!
24//! `-C / --cwd P` overrides the shell cwd. The harness is exec'd
25//! with cwd set to P and the on-disk projection is keyed on P.
26//!
27//! ## Launch
28//!
29//! On Unix the harness binary is `execvp`'d, replacing the current
30//! process. On Windows it's spawned and waited on with the exit
31//! code propagated. If `exec` itself fails (e.g. the binary disappears
32//! between PATH check and exec), the recipe is printed to stderr.
33//!
34//! Exec is mockable via [`ExecStrategy`]: production uses [`RealExec`],
35//! integration tests use [`RecordingExec`] to capture
36//! `(binary, args, cwd)` without launching anything.
37//!
38//! See `docs/superpowers/specs/2026-05-08-path-resume-command-design.md`
39//! for the full design.
40
41#![cfg(not(target_os = "emscripten"))]
42
43use anyhow::{Context, Result};
44use clap::Args;
45use std::path::PathBuf;
46
47/// Re-exported so external callers (integration tests, future consumers)
48/// can construct [`ResumeArgs`] without depending on the `cmd_share`
49/// module directly.
50pub use crate::cmd_share::HarnessArg;
51
52#[derive(Args, Debug)]
53pub struct ResumeArgs {
54    /// Toolpath document to resume from. Accepted shapes: a Pathbase
55    /// URL (`https://host/owner/repo/slug`), a bare Pathbase shorthand
56    /// (`owner/repo/slug`), a path to a local toolpath JSON file, or a
57    /// cache id (e.g. `claude-abc`, `pathbase-foo-bar-baz`).
58    pub input: String,
59
60    /// Working directory to run the resumed harness from. Defaults to
61    /// the current shell cwd. The on-disk projection is keyed on this
62    /// directory and the harness will be exec'd with cwd set to it.
63    #[arg(short = 'C', long)]
64    pub cwd: Option<PathBuf>,
65
66    /// Pin the resume target. Skips the interactive picker.
67    #[arg(long, value_enum)]
68    pub harness: Option<HarnessArg>,
69
70    /// Skip the cache entirely when fetching from Pathbase: don't read
71    /// an existing entry, don't write the fetched body. Useful for
72    /// ephemeral environments where you don't want the cache to grow.
73    #[arg(long)]
74    pub no_cache: bool,
75
76    /// Force a re-fetch from Pathbase even if a cache entry exists,
77    /// overwriting it with the new bytes. Default behavior is to use
78    /// the cached doc on hit and never round-trip.
79    #[arg(long)]
80    pub force: bool,
81
82    /// Pathbase server URL. Falls back to the stored session's URL,
83    /// then `$PATHBASE_URL`, then `https://pathbase.dev`.
84    #[arg(long)]
85    pub url: Option<String>,
86}
87
88pub fn run(args: ResumeArgs) -> Result<()> {
89    run_with_strategy(args, &RealExec)
90}
91
92/// Internal entry point that the integration tests call with a
93/// `RecordingExec` strategy. Production callers use [`run`].
94pub fn run_with_strategy(args: ResumeArgs, exec: &dyn ExecStrategy) -> Result<()> {
95    let (graph, source_harness) = resolve_input(&args)?;
96    let path = ensure_path_with_agent(&graph)?;
97
98    let cwd = match args.cwd.as_ref() {
99        Some(p) => {
100            std::fs::canonicalize(p).with_context(|| format!("resolve cwd path {}", p.display()))?
101        }
102        None => std::env::current_dir()?,
103    };
104
105    let target = pick_harness(args.harness, source_harness, None)?;
106    eprintln!(
107        "Picked harness: {}{}",
108        target.name(),
109        if Some(target) == source_harness {
110            " (source)"
111        } else {
112            ""
113        }
114    );
115
116    let session_id = project_into_harness(path, target, &cwd)?;
117    let argv = argv_for(target, &session_id);
118    exec_harness(target.name(), &argv, &cwd, exec)
119}
120
121use toolpath::v1::{Graph, Path as TPath, PathOrRef};
122
123/// Read a path's source harness from `meta.source` (set by
124/// `toolpath-convo::derive_path` to the provider id), falling back to
125/// actor-string sniffing across the path's steps.
126pub(crate) fn infer_source_harness(path: &TPath) -> Option<crate::cmd_share::Harness> {
127    use crate::cmd_share::Harness;
128    let meta_source = path.meta.as_ref().and_then(|m| m.source.as_deref());
129    if let Some(source) = meta_source {
130        match source {
131            "claude-code" => return Some(Harness::Claude),
132            "gemini-cli" => return Some(Harness::Gemini),
133            "codex" => return Some(Harness::Codex),
134            "opencode" => return Some(Harness::Opencode),
135            "pi" => return Some(Harness::Pi),
136            _ => {} // fall through to actor sniffing
137        }
138    }
139    for step in &path.steps {
140        let actor = &step.step.actor;
141        if actor.starts_with("agent:claude-code") {
142            return Some(Harness::Claude);
143        }
144        if actor.starts_with("agent:gemini-cli") || actor.starts_with("agent:gemini") {
145            return Some(Harness::Gemini);
146        }
147        if actor.starts_with("agent:codex") {
148            return Some(Harness::Codex);
149        }
150        if actor.starts_with("agent:opencode") {
151            return Some(Harness::Opencode);
152        }
153        if actor.starts_with("agent:pi") {
154            return Some(Harness::Pi);
155        }
156    }
157    None
158}
159
160/// Validate that a parsed Toolpath document is a single inline Path
161/// carrying at least one `agent:*` actor. Returns the inner Path borrow
162/// on success.
163pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> {
164    if g.paths.is_empty() {
165        anyhow::bail!("resume needs a `Path`; expected one path, got an empty graph");
166    }
167    if g.paths.len() > 1 {
168        anyhow::bail!(
169            "resume needs a single `Path`; input is a graph with {} paths. \
170             Pick one with `path query …` or split first.",
171            g.paths.len()
172        );
173    }
174    let path = match &g.paths[0] {
175        PathOrRef::Path(p) => p.as_ref(),
176        PathOrRef::Ref(_) => anyhow::bail!(
177            "resume needs an inline `Path`; got a $ref. Resolve it first with `path import` or fetch the document."
178        ),
179    };
180    let has_agent = path
181        .steps
182        .iter()
183        .any(|s| s.step.actor.starts_with("agent:"));
184    if !has_agent {
185        anyhow::bail!(
186            "no agent session in input — `path resume` only works on harness-derived paths"
187        );
188    }
189    Ok(path)
190}
191
192/// Resolve the user-supplied `<input>` argument into a parsed `Graph`
193/// plus the source harness inferred from its single inline path (if
194/// any). See spec § "Input resolution" for the order.
195pub(crate) fn resolve_input(
196    args: &ResumeArgs,
197) -> Result<(Graph, Option<crate::cmd_share::Harness>)> {
198    let raw = args.input.as_str();
199
200    enum Shape<'a> {
201        PathbaseUrl(&'a str),
202        PathbaseShorthand(&'a str),
203        FilePath(&'a str),
204        CacheId(&'a str),
205    }
206
207    let shape = if raw.starts_with("http://") || raw.starts_with("https://") {
208        Shape::PathbaseUrl(raw)
209    } else if looks_like_pathbase_shorthand(raw) {
210        Shape::PathbaseShorthand(raw)
211    } else if std::path::Path::new(raw).is_file() {
212        Shape::FilePath(raw)
213    } else {
214        Shape::CacheId(raw)
215    };
216
217    let graph: Graph = match shape {
218        Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => {
219            // Probe the local cache before going to the network. The cache
220            // id is purely a function of (owner, repo, slug), so we can
221            // compute it without fetching. `--force` skips the probe and
222            // re-fetches; `--no-cache` skips both the probe AND the post-
223            // fetch write (still useful for ephemeral environments).
224            let cache_id = crate::cmd_import::pathbase_cache_id_of(u, args.url.as_deref())?;
225            if !args.force
226                && !args.no_cache
227                && let Ok(cache_path) = crate::cmd_cache::cache_path(&cache_id)
228                && cache_path.exists()
229            {
230                let json = std::fs::read_to_string(&cache_path)
231                    .with_context(|| format!("read {}", cache_path.display()))?;
232                eprintln!("Resolved {} → {} (cached)", raw, cache_id);
233                Graph::from_json(&json)
234                    .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e))?
235            } else {
236                let derived = crate::cmd_import::pathbase_fetch_to_doc(u, args.url.as_deref())?;
237                if !args.no_cache {
238                    // force=true here: we either short-circuited above
239                    // (cache miss) or the user explicitly passed --force,
240                    // and either way we want the new bytes to land.
241                    crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?;
242                    eprintln!("Resolved {} → {}", raw, derived.cache_id);
243                }
244                derived.doc
245            }
246        }
247        Shape::FilePath(p) => {
248            let json = std::fs::read_to_string(p).with_context(|| format!("read {}", p))?;
249            Graph::from_json(&json)
250                .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))?
251        }
252        Shape::CacheId(id) => {
253            let file = crate::cmd_cache::cache_ref(id).map_err(|e| {
254                anyhow::anyhow!(
255                    "couldn't resolve `{}` as a URL, file path, or cache id: {}",
256                    raw,
257                    e
258                )
259            })?;
260            let json = std::fs::read_to_string(&file)
261                .with_context(|| format!("read {}", file.display()))?;
262            Graph::from_json(&json)
263                .map_err(|e| anyhow::anyhow!("not a valid toolpath document: {}", e))?
264        }
265    };
266
267    let harness = graph.single_path().and_then(infer_source_harness);
268    Ok((graph, harness))
269}
270
271/// Probe `$PATH` (or `path_override`, for tests) for a given binary name.
272/// Cross-platform: on Windows, also tries `<name>.exe`.
273pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path>) -> bool {
274    let dirs: Vec<std::path::PathBuf> = match path_override {
275        Some(p) => vec![p.to_path_buf()],
276        None => std::env::var_os("PATH")
277            .map(|p| std::env::split_paths(&p).collect())
278            .unwrap_or_default(),
279    };
280    for d in dirs {
281        let candidate = d.join(name);
282        if candidate.is_file() {
283            return true;
284        }
285        #[cfg(windows)]
286        {
287            let exe = d.join(format!("{name}.exe"));
288            if exe.is_file() {
289                return true;
290            }
291        }
292    }
293    false
294}
295
296const ALL_HARNESSES: &[crate::cmd_share::Harness] = &[
297    crate::cmd_share::Harness::Claude,
298    crate::cmd_share::Harness::Gemini,
299    crate::cmd_share::Harness::Codex,
300    crate::cmd_share::Harness::Opencode,
301    crate::cmd_share::Harness::Pi,
302];
303
304/// Decide which harness to resume in.
305///
306/// - If `arg` is `Some`, validate the named harness is on PATH and return it.
307/// - Otherwise, enumerate installed harnesses and launch the fzf picker.
308///   `source` is used to label the source row in the picker UI.
309///
310/// `path_override` is `None` in production; tests pass `Some(dir)` to fake `$PATH`.
311pub(crate) fn pick_harness(
312    arg: Option<HarnessArg>,
313    source: Option<crate::cmd_share::Harness>,
314    path_override: Option<&std::path::Path>,
315) -> Result<crate::cmd_share::Harness> {
316    use crate::cmd_share::Harness;
317
318    if let Some(a) = arg {
319        let h = Harness::from_arg(a);
320        if !binary_on_path(h.name(), path_override) {
321            anyhow::bail!(
322                "harness `{}` isn't on PATH; install it or pick another with `--harness`",
323                h.name()
324            );
325        }
326        return Ok(h);
327    }
328
329    let installed: Vec<Harness> = ALL_HARNESSES
330        .iter()
331        .copied()
332        .filter(|h| binary_on_path(h.name(), path_override))
333        .collect();
334
335    if installed.is_empty() {
336        anyhow::bail!(
337            "no installed harnesses found on PATH; install one of: claude, gemini, codex, opencode, pi"
338        );
339    }
340
341    interactive_pick(&installed, source)
342}
343
344fn interactive_pick(
345    installed: &[crate::cmd_share::Harness],
346    source: Option<crate::cmd_share::Harness>,
347) -> Result<crate::cmd_share::Harness> {
348    if !crate::fuzzy::available() {
349        let hint = if crate::fuzzy::embedded_picker_available() {
350            "rerun in a terminal"
351        } else {
352            "install `fzf` (or build with the default `embedded-picker` feature) and rerun in a terminal"
353        };
354        anyhow::bail!("interactive picker requires a TTY; pass `--harness <X>` or {hint}");
355    }
356    let mut lines: Vec<String> = Vec::with_capacity(installed.len());
357    for h in installed {
358        let suffix = if Some(*h) == source { "  (source)" } else { "" };
359        lines.push(format!("{}{}", h.symbol(), suffix));
360    }
361
362    let header = match source {
363        Some(s) => format!("pick a harness to resume in (source: {})", s.name()),
364        None => "pick a harness to resume in".to_string(),
365    };
366
367    let opts = crate::fuzzy::PickOptions {
368        with_nth: "1..",
369        header: Some(&header),
370        ..Default::default()
371    };
372    let selected = match crate::fuzzy::pick(&lines, &opts)
373        .map_err(|e| anyhow::anyhow!("fzf failed: {}", e))?
374    {
375        crate::fuzzy::PickResult::Selected(rows) => rows.into_iter().next().unwrap_or_default(),
376        crate::fuzzy::PickResult::Cancelled => std::process::exit(130),
377        crate::fuzzy::PickResult::NoMatch => {
378            anyhow::bail!("fzf returned no match — picker UI was empty?");
379        }
380    };
381
382    for h in installed {
383        if selected.starts_with(h.symbol()) {
384            return Ok(*h);
385        }
386    }
387    anyhow::bail!("picker returned an unrecognized row: {selected}")
388}
389
390/// Static map from harness to resume-argv shape. Lives here because
391/// it's a per-harness CLI convention, not a projection concern.
392pub(crate) fn argv_for(harness: crate::cmd_share::Harness, session_id: &str) -> Vec<String> {
393    use crate::cmd_share::Harness;
394    match harness {
395        Harness::Claude => vec!["-r".into(), session_id.into()],
396        Harness::Gemini => vec!["--resume".into(), session_id.into()],
397        Harness::Codex => vec!["resume".into(), session_id.into()],
398        Harness::Opencode => vec!["--session".into(), session_id.into()],
399        Harness::Pi => vec!["--session".into(), session_id.into()],
400    }
401}
402
403/// Project a Path into the chosen harness's on-disk layout under `cwd`,
404/// returning the projected session id.
405pub(crate) fn project_into_harness(
406    path: &TPath,
407    harness: crate::cmd_share::Harness,
408    cwd: &std::path::Path,
409) -> Result<String> {
410    use crate::cmd_share::Harness;
411    match harness {
412        Harness::Claude => crate::cmd_export::project_claude(path, cwd),
413        Harness::Gemini => crate::cmd_export::project_gemini(path, cwd),
414        Harness::Codex => crate::cmd_export::project_codex(path, cwd),
415        Harness::Opencode => crate::cmd_export::project_opencode(path, cwd),
416        Harness::Pi => crate::cmd_export::project_pi(path, cwd),
417    }
418}
419
420/// What `exec_harness` saw (for tests).
421#[derive(Debug, Clone, Default)]
422pub struct CapturedExec {
423    pub binary: String,
424    pub args: Vec<String>,
425    pub cwd: std::path::PathBuf,
426}
427
428/// Pluggable exec backend. Production uses `RealExec` (`execvp` on
429/// Unix, spawn-and-wait on Windows). Tests use `RecordingExec`.
430pub trait ExecStrategy {
431    fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>;
432}
433
434/// Production implementation. On Unix this never returns on success
435/// (the current process is replaced); on Windows it spawns the child,
436/// waits, and propagates the exit code.
437pub struct RealExec;
438
439impl ExecStrategy for RealExec {
440    fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> {
441        let mut cmd = std::process::Command::new(binary);
442        cmd.args(args);
443        cmd.current_dir(cwd);
444
445        eprintln!(
446            "Resuming: {} {} (cwd: {})",
447            binary,
448            args.join(" "),
449            cwd.display()
450        );
451
452        #[cfg(unix)]
453        {
454            use std::os::unix::process::CommandExt;
455            // exec only returns if it fails.
456            let err = cmd.exec();
457            anyhow::bail!(
458                "couldn't exec `{}`: {}. Recipe: {} {} (run from {})",
459                binary,
460                err,
461                binary,
462                args.join(" "),
463                cwd.display()
464            );
465        }
466        #[cfg(not(unix))]
467        {
468            let status = cmd
469                .spawn()
470                .with_context(|| format!("spawn {}", binary))?
471                .wait()
472                .with_context(|| format!("wait for {}", binary))?;
473            std::process::exit(status.code().unwrap_or(1));
474        }
475    }
476}
477
478/// Recording strategy for tests. `captured()` returns the most recent
479/// invocation.
480#[derive(Default)]
481pub struct RecordingExec {
482    inner: std::sync::Mutex<CapturedExec>,
483}
484
485impl RecordingExec {
486    pub fn captured(&self) -> CapturedExec {
487        self.inner.lock().unwrap().clone()
488    }
489}
490
491impl ExecStrategy for RecordingExec {
492    fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()> {
493        let mut g = self.inner.lock().unwrap();
494        *g = CapturedExec {
495            binary: binary.to_string(),
496            args: args.to_vec(),
497            cwd: cwd.to_path_buf(),
498        };
499        Ok(())
500    }
501}
502
503pub(crate) fn exec_harness(
504    binary: &str,
505    args: &[String],
506    cwd: &std::path::Path,
507    strategy: &dyn ExecStrategy,
508) -> Result<()> {
509    strategy.exec(binary, args, cwd)
510}
511
512fn looks_like_pathbase_shorthand(s: &str) -> bool {
513    // Three non-empty slash-separated segments, none containing whitespace
514    // or starting with a dot/slash (which would indicate a relative or
515    // absolute path).
516    if s.starts_with('.') || s.starts_with('/') {
517        return false;
518    }
519    let segs: Vec<&str> = s.split('/').collect();
520    segs.len() == 3
521        && segs
522            .iter()
523            .all(|s| !s.is_empty() && !s.contains(char::is_whitespace))
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn run_with_strategy_records_invocation_for_file_input_with_explicit_harness() {
532        let _env = crate::config::TEST_ENV_LOCK
533            .lock()
534            .unwrap_or_else(|e| e.into_inner());
535        let _home = scoped_home_for_resume();
536        let _path_guard = ScopedPathForResume::with_binaries(&["claude"]);
537        let cwd = tempfile::tempdir().unwrap();
538        let doc_file = cwd.path().join("doc.json");
539
540        // Build a minimal path with a conversation.append step that
541        // project_claude can consume, reusing the existing helper.
542        let mut path = make_convo_path_for_resume("claude-code://resume-test-session");
543        // Overwrite the actor to agent:claude-code so run_with_strategy can
544        // pass the ensure_path_with_agent check.
545        path.steps[0].step.actor = "agent:claude-code".to_string();
546
547        let graph = toolpath::v1::Graph::from_path(path);
548        std::fs::write(&doc_file, graph.to_json().unwrap()).unwrap();
549
550        let args = ResumeArgs {
551            input: doc_file.to_string_lossy().to_string(),
552            cwd: Some(cwd.path().to_path_buf()),
553            harness: Some(HarnessArg::Claude),
554            no_cache: false,
555            force: false,
556            url: None,
557        };
558
559        let recorder = RecordingExec::default();
560        run_with_strategy(args, &recorder).unwrap();
561
562        let cap = recorder.captured();
563        assert_eq!(cap.binary, "claude");
564        assert_eq!(cap.args[0], "-r");
565        assert_eq!(cap.cwd, std::fs::canonicalize(cwd.path()).unwrap());
566    }
567
568    use crate::cmd_share::Harness;
569    use toolpath::v1::{Graph, PathMeta, PathOrRef};
570
571    fn make_step_with_actor(id: &str, actor: &str) -> toolpath::v1::Step {
572        toolpath::v1::Step::new(id, actor, "2026-01-01T00:00:00Z")
573            .with_raw_change("src/main.rs", "@@ -1 +1 @@\n-old\n+new")
574    }
575
576    fn make_path_with_actor(actor: &str) -> toolpath::v1::Path {
577        use toolpath::v1::{Path, PathIdentity};
578        let step = make_step_with_actor("s1", actor);
579        Path {
580            path: PathIdentity {
581                id: "p1".to_string(),
582                base: None,
583                head: "s1".to_string(),
584                graph_ref: None,
585            },
586            steps: vec![step],
587            meta: None,
588        }
589    }
590
591    #[test]
592    fn infer_source_harness_meta_source_wins() {
593        let mut path = make_path_with_actor("agent:codex");
594        path.meta = Some(PathMeta {
595            source: Some("claude-code".to_string()),
596            ..Default::default()
597        });
598        assert_eq!(infer_source_harness(&path), Some(Harness::Claude));
599    }
600
601    #[test]
602    fn infer_source_harness_meta_source_unknown_falls_through_to_actor() {
603        let mut path = make_path_with_actor("agent:gemini-cli");
604        path.meta = Some(PathMeta {
605            source: Some("something-bespoke".to_string()),
606            ..Default::default()
607        });
608        assert_eq!(infer_source_harness(&path), Some(Harness::Gemini));
609    }
610
611    #[test]
612    fn infer_source_harness_actor_sniff_codex() {
613        let path = make_path_with_actor("agent:codex");
614        assert_eq!(infer_source_harness(&path), Some(Harness::Codex));
615    }
616
617    #[test]
618    fn infer_source_harness_actor_sniff_opencode() {
619        let path = make_path_with_actor("agent:opencode");
620        assert_eq!(infer_source_harness(&path), Some(Harness::Opencode));
621    }
622
623    #[test]
624    fn infer_source_harness_actor_sniff_pi() {
625        let path = make_path_with_actor("agent:pi");
626        assert_eq!(infer_source_harness(&path), Some(Harness::Pi));
627    }
628
629    #[test]
630    fn infer_source_harness_returns_none_when_no_signal() {
631        let path = make_path_with_actor("human:alex");
632        assert_eq!(infer_source_harness(&path), None);
633    }
634
635    #[test]
636    fn ensure_path_with_agent_accepts_single_path_with_agent_actor() {
637        let g = Graph::from_path(make_path_with_actor("agent:claude-code"));
638        assert!(ensure_path_with_agent(&g).is_ok());
639    }
640
641    #[test]
642    fn ensure_path_with_agent_rejects_empty_graph() {
643        let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
644        g.paths.clear();
645        let err = ensure_path_with_agent(&g).unwrap_err();
646        assert!(err.to_string().contains("expected"));
647        assert!(err.to_string().contains("empty"));
648    }
649
650    #[test]
651    fn ensure_path_with_agent_rejects_multi_path_graph() {
652        let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
653        g.paths.push(PathOrRef::Path(Box::new(make_path_with_actor(
654            "agent:claude-code",
655        ))));
656        let err = ensure_path_with_agent(&g).unwrap_err();
657        let s = err.to_string();
658        assert!(s.contains("single `Path`"), "actual: {s}");
659        assert!(s.contains("2 paths"), "actual: {s}");
660    }
661
662    #[test]
663    fn ensure_path_with_agent_rejects_agentless_path() {
664        let g = Graph::from_path(make_path_with_actor("human:alex"));
665        let err = ensure_path_with_agent(&g).unwrap_err();
666        assert!(err.to_string().contains("no agent session"));
667    }
668
669    #[test]
670    fn ensure_path_with_agent_rejects_path_ref_only_graph() {
671        use toolpath::v1::PathRef;
672        let mut g = Graph::from_path(make_path_with_actor("agent:claude-code"));
673        g.paths = vec![PathOrRef::Ref(PathRef {
674            ref_url: "$ref://something".into(),
675        })];
676        let err = ensure_path_with_agent(&g).unwrap_err();
677        assert!(err.to_string().contains("inline `Path`"), "actual: {}", err);
678    }
679
680    #[test]
681    fn resolve_input_file_path() {
682        let tmp = tempfile::tempdir().unwrap();
683        let p = tmp.path().join("doc.json");
684        let graph = toolpath::v1::Graph::from_path(make_path_with_actor("agent:claude-code"));
685        std::fs::write(&p, graph.to_json().unwrap()).unwrap();
686
687        let args = ResumeArgs {
688            input: p.to_string_lossy().to_string(),
689            cwd: None,
690            harness: None,
691            no_cache: false,
692            force: false,
693            url: None,
694        };
695        let (g, harness) = resolve_input(&args).unwrap();
696        let _path = ensure_path_with_agent(&g).unwrap();
697        assert_eq!(harness, Some(Harness::Claude));
698    }
699
700    #[test]
701    fn resolve_input_url_dispatches_to_pathbase_fetch() {
702        let _env = crate::config::TEST_ENV_LOCK
703            .lock()
704            .unwrap_or_else(|e| e.into_inner());
705        use crate::cmd_pathbase::tests::MockServer;
706        let body = {
707            let mut path = make_path_with_actor("agent:codex");
708            path.meta = Some(toolpath::v1::PathMeta {
709                source: Some("codex".to_string()),
710                ..Default::default()
711            });
712            toolpath::v1::Graph::from_path(path).to_json().unwrap()
713        };
714        // MockServer::start requires &'static str — leak the body to satisfy this.
715        let body_static: &'static str = Box::leak(body.into_boxed_str());
716        let server = MockServer::start("HTTP/1.1 200 OK", body_static);
717
718        let args = ResumeArgs {
719            input: format!(
720                "{}/u/alex/repos/pathstash/graphs/fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537",
721                server.base()
722            ),
723            cwd: None,
724            harness: None,
725            no_cache: true, // skip cache write in tests
726            force: false,
727            url: None,
728        };
729        let (g, harness) = resolve_input(&args).unwrap();
730        let _ = ensure_path_with_agent(&g).unwrap();
731        assert_eq!(harness, Some(Harness::Codex));
732    }
733
734    #[test]
735    fn resolve_input_url_uses_cache_on_hit_without_refetching() {
736        // Regression for the second-invocation cache-hit error: re-running
737        // `path resume <url>` should silently reuse the cached doc instead
738        // of erroring. We seed the cache with a known-good doc, point the
739        // input at a 500-erroring mock server (so any network round-trip
740        // would surface as an error), and confirm resolve_input still
741        // returns the cached graph.
742        let _env = crate::config::TEST_ENV_LOCK
743            .lock()
744            .unwrap_or_else(|e| e.into_inner());
745
746        // Pin TOOLPATH_CONFIG_DIR to a tempdir so we don't pollute the
747        // user's real cache.
748        let cfg_dir = tempfile::tempdir().unwrap();
749        let prev_cfg = std::env::var_os("TOOLPATH_CONFIG_DIR");
750        unsafe {
751            std::env::set_var("TOOLPATH_CONFIG_DIR", cfg_dir.path());
752        }
753
754        // Seed the cache with a codex-source graph. Cache id keys on the
755        // graph UUID since Pathbase 1.1+ addresses graphs by UUID.
756        const FIXTURE_UUID: &str = "fe94b6f9-b0af-4cdd-b9ca-3c9a2a697537";
757        let cache_id = format!("pathbase-alex-pathstash-{FIXTURE_UUID}");
758        let cache_id = cache_id.as_str();
759        let documents = cfg_dir.path().join("documents");
760        std::fs::create_dir_all(&documents).unwrap();
761        let cached_graph = {
762            let mut path = make_path_with_actor("agent:codex");
763            path.meta = Some(toolpath::v1::PathMeta {
764                source: Some("codex".to_string()),
765                ..Default::default()
766            });
767            toolpath::v1::Graph::from_path(path)
768        };
769        std::fs::write(
770            documents.join(format!("{cache_id}.json")),
771            cached_graph.to_json().unwrap(),
772        )
773        .unwrap();
774
775        // Mock server that 500s any request — proves we never call out.
776        use crate::cmd_pathbase::tests::MockServer;
777        let server = MockServer::start("HTTP/1.1 500 Internal Server Error", "boom");
778
779        let args = ResumeArgs {
780            input: format!(
781                "{}/u/alex/repos/pathstash/graphs/{FIXTURE_UUID}",
782                server.base()
783            ),
784            cwd: None,
785            harness: None,
786            no_cache: false,
787            force: false,
788            url: None,
789        };
790        let result = resolve_input(&args);
791
792        // Restore env before asserting so a panic doesn't poison sibling tests.
793        unsafe {
794            match prev_cfg {
795                Some(v) => std::env::set_var("TOOLPATH_CONFIG_DIR", v),
796                None => std::env::remove_var("TOOLPATH_CONFIG_DIR"),
797            }
798        }
799
800        let (g, harness) = result.expect("resolve_input should reuse cache without refetching");
801        let _ = ensure_path_with_agent(&g).unwrap();
802        assert_eq!(harness, Some(Harness::Codex));
803    }
804
805    #[test]
806    fn resolve_input_unresolvable_errors_clearly() {
807        let _env = crate::config::TEST_ENV_LOCK
808            .lock()
809            .unwrap_or_else(|e| e.into_inner());
810        let args = ResumeArgs {
811            input: "definitely/not/a/real/cache/id".to_string(),
812            cwd: None,
813            harness: None,
814            no_cache: false,
815            force: false,
816            url: None,
817        };
818        let err = resolve_input(&args).unwrap_err();
819        let s = err.to_string();
820        assert!(s.contains("couldn't resolve"), "actual: {s}");
821    }
822
823    fn fake_path_with(binaries: &[&str]) -> tempfile::TempDir {
824        let td = tempfile::tempdir().unwrap();
825        for b in binaries {
826            let p = td.path().join(b);
827            std::fs::write(&p, "#!/bin/sh\nexit 0\n").unwrap();
828            #[cfg(unix)]
829            {
830                use std::os::unix::fs::PermissionsExt;
831                let mut perm = std::fs::metadata(&p).unwrap().permissions();
832                perm.set_mode(0o755);
833                std::fs::set_permissions(&p, perm).unwrap();
834            }
835        }
836        td
837    }
838
839    #[test]
840    fn binary_on_path_finds_present_binary() {
841        let td = fake_path_with(&["claude"]);
842        assert!(binary_on_path("claude", Some(td.path())));
843        assert!(!binary_on_path("gemini", Some(td.path())));
844    }
845
846    #[test]
847    fn pick_harness_explicit_arg_validates_path() {
848        let td = fake_path_with(&["claude"]);
849        let result = pick_harness(Some(HarnessArg::Claude), None, Some(td.path()));
850        assert_eq!(result.unwrap(), Harness::Claude);
851
852        let err = pick_harness(Some(HarnessArg::Gemini), None, Some(td.path())).unwrap_err();
853        assert!(err.to_string().contains("`gemini` isn't on PATH"));
854    }
855
856    #[test]
857    fn pick_harness_zero_installed_errors() {
858        let td = fake_path_with(&[]);
859        let err = pick_harness(None, Some(Harness::Claude), Some(td.path())).unwrap_err();
860        assert!(
861            err.to_string().contains("no installed harnesses")
862                || err.to_string().contains("no harnesses on PATH"),
863            "actual: {}",
864            err
865        );
866    }
867
868    #[test]
869    fn argv_for_returns_harness_specific_shape() {
870        assert_eq!(
871            argv_for(Harness::Claude, "abc"),
872            vec!["-r".to_string(), "abc".to_string()]
873        );
874        assert_eq!(
875            argv_for(Harness::Gemini, "abc"),
876            vec!["--resume".to_string(), "abc".to_string()]
877        );
878        assert_eq!(
879            argv_for(Harness::Codex, "abc"),
880            vec!["resume".to_string(), "abc".to_string()]
881        );
882        assert_eq!(
883            argv_for(Harness::Opencode, "abc"),
884            vec!["--session".to_string(), "abc".to_string()]
885        );
886        assert_eq!(
887            argv_for(Harness::Pi, "abc"),
888            vec!["--session".to_string(), "abc".to_string()]
889        );
890    }
891
892    #[test]
893    fn project_into_harness_claude_round_trip() {
894        let _env = crate::config::TEST_ENV_LOCK
895            .lock()
896            .unwrap_or_else(|e| e.into_inner());
897        let _home = scoped_home_for_resume();
898        let cwd = tempfile::tempdir().unwrap();
899        let path = make_convo_path_for_resume("claude-code://resume-test-session");
900
901        let session_id = project_into_harness(&path, Harness::Claude, cwd.path()).unwrap();
902        assert!(!session_id.is_empty());
903    }
904
905    /// Build a minimal `toolpath::v1::Path` with a single `conversation.append`
906    /// step using the given `artifact_key` (e.g. `"claude-code://my-session"`).
907    /// Required for projectors that extract the session id from the artifact key.
908    fn make_convo_path_for_resume(artifact_key: &str) -> toolpath::v1::Path {
909        use std::collections::HashMap;
910        let mut extra = HashMap::new();
911        extra.insert("role".to_string(), serde_json::json!("user"));
912        extra.insert("text".to_string(), serde_json::json!("hello"));
913        let step = toolpath::v1::Step {
914            step: toolpath::v1::StepIdentity {
915                id: "s1".to_string(),
916                parents: vec![],
917                actor: "human:test".to_string(),
918                timestamp: "2026-01-01T00:00:00Z".to_string(),
919            },
920            change: {
921                let mut m = HashMap::new();
922                m.insert(
923                    artifact_key.to_string(),
924                    toolpath::v1::ArtifactChange {
925                        raw: None,
926                        structural: Some(toolpath::v1::StructuralChange {
927                            change_type: "conversation.append".to_string(),
928                            extra,
929                        }),
930                    },
931                );
932                m
933            },
934            meta: None,
935        };
936        toolpath::v1::Path {
937            path: toolpath::v1::PathIdentity {
938                id: "test-path".to_string(),
939                base: None,
940                head: "s1".to_string(),
941                graph_ref: None,
942            },
943            steps: vec![step],
944            meta: None,
945        }
946    }
947
948    fn scoped_home_for_resume() -> ScopedHomeForResume {
949        ScopedHomeForResume::new()
950    }
951
952    struct ScopedPathForResume {
953        _bin_dir: tempfile::TempDir,
954        prev: Option<std::ffi::OsString>,
955    }
956
957    impl ScopedPathForResume {
958        /// Prepends a tempdir containing the named binaries to `PATH` for
959        /// the guard's lifetime.
960        fn with_binaries(binaries: &[&str]) -> Self {
961            let bin_dir = fake_path_with(binaries);
962            let prev = std::env::var_os("PATH");
963            let new_path = std::env::join_paths(
964                std::iter::once(bin_dir.path().to_path_buf())
965                    .chain(std::env::split_paths(&prev.clone().unwrap_or_default())),
966            )
967            .unwrap();
968            unsafe {
969                std::env::set_var("PATH", new_path);
970            }
971            Self {
972                _bin_dir: bin_dir,
973                prev,
974            }
975        }
976    }
977
978    impl Drop for ScopedPathForResume {
979        fn drop(&mut self) {
980            unsafe {
981                match &self.prev {
982                    Some(v) => std::env::set_var("PATH", v),
983                    None => std::env::remove_var("PATH"),
984                }
985            }
986        }
987    }
988
989    struct ScopedHomeForResume {
990        _td: tempfile::TempDir,
991        prev: Option<std::ffi::OsString>,
992    }
993
994    impl ScopedHomeForResume {
995        fn new() -> Self {
996            let td = tempfile::tempdir().unwrap();
997            let prev = std::env::var_os("HOME");
998            unsafe {
999                std::env::set_var("HOME", td.path());
1000            }
1001            Self { _td: td, prev }
1002        }
1003    }
1004
1005    impl Drop for ScopedHomeForResume {
1006        fn drop(&mut self) {
1007            unsafe {
1008                match &self.prev {
1009                    Some(v) => std::env::set_var("HOME", v),
1010                    None => std::env::remove_var("HOME"),
1011                }
1012            }
1013        }
1014    }
1015
1016    #[test]
1017    fn exec_strategy_recording_captures_invocation() {
1018        let recorder = RecordingExec::default();
1019        let strategy: &dyn ExecStrategy = &recorder;
1020        exec_harness(
1021            "claude",
1022            &["-r".into(), "abc123".into()],
1023            std::path::Path::new("/tmp/x"),
1024            strategy,
1025        )
1026        .unwrap();
1027
1028        let captured = recorder.captured();
1029        assert_eq!(captured.binary, "claude");
1030        assert_eq!(captured.args, vec!["-r".to_string(), "abc123".to_string()]);
1031        assert_eq!(captured.cwd, std::path::PathBuf::from("/tmp/x"));
1032    }
1033}