1use std::{
9 ffi::OsStr,
10 path::{Path, PathBuf},
11};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum Login {
16 Command(&'static [&'static str]),
18 Interactive {
20 args: &'static [&'static str],
21 hint: &'static str,
22 },
23 ApiKey(KeyStore),
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Status {
30 Command(&'static [&'static str]),
32 Stored(KeyStore),
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Logout {
39 Command(&'static [&'static str]),
40 Stored(KeyStore),
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum KeyStore {
47 JsonEntries(&'static str),
49 DshRefs {
51 path: &'static str,
52 variable: &'static str,
53 },
54 Pi { dir: &'static str },
57}
58
59#[derive(Debug, Clone, Copy)]
60pub struct AdapterDescriptor {
61 pub name: &'static str,
63 pub product: &'static str,
65 pub command: &'static str,
66 pub args: &'static [&'static str],
67 pub prompt_args: &'static [&'static str],
70 pub model_args: &'static [&'static str],
71 pub effort_args: &'static [&'static str],
72 pub model_hint: &'static str,
74 pub home_environment: &'static [(&'static str, &'static str)],
77 pub fixed_environment: &'static [(&'static str, &'static str)],
79 pub removed_environment: &'static [&'static str],
82 pub full_permission_args: &'static [&'static str],
87 pub full_permission_environment: &'static [(&'static str, &'static str)],
89 pub search_dirs: &'static [&'static str],
94 pub login: Login,
95 pub status: Status,
96 pub logout: Logout,
97}
98
99const USER_BIN_DIRS: &[&str] = &[".local/bin"];
101
102const 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 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 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 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 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 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
246pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
248 ADAPTERS.iter().find(|adapter| adapter.name == name)
249}
250
251pub 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
269pub 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
285pub 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 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 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 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}