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            let Ok(value) = serde_json::from_str::<serde_json::Value>(output) else {
332                return if succeeded {
333                    "signed in".into()
334                } else {
335                    signed_out
336                };
337            };
338            if value.get("loggedIn").and_then(serde_json::Value::as_bool) != Some(true) {
339                return signed_out;
340            }
341            let method = match value.get("authMethod").and_then(serde_json::Value::as_str) {
342                Some("claude.ai") => "Claude account",
343                Some("api_key" | "apiKey" | "console") => "API key",
344                Some("oauth_token" | "oauthToken") => "OAuth token",
345                _ => "other method",
346            };
347            match value
348                .get("subscriptionType")
349                .and_then(serde_json::Value::as_str)
350                .filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
351            {
352                Some(plan) => format!("signed in ({method}, {plan})"),
353                None => format!("signed in ({method})"),
354            }
355        }
356        StatusSummary::CodexText => {
357            let lower = output.to_ascii_lowercase();
358            if !succeeded || lower.contains("not logged in") {
359                signed_out
360            } else if lower.contains("api key") {
361                "signed in (API key)".into()
362            } else if lower.contains("chatgpt") {
363                "signed in (ChatGPT account)".into()
364            } else {
365                "signed in".into()
366            }
367        }
368        StatusSummary::ExitStatus => {
369            if succeeded {
370                "signed in".into()
371            } else {
372                signed_out
373            }
374        }
375    }
376}
377
378/// Resolve `command` in the per-user `search_dirs`, then on `PATH`. A command
379/// containing a path separator is used as given.
380pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
381    if command.contains('/') {
382        let path = Path::new(command);
383        return path.is_file().then(|| path.to_path_buf());
384    }
385    std::env::join_paths(search_dirs)
386        .ok()
387        .and_then(|dirs| {
388            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
389            which::which_in(command, Some(dirs), cwd).ok()
390        })
391        .or_else(|| which::which(command).ok())
392}
393
394/// Absolute per-user search directories for `adapter` under `home`.
395pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
396    adapter
397        .search_dirs
398        .iter()
399        .chain(USER_BIN_DIRS)
400        .map(|dir| home.join(dir))
401        .collect()
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    #[test]
409    fn descriptors_are_unique_and_self_consistent() {
410        let mut names: Vec<_> = ADAPTERS.iter().map(|adapter| adapter.name).collect();
411        names.sort_unstable();
412        names.dedup();
413        assert_eq!(names.len(), ADAPTERS.len());
414        for adapter in ADAPTERS {
415            assert!(
416                adapter.model_args.is_empty()
417                    || adapter.model_args.iter().any(|arg| arg.contains("{model}")),
418                "{}",
419                adapter.name
420            );
421            assert!(
422                adapter.effort_args.is_empty()
423                    || adapter
424                        .effort_args
425                        .iter()
426                        .any(|arg| arg.contains("{effort}")),
427                "{}",
428                adapter.name
429            );
430            // Anything SCV sets must survive the removal pass.
431            for (variable, _) in adapter
432                .home_environment
433                .iter()
434                .chain(adapter.fixed_environment)
435            {
436                assert!(!variable.ends_with("_API_KEY"), "{variable}");
437            }
438            // Stored credentials live inside the directory SCV points the CLI at.
439            for store in [
440                match adapter.status {
441                    Status::Stored(store) => Some(store),
442                    Status::Command(_) => None,
443                },
444                match adapter.logout {
445                    Logout::Stored(store) => Some(store),
446                    Logout::Command(_) => None,
447                },
448                match adapter.login {
449                    Login::ApiKey(store) => Some(store),
450                    _ => None,
451                },
452            ]
453            .into_iter()
454            .flatten()
455            {
456                let path = match store {
457                    KeyStore::JsonEntries(path) | KeyStore::DshRefs { path, .. } => path,
458                    KeyStore::Pi { dir } => dir,
459                };
460                assert!(
461                    adapter
462                        .home_environment
463                        .iter()
464                        .any(|(_, home)| !home.is_empty() && path.starts_with(home)),
465                    "{}: {path}",
466                    adapter.name
467                );
468            }
469        }
470    }
471
472    #[test]
473    fn status_summaries_never_echo_accounts_or_keys() {
474        let claude = r#"{"loggedIn":true,"authMethod":"claude.ai","email":"me@example.com","orgName":"me@example.com's Organization","subscriptionType":"max"}"#;
475        assert_eq!(
476            summarize_status(StatusSummary::ClaudeJson, true, claude),
477            "signed in (Claude account, max)"
478        );
479        assert_eq!(
480            summarize_status(
481                StatusSummary::ClaudeJson,
482                true,
483                r#"{"loggedIn":true,"authMethod":"api_key","subscriptionType":"me@example.com"}"#
484            ),
485            "signed in (API key)"
486        );
487        assert_eq!(
488            summarize_status(StatusSummary::ClaudeJson, false, r#"{"loggedIn":false}"#),
489            "not signed in"
490        );
491        assert_eq!(
492            summarize_status(
493                StatusSummary::CodexText,
494                true,
495                "Logged in using an API key - sk-proj-***abcd"
496            ),
497            "signed in (API key)"
498        );
499        assert_eq!(
500            summarize_status(StatusSummary::CodexText, true, "Logged in using ChatGPT"),
501            "signed in (ChatGPT account)"
502        );
503        assert_eq!(
504            summarize_status(StatusSummary::CodexText, false, "Not logged in"),
505            "not signed in"
506        );
507        for adapter in ADAPTERS {
508            if let Status::Command(_) = adapter.status {
509                assert_ne!(
510                    adapter.status_summary,
511                    StatusSummary::ExitStatus,
512                    "{}",
513                    adapter.name
514                );
515            }
516        }
517    }
518
519    #[test]
520    fn removal_covers_every_adapter_and_generic_api_keys() {
521        for removed in [
522            "OPENAI_API_KEY",
523            "CLAUDE_CONFIG_DIR",
524            "GROK_HOME",
525            "GROK_AUTH",
526            "XAI_API_KEY",
527            "DSH_HOME",
528            "DSH_PERMISSION_MODE",
529            "DEEPSEEK_BASE_URL",
530            "PI_CODING_AGENT_DIR",
531            "OPENROUTER_API_KEY",
532            "SCV_CONFIG",
533        ] {
534            assert!(is_removed_agent_variable(OsStr::new(removed)), "{removed}");
535        }
536        for kept in ["PATH", "HOME", "LANG", "GH_TOKEN", "GROKKING", "PIPX_HOME"] {
537            assert!(!is_removed_agent_variable(OsStr::new(kept)), "{kept}");
538        }
539    }
540
541    #[test]
542    fn executables_resolve_from_per_user_directories_before_path() {
543        let dir = tempfile::tempdir().unwrap();
544        let bin = dir.path().join(".grok/bin");
545        std::fs::create_dir_all(&bin).unwrap();
546        let name = "scv-test-agent-only-in-home";
547        let executable = bin.join(name);
548        std::fs::write(&executable, "#!/bin/sh\n").unwrap();
549        #[cfg(unix)]
550        {
551            use std::os::unix::fs::PermissionsExt;
552            std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
553        }
554        let grok = adapter("grok").unwrap();
555        let dirs = adapter_search_dirs(grok, dir.path());
556        assert!(dirs.contains(&dir.path().join(".local/bin")));
557        assert_eq!(
558            resolve_agent_executable(name, &dirs),
559            Some(executable.clone())
560        );
561        assert_eq!(resolve_agent_executable(name, &[]), None);
562        // A per-user install wins over the same command on PATH.
563        let shadow = bin.join("sh");
564        std::fs::write(&shadow, "#!/bin/sh\n").unwrap();
565        #[cfg(unix)]
566        {
567            use std::os::unix::fs::PermissionsExt;
568            std::fs::set_permissions(&shadow, std::fs::Permissions::from_mode(0o755)).unwrap();
569        }
570        assert_eq!(resolve_agent_executable("sh", &dirs), Some(shadow));
571        assert!(resolve_agent_executable("sh", &[]).is_some());
572        assert_eq!(
573            resolve_agent_executable(executable.to_str().unwrap(), &[]),
574            Some(executable)
575        );
576    }
577}