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)]
39pub enum OutputFormat {
40 Text,
42 ClaudeStreamJson,
45 CodexJsonl,
48 PiJson,
51}
52
53impl OutputFormat {
54 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum StatusSummary {
69 ClaudeJson,
71 CodexText,
73 ExitStatus,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Logout {
80 Command(&'static [&'static str]),
81 Stored(KeyStore),
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum KeyStore {
88 JsonEntries(&'static str),
90 DshRefs {
92 path: &'static str,
93 variable: &'static str,
94 },
95 Pi { dir: &'static str },
98}
99
100#[derive(Debug, Clone, Copy)]
101pub struct AdapterDescriptor {
102 pub name: &'static str,
104 pub product: &'static str,
106 pub command: &'static str,
107 pub args: &'static [&'static str],
108 pub prompt_args: &'static [&'static str],
111 pub model_args: &'static [&'static str],
112 pub effort_args: &'static [&'static str],
113 pub model_hint: &'static str,
115 pub home_environment: &'static [(&'static str, &'static str)],
118 pub fixed_environment: &'static [(&'static str, &'static str)],
120 pub removed_environment: &'static [&'static str],
123 pub full_permission_args: &'static [&'static str],
128 pub full_permission_environment: &'static [(&'static str, &'static str)],
130 pub search_dirs: &'static [&'static str],
135 pub login: Login,
136 pub status: Status,
137 pub status_summary: StatusSummary,
139 pub logout: Logout,
140 pub output: OutputFormat,
142}
143
144const USER_BIN_DIRS: &[&str] = &[".local/bin"];
146
147const 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 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 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 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: 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 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 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
302pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
304 ADAPTERS.iter().find(|adapter| adapter.name == name)
305}
306
307pub 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
325pub 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 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
382pub 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
398pub 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 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 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 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}