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