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}
26
27/// How SCV reports whether an agent is signed in.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Status {
30    /// The CLI prints its own status and exits non-zero when signed out.
31    Command(&'static [&'static str]),
32    /// SCV inspects the CLI's credential file without printing secrets.
33    Stored(KeyStore),
34}
35
36/// What a CLI prints on stdout when SCV runs it, and so how SCV reads its
37/// reply, usage, and failure out of it.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum OutputFormat {
40    /// Plain text: stdout is the reply.
41    Text,
42    /// Claude Code `--output-format stream-json --verbose`: one JSON event per
43    /// line, ending with a `result` event.
44    ClaudeStreamJson,
45    /// `codex exec --json`: JSON events per line; SCV also passes `-o <file>`
46    /// so the final message survives an unparsable stream.
47    CodexJsonl,
48    /// pi `--mode json`: JSON events per line; the reply is the last
49    /// assistant `message_end`.
50    PiJson,
51}
52
53impl OutputFormat {
54    /// Arguments that select this format, placed after the fixed arguments.
55    pub fn args(self) -> &'static [&'static str] {
56        match self {
57            Self::Text => &[],
58            Self::ClaudeStreamJson => &["--output-format", "stream-json", "--verbose"],
59            Self::CodexJsonl => &["--json"],
60            Self::PiJson => &["--mode", "json"],
61        }
62    }
63}
64
65/// How SCV condenses a CLI's own status output. The raw output names the
66/// account (an email) or part of a key, so it is never printed.
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum StatusSummary {
69    /// `claude auth status` JSON: `loggedIn`, `authMethod`, `subscriptionType`.
70    ClaudeJson,
71    /// `codex login status` text: "Logged in using an API key" or "ChatGPT".
72    CodexText,
73    /// The exit status alone.
74    ExitStatus,
75}
76
77/// How SCV signs an agent out.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Logout {
80    Command(&'static [&'static str]),
81    /// SCV removes the credentials it can see in the CLI's own files.
82    Stored(KeyStore),
83}
84
85/// A CLI's native credential file, relative to the adapter home.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum KeyStore {
88    /// A JSON object whose entries are stored sign-ins (Grok's `auth.json`).
89    JsonEntries(&'static str),
90    /// DeepSeek Harness `.credentials.yaml`, holding `refs.<variable>`.
91    DshRefs {
92        path: &'static str,
93        variable: &'static str,
94    },
95    /// pi's agent directory: `auth.json`, plus the SCV-configured
96    /// OpenAI-compatible endpoint in `models.json` and `settings.json`.
97    Pi { dir: &'static str },
98}
99
100#[derive(Debug, Clone, Copy)]
101pub struct AdapterDescriptor {
102    /// Short name: the tool is `agent_<name>` and the home `adapters/<name>`.
103    pub name: &'static str,
104    /// Product name for messages.
105    pub product: &'static str,
106    pub command: &'static str,
107    pub args: &'static [&'static str],
108    /// Placed immediately before the prompt, for CLIs whose prompt is a flag
109    /// value (`grok -p <prompt>`).
110    pub prompt_args: &'static [&'static str],
111    pub model_args: &'static [&'static str],
112    pub effort_args: &'static [&'static str],
113    /// Describes the `model` argument for the calling model.
114    pub model_hint: &'static str,
115    /// Variables pointing the CLI's state into the adapter home, as paths
116    /// relative to it (`""` is the home itself).
117    pub home_environment: &'static [(&'static str, &'static str)],
118    /// Fixed variables for every delegated run.
119    pub fixed_environment: &'static [(&'static str, &'static str)],
120    /// Credential, endpoint, and state-location variables no delegated agent
121    /// inherits. A trailing `*` matches a prefix.
122    pub removed_environment: &'static [&'static str],
123    /// Added after `args` when `[agents.<name>] permissions = "full"`: the
124    /// CLI's own switches that turn off its approval prompts and sandbox and
125    /// enable web search where the CLI gates it. Empty when the CLI has no
126    /// permission system of its own.
127    pub full_permission_args: &'static [&'static str],
128    /// Variables set for `permissions = "full"`, for CLIs configured that way.
129    pub full_permission_environment: &'static [(&'static str, &'static str)],
130    /// Per-user install directories searched before `PATH`, relative to the
131    /// user's home, as a login shell orders them. A user service's `PATH`
132    /// omits them, so without this the daemon would miss or pick a different
133    /// install than the user's shell.
134    pub search_dirs: &'static [&'static str],
135    pub login: Login,
136    pub status: Status,
137    /// How a [`Status::Command`] result is summarized.
138    pub status_summary: StatusSummary,
139    pub logout: Logout,
140    /// What the CLI prints when SCV delegates to it.
141    pub output: OutputFormat,
142}
143
144/// Directories every adapter searches before `PATH`, relative to the user's home.
145const USER_BIN_DIRS: &[&str] = &[".local/bin"];
146
147/// Removed from every agent regardless of adapter: SCV's own selectors and
148/// cloud keys that name no single agent. Any variable ending in `_API_KEY`
149/// is removed as well.
150const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
151    "SCV_CONFIG",
152    "SCV_MODEL",
153    "SCV_PROVIDER",
154    "SCV_BASE_URL",
155    "SCV_API_KEY_ENV",
156    "GEMINI_API_KEY",
157    "GOOGLE_API_KEY",
158    "AZURE_OPENAI_API_KEY",
159    "AZURE_OPENAI_ENDPOINT",
160];
161
162const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
163const DSH_STORE: KeyStore = KeyStore::DshRefs {
164    path: ".dsh/.credentials.yaml",
165    variable: "DEEPSEEK_API_KEY",
166};
167
168pub const ADAPTERS: &[AdapterDescriptor] = &[
169    AdapterDescriptor {
170        name: "claude",
171        product: "Claude Code",
172        command: "claude",
173        args: &["-p"],
174        prompt_args: &[],
175        model_args: &["--model", "{model}"],
176        effort_args: &["--effort", "{effort}"],
177        model_hint: "Claude model alias or ID, such as sonnet or opus.",
178        home_environment: &[],
179        fixed_environment: &[],
180        removed_environment: &[
181            "ANTHROPIC_API_KEY",
182            "ANTHROPIC_BASE_URL",
183            "ANTHROPIC_AUTH_TOKEN",
184            "CLAUDE_CODE_OAUTH_TOKEN",
185            "CLAUDE_CONFIG_DIR",
186        ],
187        // Also allows WebSearch and WebFetch without prompting.
188        full_permission_args: &["--permission-mode", "bypassPermissions"],
189        full_permission_environment: &[],
190        search_dirs: &[],
191        login: Login::Command(&["auth", "login"]),
192        status: Status::Command(&["auth", "status"]),
193        status_summary: StatusSummary::ClaudeJson,
194        logout: Logout::Command(&["auth", "logout"]),
195        output: OutputFormat::ClaudeStreamJson,
196    },
197    AdapterDescriptor {
198        name: "codex",
199        product: "Codex",
200        command: "codex",
201        args: &["exec"],
202        prompt_args: &[],
203        model_args: &["-m", "{model}"],
204        effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
205        model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
206        home_environment: &[("CODEX_HOME", "")],
207        fixed_environment: &[],
208        removed_environment: &[
209            "OPENAI_API_KEY",
210            "OPENAI_BASE_URL",
211            "OPENAI_ORG_ID",
212            "OPENAI_PROJECT_ID",
213            "CODEX_API_KEY",
214            "CODEX_BASE_URL",
215        ],
216        // `codex exec` has no `--search`; `web_search = "live"` is its config form.
217        full_permission_args: &[
218            "--dangerously-bypass-approvals-and-sandbox",
219            "-c",
220            "web_search=\"live\"",
221        ],
222        full_permission_environment: &[],
223        search_dirs: &[],
224        login: Login::Command(&["login"]),
225        status: Status::Command(&["login", "status"]),
226        status_summary: StatusSummary::CodexText,
227        logout: Logout::Command(&["logout"]),
228        output: OutputFormat::CodexJsonl,
229    },
230    AdapterDescriptor {
231        name: "grok",
232        product: "Grok Build",
233        command: "grok",
234        args: &[],
235        prompt_args: &["-p"],
236        model_args: &["-m", "{model}"],
237        effort_args: &["--reasoning-effort", "{effort}"],
238        model_hint: "xAI Grok model ID, such as grok-4.7.",
239        home_environment: &[("GROK_HOME", ".grok")],
240        fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
241        removed_environment: &["GROK_*", "XAI_API_KEY"],
242        // Web search is on unless `--disable-web-search` is passed.
243        full_permission_args: &["--always-approve"],
244        full_permission_environment: &[],
245        search_dirs: &[".grok/bin"],
246        login: Login::Command(&["login"]),
247        status: Status::Stored(KeyStore::JsonEntries(".grok/auth.json")),
248        status_summary: StatusSummary::ExitStatus,
249        logout: Logout::Command(&["logout"]),
250        // `--output-format json` exists but its success shape is unverified here.
251        output: OutputFormat::Text,
252    },
253    AdapterDescriptor {
254        name: "dsh",
255        product: "DeepSeek Harness",
256        command: "dsh",
257        args: &["--profile", "headless"],
258        prompt_args: &[],
259        model_args: &[],
260        effort_args: &[],
261        model_hint: "Model ID in the form this agent's CLI accepts.",
262        home_environment: &[("DSH_HOME", ".dsh")],
263        fixed_environment: &[],
264        removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
265        // Bypasses its file sandbox and sets its approval policy to `never`.
266        full_permission_args: &[],
267        full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
268        search_dirs: &[],
269        login: Login::ApiKey(DSH_STORE),
270        status: Status::Stored(DSH_STORE),
271        status_summary: StatusSummary::ExitStatus,
272        logout: Logout::Stored(DSH_STORE),
273        output: OutputFormat::Text,
274    },
275    AdapterDescriptor {
276        name: "pi",
277        product: "pi",
278        command: "pi",
279        args: &["-p"],
280        prompt_args: &[],
281        model_args: &["--model", "{model}"],
282        effort_args: &["--thinking", "{effort}"],
283        model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
284        home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
285        fixed_environment: &[],
286        removed_environment: &["PI_*"],
287        // pi has no approval prompts or sandbox, and no built-in web search.
288        full_permission_args: &[],
289        full_permission_environment: &[],
290        search_dirs: &[],
291        login: Login::Interactive {
292            args: &[],
293            hint: "run /login and choose a provider, then /quit",
294        },
295        status: Status::Stored(PI_STORE),
296        status_summary: StatusSummary::ExitStatus,
297        logout: Logout::Stored(PI_STORE),
298        output: OutputFormat::PiJson,
299    },
300];
301
302/// The descriptor for `name`, such as `"codex"`.
303pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
304    ADAPTERS.iter().find(|adapter| adapter.name == name)
305}
306
307/// Whether a delegated agent must not inherit `variable`: SCV's selectors,
308/// any `*_API_KEY`, and every adapter's credential and state variables, so
309/// no agent sees another's credentials either.
310pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
311    let Some(variable) = variable.to_str() else {
312        return false;
313    };
314    variable.ends_with("_API_KEY")
315        || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
316        || ADAPTERS
317            .iter()
318            .flat_map(|adapter| adapter.removed_environment)
319            .any(|rule| match rule.strip_suffix('*') {
320                Some(prefix) => variable.starts_with(prefix),
321                None => variable == *rule,
322            })
323}
324
325/// One line describing a CLI's own status result without echoing it: the raw
326/// output names the signed-in account or part of a key.
327pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
328    let signed_out = "not signed in".to_owned();
329    match summary {
330        StatusSummary::ClaudeJson => {
331            // The first JSON value; anything after it (such as stderr) is ignored.
332            let first = serde_json::Deserializer::from_str(output)
333                .into_iter::<serde_json::Value>()
334                .next();
335            let Some(Ok(value)) = first else {
336                return if succeeded {
337                    "signed in".into()
338                } else {
339                    signed_out
340                };
341            };
342            if value.get("loggedIn").and_then(serde_json::Value::as_bool) != Some(true) {
343                return signed_out;
344            }
345            let method = match value.get("authMethod").and_then(serde_json::Value::as_str) {
346                Some("claude.ai") => "Claude account",
347                Some("api_key" | "apiKey" | "console") => "API key",
348                Some("oauth_token" | "oauthToken") => "OAuth token",
349                _ => "other method",
350            };
351            match value
352                .get("subscriptionType")
353                .and_then(serde_json::Value::as_str)
354                .filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
355            {
356                Some(plan) => format!("signed in ({method}, {plan})"),
357                None => format!("signed in ({method})"),
358            }
359        }
360        StatusSummary::CodexText => {
361            let lower = output.to_ascii_lowercase();
362            if !succeeded || lower.contains("not logged in") {
363                signed_out
364            } else if lower.contains("api key") {
365                "signed in (API key)".into()
366            } else if lower.contains("chatgpt") {
367                "signed in (ChatGPT account)".into()
368            } else {
369                "signed in".into()
370            }
371        }
372        StatusSummary::ExitStatus => {
373            if succeeded {
374                "signed in".into()
375            } else {
376                signed_out
377            }
378        }
379    }
380}
381
382/// Resolve `command` in the per-user `search_dirs`, then on `PATH`. A command
383/// containing a path separator is used as given.
384pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
385    if command.contains('/') {
386        let path = Path::new(command);
387        return path.is_file().then(|| path.to_path_buf());
388    }
389    std::env::join_paths(search_dirs)
390        .ok()
391        .and_then(|dirs| {
392            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
393            which::which_in(command, Some(dirs), cwd).ok()
394        })
395        .or_else(|| which::which(command).ok())
396}
397
398/// Absolute per-user search directories for `adapter` under `home`.
399pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
400    adapter
401        .search_dirs
402        .iter()
403        .chain(USER_BIN_DIRS)
404        .map(|dir| home.join(dir))
405        .collect()
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn descriptors_are_unique_and_self_consistent() {
414        let mut names: Vec<_> = ADAPTERS.iter().map(|adapter| adapter.name).collect();
415        names.sort_unstable();
416        names.dedup();
417        assert_eq!(names.len(), ADAPTERS.len());
418        for adapter in ADAPTERS {
419            assert!(
420                adapter.model_args.is_empty()
421                    || adapter.model_args.iter().any(|arg| arg.contains("{model}")),
422                "{}",
423                adapter.name
424            );
425            assert!(
426                adapter.effort_args.is_empty()
427                    || adapter
428                        .effort_args
429                        .iter()
430                        .any(|arg| arg.contains("{effort}")),
431                "{}",
432                adapter.name
433            );
434            // Anything SCV sets must survive the removal pass.
435            for (variable, _) in adapter
436                .home_environment
437                .iter()
438                .chain(adapter.fixed_environment)
439            {
440                assert!(!variable.ends_with("_API_KEY"), "{variable}");
441            }
442            // Stored credentials live inside the directory SCV points the CLI at.
443            for store in [
444                match adapter.status {
445                    Status::Stored(store) => Some(store),
446                    Status::Command(_) => None,
447                },
448                match adapter.logout {
449                    Logout::Stored(store) => Some(store),
450                    Logout::Command(_) => None,
451                },
452                match adapter.login {
453                    Login::ApiKey(store) => Some(store),
454                    _ => None,
455                },
456            ]
457            .into_iter()
458            .flatten()
459            {
460                let path = match store {
461                    KeyStore::JsonEntries(path) | KeyStore::DshRefs { path, .. } => path,
462                    KeyStore::Pi { dir } => dir,
463                };
464                assert!(
465                    adapter
466                        .home_environment
467                        .iter()
468                        .any(|(_, home)| !home.is_empty() && path.starts_with(home)),
469                    "{}: {path}",
470                    adapter.name
471                );
472            }
473        }
474    }
475
476    #[test]
477    fn status_summaries_never_echo_accounts_or_keys() {
478        let claude = r#"{"loggedIn":true,"authMethod":"claude.ai","email":"me@example.com","orgName":"me@example.com's Organization","subscriptionType":"max"}"#;
479        assert_eq!(
480            summarize_status(StatusSummary::ClaudeJson, true, claude),
481            "signed in (Claude account, max)"
482        );
483        assert_eq!(
484            summarize_status(
485                StatusSummary::ClaudeJson,
486                true,
487                r#"{"loggedIn":true,"authMethod":"api_key","subscriptionType":"me@example.com"}"#
488            ),
489            "signed in (API key)"
490        );
491        assert_eq!(
492            summarize_status(StatusSummary::ClaudeJson, false, r#"{"loggedIn":false}"#),
493            "not signed in"
494        );
495        assert_eq!(
496            summarize_status(
497                StatusSummary::ClaudeJson,
498                true,
499                "{\"loggedIn\":true,\"authMethod\":\"claude.ai\"}\n\nsome stderr"
500            ),
501            "signed in (Claude account)"
502        );
503        assert_eq!(
504            summarize_status(
505                StatusSummary::CodexText,
506                true,
507                "Logged in using an API key - sk-proj-***abcd"
508            ),
509            "signed in (API key)"
510        );
511        assert_eq!(
512            summarize_status(StatusSummary::CodexText, true, "Logged in using ChatGPT"),
513            "signed in (ChatGPT account)"
514        );
515        assert_eq!(
516            summarize_status(StatusSummary::CodexText, false, "Not logged in"),
517            "not signed in"
518        );
519        for adapter in ADAPTERS {
520            if let Status::Command(_) = adapter.status {
521                assert_ne!(
522                    adapter.status_summary,
523                    StatusSummary::ExitStatus,
524                    "{}",
525                    adapter.name
526                );
527            }
528        }
529    }
530
531    #[test]
532    fn removal_covers_every_adapter_and_generic_api_keys() {
533        for removed in [
534            "OPENAI_API_KEY",
535            "CLAUDE_CONFIG_DIR",
536            "GROK_HOME",
537            "GROK_AUTH",
538            "XAI_API_KEY",
539            "DSH_HOME",
540            "DSH_PERMISSION_MODE",
541            "DEEPSEEK_BASE_URL",
542            "PI_CODING_AGENT_DIR",
543            "OPENROUTER_API_KEY",
544            "SCV_CONFIG",
545        ] {
546            assert!(is_removed_agent_variable(OsStr::new(removed)), "{removed}");
547        }
548        for kept in ["PATH", "HOME", "LANG", "GH_TOKEN", "GROKKING", "PIPX_HOME"] {
549            assert!(!is_removed_agent_variable(OsStr::new(kept)), "{kept}");
550        }
551    }
552
553    #[test]
554    fn executables_resolve_from_per_user_directories_before_path() {
555        let dir = tempfile::tempdir().unwrap();
556        let bin = dir.path().join(".grok/bin");
557        std::fs::create_dir_all(&bin).unwrap();
558        let name = "scv-test-agent-only-in-home";
559        let executable = bin.join(name);
560        std::fs::write(&executable, "#!/bin/sh\n").unwrap();
561        #[cfg(unix)]
562        {
563            use std::os::unix::fs::PermissionsExt;
564            std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
565        }
566        let grok = adapter("grok").unwrap();
567        let dirs = adapter_search_dirs(grok, dir.path());
568        assert!(dirs.contains(&dir.path().join(".local/bin")));
569        assert_eq!(
570            resolve_agent_executable(name, &dirs),
571            Some(executable.clone())
572        );
573        assert_eq!(resolve_agent_executable(name, &[]), None);
574        // A per-user install wins over the same command on PATH.
575        let shadow = bin.join("sh");
576        std::fs::write(&shadow, "#!/bin/sh\n").unwrap();
577        #[cfg(unix)]
578        {
579            use std::os::unix::fs::PermissionsExt;
580            std::fs::set_permissions(&shadow, std::fs::Permissions::from_mode(0o755)).unwrap();
581        }
582        assert_eq!(resolve_agent_executable("sh", &dirs), Some(shadow));
583        assert!(resolve_agent_executable("sh", &[]).is_some());
584        assert_eq!(
585            resolve_agent_executable(executable.to_str().unwrap(), &[]),
586            Some(executable)
587        );
588    }
589}