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/// How SCV signs an agent out.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Logout {
39    Command(&'static [&'static str]),
40    /// SCV removes the credentials it can see in the CLI's own files.
41    Stored(KeyStore),
42}
43
44/// A CLI's native credential file, relative to the adapter home.
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum KeyStore {
47    /// A JSON object whose entries are stored sign-ins (Grok's `auth.json`).
48    JsonEntries(&'static str),
49    /// DeepSeek Harness `.credentials.yaml`, holding `refs.<variable>`.
50    DshRefs {
51        path: &'static str,
52        variable: &'static str,
53    },
54    /// pi's agent directory: `auth.json`, plus the SCV-configured
55    /// OpenAI-compatible endpoint in `models.json` and `settings.json`.
56    Pi { dir: &'static str },
57}
58
59#[derive(Debug, Clone, Copy)]
60pub struct AdapterDescriptor {
61    /// Short name: the tool is `agent_<name>` and the home `adapters/<name>`.
62    pub name: &'static str,
63    /// Product name for messages.
64    pub product: &'static str,
65    pub command: &'static str,
66    pub args: &'static [&'static str],
67    /// Placed immediately before the prompt, for CLIs whose prompt is a flag
68    /// value (`grok -p <prompt>`).
69    pub prompt_args: &'static [&'static str],
70    pub model_args: &'static [&'static str],
71    pub effort_args: &'static [&'static str],
72    /// Describes the `model` argument for the calling model.
73    pub model_hint: &'static str,
74    /// Variables pointing the CLI's state into the adapter home, as paths
75    /// relative to it (`""` is the home itself).
76    pub home_environment: &'static [(&'static str, &'static str)],
77    /// Fixed variables for every delegated run.
78    pub fixed_environment: &'static [(&'static str, &'static str)],
79    /// Credential, endpoint, and state-location variables no delegated agent
80    /// inherits. A trailing `*` matches a prefix.
81    pub removed_environment: &'static [&'static str],
82    /// Added after `args` when `[agents.<name>] permissions = "full"`: the
83    /// CLI's own switches that turn off its approval prompts and sandbox and
84    /// enable web search where the CLI gates it. Empty when the CLI has no
85    /// permission system of its own.
86    pub full_permission_args: &'static [&'static str],
87    /// Variables set for `permissions = "full"`, for CLIs configured that way.
88    pub full_permission_environment: &'static [(&'static str, &'static str)],
89    /// Per-user install directories searched before `PATH`, relative to the
90    /// user's home, as a login shell orders them. A user service's `PATH`
91    /// omits them, so without this the daemon would miss or pick a different
92    /// install than the user's shell.
93    pub search_dirs: &'static [&'static str],
94    pub login: Login,
95    pub status: Status,
96    pub logout: Logout,
97}
98
99/// Directories every adapter searches before `PATH`, relative to the user's home.
100const USER_BIN_DIRS: &[&str] = &[".local/bin"];
101
102/// Removed from every agent regardless of adapter: SCV's own selectors and
103/// cloud keys that name no single agent. Any variable ending in `_API_KEY`
104/// is removed as well.
105const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
106    "SCV_CONFIG",
107    "SCV_MODEL",
108    "SCV_PROVIDER",
109    "SCV_BASE_URL",
110    "SCV_API_KEY_ENV",
111    "GEMINI_API_KEY",
112    "GOOGLE_API_KEY",
113    "AZURE_OPENAI_API_KEY",
114    "AZURE_OPENAI_ENDPOINT",
115];
116
117const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
118const DSH_STORE: KeyStore = KeyStore::DshRefs {
119    path: ".dsh/.credentials.yaml",
120    variable: "DEEPSEEK_API_KEY",
121};
122
123pub const ADAPTERS: &[AdapterDescriptor] = &[
124    AdapterDescriptor {
125        name: "claude",
126        product: "Claude Code",
127        command: "claude",
128        args: &["-p"],
129        prompt_args: &[],
130        model_args: &["--model", "{model}"],
131        effort_args: &["--effort", "{effort}"],
132        model_hint: "Claude model alias or ID, such as sonnet or opus.",
133        home_environment: &[],
134        fixed_environment: &[],
135        removed_environment: &[
136            "ANTHROPIC_API_KEY",
137            "ANTHROPIC_BASE_URL",
138            "ANTHROPIC_AUTH_TOKEN",
139            "CLAUDE_CODE_OAUTH_TOKEN",
140            "CLAUDE_CONFIG_DIR",
141        ],
142        // Also allows WebSearch and WebFetch without prompting.
143        full_permission_args: &["--permission-mode", "bypassPermissions"],
144        full_permission_environment: &[],
145        search_dirs: &[],
146        login: Login::Command(&["auth", "login"]),
147        status: Status::Command(&["auth", "status", "--text"]),
148        logout: Logout::Command(&["auth", "logout"]),
149    },
150    AdapterDescriptor {
151        name: "codex",
152        product: "Codex",
153        command: "codex",
154        args: &["exec"],
155        prompt_args: &[],
156        model_args: &["-m", "{model}"],
157        effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
158        model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
159        home_environment: &[("CODEX_HOME", "")],
160        fixed_environment: &[],
161        removed_environment: &[
162            "OPENAI_API_KEY",
163            "OPENAI_BASE_URL",
164            "OPENAI_ORG_ID",
165            "OPENAI_PROJECT_ID",
166            "CODEX_API_KEY",
167            "CODEX_BASE_URL",
168        ],
169        // `codex exec` has no `--search`; `web_search = "live"` is its config form.
170        full_permission_args: &[
171            "--dangerously-bypass-approvals-and-sandbox",
172            "-c",
173            "web_search=\"live\"",
174        ],
175        full_permission_environment: &[],
176        search_dirs: &[],
177        login: Login::Command(&["login"]),
178        status: Status::Command(&["login", "status"]),
179        logout: Logout::Command(&["logout"]),
180    },
181    AdapterDescriptor {
182        name: "grok",
183        product: "Grok Build",
184        command: "grok",
185        args: &[],
186        prompt_args: &["-p"],
187        model_args: &["-m", "{model}"],
188        effort_args: &["--reasoning-effort", "{effort}"],
189        model_hint: "xAI Grok model ID, such as grok-4.7.",
190        home_environment: &[("GROK_HOME", ".grok")],
191        fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
192        removed_environment: &["GROK_*", "XAI_API_KEY"],
193        // Web search is on unless `--disable-web-search` is passed.
194        full_permission_args: &["--always-approve"],
195        full_permission_environment: &[],
196        search_dirs: &[".grok/bin"],
197        login: Login::Command(&["login"]),
198        status: Status::Stored(KeyStore::JsonEntries(".grok/auth.json")),
199        logout: Logout::Command(&["logout"]),
200    },
201    AdapterDescriptor {
202        name: "dsh",
203        product: "DeepSeek Harness",
204        command: "dsh",
205        args: &["--profile", "headless"],
206        prompt_args: &[],
207        model_args: &[],
208        effort_args: &[],
209        model_hint: "Model ID in the form this agent's CLI accepts.",
210        home_environment: &[("DSH_HOME", ".dsh")],
211        fixed_environment: &[],
212        removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
213        // Bypasses its file sandbox and sets its approval policy to `never`.
214        full_permission_args: &[],
215        full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
216        search_dirs: &[],
217        login: Login::ApiKey(DSH_STORE),
218        status: Status::Stored(DSH_STORE),
219        logout: Logout::Stored(DSH_STORE),
220    },
221    AdapterDescriptor {
222        name: "pi",
223        product: "pi",
224        command: "pi",
225        args: &["-p"],
226        prompt_args: &[],
227        model_args: &["--model", "{model}"],
228        effort_args: &["--thinking", "{effort}"],
229        model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
230        home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
231        fixed_environment: &[],
232        removed_environment: &["PI_*"],
233        // pi has no approval prompts or sandbox, and no built-in web search.
234        full_permission_args: &[],
235        full_permission_environment: &[],
236        search_dirs: &[],
237        login: Login::Interactive {
238            args: &[],
239            hint: "run /login and choose a provider, then /quit",
240        },
241        status: Status::Stored(PI_STORE),
242        logout: Logout::Stored(PI_STORE),
243    },
244];
245
246/// The descriptor for `name`, such as `"codex"`.
247pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
248    ADAPTERS.iter().find(|adapter| adapter.name == name)
249}
250
251/// Whether a delegated agent must not inherit `variable`: SCV's selectors,
252/// any `*_API_KEY`, and every adapter's credential and state variables, so
253/// no agent sees another's credentials either.
254pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
255    let Some(variable) = variable.to_str() else {
256        return false;
257    };
258    variable.ends_with("_API_KEY")
259        || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
260        || ADAPTERS
261            .iter()
262            .flat_map(|adapter| adapter.removed_environment)
263            .any(|rule| match rule.strip_suffix('*') {
264                Some(prefix) => variable.starts_with(prefix),
265                None => variable == *rule,
266            })
267}
268
269/// Resolve `command` in the per-user `search_dirs`, then on `PATH`. A command
270/// containing a path separator is used as given.
271pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
272    if command.contains('/') {
273        let path = Path::new(command);
274        return path.is_file().then(|| path.to_path_buf());
275    }
276    std::env::join_paths(search_dirs)
277        .ok()
278        .and_then(|dirs| {
279            let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
280            which::which_in(command, Some(dirs), cwd).ok()
281        })
282        .or_else(|| which::which(command).ok())
283}
284
285/// Absolute per-user search directories for `adapter` under `home`.
286pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
287    adapter
288        .search_dirs
289        .iter()
290        .chain(USER_BIN_DIRS)
291        .map(|dir| home.join(dir))
292        .collect()
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298
299    #[test]
300    fn descriptors_are_unique_and_self_consistent() {
301        let mut names: Vec<_> = ADAPTERS.iter().map(|adapter| adapter.name).collect();
302        names.sort_unstable();
303        names.dedup();
304        assert_eq!(names.len(), ADAPTERS.len());
305        for adapter in ADAPTERS {
306            assert!(
307                adapter.model_args.is_empty()
308                    || adapter.model_args.iter().any(|arg| arg.contains("{model}")),
309                "{}",
310                adapter.name
311            );
312            assert!(
313                adapter.effort_args.is_empty()
314                    || adapter
315                        .effort_args
316                        .iter()
317                        .any(|arg| arg.contains("{effort}")),
318                "{}",
319                adapter.name
320            );
321            // Anything SCV sets must survive the removal pass.
322            for (variable, _) in adapter
323                .home_environment
324                .iter()
325                .chain(adapter.fixed_environment)
326            {
327                assert!(!variable.ends_with("_API_KEY"), "{variable}");
328            }
329            // Stored credentials live inside the directory SCV points the CLI at.
330            for store in [
331                match adapter.status {
332                    Status::Stored(store) => Some(store),
333                    Status::Command(_) => None,
334                },
335                match adapter.logout {
336                    Logout::Stored(store) => Some(store),
337                    Logout::Command(_) => None,
338                },
339                match adapter.login {
340                    Login::ApiKey(store) => Some(store),
341                    _ => None,
342                },
343            ]
344            .into_iter()
345            .flatten()
346            {
347                let path = match store {
348                    KeyStore::JsonEntries(path) | KeyStore::DshRefs { path, .. } => path,
349                    KeyStore::Pi { dir } => dir,
350                };
351                assert!(
352                    adapter
353                        .home_environment
354                        .iter()
355                        .any(|(_, home)| !home.is_empty() && path.starts_with(home)),
356                    "{}: {path}",
357                    adapter.name
358                );
359            }
360        }
361    }
362
363    #[test]
364    fn removal_covers_every_adapter_and_generic_api_keys() {
365        for removed in [
366            "OPENAI_API_KEY",
367            "CLAUDE_CONFIG_DIR",
368            "GROK_HOME",
369            "GROK_AUTH",
370            "XAI_API_KEY",
371            "DSH_HOME",
372            "DSH_PERMISSION_MODE",
373            "DEEPSEEK_BASE_URL",
374            "PI_CODING_AGENT_DIR",
375            "OPENROUTER_API_KEY",
376            "SCV_CONFIG",
377        ] {
378            assert!(is_removed_agent_variable(OsStr::new(removed)), "{removed}");
379        }
380        for kept in ["PATH", "HOME", "LANG", "GH_TOKEN", "GROKKING", "PIPX_HOME"] {
381            assert!(!is_removed_agent_variable(OsStr::new(kept)), "{kept}");
382        }
383    }
384
385    #[test]
386    fn executables_resolve_from_per_user_directories_before_path() {
387        let dir = tempfile::tempdir().unwrap();
388        let bin = dir.path().join(".grok/bin");
389        std::fs::create_dir_all(&bin).unwrap();
390        let name = "scv-test-agent-only-in-home";
391        let executable = bin.join(name);
392        std::fs::write(&executable, "#!/bin/sh\n").unwrap();
393        #[cfg(unix)]
394        {
395            use std::os::unix::fs::PermissionsExt;
396            std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
397        }
398        let grok = adapter("grok").unwrap();
399        let dirs = adapter_search_dirs(grok, dir.path());
400        assert!(dirs.contains(&dir.path().join(".local/bin")));
401        assert_eq!(
402            resolve_agent_executable(name, &dirs),
403            Some(executable.clone())
404        );
405        assert_eq!(resolve_agent_executable(name, &[]), None);
406        // A per-user install wins over the same command on PATH.
407        let shadow = bin.join("sh");
408        std::fs::write(&shadow, "#!/bin/sh\n").unwrap();
409        #[cfg(unix)]
410        {
411            use std::os::unix::fs::PermissionsExt;
412            std::fs::set_permissions(&shadow, std::fs::Permissions::from_mode(0o755)).unwrap();
413        }
414        assert_eq!(resolve_agent_executable("sh", &dirs), Some(shadow));
415        assert!(resolve_agent_executable("sh", &[]).is_some());
416        assert_eq!(
417            resolve_agent_executable(executable.to_str().unwrap(), &[]),
418            Some(executable)
419        );
420    }
421}