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