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 Resume {
69 Unsupported,
71 Supported {
72 start: &'static [&'static str],
75 subcommand: &'static [&'static str],
78 options: &'static [&'static str],
80 positional: &'static [&'static str],
83 },
84}
85
86impl Resume {
87 pub fn is_supported(self) -> bool {
88 matches!(self, Self::Supported { .. })
89 }
90
91 pub fn assigns_id(self) -> bool {
93 matches!(self, Self::Supported { start, .. } if !start.is_empty())
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub struct ConversationFiles {
101 pub dir: &'static str,
102 pub extension: &'static str,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum StatusSummary {
109 ClaudeJson,
111 CodexText,
113 ExitStatus,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum Logout {
120 Command(&'static [&'static str]),
121 Stored(KeyStore),
123}
124
125#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum KeyStore {
128 JsonEntries(&'static str),
130 DshRefs {
132 path: &'static str,
133 variable: &'static str,
134 },
135 Pi { dir: &'static str },
138}
139
140#[derive(Debug, Clone, Copy)]
141pub struct AdapterDescriptor {
142 pub name: &'static str,
144 pub product: &'static str,
146 pub command: &'static str,
147 pub args: &'static [&'static str],
148 pub prompt_args: &'static [&'static str],
151 pub model_args: &'static [&'static str],
152 pub effort_args: &'static [&'static str],
153 pub model_hint: &'static str,
155 pub home_environment: &'static [(&'static str, &'static str)],
158 pub fixed_environment: &'static [(&'static str, &'static str)],
160 pub removed_environment: &'static [&'static str],
163 pub full_permission_args: &'static [&'static str],
168 pub full_permission_environment: &'static [(&'static str, &'static str)],
170 pub search_dirs: &'static [&'static str],
175 pub login: Login,
176 pub status: Status,
177 pub status_summary: StatusSummary,
179 pub logout: Logout,
180 pub output: OutputFormat,
182 pub resume: Resume,
184 pub conversation_files: Option<ConversationFiles>,
186}
187
188const USER_BIN_DIRS: &[&str] = &[".local/bin"];
190
191const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
195 "SCV_CONFIG",
196 "SCV_MODEL",
197 "SCV_PROVIDER",
198 "SCV_BASE_URL",
199 "SCV_API_KEY_ENV",
200 "GEMINI_API_KEY",
201 "GOOGLE_API_KEY",
202 "AZURE_OPENAI_API_KEY",
203 "AZURE_OPENAI_ENDPOINT",
204];
205
206const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
207const DSH_STORE: KeyStore = KeyStore::DshRefs {
208 path: ".dsh/.credentials.yaml",
209 variable: "DEEPSEEK_API_KEY",
210};
211
212pub const ADAPTERS: &[AdapterDescriptor] = &[
213 AdapterDescriptor {
214 name: "claude",
215 product: "Claude Code",
216 command: "claude",
217 args: &["-p"],
218 prompt_args: &[],
219 model_args: &["--model", "{model}"],
220 effort_args: &["--effort", "{effort}"],
221 model_hint: "Claude model alias or ID, such as sonnet or opus.",
222 home_environment: &[],
223 fixed_environment: &[],
224 removed_environment: &[
225 "ANTHROPIC_API_KEY",
226 "ANTHROPIC_BASE_URL",
227 "ANTHROPIC_AUTH_TOKEN",
228 "CLAUDE_CODE_OAUTH_TOKEN",
229 "CLAUDE_CONFIG_DIR",
230 ],
231 full_permission_args: &["--permission-mode", "bypassPermissions"],
233 full_permission_environment: &[],
234 search_dirs: &[],
235 login: Login::Command(&["auth", "login"]),
236 status: Status::Command(&["auth", "status"]),
237 status_summary: StatusSummary::ClaudeJson,
238 logout: Logout::Command(&["auth", "logout"]),
239 output: OutputFormat::ClaudeStreamJson,
240 resume: Resume::Supported {
242 start: &["--session-id", "{session}"],
243 subcommand: &[],
244 options: &["--resume", "{session}"],
245 positional: &[],
246 },
247 conversation_files: Some(ConversationFiles {
248 dir: ".claude/projects",
249 extension: "jsonl",
250 }),
251 },
252 AdapterDescriptor {
253 name: "codex",
254 product: "Codex",
255 command: "codex",
256 args: &["exec"],
257 prompt_args: &[],
258 model_args: &["-m", "{model}"],
259 effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
260 model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
261 home_environment: &[("CODEX_HOME", "")],
262 fixed_environment: &[],
263 removed_environment: &[
264 "OPENAI_API_KEY",
265 "OPENAI_BASE_URL",
266 "OPENAI_ORG_ID",
267 "OPENAI_PROJECT_ID",
268 "CODEX_API_KEY",
269 "CODEX_BASE_URL",
270 ],
271 full_permission_args: &[
273 "--dangerously-bypass-approvals-and-sandbox",
274 "-c",
275 "web_search=\"live\"",
276 ],
277 full_permission_environment: &[],
278 search_dirs: &[],
279 login: Login::Command(&["login"]),
280 status: Status::Command(&["login", "status"]),
281 status_summary: StatusSummary::CodexText,
282 logout: Logout::Command(&["logout"]),
283 output: OutputFormat::CodexJsonl,
284 resume: Resume::Supported {
287 start: &[],
288 subcommand: &["resume"],
289 options: &[],
290 positional: &["{session}"],
291 },
292 conversation_files: Some(ConversationFiles {
293 dir: "sessions",
294 extension: "jsonl",
295 }),
296 },
297 AdapterDescriptor {
298 name: "grok",
299 product: "Grok Build",
300 command: "grok",
301 args: &[],
302 prompt_args: &["-p"],
303 model_args: &["-m", "{model}"],
304 effort_args: &["--reasoning-effort", "{effort}"],
305 model_hint: "xAI Grok model ID, such as grok-4.7.",
306 home_environment: &[("GROK_HOME", ".grok")],
307 fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
308 removed_environment: &["GROK_*", "XAI_API_KEY"],
309 full_permission_args: &["--always-approve"],
311 full_permission_environment: &[],
312 search_dirs: &[".grok/bin"],
313 login: Login::Command(&["login"]),
314 status: Status::Stored(KeyStore::JsonEntries(".grok/auth.json")),
315 status_summary: StatusSummary::ExitStatus,
316 logout: Logout::Command(&["logout"]),
317 output: OutputFormat::Text,
319 resume: Resume::Unsupported,
322 conversation_files: None,
323 },
324 AdapterDescriptor {
325 name: "dsh",
326 product: "DeepSeek Harness",
327 command: "dsh",
328 args: &["--profile", "headless"],
329 prompt_args: &[],
330 model_args: &[],
331 effort_args: &[],
332 model_hint: "Model ID in the form this agent's CLI accepts.",
333 home_environment: &[("DSH_HOME", ".dsh")],
334 fixed_environment: &[],
335 removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
336 full_permission_args: &[],
338 full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
339 search_dirs: &[],
340 login: Login::ApiKey(DSH_STORE),
341 status: Status::Stored(DSH_STORE),
342 status_summary: StatusSummary::ExitStatus,
343 logout: Logout::Stored(DSH_STORE),
344 output: OutputFormat::Text,
345 resume: Resume::Unsupported,
347 conversation_files: None,
348 },
349 AdapterDescriptor {
350 name: "pi",
351 product: "pi",
352 command: "pi",
353 args: &["-p"],
354 prompt_args: &[],
355 model_args: &["--model", "{model}"],
356 effort_args: &["--thinking", "{effort}"],
357 model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
358 home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
359 fixed_environment: &[],
360 removed_environment: &["PI_*"],
361 full_permission_args: &[],
363 full_permission_environment: &[],
364 search_dirs: &[],
365 login: Login::Interactive {
366 args: &[],
367 hint: "run /login and choose a provider, then /quit",
368 },
369 status: Status::Stored(PI_STORE),
370 status_summary: StatusSummary::ExitStatus,
371 logout: Logout::Stored(PI_STORE),
372 output: OutputFormat::PiJson,
373 resume: Resume::Supported {
375 start: &["--session-id", "{session}"],
376 subcommand: &[],
377 options: &["--session-id", "{session}"],
378 positional: &[],
379 },
380 conversation_files: Some(ConversationFiles {
381 dir: ".pi/agent/sessions",
382 extension: "jsonl",
383 }),
384 },
385];
386
387pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
389 ADAPTERS.iter().find(|adapter| adapter.name == name)
390}
391
392pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
396 let Some(variable) = variable.to_str() else {
397 return false;
398 };
399 variable.ends_with("_API_KEY")
400 || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
401 || ADAPTERS
402 .iter()
403 .flat_map(|adapter| adapter.removed_environment)
404 .any(|rule| match rule.strip_suffix('*') {
405 Some(prefix) => variable.starts_with(prefix),
406 None => variable == *rule,
407 })
408}
409
410pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
413 let signed_out = "not signed in".to_owned();
414 match summary {
415 StatusSummary::ClaudeJson => {
416 let first = serde_json::Deserializer::from_str(output)
418 .into_iter::<serde_json::Value>()
419 .next();
420 let Some(Ok(value)) = first else {
421 return if succeeded {
422 "signed in".into()
423 } else {
424 signed_out
425 };
426 };
427 if value.get("loggedIn").and_then(serde_json::Value::as_bool) != Some(true) {
428 return signed_out;
429 }
430 let method = match value.get("authMethod").and_then(serde_json::Value::as_str) {
431 Some("claude.ai") => "Claude account",
432 Some("api_key" | "apiKey" | "console") => "API key",
433 Some("oauth_token" | "oauthToken") => "OAuth token",
434 _ => "other method",
435 };
436 match value
437 .get("subscriptionType")
438 .and_then(serde_json::Value::as_str)
439 .filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
440 {
441 Some(plan) => format!("signed in ({method}, {plan})"),
442 None => format!("signed in ({method})"),
443 }
444 }
445 StatusSummary::CodexText => {
446 let lower = output.to_ascii_lowercase();
447 if !succeeded || lower.contains("not logged in") {
448 signed_out
449 } else if lower.contains("api key") {
450 "signed in (API key)".into()
451 } else if lower.contains("chatgpt") {
452 "signed in (ChatGPT account)".into()
453 } else {
454 "signed in".into()
455 }
456 }
457 StatusSummary::ExitStatus => {
458 if succeeded {
459 "signed in".into()
460 } else {
461 signed_out
462 }
463 }
464 }
465}
466
467pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
470 if command.contains('/') {
471 let path = Path::new(command);
472 return path.is_file().then(|| path.to_path_buf());
473 }
474 std::env::join_paths(search_dirs)
475 .ok()
476 .and_then(|dirs| {
477 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
478 which::which_in(command, Some(dirs), cwd).ok()
479 })
480 .or_else(|| which::which(command).ok())
481}
482
483pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
485 adapter
486 .search_dirs
487 .iter()
488 .chain(USER_BIN_DIRS)
489 .map(|dir| home.join(dir))
490 .collect()
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
496
497 #[test]
498 fn descriptors_are_unique_and_self_consistent() {
499 let mut names: Vec<_> = ADAPTERS.iter().map(|adapter| adapter.name).collect();
500 names.sort_unstable();
501 names.dedup();
502 assert_eq!(names.len(), ADAPTERS.len());
503 for adapter in ADAPTERS {
504 assert!(
505 adapter.model_args.is_empty()
506 || adapter.model_args.iter().any(|arg| arg.contains("{model}")),
507 "{}",
508 adapter.name
509 );
510 assert!(
511 adapter.effort_args.is_empty()
512 || adapter
513 .effort_args
514 .iter()
515 .any(|arg| arg.contains("{effort}")),
516 "{}",
517 adapter.name
518 );
519 if let Resume::Supported {
520 start,
521 subcommand,
522 options,
523 positional,
524 } = adapter.resume
525 {
526 let names_session =
527 |args: &[&str]| args.iter().any(|arg| arg.contains("{session}"));
528 assert!(start.is_empty() || names_session(start), "{}", adapter.name);
529 assert!(
530 names_session(options) || names_session(positional),
531 "{}",
532 adapter.name
533 );
534 assert!(!names_session(subcommand), "{}", adapter.name);
535 assert!(adapter.conversation_files.is_some(), "{}", adapter.name);
536 }
537 for (variable, _) in adapter
539 .home_environment
540 .iter()
541 .chain(adapter.fixed_environment)
542 {
543 assert!(!variable.ends_with("_API_KEY"), "{variable}");
544 }
545 for store in [
547 match adapter.status {
548 Status::Stored(store) => Some(store),
549 Status::Command(_) => None,
550 },
551 match adapter.logout {
552 Logout::Stored(store) => Some(store),
553 Logout::Command(_) => None,
554 },
555 match adapter.login {
556 Login::ApiKey(store) => Some(store),
557 _ => None,
558 },
559 ]
560 .into_iter()
561 .flatten()
562 {
563 let path = match store {
564 KeyStore::JsonEntries(path) | KeyStore::DshRefs { path, .. } => path,
565 KeyStore::Pi { dir } => dir,
566 };
567 assert!(
568 adapter
569 .home_environment
570 .iter()
571 .any(|(_, home)| !home.is_empty() && path.starts_with(home)),
572 "{}: {path}",
573 adapter.name
574 );
575 }
576 }
577 }
578
579 #[test]
580 fn status_summaries_never_echo_accounts_or_keys() {
581 let claude = r#"{"loggedIn":true,"authMethod":"claude.ai","email":"me@example.com","orgName":"me@example.com's Organization","subscriptionType":"max"}"#;
582 assert_eq!(
583 summarize_status(StatusSummary::ClaudeJson, true, claude),
584 "signed in (Claude account, max)"
585 );
586 assert_eq!(
587 summarize_status(
588 StatusSummary::ClaudeJson,
589 true,
590 r#"{"loggedIn":true,"authMethod":"api_key","subscriptionType":"me@example.com"}"#
591 ),
592 "signed in (API key)"
593 );
594 assert_eq!(
595 summarize_status(StatusSummary::ClaudeJson, false, r#"{"loggedIn":false}"#),
596 "not signed in"
597 );
598 assert_eq!(
599 summarize_status(
600 StatusSummary::ClaudeJson,
601 true,
602 "{\"loggedIn\":true,\"authMethod\":\"claude.ai\"}\n\nsome stderr"
603 ),
604 "signed in (Claude account)"
605 );
606 assert_eq!(
607 summarize_status(
608 StatusSummary::CodexText,
609 true,
610 "Logged in using an API key - sk-proj-***abcd"
611 ),
612 "signed in (API key)"
613 );
614 assert_eq!(
615 summarize_status(StatusSummary::CodexText, true, "Logged in using ChatGPT"),
616 "signed in (ChatGPT account)"
617 );
618 assert_eq!(
619 summarize_status(StatusSummary::CodexText, false, "Not logged in"),
620 "not signed in"
621 );
622 for adapter in ADAPTERS {
623 if let Status::Command(_) = adapter.status {
624 assert_ne!(
625 adapter.status_summary,
626 StatusSummary::ExitStatus,
627 "{}",
628 adapter.name
629 );
630 }
631 }
632 }
633
634 #[test]
635 fn removal_covers_every_adapter_and_generic_api_keys() {
636 for removed in [
637 "OPENAI_API_KEY",
638 "CLAUDE_CONFIG_DIR",
639 "GROK_HOME",
640 "GROK_AUTH",
641 "XAI_API_KEY",
642 "DSH_HOME",
643 "DSH_PERMISSION_MODE",
644 "DEEPSEEK_BASE_URL",
645 "PI_CODING_AGENT_DIR",
646 "OPENROUTER_API_KEY",
647 "SCV_CONFIG",
648 ] {
649 assert!(is_removed_agent_variable(OsStr::new(removed)), "{removed}");
650 }
651 for kept in ["PATH", "HOME", "LANG", "GH_TOKEN", "GROKKING", "PIPX_HOME"] {
652 assert!(!is_removed_agent_variable(OsStr::new(kept)), "{kept}");
653 }
654 }
655
656 #[test]
657 fn executables_resolve_from_per_user_directories_before_path() {
658 let dir = tempfile::tempdir().unwrap();
659 let bin = dir.path().join(".grok/bin");
660 std::fs::create_dir_all(&bin).unwrap();
661 let name = "scv-test-agent-only-in-home";
662 let executable = bin.join(name);
663 std::fs::write(&executable, "#!/bin/sh\n").unwrap();
664 #[cfg(unix)]
665 {
666 use std::os::unix::fs::PermissionsExt;
667 std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
668 }
669 let grok = adapter("grok").unwrap();
670 let dirs = adapter_search_dirs(grok, dir.path());
671 assert!(dirs.contains(&dir.path().join(".local/bin")));
672 assert_eq!(
673 resolve_agent_executable(name, &dirs),
674 Some(executable.clone())
675 );
676 assert_eq!(resolve_agent_executable(name, &[]), None);
677 let shadow = bin.join("sh");
679 std::fs::write(&shadow, "#!/bin/sh\n").unwrap();
680 #[cfg(unix)]
681 {
682 use std::os::unix::fs::PermissionsExt;
683 std::fs::set_permissions(&shadow, std::fs::Permissions::from_mode(0o755)).unwrap();
684 }
685 assert_eq!(resolve_agent_executable("sh", &dirs), Some(shadow));
686 assert!(resolve_agent_executable("sh", &[]).is_some());
687 assert_eq!(
688 resolve_agent_executable(executable.to_str().unwrap(), &[]),
689 Some(executable)
690 );
691 }
692}