Skip to main content

path_cli/
cmd_share.rs

1//! `path share` — interactive Pathbase upload across installed agent
2//! harnesses. See `docs/superpowers/specs/2026-05-07-path-share-command-design.md`.
3
4#![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    /// Pathbase server URL (defaults to the stored session's server)
28    #[arg(long)]
29    pub url: Option<String>,
30
31    /// Force the anonymous endpoint, ignoring any stored credentials
32    #[arg(long, conflicts_with_all = ["repo", "public"])]
33    pub anon: bool,
34
35    /// Target a specific repo as `owner/name` instead of `<you>/pathstash`
36    #[arg(long, value_parser = crate::cmd_export::parse_repo_spec)]
37    pub repo: Option<RepoSpec>,
38
39    /// Human-readable display label for the uploaded graph
40    /// (defaults to the toolpath document id). Free-form; not used
41    /// in the URL — graphs are addressed by UUID server-side.
42    #[arg(long, alias = "slug")]
43    pub name: Option<String>,
44
45    /// Mark the uploaded graph public (default: unlisted, addressable only by UUID)
46    #[arg(long)]
47    pub public: bool,
48
49    /// Narrow the picker to one harness, or skip the picker entirely
50    /// when used with --session.
51    #[arg(long, value_enum)]
52    pub harness: Option<HarnessArg>,
53
54    /// Skip the picker. Requires --harness; requires --project for
55    /// claude/gemini/pi.
56    #[arg(long, requires = "harness")]
57    pub session: Option<String>,
58
59    /// Override cwd-as-project. Filters the picker to sessions tied to
60    /// this project across all harnesses.
61    #[arg(long)]
62    pub project: Option<PathBuf>,
63
64    /// Skip writing the cache; derive in-memory only
65    #[arg(long)]
66    pub no_cache: bool,
67}
68
69/// Which agent harness a session was produced by.
70#[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    /// Padded so all symbols line up in the fzf column. Longest is
95    /// "opencode" (8); pad shorter names to match.
96    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    /// True when the underlying provider keys sessions by project path.
109    /// claude/gemini/pi: true. codex/opencode/cursor: false (sessions
110    /// store cwd per-row, not as a directory key — cursor stores it as
111    /// `workspaceIdentifier.uri.fsPath` on each composer).
112    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/// One row in the unified session picker.
143#[derive(Debug, Clone)]
144pub(crate) struct SessionRow {
145    pub(crate) harness: Harness,
146    /// Project path for keyed providers; `None` for codex/opencode.
147    pub(crate) project: Option<String>,
148    /// Recorded cwd from the session (codex/opencode only).
149    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/// Bundle of provider managers used during aggregation. Production code
158/// builds this from real `$HOME` via `from_environment`; tests construct
159/// it directly with provider-specific resolvers.
160#[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    /// Build the production bundle. Each provider is included
173    /// unconditionally (its `new()` doesn't fail on a missing home dir);
174    /// `gather_sessions` skips the ones whose listing returns empty/NotFound.
175    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
188/// Aggregate sessions across the harnesses in `bundle`, ranked so that
189/// rows whose project (or recorded cwd) canonicalizes to `cwd` come
190/// first, sorted by descending `last_activity`.
191///
192/// Filters: `harness_filter` keeps only rows from one harness; `project_filter`
193/// keeps only rows whose project (for keyed) or cwd (for session-keyed)
194/// canonicalizes to that path.
195pub(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            // SessionMeta.timestamp is a String; parse to DateTime when possible.
385            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        // Copilot stores cwd as a String (from session.start `context.cwd`).
467        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        // Cursor stores each composer's workspace as the absolute
551        // path of the folder Cursor.app was open on. Sessions
552        // without a workspace (numeric/remote workspace ids) are
553        // dropped from the picker — we can't tell what they're
554        // tied to.
555        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    // Build upload args + base URL once and reuse for both the explicit
641    // path and the picker path. `needs_auth` decides whether preflight
642    // can fall back to anon on credential failure.
643    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        // Explicit-args: validate creds before derive so a credential
655        // failure doesn't waste the derive/cache work.
656        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    // We have rows AND fzf available — now validate credentials before
681    // making the user pick a session. If preflight returns Anon (either
682    // explicit --anon, no creds + no auth flags, or auth probe failed
683    // and fell back), the picker still fires with that knowledge baked in.
684    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        // Stacked layout: preview above the list, list below. Fits narrow
693        // terminals better than the default side-by-side and gives the
694        // session preview the full terminal width to render `path show`.
695        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            // Selected with an empty payload should not happen (fzf exits 0
704            // only when at least one row was confirmed), but treat it like
705            // no-match for safety.
706            None => return Ok(()),
707        },
708        // No row matched the query — exit 0, same as today, no extra noise.
709        crate::fuzzy::PickResult::NoMatch => return Ok(()),
710        // Esc / Ctrl-C: deliberate user cancel. Signal to the shell with
711        // exit 130 so it's distinguishable from a successful share.
712        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, // unused by share_explicit
725        project: if h.project_keyed() {
726            Some(PathBuf::from(&key))
727        } else {
728            None
729        },
730        no_cache: args.no_cache,
731    };
732    // Show the conversation title in the confirmation line; the session id
733    // is opaque and doesn't help the user verify they picked the right
734    // thing. `{:?}` adds the surrounding quotes per the spec.
735    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    // Pad harness names so the path column lines up: "opencode:" is the
764    // longest at 9 chars (8 + colon).
765    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
798/// Cross-platform `$HOME` lookup matching the providers' internal helpers.
799/// Returns `None` only when neither `$HOME` nor `$USERPROFILE` is set.
800fn 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/// Human-readable status of a harness's on-disk store: either the (possibly
807/// home-relative) path with a "(0 sessions)" hint, or the path with a
808/// "not found" hint when the directory/database is absent.
809#[derive(Debug, PartialEq, Eq)]
810struct HarnessStatus {
811    /// Display path (tilde-prefixed when under `$HOME`).
812    path: String,
813    /// True when the path exists on disk.
814    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    /// Status when the resolver itself failed (e.g. no $HOME).
827    fn unresolved() -> Self {
828        Self {
829            path: "<no home directory>".to_string(),
830            exists: false,
831        }
832    }
833}
834
835/// Format a single status line, padding the harness name so that the path
836/// column lines up across all five rows. The longest name is "opencode" (8).
837fn 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
933/// Display `path` as `~/relative/part` when it's under `home`, otherwise
934/// return its absolute lossy form. Pure helper — does no filesystem I/O.
935fn 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        // strip_prefix returns the empty path when path == home; treat that
940        // as plain "~".
941        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        // The cache entry should always reflect what was just uploaded.
970        // `path share` is "ship the current state of this session"; if
971        // the conversation has grown since a prior share, the in-memory
972        // body has the new turns but a stale cache file would not — and
973        // the upload uses the fresh body, not the cache. Always
974        // overwrite so cache and upload agree (use `--no-cache` to skip
975        // the cache write entirely).
976        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
996/// Build the TSV line fed to the picker. Three hidden parser-only
997/// columns lead the row (harness key, project/cwd, session id); a
998/// fourth column carries the pre-formatted display string from
999/// `fuzzy::render_row`; a fifth carries the raw title so
1000/// `parse_picker_row` can recover it without reparsing the display.
1001///
1002/// The display column is space-padded rather than tab-separated so the
1003/// columns line up consistently across pickers — terminal tab stops
1004/// produce ugly variable gaps in both fzf and skim.
1005fn 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
1031/// Inverse of [`format_picker_row`] — pulls (harness, key, session,
1032/// title) back out of the line the picker returned. Returns `None` if
1033/// the line is malformed.
1034fn 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    // Skip the pre-formatted display column (col 4) to reach the raw
1043    // title at col 5.
1044    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        // Empty bundle => no rows, no panic.
1199        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        // Date-bucketed layout: ~/.codex/sessions/YYYY/MM/DD/rollout-*-<id>.jsonl
1231        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        // ~/.copilot/session-state/<id>/events.jsonl (cwd under session.start.context)
1269        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        // Filtering to a different harness drops the copilot row.
1297        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        // Two claude sessions: one in cwd (older), one elsewhere (newer).
1304        // Despite the elsewhere row being newer, the cwd-match must come first.
1305        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        // Bump activity on the not-in-cwd session by writing a later timestamp.
1309        let not_dir = claude_dir.join("projects").join("-other-project");
1310        std::fs::create_dir_all(&not_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        // `paths_match` is the function that produces `SessionRow.matches_cwd`
1330        // (collect_* all delegate to it). Without canonicalization, a user who
1331        // navigated to a project via a symlink would see their cwd-row sink
1332        // in the picker because the symlink path string ≠ the project path
1333        // string. Verify both arguments are canonicalized.
1334        //
1335        // Note: we test `paths_match` directly rather than going through
1336        // `gather_sessions` because Claude's project-dir slug encoding is
1337        // lossy (sanitize_project_path: '/', '_', '.' → '-'; unsanitize: only
1338        // '-' → '/'). On macOS, tempdir paths contain '.' and end up under
1339        // /private/var/..., so the unsanitized slug never round-trips back to
1340        // the real on-disk path. This direct test covers the canonicalization
1341        // bug regardless of platform-specific tempdir layouts.
1342        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        // Sanity-check the setup: the symlink and its target are different
1349        // string-paths but resolve to the same canonical path.
1350        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        // The actual property under test.
1357        assert!(
1358            paths_match(&real_project, &symlink_path),
1359            "paths_match must canonicalize both sides so symlink == target"
1360        );
1361        // And symmetric.
1362        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        // tab_safe replaces the tab with a space, but the title content
1386        // otherwise round-trips.
1387        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"); // codex has no project; cwd carried as the keyed slot
1406        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        // "claude:" (7) needs 2 trailing spaces; "opencode:" (9) needs 0;
1481        // "pi:" (3) needs 6. The visible-path column should always start at
1482        // the same offset.
1483        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        // Bundle whose claude resolver points at a directory that doesn't
1494        // exist on disk; the status should still resolve a path and report
1495        // it as missing rather than going through the `unresolved` branch.
1496        let temp = TempDir::new().unwrap();
1497        let claude_dir = temp.path().join(".claude"); // never created
1498        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        // Every harness slot is None, so each status hits the unresolved branch.
1530        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}