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 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
378pub 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
394pub 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 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 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 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}