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