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