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    /// SCV has no verified way to tell: the agent is offered whenever it is
37    /// installed, a call that fails signed out gets the sign-in hint, and
38    /// `scv agents check` tells by calling it.
39    Unchecked,
40}
41
42/// What a CLI prints on stdout when SCV runs it, and so how SCV reads its
43/// reply, usage, and failure out of it.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum OutputFormat {
46    /// Plain text: stdout is the reply.
47    Text,
48    /// Claude Code `--output-format stream-json --verbose`: one JSON event per
49    /// line, ending with a `result` event.
50    ClaudeStreamJson,
51    /// `codex exec --json`: JSON events per line; SCV also passes `-o <file>`
52    /// so the final message survives an unparsable stream.
53    CodexJsonl,
54    /// pi `--mode json`: JSON events per line; the reply is the last
55    /// assistant `message_end`.
56    PiJson,
57    /// Gemini CLI `--output-format stream-json`: `init` (the session ID),
58    /// `message` (deltas or whole messages), `tool_use`, `tool_result`,
59    /// `error`, and a final `result` with usage; the reply is the assistant
60    /// text after the last tool call. A single `{"response":…}` object (its
61    /// `json` format) reads too.
62    GeminiStreamJson,
63    /// Cursor CLI `--output-format stream-json`: Claude Code's event shapes
64    /// (`system` init, `assistant`, `result`) plus `tool_call` events.
65    CursorStreamJson,
66    /// Amp `--stream-json`, which follows Claude Code's stream-json events.
67    AmpStreamJson,
68}
69
70impl OutputFormat {
71    /// Arguments that select this format, placed after the fixed arguments.
72    pub(crate) fn args(self) -> &'static [&'static str] {
73        match self {
74            Self::Text => &[],
75            Self::ClaudeStreamJson => &["--output-format", "stream-json", "--verbose"],
76            Self::CodexJsonl => &["--json"],
77            Self::PiJson => &["--mode", "json"],
78            Self::GeminiStreamJson | Self::CursorStreamJson => &["--output-format", "stream-json"],
79            Self::AmpStreamJson => &["--stream-json"],
80        }
81    }
82
83    /// Whether the reply, usage, and session come in Claude Code's
84    /// stream-json event shapes.
85    pub(crate) fn claude_like(self) -> bool {
86        matches!(
87            self,
88            Self::ClaudeStreamJson | Self::CursorStreamJson | Self::AmpStreamJson
89        )
90    }
91}
92
93/// How SCV talks to an agent.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum Transport {
96    /// One CLI process per turn: the prompt is an argument and the reply is
97    /// read from its output ([`OutputFormat`]), continued through [`Resume`].
98    Process,
99    /// A long-running `scv server --stdio` per conversation, driven over the
100    /// SCV protocol: its tool approvals are relayed to the calling session
101    /// and its events become progress.
102    ScvProtocol,
103}
104
105/// How to start an agent's Agent Client Protocol (ACP) server: a long-running
106/// process speaking JSON-RPC 2.0 over stdio, one conversation per ACP session.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct AcpLaunch {
109    /// The ACP server executable: the agent itself or its official adapter.
110    pub command: &'static str,
111    /// Its arguments; a `{full}` entry is replaced by `full_args` under
112    /// policy mode `auto` and dropped otherwise.
113    pub(crate) args: &'static [&'static str],
114    pub(crate) full_args: &'static [&'static str],
115    /// The ACP session mode that grants full permissions, for agents whose
116    /// permission level is a session mode. SCV selects it under `auto` and
117    /// checks the mode the session reports against it under every policy
118    /// mode.
119    pub full_mode: Option<&'static str>,
120    /// Environment for the ACP server under `auto`, for settings the server
121    /// reads only from its environment.
122    pub full_environment: &'static [(&'static str, &'static str)],
123    /// The server takes `model` and `effort` as session config options even
124    /// though the CLI run once per turn takes neither as an argument, as
125    /// DeepSeek Harness's does. Other agents take them over ACP where their
126    /// CLI takes them as arguments, unless `no_session_options`.
127    pub session_options: bool,
128    /// The server takes neither `model` nor `effort` from SCV, though the
129    /// CLI run once per turn may take them as arguments: Gemini CLI's server
130    /// changes a session's model only through `session/set_model`, which
131    /// SCV does not send. Never set with `session_options`.
132    pub no_session_options: bool,
133}
134
135/// Expand `launch.args` for the policy mode: `full` under `auto`.
136pub fn acp_args(launch: &AcpLaunch, full: bool) -> Vec<String> {
137    let mut args = Vec::with_capacity(launch.args.len() + launch.full_args.len());
138    for arg in launch.args {
139        if *arg == "{full}" {
140            if full {
141                args.extend(launch.full_args.iter().map(|arg| (*arg).to_owned()));
142            }
143        } else {
144            args.push((*arg).to_owned());
145        }
146    }
147    args
148}
149
150/// Whether the owner can take a conversation over in the CLI's own
151/// interactive terminal (`attach`). A terminal is offered only where it
152/// asks nothing that SCV's approval gate decides: keys typed into it must
153/// never answer an approval.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum Takeover {
156    /// No verified interactive mode that resumes a session by its ID.
157    Unsupported,
158    /// Its terminal answers approval prompts that SCV's gate must decide;
159    /// the text says which, for the refusal.
160    Refused(&'static str),
161    /// `args` (with `{session}` replaced) open the CLI's interactive UI on
162    /// the conversation's own session, and `full_args`, its full-autonomy
163    /// switches, turn every approval prompt off. The takeover is offered
164    /// only under policy mode `auto`: with its prompts on, the CLI asks in
165    /// its terminal what it relays to SCV's gate when SCV runs it.
166    Supported {
167        args: &'static [&'static str],
168        full_args: &'static [&'static str],
169    },
170}
171
172/// Why Claude Code's terminal is never offered.
173const CLAUDE_TAKEOVER: &str = "Claude Code's own terminal asks before some actions even with its \
174     prompts off (deleting files, publishing), and keys typed there would answer those prompts \
175     instead of SCV's approval gate";
176
177/// How a CLI continues an earlier conversation. `{session}` in any argument
178/// is replaced by the conversation's vendor session ID.
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum Resume {
181    /// Every call starts a fresh conversation.
182    Unsupported,
183    Supported {
184        /// Starts a conversation under an ID SCV chooses. Empty when the CLI
185        /// picks its own ID and reports it in its output (Codex).
186        start: &'static [&'static str],
187        /// Placed right after the fixed arguments when continuing: a
188        /// subcommand such as Codex's `exec resume`.
189        subcommand: &'static [&'static str],
190        /// Options that continue the conversation.
191        options: &'static [&'static str],
192        /// Placed immediately before the prompt when continuing, for a CLI
193        /// that takes the session ID as a positional argument.
194        positional: &'static [&'static str],
195    },
196}
197
198impl Resume {
199    pub(crate) fn is_supported(self) -> bool {
200        matches!(self, Self::Supported { .. })
201    }
202
203    /// Whether SCV chooses the vendor session ID when a conversation starts.
204    pub(crate) fn assigns_id(self) -> bool {
205        matches!(self, Self::Supported { start, .. } if !start.is_empty())
206    }
207}
208
209/// Where a CLI keeps conversation transcripts inside its agent home:
210/// files with `extension` anywhere below `dir`, named after their session ID.
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub struct ConversationFiles {
213    pub(crate) dir: &'static str,
214    pub(crate) extension: &'static str,
215    /// Names carry only this many leading characters of the session ID, as
216    /// Gemini CLI's `session-<time>-<first 8>.jsonl` do; `None` when they
217    /// carry all of it.
218    pub(crate) id_prefix: Option<usize>,
219}
220
221/// How SCV condenses a CLI's own status output. The raw output names the
222/// account (an email) or part of a key, so it is never printed.
223#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum StatusSummary {
225    /// `codex login status` text: "Logged in using an API key" or "ChatGPT".
226    CodexText,
227    /// The exit status alone.
228    ExitStatus,
229}
230
231/// How SCV signs an agent out.
232#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum Logout {
234    Command(&'static [&'static str]),
235    /// SCV removes the credentials it can see in the CLI's own files.
236    Stored(KeyStore),
237    /// Open the CLI interactively; `hint` names its in-app sign-out.
238    Interactive {
239        args: &'static [&'static str],
240        hint: &'static str,
241    },
242}
243
244/// A CLI's native credential file, relative to the agent home.
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum KeyStore {
247    /// Claude Code's API key file, plus the native credentials file checked
248    /// for a conflicting Claude subscription login.
249    ClaudeApiKey {
250        key: &'static str,
251        credentials: &'static str,
252    },
253    /// Claude Code's subscription credentials and config files.
254    ClaudeAccount {
255        credentials: &'static str,
256        config: &'static str,
257    },
258    /// Grok: sign-ins from `grok login` in `auth` (a JSON object of entries),
259    /// or an API key in the `config` profile of its default model.
260    Grok {
261        auth: &'static str,
262        config: &'static str,
263    },
264    /// DeepSeek Harness `.credentials.yaml`, holding `refs.<variable>`.
265    DshRefs {
266        path: &'static str,
267        variable: &'static str,
268    },
269    /// pi's agent directory: `auth.json`, plus the SCV-configured
270    /// OpenAI-compatible endpoint in `models.json` and `settings.json`.
271    Pi { dir: &'static str },
272    /// A nested SCV's own `config.toml`, holding the provider copied from the
273    /// user's SCV by `scv agents import scv`.
274    Scv { config: &'static str },
275    /// Gemini CLI's `.gemini` directory: `settings.json` names the auth
276    /// method chosen at `/auth`, a Google sign-in is `oauth_creds.json`, and
277    /// an API key entered there is in the encrypted `gemini-credentials.json`
278    /// (or set as `GEMINI_API_KEY` in its `.env`).
279    Gemini { dir: &'static str },
280}
281
282/// How a CLI run once per turn reports the permission mode it runs in, for
283/// SCV to check against the policy before it trusts the run.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum ModeReport {
286    /// Claude Code's stream-json `system`/`init` event carries
287    /// `permissionMode`, in the same event as `apiKeySource`.
288    ClaudeInit,
289    /// `codex exec --json` reports no mode. The rollout file under the agent
290    /// home's `sessions/` records a `turn_context` with its `approval_policy`
291    /// and `sandbox_policy`, read shortly after the turn starts.
292    CodexRollout,
293    /// Nothing SCV reads: the mode is unverified, or the agent has no
294    /// full-autonomy switch to verify.
295    None,
296}
297
298/// Claude Code deny rules SCV passes to every run (`--disallowedTools`, or
299/// `_meta.claudeCode.options.disallowedTools` over ACP): force-pushing
300/// `main` in each spelling its prefix patterns can express, and a direct
301/// `cargo publish`. Its `bypassPermissions` mode approves every tool call
302/// except explicit deny rules. A prefix pattern matches the plain spelling,
303/// not a script or `bash -c` that hides it.
304pub const CLAUDE_DENY_RULES: &[&str] = &[
305    "Bash(git push --force origin main:*)",
306    "Bash(git push -f origin main:*)",
307    "Bash(git push --force-with-lease origin main:*)",
308    "Bash(git push --force origin HEAD:main:*)",
309    "Bash(git push -f origin HEAD:main:*)",
310    "Bash(git push --force-with-lease origin HEAD:main:*)",
311    "Bash(git push origin --force main:*)",
312    "Bash(git push origin -f main:*)",
313    "Bash(git push origin --force-with-lease main:*)",
314    "Bash(git push origin main --force:*)",
315    "Bash(git push origin main -f:*)",
316    "Bash(git push origin main --force-with-lease:*)",
317    "Bash(git push origin +main:*)",
318    "Bash(git push origin +HEAD:main:*)",
319    "Bash(cargo publish:*)",
320];
321
322/// Which billing identity a Claude Code agent is pinned to.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum AgentAuth {
325    None,
326    ClaudeApiKey,
327    ClaudeSubscription,
328}
329
330#[derive(Debug, Clone, Copy)]
331pub struct AdapterDescriptor {
332    /// Short name: the `agent` tool's value for it and the home `agents/<name>`.
333    pub name: &'static str,
334    /// Agent family used for reviewer diversity and sibling exclusion.
335    pub family: &'static str,
336    /// Credential identity pinned by this descriptor, when any.
337    pub auth: AgentAuth,
338    /// Product name for messages and the tool description.
339    pub product: &'static str,
340    /// What this harness offers, as one factual clause for the tool
341    /// description, so the model can choose between agents.
342    pub(crate) offers: &'static str,
343    pub command: &'static str,
344    pub args: &'static [&'static str],
345    /// Placed immediately before the prompt, for CLIs whose prompt is a flag
346    /// value (`grok -p <prompt>`).
347    pub prompt_args: &'static [&'static str],
348    pub model_args: &'static [&'static str],
349    pub effort_args: &'static [&'static str],
350    /// Describes the `model` argument for the calling model.
351    pub model_hint: &'static str,
352    /// Variables pointing the CLI's state into the agent home, as paths
353    /// relative to it (`""` is the home itself).
354    pub home_environment: &'static [(&'static str, &'static str)],
355    /// Fixed variables for every delegated run.
356    pub fixed_environment: &'static [(&'static str, &'static str)],
357    /// Credential, endpoint, and state-location variables no delegated agent
358    /// inherits. A trailing `*` matches a prefix.
359    pub(crate) removed_environment: &'static [&'static str],
360    /// Added after `args` when the agent's policy mode is `auto`: the CLI's
361    /// own switches that turn off its approval prompts and sandbox and
362    /// enable web search where the CLI gates it. Empty when the CLI has no
363    /// permission system of its own.
364    pub full_permission_args: &'static [&'static str],
365    /// Variables set under `auto`, for CLIs configured that way.
366    pub full_permission_environment: &'static [(&'static str, &'static str)],
367    /// How its CLI reports the permission mode it runs in.
368    pub mode_report: ModeReport,
369    /// Per-user install directories searched before `PATH`, relative to the
370    /// user's home, as a login shell orders them. A user service's `PATH`
371    /// omits them, so without this the daemon would miss or pick a different
372    /// install than the user's shell.
373    pub(crate) search_dirs: &'static [&'static str],
374    pub login: Login,
375    pub status: Status,
376    /// How a [`Status::Command`] result is summarized.
377    pub status_summary: StatusSummary,
378    pub logout: Logout,
379    /// What the CLI prints when SCV delegates to it.
380    pub output: OutputFormat,
381    /// How SCV continues a conversation with it, when it can.
382    pub resume: Resume,
383    /// Transcripts `scv agents gc` may remove; `None` when unknown.
384    pub conversation_files: Option<ConversationFiles>,
385    /// Files in the agent home that hold its sign-in or keys, relative to it,
386    /// which `scv config show` reports without reading.
387    pub credential_files: &'static [&'static str],
388    /// How SCV talks to the agent.
389    pub transport: Transport,
390    /// Its ACP server, when it has a verified one. With `[agents.<name>]
391    /// transport = "auto"` SCV prefers it over [`Transport::Process`] once the
392    /// command is installed.
393    pub acp: Option<AcpLaunch>,
394    /// Whether its CLI has a verified one-process-per-turn mode. An agent
395    /// without one runs only over its ACP server: `auto` then requires the
396    /// server, and `transport = "resume"` is a configuration error.
397    pub cli_turns: bool,
398    /// Whether the owner can take a conversation over in its terminal.
399    pub takeover: Takeover,
400}
401
402impl AdapterDescriptor {
403    /// Whether it has a full-autonomy switch of its own: arguments or
404    /// variables on its CLI or its ACP server, or an ACP session mode. One
405    /// without (pi, OpenCode, Kimi Code, a nested SCV) has no mode to verify.
406    pub fn mode_switch(&self) -> bool {
407        !self.full_permission_args.is_empty()
408            || !self.full_permission_environment.is_empty()
409            || self.acp.is_some_and(|launch| {
410                launch.full_mode.is_some()
411                    || !launch.full_args.is_empty()
412                    || !launch.full_environment.is_empty()
413            })
414    }
415}
416
417/// Directories every adapter searches before `PATH`, relative to the user's home.
418const USER_BIN_DIRS: &[&str] = &[".local/bin"];
419
420/// Removed from every agent regardless of adapter: SCV's own selectors and
421/// cloud keys that name no single agent. Any variable ending in `_API_KEY`
422/// is removed as well.
423const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
424    "SCV_CONFIG",
425    "SCV_MODEL",
426    "SCV_PROVIDER",
427    "SCV_BASE_URL",
428    "SCV_API_KEY_ENV",
429    "GEMINI_API_KEY",
430    "GOOGLE_API_KEY",
431    "AZURE_OPENAI_API_KEY",
432    "AZURE_OPENAI_ENDPOINT",
433    // Static AWS keys and the Bedrock bearer token. OpenCode and Amp can
434    // use them as model credentials, which no delegated agent may inherit.
435    // Profile, web-identity, and container credential variables are not
436    // keys and stay.
437    "AWS_ACCESS_KEY_ID",
438    "AWS_SECRET_ACCESS_KEY",
439    "AWS_SESSION_TOKEN",
440    "AWS_BEARER_TOKEN_BEDROCK",
441];
442
443const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
444const SCV_STORE: KeyStore = KeyStore::Scv {
445    config: "config.toml",
446};
447const DSH_STORE: KeyStore = KeyStore::DshRefs {
448    path: ".dsh/.credentials.yaml",
449    variable: "DEEPSEEK_API_KEY",
450};
451const GEMINI_STORE: KeyStore = KeyStore::Gemini { dir: ".gemini" };
452const CLAUDE_KEY_STORE: KeyStore = KeyStore::ClaudeApiKey {
453    key: ".anthropic-api-key",
454    credentials: ".claude/.credentials.json",
455};
456const CLAUDE_ACCOUNT_STORE: KeyStore = KeyStore::ClaudeAccount {
457    credentials: ".claude/.credentials.json",
458    config: ".claude.json",
459};
460
461const CLAUDE_BASE: AdapterDescriptor = AdapterDescriptor {
462    name: "claude",
463    family: "claude",
464    auth: AgentAuth::None,
465    product: "Claude Code",
466    offers: "Anthropic's coding agent; it reads, edits, and runs code in a project and can search and fetch the web",
467    command: "claude",
468    args: &["-p"],
469    prompt_args: &[],
470    model_args: &["--model", "{model}"],
471    effort_args: &["--effort", "{effort}"],
472    model_hint: "Claude model alias or ID, such as sonnet or opus.",
473    home_environment: &[],
474    fixed_environment: &[],
475    removed_environment: &[
476        "ANTHROPIC_API_KEY",
477        "ANTHROPIC_BASE_URL",
478        "ANTHROPIC_AUTH_TOKEN",
479        "CLAUDE_CODE_OAUTH_TOKEN",
480        "CLAUDE_CONFIG_DIR",
481    ],
482    full_permission_args: &["--permission-mode", "bypassPermissions"],
483    full_permission_environment: &[],
484    mode_report: ModeReport::ClaudeInit,
485    search_dirs: &[],
486    login: Login::Command(&[]),
487    status: Status::Stored(CLAUDE_KEY_STORE),
488    status_summary: StatusSummary::ExitStatus,
489    logout: Logout::Command(&[]),
490    output: OutputFormat::ClaudeStreamJson,
491    resume: Resume::Supported {
492        start: &["--session-id", "{session}"],
493        subcommand: &[],
494        options: &["--resume", "{session}"],
495        positional: &[],
496    },
497    conversation_files: Some(ConversationFiles {
498        dir: ".claude/projects",
499        extension: "jsonl",
500        id_prefix: None,
501    }),
502    credential_files: &[".anthropic-api-key", ".claude/.credentials.json"],
503    transport: Transport::Process,
504    acp: Some(AcpLaunch {
505        command: "claude-agent-acp",
506        args: &[],
507        full_args: &[],
508        full_mode: Some("bypassPermissions"),
509        full_environment: &[],
510        session_options: false,
511        no_session_options: false,
512    }),
513    cli_turns: true,
514    takeover: Takeover::Refused(CLAUDE_TAKEOVER),
515};
516
517pub const ADAPTERS: &[AdapterDescriptor] = &[
518    AdapterDescriptor {
519        name: "claude",
520        family: "claude",
521        auth: AgentAuth::ClaudeApiKey,
522        product: "Claude Code (API key)",
523        offers: "Anthropic's coding agent; it reads, edits, and runs code in a project and can search and fetch the web; billed per token to the Anthropic API key SCV holds for it",
524        login: Login::ApiKey(CLAUDE_KEY_STORE),
525        status: Status::Stored(CLAUDE_KEY_STORE),
526        logout: Logout::Stored(CLAUDE_KEY_STORE),
527        acp: Some(AcpLaunch {
528            command: "claude-agent-acp",
529            args: &["--hide-claude-auth"],
530            full_args: &[],
531            full_mode: Some("bypassPermissions"),
532            full_environment: &[],
533            session_options: false,
534            no_session_options: false,
535        }),
536        ..CLAUDE_BASE
537    },
538    AdapterDescriptor {
539        name: "claude-ai",
540        family: "claude",
541        auth: AgentAuth::ClaudeSubscription,
542        product: "Claude Code (Claude subscription)",
543        offers: "Anthropic's coding agent; it reads, edits, and runs code in a project and can search and fetch the web; runs on the owner's Claude subscription",
544        login: Login::Command(&["auth", "login", "--claudeai"]),
545        status: Status::Stored(CLAUDE_ACCOUNT_STORE),
546        logout: Logout::Command(&["auth", "logout"]),
547        credential_files: &[".claude/.credentials.json"],
548        acp: Some(AcpLaunch {
549            command: "claude-agent-acp",
550            args: &[],
551            full_args: &[],
552            full_mode: Some("bypassPermissions"),
553            full_environment: &[],
554            session_options: false,
555            no_session_options: false,
556        }),
557        ..CLAUDE_BASE
558    },
559    AdapterDescriptor {
560        name: "codex",
561        family: "codex",
562        auth: AgentAuth::None,
563        product: "Codex",
564        offers: "OpenAI's coding agent; it reads, edits, and runs code in a project, with live web search under full permissions",
565        command: "codex",
566        args: &["exec"],
567        prompt_args: &[],
568        model_args: &["-m", "{model}"],
569        effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
570        model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
571        home_environment: &[("CODEX_HOME", "")],
572        fixed_environment: &[],
573        removed_environment: &[
574            "OPENAI_API_KEY",
575            "OPENAI_BASE_URL",
576            "OPENAI_ORG_ID",
577            "OPENAI_PROJECT_ID",
578            "CODEX_API_KEY",
579            "CODEX_BASE_URL",
580            "CODEX_CONFIG",
581        ],
582        // `codex exec` has no `--search`; `web_search = "live"` is its config form.
583        full_permission_args: &[
584            "--dangerously-bypass-approvals-and-sandbox",
585            "-c",
586            "web_search=\"live\"",
587        ],
588        full_permission_environment: &[],
589        mode_report: ModeReport::CodexRollout,
590        search_dirs: &[],
591        login: Login::Command(&["login"]),
592        status: Status::Command(&["login", "status"]),
593        status_summary: StatusSummary::CodexText,
594        logout: Logout::Command(&["logout"]),
595        output: OutputFormat::CodexJsonl,
596        // The thread ID arrives in `thread.started`; `exec resume` takes it
597        // as a positional argument before the prompt.
598        resume: Resume::Supported {
599            start: &[],
600            subcommand: &["resume"],
601            options: &[],
602            positional: &["{session}"],
603        },
604        conversation_files: Some(ConversationFiles {
605            dir: "sessions",
606            extension: "jsonl",
607            id_prefix: None,
608        }),
609        credential_files: &["auth.json"],
610        transport: Transport::Process,
611        // The official adapter from the ACP organisation (npm
612        // @agentclientprotocol/codex-acp). It reads `$CODEX_HOME/config.toml`
613        // and takes no `-c` overrides; `CODEX_CONFIG` is its JSON form of
614        // them, merged into every session, so full permissions keep live web
615        // search without rewriting the imported config.
616        acp: Some(AcpLaunch {
617            command: "codex-acp",
618            args: &[],
619            full_args: &[],
620            full_mode: Some("agent-full-access"),
621            full_environment: &[("CODEX_CONFIG", r#"{"web_search":"live"}"#)],
622            session_options: false,
623            no_session_options: false,
624        }),
625        cli_turns: true,
626        // `codex resume <thread>` opens its TUI on the thread `exec` and
627        // `codex-acp` use. Under these switches Codex never asks for
628        // approval, so its terminal answers nothing SCV's gate decides.
629        takeover: Takeover::Supported {
630            args: &["resume", "{session}"],
631            full_args: &[
632                "--dangerously-bypass-approvals-and-sandbox",
633                "-c",
634                "web_search=\"live\"",
635            ],
636        },
637    },
638    AdapterDescriptor {
639        name: "grok",
640        family: "grok",
641        auth: AgentAuth::None,
642        product: "Grok Build",
643        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",
644        command: "grok",
645        args: &[],
646        prompt_args: &["-p"],
647        model_args: &["-m", "{model}"],
648        effort_args: &["--reasoning-effort", "{effort}"],
649        model_hint: "xAI Grok model ID, such as grok-4.7.",
650        home_environment: &[("GROK_HOME", ".grok")],
651        fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
652        removed_environment: &["GROK_*", "XAI_API_KEY"],
653        // Web search is on unless `--disable-web-search` is passed.
654        full_permission_args: &["--always-approve"],
655        full_permission_environment: &[],
656        mode_report: ModeReport::None,
657        search_dirs: &[".grok/bin"],
658        login: Login::Command(&["login"]),
659        status: Status::Stored(KeyStore::Grok {
660            auth: ".grok/auth.json",
661            config: ".grok/config.toml",
662        }),
663        status_summary: StatusSummary::ExitStatus,
664        logout: Logout::Command(&["logout"]),
665        // `--output-format json` exists but its success shape is unverified here.
666        output: OutputFormat::Text,
667        // Grok documents `--session-id` and `--resume`, but they cannot be
668        // verified while it is signed out here.
669        resume: Resume::Unsupported,
670        conversation_files: None,
671        credential_files: &[".grok/auth.json", ".grok/config.toml"],
672        transport: Transport::Process,
673        // Native: `grok agent [options] stdio`; options precede the mode.
674        acp: Some(AcpLaunch {
675            command: "grok",
676            args: &["agent", "{full}", "stdio"],
677            full_args: &["--always-approve"],
678            full_mode: None,
679            full_environment: &[],
680            session_options: false,
681            no_session_options: false,
682        }),
683        cli_turns: true,
684        takeover: Takeover::Unsupported,
685    },
686    AdapterDescriptor {
687        name: "dsh",
688        family: "dsh",
689        auth: AgentAuth::None,
690        product: "DeepSeek Harness",
691        offers: "a coding agent on DeepSeek models; it reads, edits, and runs code in a project",
692        command: "dsh",
693        args: &["--profile", "headless"],
694        prompt_args: &[],
695        model_args: &[],
696        effort_args: &[],
697        model_hint: "provider/model as its ACP server lists them, such as deepseek-official/deepseek-v4-pro.",
698        home_environment: &[("DSH_HOME", ".dsh")],
699        fixed_environment: &[],
700        removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
701        // Bypasses its file sandbox and sets its approval policy to `never`.
702        full_permission_args: &[],
703        full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
704        mode_report: ModeReport::None,
705        search_dirs: &[],
706        login: Login::ApiKey(DSH_STORE),
707        status: Status::Stored(DSH_STORE),
708        status_summary: StatusSummary::ExitStatus,
709        logout: Logout::Stored(DSH_STORE),
710        output: OutputFormat::Text,
711        // Only its interactive profile documents `--resume`.
712        resume: Resume::Unsupported,
713        conversation_files: None,
714        credential_files: &[".dsh/.credentials.yaml"],
715        transport: Transport::Process,
716        // Native: the shipped `acp` profile. Its full mode is the
717        // `DSH_PERMISSION_MODE` variable above. Its sessions offer `model`
718        // (every configured provider's models, as `["provider","model"]`)
719        // and `reasoning_effort`, which the headless profile cannot take.
720        acp: Some(AcpLaunch {
721            command: "dsh",
722            args: &["--profile", "acp"],
723            full_args: &[],
724            full_mode: None,
725            full_environment: &[],
726            session_options: true,
727            no_session_options: false,
728        }),
729        cli_turns: true,
730        takeover: Takeover::Unsupported,
731    },
732    AdapterDescriptor {
733        name: "pi",
734        family: "pi",
735        auth: AgentAuth::None,
736        product: "pi",
737        offers: "a minimal coding agent (read, write, edit, bash) that can run on SCV's own model endpoint; it has no web search",
738        command: "pi",
739        args: &["-p"],
740        prompt_args: &[],
741        model_args: &["--model", "{model}"],
742        effort_args: &["--thinking", "{effort}"],
743        model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
744        home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
745        fixed_environment: &[],
746        removed_environment: &["PI_*"],
747        // pi has no approval prompts or sandbox, and no built-in web search.
748        full_permission_args: &[],
749        full_permission_environment: &[],
750        mode_report: ModeReport::None,
751        search_dirs: &[],
752        login: Login::Interactive {
753            args: &[],
754            hint: "run /login and choose a provider, then /quit",
755        },
756        status: Status::Stored(PI_STORE),
757        status_summary: StatusSummary::ExitStatus,
758        logout: Logout::Stored(PI_STORE),
759        output: OutputFormat::PiJson,
760        // `--session-id` uses the exact project session, creating it if missing.
761        resume: Resume::Supported {
762            start: &["--session-id", "{session}"],
763            subcommand: &[],
764            options: &["--session-id", "{session}"],
765            positional: &[],
766        },
767        conversation_files: Some(ConversationFiles {
768            dir: ".pi/agent/sessions",
769            extension: "jsonl",
770            id_prefix: None,
771        }),
772        credential_files: &[".pi/agent/auth.json", ".pi/agent/models.json"],
773        transport: Transport::Process,
774        // Only a community ACP adapter exists.
775        acp: None,
776        cli_turns: true,
777        takeover: Takeover::Unsupported,
778    },
779    AdapterDescriptor {
780        name: "gemini",
781        family: "gemini",
782        auth: AgentAuth::None,
783        product: "Gemini CLI",
784        offers: "Google's coding agent; it reads, edits, and runs code in a project and has Google Search",
785        command: "gemini",
786        args: &[],
787        // `-p` is its headless mode; a positional prompt on a terminal opens
788        // its interactive UI.
789        prompt_args: &["-p"],
790        model_args: &["-m", "{model}"],
791        // It has no effort or thinking-level flag.
792        effort_args: &[],
793        model_hint: "Gemini model ID or alias, such as gemini-2.5-pro, pro, flash, or auto; it takes no effort.",
794        // Its `.gemini` directory sits under `GEMINI_CLI_HOME`.
795        home_environment: &[("GEMINI_CLI_HOME", "")],
796        // An API key entered at `/auth` goes to `.gemini/gemini-credentials.json`
797        // (encrypted) instead of the system keychain, which every home shares.
798        fixed_environment: &[("GEMINI_FORCE_FILE_STORAGE", "true")],
799        removed_environment: &[
800            "GEMINI_*",
801            "GOOGLE_GENAI_*",
802            "GOOGLE_GEMINI_BASE_URL",
803            "GOOGLE_VERTEX_BASE_URL",
804            "GOOGLE_CLOUD_*",
805            "GOOGLE_APPLICATION_CREDENTIALS",
806        ],
807        // `yolo` approves every tool call, Google Search included, but only
808        // in a trusted folder: elsewhere it falls back to `default`, and a
809        // headless run stops. `--skip-trust` trusts the workspace for this
810        // run, which also applies its own `.gemini` settings, policies,
811        // hooks, and MCP servers, so it comes only with the full-autonomy
812        // switch. Its sandbox is off unless its settings turn it on.
813        full_permission_args: &["--approval-mode", "yolo", "--skip-trust"],
814        full_permission_environment: &[],
815        // Its `init` event carries only the session ID and model.
816        mode_report: ModeReport::None,
817        // npm's documented per-user global prefix.
818        search_dirs: &[".npm-global/bin"],
819        login: Login::Interactive {
820            args: &[],
821            hint: "run /auth and choose Sign in with Google or Use Gemini API Key, then /quit",
822        },
823        // It has no status or logout command.
824        status: Status::Stored(GEMINI_STORE),
825        status_summary: StatusSummary::ExitStatus,
826        logout: Logout::Stored(GEMINI_STORE),
827        output: OutputFormat::GeminiStreamJson,
828        // `--session-id` starts a session under SCV's ID and `--resume` takes
829        // that full ID, both headless, within the project of the working
830        // directory.
831        resume: Resume::Supported {
832            start: &["--session-id", "{session}"],
833            subcommand: &[],
834            options: &["--resume", "{session}"],
835            positional: &[],
836        },
837        // `tmp/<project>/chats/session-<time>-<first 8 of the ID>.jsonl`.
838        conversation_files: Some(ConversationFiles {
839            dir: ".gemini/tmp",
840            extension: "jsonl",
841            id_prefix: Some(8),
842        }),
843        credential_files: &[
844            ".gemini/oauth_creds.json",
845            ".gemini/gemini-credentials.json",
846            ".gemini/.env",
847        ],
848        transport: Transport::Process,
849        // Native: `gemini --acp` (`--experimental-acp` is its deprecated
850        // spelling). Its sessions list approval modes, but it answers
851        // `session/set_mode` with `{}` and reports no change, so SCV starts
852        // it in `yolo` with the same switches as its CLI instead of selecting
853        // that mode. It changes a session's model only through
854        // `session/set_model`.
855        acp: Some(AcpLaunch {
856            command: "gemini",
857            args: &["{full}", "--acp"],
858            full_args: &["--approval-mode", "yolo", "--skip-trust"],
859            full_mode: None,
860            full_environment: &[],
861            session_options: false,
862            no_session_options: true,
863        }),
864        cli_turns: true,
865        takeover: Takeover::Unsupported,
866    },
867    AdapterDescriptor {
868        name: "qwen",
869        family: "qwen",
870        auth: AgentAuth::None,
871        product: "Qwen Code",
872        offers: "Alibaba's coding agent on Qwen models; it reads, edits, and runs code in a project",
873        command: "qwen",
874        args: &[],
875        prompt_args: &["-p"],
876        model_args: &["-m", "{model}"],
877        effort_args: &[],
878        model_hint: "Qwen model ID, such as qwen3-coder-plus.",
879        home_environment: &[],
880        fixed_environment: &[],
881        removed_environment: &["QWEN_*", "DASHSCOPE_*"],
882        full_permission_args: &["--yolo"],
883        full_permission_environment: &[],
884        mode_report: ModeReport::None,
885        search_dirs: &[".npm-global/bin"],
886        login: Login::Interactive {
887            args: &[],
888            hint: "choose how to sign in (Qwen OAuth, or an OpenAI-compatible key), then /quit",
889        },
890        status: Status::Unchecked,
891        status_summary: StatusSummary::ExitStatus,
892        logout: Logout::Interactive {
893            args: &[],
894            hint: "sign out with its own command (/auth), then /quit",
895        },
896        // Its stream-json output is unverified here; stdout is the reply.
897        output: OutputFormat::Text,
898        resume: Resume::Unsupported,
899        conversation_files: None,
900        credential_files: &[".qwen/oauth_creds.json", ".qwen/.env"],
901        transport: Transport::Process,
902        // Native: a Gemini CLI fork with the same ACP mode.
903        acp: Some(AcpLaunch {
904            command: "qwen",
905            args: &["{full}", "--experimental-acp"],
906            full_args: &["--yolo"],
907            full_mode: None,
908            full_environment: &[],
909            session_options: false,
910            no_session_options: false,
911        }),
912        cli_turns: true,
913        takeover: Takeover::Unsupported,
914    },
915    AdapterDescriptor {
916        name: "opencode",
917        family: "opencode",
918        auth: AgentAuth::None,
919        product: "OpenCode",
920        offers: "an open-source coding agent that runs on many model providers; it reads, edits, and runs code in a project",
921        command: "opencode",
922        args: &["run"],
923        prompt_args: &[],
924        model_args: &["-m", "{model}"],
925        effort_args: &[],
926        model_hint: "provider/model as OpenCode names it, such as anthropic/claude-sonnet-4-5.",
927        home_environment: &[],
928        fixed_environment: &[],
929        removed_environment: &["OPENCODE_*"],
930        // Its permissions are configuration, not a switch.
931        full_permission_args: &[],
932        full_permission_environment: &[],
933        mode_report: ModeReport::None,
934        search_dirs: &[".opencode/bin", ".npm-global/bin"],
935        login: Login::Command(&["auth", "login"]),
936        status: Status::Unchecked,
937        status_summary: StatusSummary::ExitStatus,
938        logout: Logout::Command(&["auth", "logout"]),
939        output: OutputFormat::Text,
940        resume: Resume::Unsupported,
941        conversation_files: None,
942        credential_files: &["data/opencode/auth.json"],
943        transport: Transport::Process,
944        // Native: `opencode acp`.
945        acp: Some(AcpLaunch {
946            command: "opencode",
947            args: &["acp"],
948            full_args: &[],
949            full_mode: None,
950            full_environment: &[],
951            session_options: false,
952            no_session_options: false,
953        }),
954        cli_turns: true,
955        takeover: Takeover::Unsupported,
956    },
957    AdapterDescriptor {
958        name: "copilot",
959        family: "copilot",
960        auth: AgentAuth::None,
961        product: "GitHub Copilot CLI",
962        offers: "GitHub's coding agent; it reads, edits, and runs code in a project and knows GitHub",
963        command: "copilot",
964        args: &[],
965        prompt_args: &["-p"],
966        model_args: &["--model", "{model}"],
967        effort_args: &[],
968        model_hint: "a model Copilot CLI offers, such as claude-sonnet-4.5 or gpt-5.",
969        home_environment: &[],
970        fixed_environment: &[],
971        removed_environment: &["COPILOT_*"],
972        // Without it, a prompt run denies every tool that needs approval.
973        full_permission_args: &["--allow-all-tools"],
974        full_permission_environment: &[],
975        mode_report: ModeReport::None,
976        search_dirs: &[".npm-global/bin"],
977        login: Login::Interactive {
978            args: &[],
979            hint: "run /login, then /exit",
980        },
981        status: Status::Unchecked,
982        status_summary: StatusSummary::ExitStatus,
983        logout: Logout::Interactive {
984            args: &[],
985            hint: "run /logout, then /exit",
986        },
987        output: OutputFormat::Text,
988        // Its resume flags could not be verified headless here.
989        resume: Resume::Unsupported,
990        conversation_files: None,
991        credential_files: &[],
992        transport: Transport::Process,
993        // No ACP server SCV has verified.
994        acp: None,
995        cli_turns: true,
996        takeover: Takeover::Unsupported,
997    },
998    AdapterDescriptor {
999        name: "cursor",
1000        family: "cursor",
1001        auth: AgentAuth::None,
1002        product: "Cursor CLI",
1003        offers: "Cursor's coding agent; it reads, edits, and runs code in a project",
1004        command: "cursor-agent",
1005        args: &["-p"],
1006        prompt_args: &[],
1007        model_args: &["--model", "{model}"],
1008        effort_args: &[],
1009        model_hint: "a model Cursor offers, such as sonnet-4.5 or gpt-5.",
1010        home_environment: &[],
1011        fixed_environment: &[],
1012        removed_environment: &["CURSOR_*"],
1013        // Runs commands without asking.
1014        full_permission_args: &["--force"],
1015        full_permission_environment: &[],
1016        mode_report: ModeReport::None,
1017        search_dirs: &[],
1018        login: Login::Command(&["login"]),
1019        status: Status::Unchecked,
1020        status_summary: StatusSummary::ExitStatus,
1021        logout: Logout::Command(&["logout"]),
1022        output: OutputFormat::CursorStreamJson,
1023        // The chat ID arrives with its events; `--resume <id>` continues it.
1024        resume: Resume::Supported {
1025            start: &[],
1026            subcommand: &[],
1027            options: &["--resume", "{session}"],
1028            positional: &[],
1029        },
1030        conversation_files: None,
1031        credential_files: &[],
1032        transport: Transport::Process,
1033        acp: None,
1034        cli_turns: true,
1035        takeover: Takeover::Unsupported,
1036    },
1037    AdapterDescriptor {
1038        name: "amp",
1039        family: "amp",
1040        auth: AgentAuth::None,
1041        product: "Amp",
1042        offers: "Sourcegraph's coding agent; it reads, edits, and runs code in a project and can search the web",
1043        command: "amp",
1044        args: &[],
1045        prompt_args: &["-x"],
1046        // Amp chooses its own models.
1047        model_args: &[],
1048        effort_args: &[],
1049        model_hint: "Amp chooses its own model; omit model.",
1050        home_environment: &[],
1051        fixed_environment: &[],
1052        removed_environment: &["AMP_*"],
1053        full_permission_args: &["--dangerously-allow-all"],
1054        full_permission_environment: &[],
1055        mode_report: ModeReport::None,
1056        search_dirs: &[".amp/bin", ".npm-global/bin"],
1057        login: Login::Command(&["login"]),
1058        status: Status::Unchecked,
1059        status_summary: StatusSummary::ExitStatus,
1060        logout: Logout::Command(&["logout"]),
1061        output: OutputFormat::AmpStreamJson,
1062        // Its thread continuation could not be verified headless here.
1063        resume: Resume::Unsupported,
1064        conversation_files: None,
1065        credential_files: &[],
1066        transport: Transport::Process,
1067        acp: None,
1068        cli_turns: true,
1069        takeover: Takeover::Unsupported,
1070    },
1071    AdapterDescriptor {
1072        name: "kimi",
1073        family: "kimi",
1074        auth: AgentAuth::None,
1075        product: "Kimi Code",
1076        offers: "Moonshot AI's coding agent on Kimi models; it reads, edits, and runs code in a project",
1077        command: "kimi",
1078        args: &[],
1079        prompt_args: &[],
1080        model_args: &[],
1081        effort_args: &[],
1082        model_hint: "a model its ACP server lists.",
1083        home_environment: &[],
1084        fixed_environment: &[],
1085        removed_environment: &["KIMI_*", "MOONSHOT_*"],
1086        full_permission_args: &[],
1087        full_permission_environment: &[],
1088        mode_report: ModeReport::None,
1089        search_dirs: &[".kimi-code/bin"],
1090        login: Login::Interactive {
1091            args: &[],
1092            hint: "run /login, then /exit (`-- login` runs `kimi login` instead: a device code, with `--region mainland-cn` or `--region global`)",
1093        },
1094        status: Status::Unchecked,
1095        status_summary: StatusSummary::ExitStatus,
1096        logout: Logout::Interactive {
1097            args: &[],
1098            hint: "sign out with its own command, then /exit",
1099        },
1100        output: OutputFormat::Text,
1101        resume: Resume::Unsupported,
1102        conversation_files: None,
1103        credential_files: &[],
1104        transport: Transport::Process,
1105        // Native: `kimi acp`. Its one-prompt mode is not verified, so it
1106        // runs over ACP only.
1107        acp: Some(AcpLaunch {
1108            command: "kimi",
1109            args: &["acp"],
1110            full_args: &[],
1111            full_mode: None,
1112            full_environment: &[],
1113            session_options: true,
1114            no_session_options: false,
1115        }),
1116        cli_turns: false,
1117        takeover: Takeover::Unsupported,
1118    },
1119    AdapterDescriptor {
1120        name: "scv",
1121        family: "scv",
1122        auth: AgentAuth::None,
1123        product: "SCV",
1124        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",
1125        command: "scv",
1126        // Every request that is not read-only is relayed to the parent's
1127        // gate, whose policy decides.
1128        args: &["server", "--stdio", "--policy", "on-risk"],
1129        prompt_args: &[],
1130        // A model is chosen per conversation through `session.start`.
1131        model_args: &[],
1132        effort_args: &[],
1133        model_hint: "Model ID for the nested SCV's provider; applies to a new conversation only.",
1134        // `SCV_HOME` already points at the agent home, where the nested
1135        // SCV keeps its config, skills, and its own delegations.
1136        home_environment: &[],
1137        fixed_environment: &[],
1138        removed_environment: &[],
1139        // Its tool approvals are relayed to the calling session instead.
1140        full_permission_args: &[],
1141        full_permission_environment: &[],
1142        mode_report: ModeReport::None,
1143        // Where `cargo install` puts `scv`; a user service's PATH omits it.
1144        search_dirs: &[".cargo/bin"],
1145        login: Login::Import,
1146        status: Status::Stored(SCV_STORE),
1147        status_summary: StatusSummary::ExitStatus,
1148        logout: Logout::Stored(SCV_STORE),
1149        output: OutputFormat::Text,
1150        resume: Resume::Unsupported,
1151        conversation_files: None,
1152        credential_files: &["config.toml"],
1153        transport: Transport::ScvProtocol,
1154        acp: None,
1155        cli_turns: true,
1156        takeover: Takeover::Unsupported,
1157    },
1158];
1159
1160/// The descriptor for `name`, such as `"codex"`.
1161pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
1162    ADAPTERS.iter().find(|adapter| adapter.name == name)
1163}
1164
1165/// Whether a delegated agent must not inherit `variable`: SCV's selectors,
1166/// any `*_API_KEY`, and every adapter's credential and state variables, so
1167/// no agent sees another's credentials either.
1168pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
1169    let Some(variable) = variable.to_str() else {
1170        return false;
1171    };
1172    variable.ends_with("_API_KEY")
1173        || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
1174        || ADAPTERS
1175            .iter()
1176            .flat_map(|adapter| adapter.removed_environment)
1177            .any(|rule| match rule.strip_suffix('*') {
1178                Some(prefix) => variable.starts_with(prefix),
1179                None => variable == *rule,
1180            })
1181}
1182
1183/// One line describing a CLI's own status result without echoing it: the raw
1184/// output names the signed-in account or part of a key.
1185pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
1186    let signed_out = "not signed in".to_owned();
1187    match summary {
1188        StatusSummary::CodexText => {
1189            let lower = output.to_ascii_lowercase();
1190            if !succeeded || lower.contains("not logged in") {
1191                signed_out
1192            } else if lower.contains("api key") {
1193                "signed in (API key)".into()
1194            } else if lower.contains("chatgpt") {
1195                "signed in (ChatGPT account)".into()
1196            } else {
1197                "signed in".into()
1198            }
1199        }
1200        StatusSummary::ExitStatus => {
1201            if succeeded {
1202                "signed in".into()
1203            } else {
1204                signed_out
1205            }
1206        }
1207    }
1208}
1209
1210/// Resolve `command` in the per-user `search_dirs`, then on `PATH`. A command
1211/// containing a path separator is used as given.
1212pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
1213    if command.contains('/') {
1214        let path = Path::new(command);
1215        return path.is_file().then(|| path.to_path_buf());
1216    }
1217    std::env::join_paths(search_dirs)
1218        .ok()
1219        .and_then(|dirs| {
1220            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
1221            which::which_in(command, Some(dirs), cwd).ok()
1222        })
1223        .or_else(|| which::which(command).ok())
1224}
1225
1226/// Absolute per-user search directories for `adapter` under `home`.
1227pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
1228    adapter
1229        .search_dirs
1230        .iter()
1231        .chain(USER_BIN_DIRS)
1232        .map(|dir| home.join(dir))
1233        .collect()
1234}
1235
1236#[cfg(test)]
1237mod tests;