Skip to main content

scv_tools/
adapters.rs

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