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