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