1#![cfg(not(target_os = "emscripten"))]
5
6use anyhow::Result;
7use chrono::{DateTime, Utc};
8use clap::{Args, ValueEnum};
9use std::path::PathBuf;
10
11use crate::cmd_export::RepoSpec;
12
13#[derive(Copy, Clone, Debug, PartialEq, Eq, ValueEnum)]
14#[value(rename_all = "lower")]
15pub enum HarnessArg {
16 Claude,
17 Gemini,
18 Codex,
19 Copilot,
20 Opencode,
21 Cursor,
22 Pi,
23}
24
25#[derive(Args, Debug)]
26pub struct ShareArgs {
27 #[arg(long)]
29 pub url: Option<String>,
30
31 #[arg(long, conflicts_with_all = ["repo", "public"])]
33 pub anon: bool,
34
35 #[arg(long, value_parser = crate::cmd_export::parse_repo_spec)]
37 pub repo: Option<RepoSpec>,
38
39 #[arg(long, alias = "slug")]
43 pub name: Option<String>,
44
45 #[arg(long)]
47 pub public: bool,
48
49 #[arg(long, value_enum)]
52 pub harness: Option<HarnessArg>,
53
54 #[arg(long, requires = "harness")]
57 pub session: Option<String>,
58
59 #[arg(long)]
62 pub project: Option<PathBuf>,
63
64 #[arg(long)]
66 pub no_cache: bool,
67}
68
69#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
71pub(crate) enum Harness {
72 Claude,
73 Gemini,
74 Codex,
75 Copilot,
76 Opencode,
77 Cursor,
78 Pi,
79}
80
81impl Harness {
82 pub(crate) fn name(&self) -> &'static str {
83 match self {
84 Harness::Claude => "claude",
85 Harness::Gemini => "gemini",
86 Harness::Codex => "codex",
87 Harness::Copilot => "copilot",
88 Harness::Opencode => "opencode",
89 Harness::Cursor => "cursor",
90 Harness::Pi => "pi",
91 }
92 }
93
94 pub(crate) fn symbol(&self) -> &'static str {
97 match self {
98 Harness::Claude => "claude ",
99 Harness::Gemini => "gemini ",
100 Harness::Codex => "codex ",
101 Harness::Copilot => "copilot ",
102 Harness::Opencode => "opencode",
103 Harness::Cursor => "cursor ",
104 Harness::Pi => "pi ",
105 }
106 }
107
108 pub(crate) fn project_keyed(&self) -> bool {
113 matches!(self, Harness::Claude | Harness::Gemini | Harness::Pi)
114 }
115
116 pub(crate) fn from_arg(arg: HarnessArg) -> Self {
117 match arg {
118 HarnessArg::Claude => Harness::Claude,
119 HarnessArg::Gemini => Harness::Gemini,
120 HarnessArg::Codex => Harness::Codex,
121 HarnessArg::Copilot => Harness::Copilot,
122 HarnessArg::Opencode => Harness::Opencode,
123 HarnessArg::Cursor => Harness::Cursor,
124 HarnessArg::Pi => Harness::Pi,
125 }
126 }
127
128 pub(crate) fn parse(s: &str) -> Option<Self> {
129 match s {
130 "claude" => Some(Harness::Claude),
131 "gemini" => Some(Harness::Gemini),
132 "codex" => Some(Harness::Codex),
133 "copilot" => Some(Harness::Copilot),
134 "opencode" => Some(Harness::Opencode),
135 "cursor" => Some(Harness::Cursor),
136 "pi" => Some(Harness::Pi),
137 _ => None,
138 }
139 }
140}
141
142#[derive(Debug, Clone)]
144pub(crate) struct SessionRow {
145 pub(crate) harness: Harness,
146 pub(crate) project: Option<String>,
148 pub(crate) cwd: Option<String>,
150 pub(crate) session_id: String,
151 pub(crate) title: String,
152 pub(crate) last_activity: Option<DateTime<Utc>>,
153 pub(crate) message_count: usize,
154 pub(crate) matches_cwd: bool,
155}
156
157#[derive(Default)]
161pub(crate) struct HarnessBundle {
162 pub(crate) claude: Option<toolpath_claude::ClaudeConvo>,
163 pub(crate) gemini: Option<toolpath_gemini::GeminiConvo>,
164 pub(crate) codex: Option<toolpath_codex::CodexConvo>,
165 pub(crate) copilot: Option<toolpath_copilot::CopilotConvo>,
166 pub(crate) opencode: Option<toolpath_opencode::OpencodeConvo>,
167 pub(crate) cursor: Option<toolpath_cursor::CursorConvo>,
168 pub(crate) pi: Option<toolpath_pi::PiConvo>,
169}
170
171impl HarnessBundle {
172 pub(crate) fn from_environment() -> Self {
176 Self {
177 claude: Some(toolpath_claude::ClaudeConvo::new()),
178 gemini: Some(toolpath_gemini::GeminiConvo::new()),
179 codex: Some(toolpath_codex::CodexConvo::new()),
180 copilot: Some(toolpath_copilot::CopilotConvo::new()),
181 opencode: Some(toolpath_opencode::OpencodeConvo::new()),
182 cursor: Some(toolpath_cursor::CursorConvo::new()),
183 pi: Some(toolpath_pi::PiConvo::new()),
184 }
185 }
186}
187
188pub(crate) fn gather_sessions(
196 bundle: &HarnessBundle,
197 cwd: &std::path::Path,
198 harness_filter: Option<Harness>,
199 project_filter: Option<&std::path::Path>,
200) -> Vec<SessionRow> {
201 let mut rows = Vec::new();
202 let canonical_cwd = canonicalize_or_self(cwd);
203 let canonical_project = project_filter.map(canonicalize_or_self);
204
205 let want = |h: Harness| harness_filter.is_none_or(|f| f == h);
206
207 if want(Harness::Claude)
208 && let Some(mgr) = &bundle.claude
209 {
210 collect_claude(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
211 }
212 if want(Harness::Gemini)
213 && let Some(mgr) = &bundle.gemini
214 {
215 collect_gemini(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
216 }
217 if want(Harness::Pi)
218 && let Some(mgr) = &bundle.pi
219 {
220 collect_pi(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
221 }
222 if want(Harness::Codex)
223 && let Some(mgr) = &bundle.codex
224 {
225 collect_codex(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
226 }
227 if want(Harness::Copilot)
228 && let Some(mgr) = &bundle.copilot
229 {
230 collect_copilot(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
231 }
232 if want(Harness::Opencode)
233 && let Some(mgr) = &bundle.opencode
234 {
235 collect_opencode(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
236 }
237 if want(Harness::Cursor)
238 && let Some(mgr) = &bundle.cursor
239 {
240 collect_cursor(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows);
241 }
242
243 rows.sort_by(|a, b| {
244 b.matches_cwd
245 .cmp(&a.matches_cwd)
246 .then_with(|| b.last_activity.cmp(&a.last_activity))
247 });
248 rows
249}
250
251fn canonicalize_or_self(p: &std::path::Path) -> std::path::PathBuf {
252 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
253}
254
255fn paths_match(a: &std::path::Path, b: &std::path::Path) -> bool {
256 canonicalize_or_self(a) == canonicalize_or_self(b)
257}
258
259fn collect_claude(
260 mgr: &toolpath_claude::ClaudeConvo,
261 canonical_cwd: &std::path::Path,
262 project_filter: Option<&std::path::Path>,
263 out: &mut Vec<SessionRow>,
264) {
265 let projects = match mgr.list_projects() {
266 Ok(ps) if !ps.is_empty() => ps,
267 Ok(_) => return,
268 Err(e) if is_not_found_claude(&e) => return,
269 Err(e) => {
270 eprintln!("warning: claude aggregation failed: {e}");
271 return;
272 }
273 };
274 for project in projects {
275 let project_path = std::path::Path::new(&project);
276 if let Some(filter) = project_filter
277 && !paths_match(project_path, filter)
278 {
279 continue;
280 }
281 let metas = match mgr.list_conversation_metadata(&project) {
282 Ok(m) => m,
283 Err(e) => {
284 eprintln!("warning: claude project {project} failed: {e}");
285 continue;
286 }
287 };
288 let matches_cwd = paths_match(project_path, canonical_cwd);
289 for m in metas {
290 out.push(SessionRow {
291 harness: Harness::Claude,
292 project: Some(m.project_path),
293 cwd: None,
294 session_id: m.session_id,
295 title: m
296 .first_user_message
297 .unwrap_or_else(|| "(no prompt)".to_string()),
298 last_activity: m.last_activity,
299 message_count: m.message_count,
300 matches_cwd,
301 });
302 }
303 }
304}
305
306fn collect_gemini(
307 mgr: &toolpath_gemini::GeminiConvo,
308 canonical_cwd: &std::path::Path,
309 project_filter: Option<&std::path::Path>,
310 out: &mut Vec<SessionRow>,
311) {
312 let projects = match mgr.list_projects() {
313 Ok(ps) if !ps.is_empty() => ps,
314 Ok(_) => return,
315 Err(e) if is_not_found_gemini(&e) => return,
316 Err(e) => {
317 eprintln!("warning: gemini aggregation failed: {e}");
318 return;
319 }
320 };
321 for project in projects {
322 let project_path = std::path::Path::new(&project);
323 if let Some(filter) = project_filter
324 && !paths_match(project_path, filter)
325 {
326 continue;
327 }
328 let metas = match mgr.list_conversation_metadata(&project) {
329 Ok(m) => m,
330 Err(e) => {
331 eprintln!("warning: gemini project {project} failed: {e}");
332 continue;
333 }
334 };
335 let matches_cwd = paths_match(project_path, canonical_cwd);
336 for m in metas {
337 out.push(SessionRow {
338 harness: Harness::Gemini,
339 project: Some(m.project_path),
340 cwd: None,
341 session_id: m.session_uuid,
342 title: m
343 .first_user_message
344 .unwrap_or_else(|| "(no prompt)".to_string()),
345 last_activity: m.last_activity,
346 message_count: m.message_count,
347 matches_cwd,
348 });
349 }
350 }
351}
352
353fn collect_pi(
354 mgr: &toolpath_pi::PiConvo,
355 canonical_cwd: &std::path::Path,
356 project_filter: Option<&std::path::Path>,
357 out: &mut Vec<SessionRow>,
358) {
359 let projects = match mgr.list_projects() {
360 Ok(ps) if !ps.is_empty() => ps,
361 Ok(_) => return,
362 Err(e) if is_not_found_pi(&e) => return,
363 Err(e) => {
364 eprintln!("warning: pi aggregation failed: {e}");
365 return;
366 }
367 };
368 for project in projects {
369 let project_path = std::path::Path::new(&project);
370 if let Some(filter) = project_filter
371 && !paths_match(project_path, filter)
372 {
373 continue;
374 }
375 let metas = match mgr.list_sessions(&project) {
376 Ok(m) => m,
377 Err(e) => {
378 eprintln!("warning: pi project {project} failed: {e}");
379 continue;
380 }
381 };
382 let matches_cwd = paths_match(project_path, canonical_cwd);
383 for m in metas {
384 let last_activity = chrono::DateTime::parse_from_rfc3339(&m.timestamp)
386 .ok()
387 .map(|d| d.with_timezone(&Utc));
388 out.push(SessionRow {
389 harness: Harness::Pi,
390 project: Some(project.clone()),
391 cwd: None,
392 session_id: m.id,
393 title: m
394 .first_user_message
395 .unwrap_or_else(|| "(no prompt)".to_string()),
396 last_activity,
397 message_count: m.entry_count,
398 matches_cwd,
399 });
400 }
401 }
402}
403
404fn collect_codex(
405 mgr: &toolpath_codex::CodexConvo,
406 canonical_cwd: &std::path::Path,
407 project_filter: Option<&std::path::Path>,
408 out: &mut Vec<SessionRow>,
409) {
410 let metas = match mgr.list_sessions() {
411 Ok(m) if !m.is_empty() => m,
412 Ok(_) => return,
413 Err(e) if is_not_found_codex(&e) => return,
414 Err(e) => {
415 eprintln!("warning: codex aggregation failed: {e}");
416 return;
417 }
418 };
419 for m in metas {
420 let cwd_str = m.cwd.as_ref().map(|p| p.to_string_lossy().into_owned());
421 if let Some(filter) = project_filter {
422 let stored = match cwd_str.as_deref() {
423 Some(s) => std::path::PathBuf::from(s),
424 None => continue,
425 };
426 if !paths_match(&stored, filter) {
427 continue;
428 }
429 }
430 let matches_cwd = m
431 .cwd
432 .as_deref()
433 .map(|p| paths_match(p, canonical_cwd))
434 .unwrap_or(false);
435 out.push(SessionRow {
436 harness: Harness::Codex,
437 project: None,
438 cwd: cwd_str,
439 session_id: m.id,
440 title: m
441 .first_user_message
442 .unwrap_or_else(|| "(no prompt)".to_string()),
443 last_activity: m.last_activity,
444 message_count: m.line_count,
445 matches_cwd,
446 });
447 }
448}
449
450fn collect_copilot(
451 mgr: &toolpath_copilot::CopilotConvo,
452 canonical_cwd: &std::path::Path,
453 project_filter: Option<&std::path::Path>,
454 out: &mut Vec<SessionRow>,
455) {
456 let metas = match mgr.list_sessions() {
457 Ok(m) if !m.is_empty() => m,
458 Ok(_) => return,
459 Err(e) if is_not_found_copilot(&e) => return,
460 Err(e) => {
461 eprintln!("warning: copilot aggregation failed: {e}");
462 return;
463 }
464 };
465 for m in metas {
466 let stored = m.cwd.as_deref().map(std::path::PathBuf::from);
468 if let Some(filter) = project_filter {
469 match &stored {
470 Some(p) if paths_match(p, filter) => {}
471 _ => continue,
472 }
473 }
474 let matches_cwd = stored
475 .as_deref()
476 .map(|p| paths_match(p, canonical_cwd))
477 .unwrap_or(false);
478 out.push(SessionRow {
479 harness: Harness::Copilot,
480 project: None,
481 cwd: m.cwd,
482 session_id: m.id,
483 title: m
484 .first_user_message
485 .unwrap_or_else(|| "(no prompt)".to_string()),
486 last_activity: m.last_activity,
487 message_count: m.line_count,
488 matches_cwd,
489 });
490 }
491}
492
493fn collect_opencode(
494 mgr: &toolpath_opencode::OpencodeConvo,
495 canonical_cwd: &std::path::Path,
496 project_filter: Option<&std::path::Path>,
497 out: &mut Vec<SessionRow>,
498) {
499 let metas = match mgr.io().list_session_metadata(None) {
500 Ok(m) if !m.is_empty() => m,
501 Ok(_) => return,
502 Err(e) if is_not_found_opencode(&e) => return,
503 Err(e) => {
504 eprintln!("warning: opencode aggregation failed: {e}");
505 return;
506 }
507 };
508 for m in metas {
509 if let Some(filter) = project_filter
510 && !paths_match(&m.directory, filter)
511 {
512 continue;
513 }
514 let matches_cwd = paths_match(&m.directory, canonical_cwd);
515 let cwd_str = m.directory.to_string_lossy().into_owned();
516 let title = match (&m.first_user_message, m.title.is_empty()) {
517 (Some(s), _) if !s.is_empty() => s.clone(),
518 (_, false) => m.title.clone(),
519 _ => "(no prompt)".to_string(),
520 };
521 out.push(SessionRow {
522 harness: Harness::Opencode,
523 project: None,
524 cwd: Some(cwd_str),
525 session_id: m.id,
526 title,
527 last_activity: m.last_activity,
528 message_count: m.message_count,
529 matches_cwd,
530 });
531 }
532}
533
534fn collect_cursor(
535 mgr: &toolpath_cursor::CursorConvo,
536 canonical_cwd: &std::path::Path,
537 project_filter: Option<&std::path::Path>,
538 out: &mut Vec<SessionRow>,
539) {
540 let metas = match mgr.io().list_session_metadata() {
541 Ok(m) if !m.is_empty() => m,
542 Ok(_) => return,
543 Err(e) if is_not_found_cursor(&e) => return,
544 Err(e) => {
545 eprintln!("warning: cursor aggregation failed: {e}");
546 return;
547 }
548 };
549 for m in metas {
550 let Some(workspace) = m.workspace_path.as_ref() else {
556 continue;
557 };
558 if let Some(filter) = project_filter
559 && !paths_match(workspace, filter)
560 {
561 continue;
562 }
563 let matches_cwd = paths_match(workspace, canonical_cwd);
564 let cwd_str = workspace.to_string_lossy().into_owned();
565 let title = match (&m.first_user_message, &m.name) {
566 (Some(s), _) if !s.is_empty() => s.clone(),
567 (_, Some(n)) if !n.is_empty() => n.clone(),
568 _ => "(no prompt)".to_string(),
569 };
570 out.push(SessionRow {
571 harness: Harness::Cursor,
572 project: None,
573 cwd: Some(cwd_str),
574 session_id: m.id,
575 title,
576 last_activity: m.last_activity,
577 message_count: m.message_count,
578 matches_cwd,
579 });
580 }
581}
582
583fn is_not_found_claude(err: &toolpath_claude::ConvoError) -> bool {
584 use toolpath_claude::ConvoError;
585 matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
586 || matches!(err, ConvoError::NoHomeDirectory)
587 || matches!(err, ConvoError::ClaudeDirectoryNotFound(_))
588}
589
590fn is_not_found_gemini(err: &toolpath_gemini::ConvoError) -> bool {
591 use toolpath_gemini::ConvoError;
592 matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
593 || matches!(err, ConvoError::NoHomeDirectory)
594 || matches!(err, ConvoError::GeminiDirectoryNotFound(_))
595}
596
597fn is_not_found_pi(err: &toolpath_pi::PiError) -> bool {
598 use toolpath_pi::PiError;
599 matches!(err, PiError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
600 || matches!(err, PiError::ProjectNotFound(_))
601}
602
603fn is_not_found_codex(err: &toolpath_codex::ConvoError) -> bool {
604 use toolpath_codex::ConvoError;
605 matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
606 || matches!(err, ConvoError::NoHomeDirectory)
607 || matches!(err, ConvoError::CodexDirectoryNotFound(_))
608}
609
610fn is_not_found_copilot(err: &toolpath_copilot::ConvoError) -> bool {
611 use toolpath_copilot::ConvoError;
612 matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
613 || matches!(err, ConvoError::NoHomeDirectory)
614 || matches!(err, ConvoError::CopilotDirectoryNotFound(_))
615}
616
617fn is_not_found_opencode(err: &toolpath_opencode::ConvoError) -> bool {
618 use toolpath_opencode::ConvoError;
619 matches!(err, ConvoError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
620 || matches!(err, ConvoError::NoHomeDirectory)
621 || matches!(err, ConvoError::OpencodeDirectoryNotFound(_))
622 || matches!(err, ConvoError::DatabaseNotFound(_))
623}
624
625fn is_not_found_cursor(err: &toolpath_cursor::CursorError) -> bool {
626 use toolpath_cursor::CursorError;
627 matches!(err, CursorError::Io(e) if e.kind() == std::io::ErrorKind::NotFound)
628 || matches!(err, CursorError::NoHomeDirectory)
629 || matches!(err, CursorError::CursorDataDirectoryNotFound(_))
630 || matches!(err, CursorError::DatabaseNotFound(_))
631}
632
633pub fn run(args: ShareArgs) -> Result<()> {
634 let harness = args.harness.map(Harness::from_arg);
635
636 if args.session.is_some() && harness.is_none() {
637 anyhow::bail!("--session requires --harness");
638 }
639
640 let upload_args = crate::cmd_export::PathbaseUploadArgs {
644 url: args.url.clone(),
645 anon: args.anon,
646 repo: args.repo.clone(),
647 name: args.name.clone(),
648 public: args.public,
649 };
650 let base_url = crate::cmd_export::resolve_upload_base_url(&upload_args);
651 let needs_auth = upload_args.repo.is_some() || upload_args.public || upload_args.name.is_some();
652
653 if let (Some(h), Some(session)) = (harness, &args.session) {
654 let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?;
657 return share_explicit(h, session.as_str(), &args, auth, base_url);
658 }
659
660 let cwd = std::env::current_dir()?;
661 let bundle = HarnessBundle::from_environment();
662 let project_filter = args.project.as_deref();
663 let rows = gather_sessions(&bundle, &cwd, harness, project_filter);
664
665 if rows.is_empty() {
666 return bail_no_sessions(&bundle, project_filter);
667 }
668
669 if !crate::fuzzy::available() {
670 eprintln!(
671 "Interactive `path share` needs `fzf` on PATH and a TTY.\n\
672 \n\
673 Manual recipe:\n \
674 path import <harness> # writes a cache entry, prints its id\n \
675 path export pathbase --input <id>"
676 );
677 anyhow::bail!("fzf unavailable; run `path import <harness>` then `path export pathbase`");
678 }
679
680 let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?;
685
686 let lines: Vec<String> = rows.iter().map(format_picker_row).collect();
687 let header = format!("share an agent session (Enter = upload to {base_url})");
688 let opts = crate::fuzzy::PickOptions {
689 with_nth: "4",
690 prompt: "share> ",
691 preview: Some("{exe} show --ansi {1} --project {2} --session {3}"),
692 preview_window: "up:60%:wrap-word",
696 header: Some(&header),
697 tiebreak: "index",
698 multi: false,
699 };
700 let line = match crate::fuzzy::pick(&lines, &opts)? {
701 crate::fuzzy::PickResult::Selected(v) => match v.into_iter().next() {
702 Some(l) => l,
703 None => return Ok(()),
707 },
708 crate::fuzzy::PickResult::NoMatch => return Ok(()),
710 crate::fuzzy::PickResult::Cancelled => std::process::exit(130),
713 };
714 let (h, key, session, title) = parse_picker_row(&line)
715 .ok_or_else(|| anyhow::anyhow!("internal: failed to parse picker row"))?;
716
717 let explicit = ShareArgs {
718 url: args.url.clone(),
719 anon: args.anon,
720 repo: args.repo.clone(),
721 name: args.name.clone(),
722 public: args.public,
723 harness: Some(harness_to_arg(h)),
724 session: None, project: if h.project_keyed() {
726 Some(PathBuf::from(&key))
727 } else {
728 None
729 },
730 no_cache: args.no_cache,
731 };
732 eprintln!("Picked {} session {:?}", h.name(), title);
736 share_explicit(h, &session, &explicit, auth, base_url)
737}
738
739fn harness_to_arg(h: Harness) -> HarnessArg {
740 match h {
741 Harness::Claude => HarnessArg::Claude,
742 Harness::Gemini => HarnessArg::Gemini,
743 Harness::Codex => HarnessArg::Codex,
744 Harness::Copilot => HarnessArg::Copilot,
745 Harness::Opencode => HarnessArg::Opencode,
746 Harness::Cursor => HarnessArg::Cursor,
747 Harness::Pi => HarnessArg::Pi,
748 }
749}
750
751fn bail_no_sessions(
752 bundle: &HarnessBundle,
753 project_filter: Option<&std::path::Path>,
754) -> Result<()> {
755 if let Some(p) = project_filter {
756 anyhow::bail!(
757 "No agent sessions found in project {}. Run without --project to see sessions across all projects.",
758 p.display()
759 );
760 }
761
762 let mut summary = String::from("No agent sessions found.\n");
763 let home = home_dir();
766 summary.push_str(&format_status_line(
767 "claude",
768 &harness_status_claude(bundle, home.as_deref()),
769 ));
770 summary.push_str(&format_status_line(
771 "gemini",
772 &harness_status_gemini(bundle, home.as_deref()),
773 ));
774 summary.push_str(&format_status_line(
775 "codex",
776 &harness_status_codex(bundle, home.as_deref()),
777 ));
778 summary.push_str(&format_status_line(
779 "copilot",
780 &harness_status_copilot(bundle, home.as_deref()),
781 ));
782 summary.push_str(&format_status_line(
783 "opencode",
784 &harness_status_opencode(bundle, home.as_deref()),
785 ));
786 summary.push_str(&format_status_line(
787 "cursor",
788 &harness_status_cursor(bundle, home.as_deref()),
789 ));
790 summary.push_str(&format_status_line(
791 "pi",
792 &harness_status_pi(bundle, home.as_deref()),
793 ));
794 eprint!("{summary}");
795 anyhow::bail!("no shareable sessions");
796}
797
798fn home_dir() -> Option<std::path::PathBuf> {
801 std::env::var_os("HOME")
802 .or_else(|| std::env::var_os("USERPROFILE"))
803 .map(std::path::PathBuf::from)
804}
805
806#[derive(Debug, PartialEq, Eq)]
810struct HarnessStatus {
811 path: String,
813 exists: bool,
815}
816
817impl HarnessStatus {
818 fn render(&self) -> String {
819 if self.exists {
820 format!("{} (0 sessions)", self.path)
821 } else {
822 format!("{} not found", self.path)
823 }
824 }
825
826 fn unresolved() -> Self {
828 Self {
829 path: "<no home directory>".to_string(),
830 exists: false,
831 }
832 }
833}
834
835fn format_status_line(name: &str, status: &HarnessStatus) -> String {
838 format!(" {:<9} {}\n", format!("{name}:"), status.render())
839}
840
841fn harness_status_claude(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
842 let Some(mgr) = &bundle.claude else {
843 return HarnessStatus::unresolved();
844 };
845 match mgr.resolver().projects_dir() {
846 Ok(p) => HarnessStatus {
847 path: home_relative(&p, home),
848 exists: p.exists(),
849 },
850 Err(_) => HarnessStatus::unresolved(),
851 }
852}
853
854fn harness_status_gemini(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
855 let Some(mgr) = &bundle.gemini else {
856 return HarnessStatus::unresolved();
857 };
858 match mgr.resolver().tmp_dir() {
859 Ok(p) => HarnessStatus {
860 path: home_relative(&p, home),
861 exists: p.exists(),
862 },
863 Err(_) => HarnessStatus::unresolved(),
864 }
865}
866
867fn harness_status_codex(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
868 let Some(mgr) = &bundle.codex else {
869 return HarnessStatus::unresolved();
870 };
871 match mgr.resolver().sessions_root() {
872 Ok(p) => HarnessStatus {
873 path: home_relative(&p, home),
874 exists: p.exists(),
875 },
876 Err(_) => HarnessStatus::unresolved(),
877 }
878}
879
880fn harness_status_copilot(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
881 let Some(mgr) = &bundle.copilot else {
882 return HarnessStatus::unresolved();
883 };
884 match mgr.resolver().session_state_dir() {
885 Ok(p) => HarnessStatus {
886 path: home_relative(&p, home),
887 exists: p.exists(),
888 },
889 Err(_) => HarnessStatus::unresolved(),
890 }
891}
892
893fn harness_status_opencode(
894 bundle: &HarnessBundle,
895 home: Option<&std::path::Path>,
896) -> HarnessStatus {
897 let Some(mgr) = &bundle.opencode else {
898 return HarnessStatus::unresolved();
899 };
900 match mgr.resolver().db_path() {
901 Ok(p) => HarnessStatus {
902 path: home_relative(&p, home),
903 exists: p.exists(),
904 },
905 Err(_) => HarnessStatus::unresolved(),
906 }
907}
908
909fn harness_status_pi(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
910 let Some(mgr) = &bundle.pi else {
911 return HarnessStatus::unresolved();
912 };
913 let p = mgr.resolver().sessions_dir().to_path_buf();
914 HarnessStatus {
915 path: home_relative(&p, home),
916 exists: p.exists(),
917 }
918}
919
920fn harness_status_cursor(bundle: &HarnessBundle, home: Option<&std::path::Path>) -> HarnessStatus {
921 let Some(mgr) = &bundle.cursor else {
922 return HarnessStatus::unresolved();
923 };
924 match mgr.resolver().db_path() {
925 Ok(p) => HarnessStatus {
926 path: home_relative(&p, home),
927 exists: p.exists(),
928 },
929 Err(_) => HarnessStatus::unresolved(),
930 }
931}
932
933fn home_relative(path: &std::path::Path, home: Option<&std::path::Path>) -> String {
936 if let Some(home) = home
937 && let Ok(rest) = path.strip_prefix(home)
938 {
939 if rest.as_os_str().is_empty() {
942 return "~".to_string();
943 }
944 return format!("~/{}", rest.display());
945 }
946 path.display().to_string()
947}
948
949fn share_explicit(
950 harness: Harness,
951 session: &str,
952 args: &ShareArgs,
953 auth: crate::cmd_pathbase::AuthMode,
954 base_url: String,
955) -> Result<()> {
956 let project = match (harness.project_keyed(), args.project.as_ref()) {
957 (true, Some(p)) => Some(p.to_string_lossy().into_owned()),
958 (true, None) => anyhow::bail!(
959 "--project required when --harness is {} and --session is set",
960 harness.name()
961 ),
962 (false, _) => None,
963 };
964
965 let derived = derive_session(harness, project.as_deref(), session)?;
966 let summary = format!("{} session {}", harness.name(), derived.cache_id);
967
968 if !args.no_cache {
969 let path = crate::cmd_cache::write_cached(&derived.cache_id, &derived.doc, true)?;
977 eprintln!(
978 "Cached {} session → {} ({})",
979 harness.name(),
980 derived.cache_id,
981 path.display()
982 );
983 }
984
985 let body = derived.doc.to_json()?;
986 let upload = crate::cmd_export::PathbaseUploadArgs {
987 url: args.url.clone(),
988 anon: args.anon,
989 repo: args.repo.clone(),
990 name: args.name.clone(),
991 public: args.public,
992 };
993 crate::cmd_export::run_pathbase_inner(auth, base_url, upload, &body, &summary)
994}
995
996fn format_picker_row(row: &SessionRow) -> String {
1006 let key = row
1007 .project
1008 .clone()
1009 .or_else(|| row.cwd.clone())
1010 .unwrap_or_default();
1011 let scope = if row.matches_cwd { "·" } else { " " };
1012 let leading = format!("{scope} {}", row.harness.symbol());
1013 let display = render_row(
1014 Some(&leading),
1015 row.last_activity,
1016 &count(row.message_count, "msgs"),
1017 Some(&project_short(&key)),
1018 &row.title,
1019 );
1020 let title = clean_for_picker_display(&row.title);
1021 format!(
1022 "{}\t{}\t{}\t{}\t{}",
1023 row.harness.name(),
1024 tab_safe(&key),
1025 tab_safe(&row.session_id),
1026 display,
1027 tab_safe(&title),
1028 )
1029}
1030
1031fn parse_picker_row(line: &str) -> Option<(Harness, String, String, String)> {
1035 let mut parts = line.split('\t');
1036 let h = Harness::parse(parts.next()?)?;
1037 let key = parts.next()?.to_string();
1038 let session = parts.next()?.to_string();
1039 if session.is_empty() {
1040 return None;
1041 }
1042 let title = parts.nth(1).unwrap_or("").to_string();
1045 Some((h, key, session, title))
1046}
1047
1048use crate::fuzzy::{clean_for_picker_display, count, project_short, render_row, tab_safe};
1049
1050fn derive_session(
1051 harness: Harness,
1052 project: Option<&str>,
1053 session: &str,
1054) -> Result<crate::cmd_import::DerivedDoc> {
1055 match harness {
1056 Harness::Claude => {
1057 crate::cmd_import::derive_claude_session(project.expect("project_keyed"), session)
1058 }
1059 Harness::Gemini => crate::cmd_import::derive_gemini_session(
1060 project.expect("project_keyed"),
1061 session,
1062 false,
1063 ),
1064 Harness::Pi => {
1065 crate::cmd_import::derive_pi_session(project.expect("project_keyed"), session, None)
1066 }
1067 Harness::Codex => crate::cmd_import::derive_codex_session(session),
1068 Harness::Copilot => crate::cmd_import::derive_copilot_session(session),
1069 Harness::Opencode => crate::cmd_import::derive_opencode_session(session, false),
1070 Harness::Cursor => crate::cmd_import::derive_cursor_session(session),
1071 }
1072}
1073
1074#[cfg(test)]
1075mod tests {
1076 use super::*;
1077
1078 #[test]
1079 fn harness_name_and_symbol_are_distinct() {
1080 let all = [
1081 Harness::Claude,
1082 Harness::Gemini,
1083 Harness::Codex,
1084 Harness::Opencode,
1085 Harness::Cursor,
1086 Harness::Pi,
1087 ];
1088 let names: Vec<&str> = all.iter().map(|h| h.name()).collect();
1089 let symbols: Vec<&str> = all.iter().map(|h| h.symbol()).collect();
1090 assert_eq!(names.len(), 6);
1091 assert_eq!(
1092 names.iter().collect::<std::collections::HashSet<_>>().len(),
1093 6,
1094 "names must be unique"
1095 );
1096 assert_eq!(
1097 symbols
1098 .iter()
1099 .collect::<std::collections::HashSet<_>>()
1100 .len(),
1101 6,
1102 "symbols must be unique"
1103 );
1104 }
1105
1106 #[test]
1107 fn harness_project_keyed_matches_design() {
1108 assert!(Harness::Claude.project_keyed());
1109 assert!(Harness::Gemini.project_keyed());
1110 assert!(Harness::Pi.project_keyed());
1111 assert!(!Harness::Codex.project_keyed());
1112 assert!(!Harness::Opencode.project_keyed());
1113 assert!(!Harness::Cursor.project_keyed());
1114 }
1115
1116 #[test]
1117 fn harness_from_arg_roundtrips() {
1118 for (arg, harness) in [
1119 (HarnessArg::Claude, Harness::Claude),
1120 (HarnessArg::Gemini, Harness::Gemini),
1121 (HarnessArg::Codex, Harness::Codex),
1122 (HarnessArg::Opencode, Harness::Opencode),
1123 (HarnessArg::Cursor, Harness::Cursor),
1124 (HarnessArg::Pi, Harness::Pi),
1125 ] {
1126 assert_eq!(Harness::from_arg(arg), harness);
1127 }
1128 }
1129
1130 use std::path::Path;
1131 use tempfile::TempDir;
1132
1133 fn write_claude_session(claude_dir: &Path, project_slug: &str, session: &str, prompt: &str) {
1134 let project_dir = claude_dir.join("projects").join(project_slug);
1135 std::fs::create_dir_all(&project_dir).unwrap();
1136 let user = format!(
1137 r#"{{"type":"user","uuid":"u-{session}","timestamp":"2024-01-02T00:00:00Z","cwd":"/test/project","message":{{"role":"user","content":"{prompt}"}}}}"#
1138 );
1139 let asst = format!(
1140 r#"{{"type":"assistant","uuid":"a-{session}","timestamp":"2024-01-02T00:00:01Z","message":{{"role":"assistant","content":"hi"}}}}"#
1141 );
1142 std::fs::write(
1143 project_dir.join(format!("{session}.jsonl")),
1144 format!("{user}\n{asst}\n"),
1145 )
1146 .unwrap();
1147 }
1148
1149 fn claude_only_bundle(home: &Path) -> HarnessBundle {
1150 let claude_dir = home.join(".claude");
1151 std::fs::create_dir_all(&claude_dir).unwrap();
1152 let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
1153 HarnessBundle {
1154 claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)),
1155 ..Default::default()
1156 }
1157 }
1158
1159 #[test]
1160 fn gather_sessions_includes_claude_rows_for_a_project() {
1161 let temp = TempDir::new().unwrap();
1162 write_claude_session(
1163 &temp.path().join(".claude"),
1164 "-test-project",
1165 "abc-session-one",
1166 "Add a feature",
1167 );
1168 let bundle = claude_only_bundle(temp.path());
1169 let cwd = Path::new("/test/project");
1170 let rows = gather_sessions(&bundle, cwd, None, None);
1171
1172 assert_eq!(rows.len(), 1);
1173 assert_eq!(rows[0].harness, Harness::Claude);
1174 assert_eq!(rows[0].session_id, "abc-session-one");
1175 assert_eq!(rows[0].project.as_deref(), Some("/test/project"));
1176 assert!(rows[0].matches_cwd, "cwd should match the project path");
1177 }
1178
1179 #[test]
1180 fn gather_sessions_marks_non_matching_project_rows() {
1181 let temp = TempDir::new().unwrap();
1182 write_claude_session(
1183 &temp.path().join(".claude"),
1184 "-test-project",
1185 "abc-session-one",
1186 "Add a feature",
1187 );
1188 let bundle = claude_only_bundle(temp.path());
1189 let cwd = Path::new("/some/other/place");
1190 let rows = gather_sessions(&bundle, cwd, None, None);
1191
1192 assert_eq!(rows.len(), 1);
1193 assert!(!rows[0].matches_cwd);
1194 }
1195
1196 #[test]
1197 fn gather_sessions_skips_harness_with_no_home_dir() {
1198 let bundle = HarnessBundle::default();
1200 let rows = gather_sessions(&bundle, Path::new("/anywhere"), None, None);
1201 assert!(rows.is_empty());
1202 }
1203
1204 #[test]
1205 fn gather_sessions_filters_by_harness() {
1206 let temp = TempDir::new().unwrap();
1207 write_claude_session(
1208 &temp.path().join(".claude"),
1209 "-test-project",
1210 "abc-session-one",
1211 "hi",
1212 );
1213 let bundle = claude_only_bundle(temp.path());
1214 let cwd = Path::new("/test/project");
1215 let rows = gather_sessions(&bundle, cwd, Some(Harness::Codex), None);
1216 assert!(rows.is_empty(), "filter to codex must drop claude rows");
1217 }
1218
1219 fn codex_only_bundle(home: &Path) -> HarnessBundle {
1220 let codex_dir = home.join(".codex");
1221 std::fs::create_dir_all(&codex_dir).unwrap();
1222 let resolver = toolpath_codex::PathResolver::new().with_codex_dir(&codex_dir);
1223 HarnessBundle {
1224 codex: Some(toolpath_codex::CodexConvo::with_resolver(resolver)),
1225 ..Default::default()
1226 }
1227 }
1228
1229 fn write_codex_session(codex_dir: &Path, id: &str, cwd: &str) {
1230 let dir = codex_dir.join("sessions/2026/05/07");
1232 std::fs::create_dir_all(&dir).unwrap();
1233 let file = dir.join(format!("rollout-2026-05-07T00-00-00-{id}.jsonl"));
1234 let meta = format!(
1235 r#"{{"timestamp":"2026-05-07T00:00:00Z","type":"session_meta","payload":{{"id":"{id}","timestamp":"2026-05-07T00:00:00Z","cwd":"{cwd}","originator":"codex-tui","cli_version":"test","source":"cli","model_provider":"openai"}}}}"#
1236 );
1237 let user = r#"{"timestamp":"2026-05-07T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}}"#;
1238 std::fs::write(file, format!("{meta}\n{user}\n")).unwrap();
1239 }
1240
1241 #[test]
1242 fn gather_sessions_includes_codex_rows_with_cwd_match() {
1243 let temp = TempDir::new().unwrap();
1244 write_codex_session(
1245 &temp.path().join(".codex"),
1246 "00000000-0000-0000-0000-0000000000aa",
1247 "/work/proj",
1248 );
1249 let bundle = codex_only_bundle(temp.path());
1250 let rows = gather_sessions(&bundle, Path::new("/work/proj"), None, None);
1251 assert_eq!(rows.len(), 1);
1252 assert_eq!(rows[0].harness, Harness::Codex);
1253 assert_eq!(rows[0].cwd.as_deref(), Some("/work/proj"));
1254 assert!(rows[0].matches_cwd);
1255 }
1256
1257 fn copilot_only_bundle(home: &Path) -> HarnessBundle {
1258 let copilot_dir = home.join(".copilot");
1259 std::fs::create_dir_all(&copilot_dir).unwrap();
1260 let resolver = toolpath_copilot::PathResolver::new().with_copilot_dir(&copilot_dir);
1261 HarnessBundle {
1262 copilot: Some(toolpath_copilot::CopilotConvo::with_resolver(resolver)),
1263 ..Default::default()
1264 }
1265 }
1266
1267 fn write_copilot_session(copilot_dir: &Path, id: &str, cwd: &str) {
1268 let dir = copilot_dir.join("session-state").join(id);
1270 std::fs::create_dir_all(&dir).unwrap();
1271 let start = format!(
1272 r#"{{"type":"session.start","timestamp":"2026-07-01T00:00:00Z","data":{{"copilotVersion":"1.0.67","context":{{"cwd":"{cwd}"}}}}}}"#
1273 );
1274 let user =
1275 r#"{"type":"user.message","timestamp":"2026-07-01T00:00:01Z","data":{"content":"hi"}}"#;
1276 std::fs::write(dir.join("events.jsonl"), format!("{start}\n{user}\n")).unwrap();
1277 }
1278
1279 #[test]
1280 fn gather_sessions_includes_copilot_rows_with_cwd_match() {
1281 let temp = TempDir::new().unwrap();
1282 write_copilot_session(&temp.path().join(".copilot"), "sess-aa", "/work/proj");
1283 let bundle = copilot_only_bundle(temp.path());
1284 let rows = gather_sessions(&bundle, Path::new("/work/proj"), None, None);
1285 assert_eq!(rows.len(), 1);
1286 assert_eq!(rows[0].harness, Harness::Copilot);
1287 assert_eq!(rows[0].cwd.as_deref(), Some("/work/proj"));
1288 assert!(rows[0].matches_cwd);
1289 }
1290
1291 #[test]
1292 fn gather_sessions_filters_to_copilot() {
1293 let temp = TempDir::new().unwrap();
1294 write_copilot_session(&temp.path().join(".copilot"), "sess-aa", "/work/proj");
1295 let bundle = copilot_only_bundle(temp.path());
1296 let rows = gather_sessions(&bundle, Path::new("/work/proj"), Some(Harness::Codex), None);
1298 assert!(rows.is_empty());
1299 }
1300
1301 #[test]
1302 fn gather_sessions_ranks_cwd_matches_first() {
1303 let temp = TempDir::new().unwrap();
1306 let claude_dir = temp.path().join(".claude");
1307 write_claude_session(&claude_dir, "-cwd-project", "in-cwd-session", "hi");
1308 let not_dir = claude_dir.join("projects").join("-other-project");
1310 std::fs::create_dir_all(¬_dir).unwrap();
1311 std::fs::write(
1312 not_dir.join("not-in-cwd-session.jsonl"),
1313 r#"{"type":"user","uuid":"u-x","timestamp":"2030-01-01T00:00:00Z","cwd":"/other/project","message":{"role":"user","content":"later"}}"#.to_string()
1314 + "\n",
1315 )
1316 .unwrap();
1317 let bundle = claude_only_bundle(temp.path());
1318 let rows = gather_sessions(&bundle, Path::new("/cwd/project"), None, None);
1319
1320 assert_eq!(rows.len(), 2);
1321 assert_eq!(rows[0].session_id, "in-cwd-session");
1322 assert!(rows[0].matches_cwd);
1323 assert!(!rows[1].matches_cwd);
1324 }
1325
1326 #[test]
1327 #[cfg(unix)]
1328 fn paths_match_canonicalizes_through_symlink() {
1329 let temp = TempDir::new().unwrap();
1343 let real_project = temp.path().join("real-project");
1344 std::fs::create_dir_all(&real_project).unwrap();
1345 let symlink_path = temp.path().join("symlink-to-project");
1346 std::os::unix::fs::symlink(&real_project, &symlink_path).unwrap();
1347
1348 assert_ne!(real_project, symlink_path);
1351 assert_eq!(
1352 std::fs::canonicalize(&real_project).unwrap(),
1353 std::fs::canonicalize(&symlink_path).unwrap(),
1354 );
1355
1356 assert!(
1358 paths_match(&real_project, &symlink_path),
1359 "paths_match must canonicalize both sides so symlink == target"
1360 );
1361 assert!(
1363 paths_match(&symlink_path, &real_project),
1364 "paths_match must be symmetric across the symlink"
1365 );
1366 }
1367
1368 #[test]
1369 fn parse_picker_row_roundtrips_keyed() {
1370 let row = SessionRow {
1371 harness: Harness::Claude,
1372 project: Some("/tmp/proj".to_string()),
1373 cwd: None,
1374 session_id: "sess-abc".to_string(),
1375 title: "Hello\tworld".to_string(),
1376 last_activity: None,
1377 message_count: 3,
1378 matches_cwd: true,
1379 };
1380 let line = format_picker_row(&row);
1381 let (harness, key, session, title) = parse_picker_row(&line).unwrap();
1382 assert_eq!(harness, Harness::Claude);
1383 assert_eq!(key, "/tmp/proj");
1384 assert_eq!(session, "sess-abc");
1385 assert_eq!(title, "Hello world");
1388 }
1389
1390 #[test]
1391 fn parse_picker_row_roundtrips_session_keyed() {
1392 let row = SessionRow {
1393 harness: Harness::Codex,
1394 project: None,
1395 cwd: Some("/work/proj".to_string()),
1396 session_id: "0190abcd".to_string(),
1397 title: "(no prompt)".to_string(),
1398 last_activity: None,
1399 message_count: 0,
1400 matches_cwd: false,
1401 };
1402 let line = format_picker_row(&row);
1403 let (harness, key, session, title) = parse_picker_row(&line).unwrap();
1404 assert_eq!(harness, Harness::Codex);
1405 assert_eq!(key, "/work/proj"); assert_eq!(session, "0190abcd");
1407 assert_eq!(title, "(no prompt)");
1408 }
1409
1410 #[test]
1411 fn parse_picker_row_carries_title_with_unicode() {
1412 let row = SessionRow {
1413 harness: Harness::Gemini,
1414 project: Some("/work/proj".to_string()),
1415 cwd: None,
1416 session_id: "11111111-2222-3333-4444-555555555555".to_string(),
1417 title: "Add the share command — finally".to_string(),
1418 last_activity: None,
1419 message_count: 42,
1420 matches_cwd: true,
1421 };
1422 let line = format_picker_row(&row);
1423 let (_, _, _, title) = parse_picker_row(&line).unwrap();
1424 assert_eq!(title, "Add the share command — finally");
1425 }
1426
1427 #[test]
1428 fn home_relative_strips_home_prefix() {
1429 let home = Path::new("/Users/alex");
1430 assert_eq!(
1431 home_relative(Path::new("/Users/alex/.claude/projects"), Some(home)),
1432 "~/.claude/projects"
1433 );
1434 }
1435
1436 #[test]
1437 fn home_relative_returns_tilde_for_home_itself() {
1438 let home = Path::new("/Users/alex");
1439 assert_eq!(home_relative(home, Some(home)), "~");
1440 }
1441
1442 #[test]
1443 fn home_relative_passes_through_paths_outside_home() {
1444 let home = Path::new("/Users/alex");
1445 assert_eq!(
1446 home_relative(Path::new("/tmp/elsewhere"), Some(home)),
1447 "/tmp/elsewhere"
1448 );
1449 }
1450
1451 #[test]
1452 fn home_relative_passes_through_when_no_home() {
1453 assert_eq!(home_relative(Path::new("/foo/bar"), None), "/foo/bar");
1454 }
1455
1456 #[test]
1457 fn harness_status_renders_existing_path_with_zero_sessions() {
1458 let s = HarnessStatus {
1459 path: "~/.claude/projects".to_string(),
1460 exists: true,
1461 };
1462 assert_eq!(s.render(), "~/.claude/projects (0 sessions)");
1463 }
1464
1465 #[test]
1466 fn harness_status_renders_missing_path_as_not_found() {
1467 let s = HarnessStatus {
1468 path: "~/.gemini/tmp".to_string(),
1469 exists: false,
1470 };
1471 assert_eq!(s.render(), "~/.gemini/tmp not found");
1472 }
1473
1474 #[test]
1475 fn format_status_line_pads_for_alignment() {
1476 let s = HarnessStatus {
1477 path: "~/.codex/sessions".to_string(),
1478 exists: true,
1479 };
1480 let claude_line = format_status_line("claude", &s);
1484 let opencode_line = format_status_line("opencode", &s);
1485 let pi_line = format_status_line("pi", &s);
1486 let offset = |line: &str| line.find('~').unwrap();
1487 assert_eq!(offset(&claude_line), offset(&opencode_line));
1488 assert_eq!(offset(&claude_line), offset(&pi_line));
1489 }
1490
1491 #[test]
1492 fn harness_status_for_missing_claude_dir_reports_not_found() {
1493 let temp = TempDir::new().unwrap();
1497 let claude_dir = temp.path().join(".claude"); let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
1499 let bundle = HarnessBundle {
1500 claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)),
1501 ..Default::default()
1502 };
1503 let status = harness_status_claude(&bundle, None);
1504 assert!(!status.exists, "missing dir must report exists=false");
1505 assert!(
1506 status.path.contains("projects"),
1507 "path must include the projects subdir (got {:?})",
1508 status.path
1509 );
1510 }
1511
1512 #[test]
1513 fn harness_status_for_present_claude_dir_reports_existence() {
1514 let temp = TempDir::new().unwrap();
1515 let claude_dir = temp.path().join(".claude");
1516 std::fs::create_dir_all(claude_dir.join("projects")).unwrap();
1517 let resolver = toolpath_claude::PathResolver::new().with_claude_dir(&claude_dir);
1518 let bundle = HarnessBundle {
1519 claude: Some(toolpath_claude::ClaudeConvo::with_resolver(resolver)),
1520 ..Default::default()
1521 };
1522 let status = harness_status_claude(&bundle, None);
1523 assert!(status.exists);
1524 }
1525
1526 #[test]
1527 fn harness_status_for_empty_bundle_is_unresolved() {
1528 let bundle = HarnessBundle::default();
1529 for status in [
1531 harness_status_claude(&bundle, None),
1532 harness_status_gemini(&bundle, None),
1533 harness_status_codex(&bundle, None),
1534 harness_status_opencode(&bundle, None),
1535 harness_status_pi(&bundle, None),
1536 ] {
1537 assert_eq!(status, HarnessStatus::unresolved());
1538 assert!(!status.exists);
1539 }
1540 }
1541}