Skip to main content

scv_tools/delegate/
adapters.rs

1//! The native agent CLIs SCV can delegate to, one descriptor each.
2//!
3//! A descriptor is the whole integration: the default command line, where the
4//! CLI keeps its state inside SCV's private agent home, which inherited
5//! variables it must never see, and how `scv agents login|status|logout`
6//! handle it. Adding an agent means adding one entry to [`ADAPTERS`].
7
8use std::{
9    ffi::OsStr,
10    path::{Path, PathBuf},
11};
12
13/// How SCV signs an agent in, inside its private agent home.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Login {
16    /// Run the CLI's own sign-in command.
17    Command(&'static [&'static str]),
18    /// Open the CLI interactively; `hint` names its in-app sign-in command.
19    Interactive {
20        args: &'static [&'static str],
21        hint: &'static str,
22    },
23    /// Prompt for an API key and store it in the CLI's own credential file.
24    ApiKey(KeyStore),
25    /// Copy SCV's own configuration: `scv agents import <name>`.
26    Import,
27}
28
29/// How SCV reports whether an agent is signed in.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Status {
32    /// The CLI prints its own status and exits non-zero when signed out.
33    Command(&'static [&'static str]),
34    /// SCV inspects the CLI's credential file without printing secrets.
35    Stored(KeyStore),
36}
37
38/// What a CLI prints on stdout when SCV runs it, and so how SCV reads its
39/// reply, usage, and failure out of it.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum OutputFormat {
42    /// Plain text: stdout is the reply.
43    Text,
44    /// Claude Code `--output-format stream-json --verbose`: one JSON event per
45    /// line, ending with a `result` event.
46    ClaudeStreamJson,
47    /// `codex exec --json`: JSON events per line; SCV also passes `-o <file>`
48    /// so the final message survives an unparsable stream.
49    CodexJsonl,
50    /// pi `--mode json`: JSON events per line; the reply is the last
51    /// assistant `message_end`.
52    PiJson,
53}
54
55impl OutputFormat {
56    /// Arguments that select this format, placed after the fixed arguments.
57    pub(crate) fn args(self) -> &'static [&'static str] {
58        match self {
59            Self::Text => &[],
60            Self::ClaudeStreamJson => &["--output-format", "stream-json", "--verbose"],
61            Self::CodexJsonl => &["--json"],
62            Self::PiJson => &["--mode", "json"],
63        }
64    }
65}
66
67/// How SCV talks to an agent.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Transport {
70    /// One CLI process per turn: the prompt is an argument and the reply is
71    /// read from its output ([`OutputFormat`]), continued through [`Resume`].
72    Process,
73    /// A long-running `scv server --stdio` per conversation, driven over the
74    /// SCV protocol: its tool approvals are relayed to the calling session
75    /// and its events become progress.
76    ScvProtocol,
77}
78
79/// How to start an agent's Agent Client Protocol (ACP) server: a long-running
80/// process speaking JSON-RPC 2.0 over stdio, one conversation per ACP session.
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct AcpLaunch {
83    /// The ACP server executable: the agent itself or its official adapter.
84    pub command: &'static str,
85    /// Its arguments; a `{full}` entry is replaced by `full_args` for
86    /// `permissions = "full"` and dropped otherwise.
87    pub(crate) args: &'static [&'static str],
88    pub(crate) full_args: &'static [&'static str],
89    /// The ACP session mode selected for `permissions = "full"`, for agents
90    /// whose permission level is a session mode.
91    pub full_mode: Option<&'static str>,
92    /// Environment for the ACP server under `permissions = "full"`, for
93    /// settings the server reads only from its environment.
94    pub full_environment: &'static [(&'static str, &'static str)],
95}
96
97/// Expand `launch.args` for the configured permission level.
98pub fn acp_args(launch: &AcpLaunch, full: bool) -> Vec<String> {
99    let mut args = Vec::with_capacity(launch.args.len() + launch.full_args.len());
100    for arg in launch.args {
101        if *arg == "{full}" {
102            if full {
103                args.extend(launch.full_args.iter().map(|arg| (*arg).to_owned()));
104            }
105        } else {
106            args.push((*arg).to_owned());
107        }
108    }
109    args
110}
111
112/// How a CLI continues an earlier conversation. `{session}` in any argument
113/// is replaced by the conversation's vendor session ID.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Resume {
116    /// Every call starts a fresh conversation.
117    Unsupported,
118    Supported {
119        /// Starts a conversation under an ID SCV chooses. Empty when the CLI
120        /// picks its own ID and reports it in its output (Codex).
121        start: &'static [&'static str],
122        /// Placed right after the fixed arguments when continuing: a
123        /// subcommand such as Codex's `exec resume`.
124        subcommand: &'static [&'static str],
125        /// Options that continue the conversation.
126        options: &'static [&'static str],
127        /// Placed immediately before the prompt when continuing, for a CLI
128        /// that takes the session ID as a positional argument.
129        positional: &'static [&'static str],
130    },
131}
132
133impl Resume {
134    pub(crate) fn is_supported(self) -> bool {
135        matches!(self, Self::Supported { .. })
136    }
137
138    /// Whether SCV chooses the vendor session ID when a conversation starts.
139    pub(crate) fn assigns_id(self) -> bool {
140        matches!(self, Self::Supported { start, .. } if !start.is_empty())
141    }
142}
143
144/// Where a CLI keeps conversation transcripts inside its agent home:
145/// files with `extension` anywhere below `dir`, named after their session ID.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ConversationFiles {
148    pub(crate) dir: &'static str,
149    pub(crate) extension: &'static str,
150}
151
152/// How SCV condenses a CLI's own status output. The raw output names the
153/// account (an email) or part of a key, so it is never printed.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum StatusSummary {
156    /// `claude auth status` JSON: `loggedIn`, `authMethod`, `subscriptionType`.
157    ClaudeJson,
158    /// `codex login status` text: "Logged in using an API key" or "ChatGPT".
159    CodexText,
160    /// The exit status alone.
161    ExitStatus,
162}
163
164/// How SCV signs an agent out.
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum Logout {
167    Command(&'static [&'static str]),
168    /// SCV removes the credentials it can see in the CLI's own files.
169    Stored(KeyStore),
170}
171
172/// A CLI's native credential file, relative to the agent home.
173#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum KeyStore {
175    /// Grok: sign-ins from `grok login` in `auth` (a JSON object of entries),
176    /// or an API key in the `config` profile of its default model.
177    Grok {
178        auth: &'static str,
179        config: &'static str,
180    },
181    /// DeepSeek Harness `.credentials.yaml`, holding `refs.<variable>`.
182    DshRefs {
183        path: &'static str,
184        variable: &'static str,
185    },
186    /// pi's agent directory: `auth.json`, plus the SCV-configured
187    /// OpenAI-compatible endpoint in `models.json` and `settings.json`.
188    Pi { dir: &'static str },
189    /// A nested SCV's own `config.toml`, holding the provider copied from the
190    /// user's SCV by `scv agents import scv`.
191    Scv { config: &'static str },
192}
193
194#[derive(Debug, Clone, Copy)]
195pub struct AdapterDescriptor {
196    /// Short name: the `agent` tool's value for it and the home `agents/<name>`.
197    pub name: &'static str,
198    /// Product name for messages and the tool description.
199    pub product: &'static str,
200    /// What this harness offers, as one factual clause for the tool
201    /// description, so the model can choose between agents.
202    pub(crate) offers: &'static str,
203    pub command: &'static str,
204    pub args: &'static [&'static str],
205    /// Placed immediately before the prompt, for CLIs whose prompt is a flag
206    /// value (`grok -p <prompt>`).
207    pub prompt_args: &'static [&'static str],
208    pub model_args: &'static [&'static str],
209    pub effort_args: &'static [&'static str],
210    /// Describes the `model` argument for the calling model.
211    pub model_hint: &'static str,
212    /// Variables pointing the CLI's state into the agent home, as paths
213    /// relative to it (`""` is the home itself).
214    pub home_environment: &'static [(&'static str, &'static str)],
215    /// Fixed variables for every delegated run.
216    pub fixed_environment: &'static [(&'static str, &'static str)],
217    /// Credential, endpoint, and state-location variables no delegated agent
218    /// inherits. A trailing `*` matches a prefix.
219    pub(crate) removed_environment: &'static [&'static str],
220    /// Added after `args` when `[agents.<name>] permissions = "full"`: the
221    /// CLI's own switches that turn off its approval prompts and sandbox and
222    /// enable web search where the CLI gates it. Empty when the CLI has no
223    /// permission system of its own.
224    pub full_permission_args: &'static [&'static str],
225    /// Variables set for `permissions = "full"`, for CLIs configured that way.
226    pub full_permission_environment: &'static [(&'static str, &'static str)],
227    /// Per-user install directories searched before `PATH`, relative to the
228    /// user's home, as a login shell orders them. A user service's `PATH`
229    /// omits them, so without this the daemon would miss or pick a different
230    /// install than the user's shell.
231    pub(crate) search_dirs: &'static [&'static str],
232    pub login: Login,
233    pub status: Status,
234    /// How a [`Status::Command`] result is summarized.
235    pub status_summary: StatusSummary,
236    pub logout: Logout,
237    /// What the CLI prints when SCV delegates to it.
238    pub output: OutputFormat,
239    /// How SCV continues a conversation with it, when it can.
240    pub resume: Resume,
241    /// Transcripts `scv agents gc` may remove; `None` when unknown.
242    pub conversation_files: Option<ConversationFiles>,
243    /// Files in the agent home that hold its sign-in or keys, relative to it,
244    /// which `scv config show` reports without reading.
245    pub credential_files: &'static [&'static str],
246    /// How SCV talks to the agent.
247    pub transport: Transport,
248    /// Its ACP server, when it has a verified one. With `[agents.<name>]
249    /// transport = "auto"` SCV prefers it over [`Transport::Process`] once the
250    /// command is installed.
251    pub acp: Option<AcpLaunch>,
252}
253
254/// Directories every adapter searches before `PATH`, relative to the user's home.
255const USER_BIN_DIRS: &[&str] = &[".local/bin"];
256
257/// Removed from every agent regardless of adapter: SCV's own selectors and
258/// cloud keys that name no single agent. Any variable ending in `_API_KEY`
259/// is removed as well.
260const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
261    "SCV_CONFIG",
262    "SCV_MODEL",
263    "SCV_PROVIDER",
264    "SCV_BASE_URL",
265    "SCV_API_KEY_ENV",
266    "GEMINI_API_KEY",
267    "GOOGLE_API_KEY",
268    "AZURE_OPENAI_API_KEY",
269    "AZURE_OPENAI_ENDPOINT",
270];
271
272const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
273const SCV_STORE: KeyStore = KeyStore::Scv {
274    config: "config.toml",
275};
276const DSH_STORE: KeyStore = KeyStore::DshRefs {
277    path: ".dsh/.credentials.yaml",
278    variable: "DEEPSEEK_API_KEY",
279};
280
281pub const ADAPTERS: &[AdapterDescriptor] = &[
282    AdapterDescriptor {
283        name: "claude",
284        product: "Claude Code",
285        offers: "Anthropic's coding agent; it reads, edits, and runs code in a project and can search and fetch the web",
286        command: "claude",
287        args: &["-p"],
288        prompt_args: &[],
289        model_args: &["--model", "{model}"],
290        effort_args: &["--effort", "{effort}"],
291        model_hint: "Claude model alias or ID, such as sonnet or opus.",
292        home_environment: &[],
293        fixed_environment: &[],
294        removed_environment: &[
295            "ANTHROPIC_API_KEY",
296            "ANTHROPIC_BASE_URL",
297            "ANTHROPIC_AUTH_TOKEN",
298            "CLAUDE_CODE_OAUTH_TOKEN",
299            "CLAUDE_CONFIG_DIR",
300        ],
301        // Also allows WebSearch and WebFetch without prompting.
302        full_permission_args: &["--permission-mode", "bypassPermissions"],
303        full_permission_environment: &[],
304        search_dirs: &[],
305        login: Login::Command(&["auth", "login"]),
306        status: Status::Command(&["auth", "status"]),
307        status_summary: StatusSummary::ClaudeJson,
308        logout: Logout::Command(&["auth", "logout"]),
309        output: OutputFormat::ClaudeStreamJson,
310        // `--resume` in print mode keeps the original session ID.
311        resume: Resume::Supported {
312            start: &["--session-id", "{session}"],
313            subcommand: &[],
314            options: &["--resume", "{session}"],
315            positional: &[],
316        },
317        conversation_files: Some(ConversationFiles {
318            dir: ".claude/projects",
319            extension: "jsonl",
320        }),
321        credential_files: &[".claude/.credentials.json"],
322        transport: Transport::Process,
323        // The official adapter from the ACP organisation (npm
324        // @agentclientprotocol/claude-agent-acp), on the Claude Agent SDK.
325        acp: Some(AcpLaunch {
326            command: "claude-agent-acp",
327            args: &[],
328            full_args: &[],
329            full_mode: Some("bypassPermissions"),
330            full_environment: &[],
331        }),
332    },
333    AdapterDescriptor {
334        name: "codex",
335        product: "Codex",
336        offers: "OpenAI's coding agent; it reads, edits, and runs code in a project, with live web search under full permissions",
337        command: "codex",
338        args: &["exec"],
339        prompt_args: &[],
340        model_args: &["-m", "{model}"],
341        effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
342        model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
343        home_environment: &[("CODEX_HOME", "")],
344        fixed_environment: &[],
345        removed_environment: &[
346            "OPENAI_API_KEY",
347            "OPENAI_BASE_URL",
348            "OPENAI_ORG_ID",
349            "OPENAI_PROJECT_ID",
350            "CODEX_API_KEY",
351            "CODEX_BASE_URL",
352            "CODEX_CONFIG",
353        ],
354        // `codex exec` has no `--search`; `web_search = "live"` is its config form.
355        full_permission_args: &[
356            "--dangerously-bypass-approvals-and-sandbox",
357            "-c",
358            "web_search=\"live\"",
359        ],
360        full_permission_environment: &[],
361        search_dirs: &[],
362        login: Login::Command(&["login"]),
363        status: Status::Command(&["login", "status"]),
364        status_summary: StatusSummary::CodexText,
365        logout: Logout::Command(&["logout"]),
366        output: OutputFormat::CodexJsonl,
367        // The thread ID arrives in `thread.started`; `exec resume` takes it
368        // as a positional argument before the prompt.
369        resume: Resume::Supported {
370            start: &[],
371            subcommand: &["resume"],
372            options: &[],
373            positional: &["{session}"],
374        },
375        conversation_files: Some(ConversationFiles {
376            dir: "sessions",
377            extension: "jsonl",
378        }),
379        credential_files: &["auth.json"],
380        transport: Transport::Process,
381        // The official adapter from the ACP organisation (npm
382        // @agentclientprotocol/codex-acp). It reads `$CODEX_HOME/config.toml`
383        // and takes no `-c` overrides; `CODEX_CONFIG` is its JSON form of
384        // them, merged into every session, so full permissions keep live web
385        // search without rewriting the imported config.
386        acp: Some(AcpLaunch {
387            command: "codex-acp",
388            args: &[],
389            full_args: &[],
390            full_mode: Some("agent-full-access"),
391            full_environment: &[("CODEX_CONFIG", r#"{"web_search":"live"}"#)],
392        }),
393    },
394    AdapterDescriptor {
395        name: "grok",
396        product: "Grok Build",
397        offers: "xAI's coding agent; it reads, edits, and runs code in a project and has live web and X search. Call it when another agent declined or refused a request, including a safety or guardrail refusal",
398        command: "grok",
399        args: &[],
400        prompt_args: &["-p"],
401        model_args: &["-m", "{model}"],
402        effort_args: &["--reasoning-effort", "{effort}"],
403        model_hint: "xAI Grok model ID, such as grok-4.7.",
404        home_environment: &[("GROK_HOME", ".grok")],
405        fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
406        removed_environment: &["GROK_*", "XAI_API_KEY"],
407        // Web search is on unless `--disable-web-search` is passed.
408        full_permission_args: &["--always-approve"],
409        full_permission_environment: &[],
410        search_dirs: &[".grok/bin"],
411        login: Login::Command(&["login"]),
412        status: Status::Stored(KeyStore::Grok {
413            auth: ".grok/auth.json",
414            config: ".grok/config.toml",
415        }),
416        status_summary: StatusSummary::ExitStatus,
417        logout: Logout::Command(&["logout"]),
418        // `--output-format json` exists but its success shape is unverified here.
419        output: OutputFormat::Text,
420        // Grok documents `--session-id` and `--resume`, but they cannot be
421        // verified while it is signed out here.
422        resume: Resume::Unsupported,
423        conversation_files: None,
424        credential_files: &[".grok/auth.json", ".grok/config.toml"],
425        transport: Transport::Process,
426        // Native: `grok agent [options] stdio`; options precede the mode.
427        acp: Some(AcpLaunch {
428            command: "grok",
429            args: &["agent", "{full}", "stdio"],
430            full_args: &["--always-approve"],
431            full_mode: None,
432            full_environment: &[],
433        }),
434    },
435    AdapterDescriptor {
436        name: "dsh",
437        product: "DeepSeek Harness",
438        offers: "a coding agent on DeepSeek models; it reads, edits, and runs code in a project",
439        command: "dsh",
440        args: &["--profile", "headless"],
441        prompt_args: &[],
442        model_args: &[],
443        effort_args: &[],
444        model_hint: "Model ID in the form this agent's CLI accepts.",
445        home_environment: &[("DSH_HOME", ".dsh")],
446        fixed_environment: &[],
447        removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
448        // Bypasses its file sandbox and sets its approval policy to `never`.
449        full_permission_args: &[],
450        full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
451        search_dirs: &[],
452        login: Login::ApiKey(DSH_STORE),
453        status: Status::Stored(DSH_STORE),
454        status_summary: StatusSummary::ExitStatus,
455        logout: Logout::Stored(DSH_STORE),
456        output: OutputFormat::Text,
457        // Only its interactive profile documents `--resume`.
458        resume: Resume::Unsupported,
459        conversation_files: None,
460        credential_files: &[".dsh/.credentials.yaml"],
461        transport: Transport::Process,
462        // Native: the shipped `acp` profile. `permissions = "full"` is the
463        // `DSH_PERMISSION_MODE` variable above.
464        acp: Some(AcpLaunch {
465            command: "dsh",
466            args: &["--profile", "acp"],
467            full_args: &[],
468            full_mode: None,
469            full_environment: &[],
470        }),
471    },
472    AdapterDescriptor {
473        name: "pi",
474        product: "pi",
475        offers: "a minimal coding agent (read, write, edit, bash) that can run on SCV's own model endpoint; it has no web search",
476        command: "pi",
477        args: &["-p"],
478        prompt_args: &[],
479        model_args: &["--model", "{model}"],
480        effort_args: &["--thinking", "{effort}"],
481        model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
482        home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
483        fixed_environment: &[],
484        removed_environment: &["PI_*"],
485        // pi has no approval prompts or sandbox, and no built-in web search.
486        full_permission_args: &[],
487        full_permission_environment: &[],
488        search_dirs: &[],
489        login: Login::Interactive {
490            args: &[],
491            hint: "run /login and choose a provider, then /quit",
492        },
493        status: Status::Stored(PI_STORE),
494        status_summary: StatusSummary::ExitStatus,
495        logout: Logout::Stored(PI_STORE),
496        output: OutputFormat::PiJson,
497        // `--session-id` uses the exact project session, creating it if missing.
498        resume: Resume::Supported {
499            start: &["--session-id", "{session}"],
500            subcommand: &[],
501            options: &["--session-id", "{session}"],
502            positional: &[],
503        },
504        conversation_files: Some(ConversationFiles {
505            dir: ".pi/agent/sessions",
506            extension: "jsonl",
507        }),
508        credential_files: &[".pi/agent/auth.json", ".pi/agent/models.json"],
509        transport: Transport::Process,
510        // Only a community ACP adapter exists.
511        acp: None,
512    },
513    AdapterDescriptor {
514        name: "scv",
515        product: "SCV",
516        offers: "a nested SCV session with its own context and tools; suited to a self-contained sub-task kept out of this conversation's context, or work in another project",
517        command: "scv",
518        args: &["server", "--stdio"],
519        prompt_args: &[],
520        // A model is chosen per conversation through `session.start`.
521        model_args: &[],
522        effort_args: &[],
523        model_hint: "Model ID for the nested SCV's provider; applies to a new conversation only.",
524        // `SCV_HOME` already points at the agent home, where the nested
525        // SCV keeps its config, skills, and its own delegations.
526        home_environment: &[],
527        fixed_environment: &[],
528        removed_environment: &[],
529        // Its tool approvals are relayed to the calling session instead.
530        full_permission_args: &[],
531        full_permission_environment: &[],
532        // Where `cargo install` puts `scv`; a user service's PATH omits it.
533        search_dirs: &[".cargo/bin"],
534        login: Login::Import,
535        status: Status::Stored(SCV_STORE),
536        status_summary: StatusSummary::ExitStatus,
537        logout: Logout::Stored(SCV_STORE),
538        output: OutputFormat::Text,
539        resume: Resume::Unsupported,
540        conversation_files: None,
541        credential_files: &["config.toml"],
542        transport: Transport::ScvProtocol,
543        acp: None,
544    },
545];
546
547/// The descriptor for `name`, such as `"codex"`.
548pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
549    ADAPTERS.iter().find(|adapter| adapter.name == name)
550}
551
552/// Whether a delegated agent must not inherit `variable`: SCV's selectors,
553/// any `*_API_KEY`, and every adapter's credential and state variables, so
554/// no agent sees another's credentials either.
555pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
556    let Some(variable) = variable.to_str() else {
557        return false;
558    };
559    variable.ends_with("_API_KEY")
560        || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
561        || ADAPTERS
562            .iter()
563            .flat_map(|adapter| adapter.removed_environment)
564            .any(|rule| match rule.strip_suffix('*') {
565                Some(prefix) => variable.starts_with(prefix),
566                None => variable == *rule,
567            })
568}
569
570/// One line describing a CLI's own status result without echoing it: the raw
571/// output names the signed-in account or part of a key.
572pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
573    let signed_out = "not signed in".to_owned();
574    match summary {
575        StatusSummary::ClaudeJson => {
576            // The first JSON value; anything after it (such as stderr) is ignored.
577            let first = serde_json::Deserializer::from_str(output)
578                .into_iter::<serde_json::Value>()
579                .next();
580            let Some(Ok(value)) = first else {
581                return if succeeded {
582                    "signed in".into()
583                } else {
584                    signed_out
585                };
586            };
587            if value.get("loggedIn").and_then(serde_json::Value::as_bool) != Some(true) {
588                return signed_out;
589            }
590            let method = match value.get("authMethod").and_then(serde_json::Value::as_str) {
591                Some("claude.ai") => "Claude account",
592                Some("api_key" | "apiKey" | "console") => "API key",
593                Some("oauth_token" | "oauthToken") => "OAuth token",
594                _ => "other method",
595            };
596            match value
597                .get("subscriptionType")
598                .and_then(serde_json::Value::as_str)
599                .filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
600            {
601                Some(plan) => format!("signed in ({method}, {plan})"),
602                None => format!("signed in ({method})"),
603            }
604        }
605        StatusSummary::CodexText => {
606            let lower = output.to_ascii_lowercase();
607            if !succeeded || lower.contains("not logged in") {
608                signed_out
609            } else if lower.contains("api key") {
610                "signed in (API key)".into()
611            } else if lower.contains("chatgpt") {
612                "signed in (ChatGPT account)".into()
613            } else {
614                "signed in".into()
615            }
616        }
617        StatusSummary::ExitStatus => {
618            if succeeded {
619                "signed in".into()
620            } else {
621                signed_out
622            }
623        }
624    }
625}
626
627/// Resolve `command` in the per-user `search_dirs`, then on `PATH`. A command
628/// containing a path separator is used as given.
629pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
630    if command.contains('/') {
631        let path = Path::new(command);
632        return path.is_file().then(|| path.to_path_buf());
633    }
634    std::env::join_paths(search_dirs)
635        .ok()
636        .and_then(|dirs| {
637            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
638            which::which_in(command, Some(dirs), cwd).ok()
639        })
640        .or_else(|| which::which(command).ok())
641}
642
643/// Absolute per-user search directories for `adapter` under `home`.
644pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
645    adapter
646        .search_dirs
647        .iter()
648        .chain(USER_BIN_DIRS)
649        .map(|dir| home.join(dir))
650        .collect()
651}
652
653#[cfg(test)]
654mod tests;