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