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