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