1#![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 pub input: String,
72
73 #[arg(short = 'C', long)]
77 pub cwd: Option<PathBuf>,
78
79 #[arg(long, value_enum)]
81 pub harness: Option<Harness>,
82
83 #[arg(long)]
87 pub no_cache: bool,
88
89 #[arg(long)]
93 pub force: bool,
94
95 #[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#[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
163pub 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
199pub(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 _ => {} }
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
243pub(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
265pub(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#[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
292pub(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 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 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
381pub(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
406pub(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
427pub(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
508pub(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 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
562pub(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#[derive(Debug, Clone, Default)]
590pub struct CapturedExec {
591 pub binary: String,
592 pub args: Vec<String>,
593 pub cwd: std::path::PathBuf,
594}
595
596pub trait ExecStrategy {
599 fn exec(&self, binary: &str, args: &[String], cwd: &std::path::Path) -> Result<()>;
600}
601
602pub 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 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#[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 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 let mut path = make_convo_path_for_resume("claude-code://resume-test-session");
711 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 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, ..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 let _env = crate::config::TEST_ENV_LOCK
911 .lock()
912 .unwrap_or_else(|e| e.into_inner());
913
914 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 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 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 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 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 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}