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    Opencode,
20    Pi,
21}
22
23#[derive(Args, Debug)]
24pub struct ShareArgs {
25    /// Pathbase server URL (defaults to the stored session's server)
26    #[arg(long)]
27    pub url: Option<String>,
28
29    /// Force the anonymous endpoint, ignoring any stored credentials
30    #[arg(long, conflicts_with_all = ["repo", "public"])]
31    pub anon: bool,
32
33    /// Target a specific repo as `owner/name` instead of `<you>/pathstash`
34    #[arg(long, value_parser = crate::cmd_export::parse_repo_spec)]
35    pub repo: Option<RepoSpec>,
36
37    /// Human-readable display label for the uploaded graph
38    /// (defaults to the toolpath document id). Free-form; not used
39    /// in the URL — graphs are addressed by UUID server-side.
40    #[arg(long, alias = "slug")]
41    pub name: Option<String>,
42
43    /// Mark the uploaded graph public (default: unlisted, addressable only by UUID)
44    #[arg(long)]
45    pub public: bool,
46
47    /// Narrow the picker to one harness, or skip the picker entirely
48    /// when used with --session.
49    #[arg(long, value_enum)]
50    pub harness: Option<HarnessArg>,
51
52    /// Skip the picker. Requires --harness; requires --project for
53    /// claude/gemini/pi.
54    #[arg(long, requires = "harness")]
55    pub session: Option<String>,
56
57    /// Override cwd-as-project. Filters the picker to sessions tied to
58    /// this project across all harnesses.
59    #[arg(long)]
60    pub project: Option<PathBuf>,
61
62    /// Skip writing the cache; derive in-memory only
63    #[arg(long)]
64    pub no_cache: bool,
65}
66
67/// Which agent harness a session was produced by.
68#[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    /// Padded so all five symbols line up in the fzf column.
89    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    /// True when the underlying provider keys sessions by project path.
100    /// claude/gemini/pi: true. codex/opencode: false (sessions store cwd
101    /// per-row, not as a directory key).
102    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/// One row in the unified session picker.
129#[derive(Debug, Clone)]
130pub(crate) struct SessionRow {
131    pub(crate) harness: Harness,
132    /// Project path for keyed providers; `None` for codex/opencode.
133    pub(crate) project: Option<String>,
134    /// Recorded cwd from the session (codex/opencode only).
135    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/// Bundle of provider managers used during aggregation. Production code
144/// builds this from real `$HOME` via `from_environment`; tests construct
145/// it directly with provider-specific resolvers.
146#[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    /// Build the production bundle. Each provider is included
157    /// unconditionally (its `new()` doesn't fail on a missing home dir);
158    /// `gather_sessions` skips the ones whose listing returns empty/NotFound.
159    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
170/// Aggregate sessions across the harnesses in `bundle`, ranked so that
171/// rows whose project (or recorded cwd) canonicalizes to `cwd` come
172/// first, sorted by descending `last_activity`.
173///
174/// Filters: `harness_filter` keeps only rows from one harness; `project_filter`
175/// keeps only rows whose project (for keyed) or cwd (for session-keyed)
176/// canonicalizes to that path.
177pub(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            // SessionMeta.timestamp is a String; parse to DateTime when possible.
357            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    // Build upload args + base URL once and reuse for both the explicit
506    // path and the picker path. `needs_auth` decides whether preflight
507    // can fall back to anon on credential failure.
508    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        // Explicit-args: validate creds before derive so a credential
520        // failure doesn't waste the derive/cache work.
521        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    // We have rows AND fzf available — now validate credentials before
546    // making the user pick a session. If preflight returns Anon (either
547    // explicit --anon, no creds + no auth flags, or auth probe failed
548    // and fell back), the picker still fires with that knowledge baked in.
549    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        // Stacked layout: preview above the list, list below. Fits narrow
558        // terminals better than the default side-by-side and gives the
559        // session preview the full terminal width to render `path show`.
560        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            // Selected with an empty payload should not happen (fzf exits 0
569            // only when at least one row was confirmed), but treat it like
570            // no-match for safety.
571            None => return Ok(()),
572        },
573        // No row matched the query — exit 0, same as today, no extra noise.
574        crate::fuzzy::PickResult::NoMatch => return Ok(()),
575        // Esc / Ctrl-C: deliberate user cancel. Signal to the shell with
576        // exit 130 so it's distinguishable from a successful share.
577        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, // unused by share_explicit
590        project: if h.project_keyed() {
591            Some(PathBuf::from(&key))
592        } else {
593            None
594        },
595        no_cache: args.no_cache,
596    };
597    // Show the conversation title in the confirmation line; the session id
598    // is opaque and doesn't help the user verify they picked the right
599    // thing. `{:?}` adds the surrounding quotes per the spec.
600    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    // Pad harness names so the path column lines up: "opencode:" is the
627    // longest at 9 chars (8 + colon).
628    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
653/// Cross-platform `$HOME` lookup matching the providers' internal helpers.
654/// Returns `None` only when neither `$HOME` nor `$USERPROFILE` is set.
655fn 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/// Human-readable status of a harness's on-disk store: either the (possibly
662/// home-relative) path with a "(0 sessions)" hint, or the path with a
663/// "not found" hint when the directory/database is absent.
664#[derive(Debug, PartialEq, Eq)]
665struct HarnessStatus {
666    /// Display path (tilde-prefixed when under `$HOME`).
667    path: String,
668    /// True when the path exists on disk.
669    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    /// Status when the resolver itself failed (e.g. no $HOME).
682    fn unresolved() -> Self {
683        Self {
684            path: "<no home directory>".to_string(),
685            exists: false,
686        }
687    }
688}
689
690/// Format a single status line, padding the harness name so that the path
691/// column lines up across all five rows. The longest name is "opencode" (8).
692fn 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
762/// Display `path` as `~/relative/part` when it's under `home`, otherwise
763/// return its absolute lossy form. Pure helper — does no filesystem I/O.
764fn 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        // strip_prefix returns the empty path when path == home; treat that
769        // as plain "~".
770        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        // The cache entry should always reflect what was just uploaded.
799        // `path share` is "ship the current state of this session"; if
800        // the conversation has grown since a prior share, the in-memory
801        // body has the new turns but a stale cache file would not — and
802        // the upload uses the fresh body, not the cache. Always
803        // overwrite so cache and upload agree (use `--no-cache` to skip
804        // the cache write entirely).
805        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
825/// Build the TSV line fed to the picker. Three hidden parser-only
826/// columns lead the row (harness key, project/cwd, session id); a
827/// fourth column carries the pre-formatted display string from
828/// `fuzzy::render_row`; a fifth carries the raw title so
829/// `parse_picker_row` can recover it without reparsing the display.
830///
831/// The display column is space-padded rather than tab-separated so the
832/// columns line up consistently across pickers — terminal tab stops
833/// produce ugly variable gaps in both fzf and skim.
834fn 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
860/// Inverse of [`format_picker_row`] — pulls (harness, key, session,
861/// title) back out of the line the picker returned. Returns `None` if
862/// the line is malformed.
863fn 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    // Skip the pre-formatted display column (col 4) to reach the raw
872    // title at col 5.
873    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        // Empty bundle => no rows, no panic.
1023        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        // Date-bucketed layout: ~/.codex/sessions/YYYY/MM/DD/rollout-*-<id>.jsonl
1055        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        // Two claude sessions: one in cwd (older), one elsewhere (newer).
1084        // Despite the elsewhere row being newer, the cwd-match must come first.
1085        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        // Bump activity on the not-in-cwd session by writing a later timestamp.
1089        let not_dir = claude_dir.join("projects").join("-other-project");
1090        std::fs::create_dir_all(&not_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        // `paths_match` is the function that produces `SessionRow.matches_cwd`
1110        // (collect_* all delegate to it). Without canonicalization, a user who
1111        // navigated to a project via a symlink would see their cwd-row sink
1112        // in the picker because the symlink path string ≠ the project path
1113        // string. Verify both arguments are canonicalized.
1114        //
1115        // Note: we test `paths_match` directly rather than going through
1116        // `gather_sessions` because Claude's project-dir slug encoding is
1117        // lossy (sanitize_project_path: '/', '_', '.' → '-'; unsanitize: only
1118        // '-' → '/'). On macOS, tempdir paths contain '.' and end up under
1119        // /private/var/..., so the unsanitized slug never round-trips back to
1120        // the real on-disk path. This direct test covers the canonicalization
1121        // bug regardless of platform-specific tempdir layouts.
1122        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        // Sanity-check the setup: the symlink and its target are different
1129        // string-paths but resolve to the same canonical path.
1130        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        // The actual property under test.
1137        assert!(
1138            paths_match(&real_project, &symlink_path),
1139            "paths_match must canonicalize both sides so symlink == target"
1140        );
1141        // And symmetric.
1142        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        // tab_safe replaces the tab with a space, but the title content
1166        // otherwise round-trips.
1167        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"); // codex has no project; cwd carried as the keyed slot
1186        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        // "claude:" (7) needs 2 trailing spaces; "opencode:" (9) needs 0;
1261        // "pi:" (3) needs 6. The visible-path column should always start at
1262        // the same offset.
1263        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        // Bundle whose claude resolver points at a directory that doesn't
1274        // exist on disk; the status should still resolve a path and report
1275        // it as missing rather than going through the `unresolved` branch.
1276        let temp = TempDir::new().unwrap();
1277        let claude_dir = temp.path().join(".claude"); // never created
1278        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        // Every harness slot is None, so each status hits the unresolved branch.
1310        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}