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