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