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 Import,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Status {
32 Command(&'static [&'static str]),
34 Stored(KeyStore),
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum OutputFormat {
42 Text,
44 ClaudeStreamJson,
47 CodexJsonl,
50 PiJson,
53}
54
55impl OutputFormat {
56 pub fn args(self) -> &'static [&'static str] {
58 match self {
59 Self::Text => &[],
60 Self::ClaudeStreamJson => &["--output-format", "stream-json", "--verbose"],
61 Self::CodexJsonl => &["--json"],
62 Self::PiJson => &["--mode", "json"],
63 }
64 }
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum Transport {
70 Process,
73 ScvProtocol,
77}
78
79impl Transport {
80 pub fn is_live(self) -> bool {
82 !matches!(self, Self::Process)
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub struct AcpLaunch {
90 pub command: &'static str,
92 pub args: &'static [&'static str],
95 pub full_args: &'static [&'static str],
96 pub full_mode: Option<&'static str>,
99 pub full_environment: &'static [(&'static str, &'static str)],
102}
103
104pub fn acp_args(launch: &AcpLaunch, full: bool) -> Vec<String> {
106 let mut args = Vec::with_capacity(launch.args.len() + launch.full_args.len());
107 for arg in launch.args {
108 if *arg == "{full}" {
109 if full {
110 args.extend(launch.full_args.iter().map(|arg| (*arg).to_owned()));
111 }
112 } else {
113 args.push((*arg).to_owned());
114 }
115 }
116 args
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum Resume {
123 Unsupported,
125 Supported {
126 start: &'static [&'static str],
129 subcommand: &'static [&'static str],
132 options: &'static [&'static str],
134 positional: &'static [&'static str],
137 },
138}
139
140impl Resume {
141 pub fn is_supported(self) -> bool {
142 matches!(self, Self::Supported { .. })
143 }
144
145 pub fn assigns_id(self) -> bool {
147 matches!(self, Self::Supported { start, .. } if !start.is_empty())
148 }
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
154pub struct ConversationFiles {
155 pub dir: &'static str,
156 pub extension: &'static str,
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum StatusSummary {
163 ClaudeJson,
165 CodexText,
167 ExitStatus,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum Logout {
174 Command(&'static [&'static str]),
175 Stored(KeyStore),
177}
178
179#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub enum KeyStore {
182 Grok {
185 auth: &'static str,
186 config: &'static str,
187 },
188 DshRefs {
190 path: &'static str,
191 variable: &'static str,
192 },
193 Pi { dir: &'static str },
196 Scv { config: &'static str },
199}
200
201#[derive(Debug, Clone, Copy)]
202pub struct AdapterDescriptor {
203 pub name: &'static str,
205 pub product: &'static str,
207 pub command: &'static str,
208 pub args: &'static [&'static str],
209 pub prompt_args: &'static [&'static str],
212 pub model_args: &'static [&'static str],
213 pub effort_args: &'static [&'static str],
214 pub model_hint: &'static str,
216 pub home_environment: &'static [(&'static str, &'static str)],
219 pub fixed_environment: &'static [(&'static str, &'static str)],
221 pub removed_environment: &'static [&'static str],
224 pub full_permission_args: &'static [&'static str],
229 pub full_permission_environment: &'static [(&'static str, &'static str)],
231 pub search_dirs: &'static [&'static str],
236 pub login: Login,
237 pub status: Status,
238 pub status_summary: StatusSummary,
240 pub logout: Logout,
241 pub output: OutputFormat,
243 pub resume: Resume,
245 pub conversation_files: Option<ConversationFiles>,
247 pub transport: Transport,
249 pub acp: Option<AcpLaunch>,
253}
254
255const USER_BIN_DIRS: &[&str] = &[".local/bin"];
257
258const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
262 "SCV_CONFIG",
263 "SCV_MODEL",
264 "SCV_PROVIDER",
265 "SCV_BASE_URL",
266 "SCV_API_KEY_ENV",
267 "GEMINI_API_KEY",
268 "GOOGLE_API_KEY",
269 "AZURE_OPENAI_API_KEY",
270 "AZURE_OPENAI_ENDPOINT",
271];
272
273const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
274const SCV_STORE: KeyStore = KeyStore::Scv {
275 config: "config.toml",
276};
277const DSH_STORE: KeyStore = KeyStore::DshRefs {
278 path: ".dsh/.credentials.yaml",
279 variable: "DEEPSEEK_API_KEY",
280};
281
282pub const ADAPTERS: &[AdapterDescriptor] = &[
283 AdapterDescriptor {
284 name: "claude",
285 product: "Claude Code",
286 command: "claude",
287 args: &["-p"],
288 prompt_args: &[],
289 model_args: &["--model", "{model}"],
290 effort_args: &["--effort", "{effort}"],
291 model_hint: "Claude model alias or ID, such as sonnet or opus.",
292 home_environment: &[],
293 fixed_environment: &[],
294 removed_environment: &[
295 "ANTHROPIC_API_KEY",
296 "ANTHROPIC_BASE_URL",
297 "ANTHROPIC_AUTH_TOKEN",
298 "CLAUDE_CODE_OAUTH_TOKEN",
299 "CLAUDE_CONFIG_DIR",
300 ],
301 full_permission_args: &["--permission-mode", "bypassPermissions"],
303 full_permission_environment: &[],
304 search_dirs: &[],
305 login: Login::Command(&["auth", "login"]),
306 status: Status::Command(&["auth", "status"]),
307 status_summary: StatusSummary::ClaudeJson,
308 logout: Logout::Command(&["auth", "logout"]),
309 output: OutputFormat::ClaudeStreamJson,
310 resume: Resume::Supported {
312 start: &["--session-id", "{session}"],
313 subcommand: &[],
314 options: &["--resume", "{session}"],
315 positional: &[],
316 },
317 conversation_files: Some(ConversationFiles {
318 dir: ".claude/projects",
319 extension: "jsonl",
320 }),
321 transport: Transport::Process,
322 acp: Some(AcpLaunch {
325 command: "claude-agent-acp",
326 args: &[],
327 full_args: &[],
328 full_mode: Some("bypassPermissions"),
329 full_environment: &[],
330 }),
331 },
332 AdapterDescriptor {
333 name: "codex",
334 product: "Codex",
335 command: "codex",
336 args: &["exec"],
337 prompt_args: &[],
338 model_args: &["-m", "{model}"],
339 effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
340 model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
341 home_environment: &[("CODEX_HOME", "")],
342 fixed_environment: &[],
343 removed_environment: &[
344 "OPENAI_API_KEY",
345 "OPENAI_BASE_URL",
346 "OPENAI_ORG_ID",
347 "OPENAI_PROJECT_ID",
348 "CODEX_API_KEY",
349 "CODEX_BASE_URL",
350 "CODEX_CONFIG",
351 ],
352 full_permission_args: &[
354 "--dangerously-bypass-approvals-and-sandbox",
355 "-c",
356 "web_search=\"live\"",
357 ],
358 full_permission_environment: &[],
359 search_dirs: &[],
360 login: Login::Command(&["login"]),
361 status: Status::Command(&["login", "status"]),
362 status_summary: StatusSummary::CodexText,
363 logout: Logout::Command(&["logout"]),
364 output: OutputFormat::CodexJsonl,
365 resume: Resume::Supported {
368 start: &[],
369 subcommand: &["resume"],
370 options: &[],
371 positional: &["{session}"],
372 },
373 conversation_files: Some(ConversationFiles {
374 dir: "sessions",
375 extension: "jsonl",
376 }),
377 transport: Transport::Process,
378 acp: Some(AcpLaunch {
384 command: "codex-acp",
385 args: &[],
386 full_args: &[],
387 full_mode: Some("agent-full-access"),
388 full_environment: &[("CODEX_CONFIG", r#"{"web_search":"live"}"#)],
389 }),
390 },
391 AdapterDescriptor {
392 name: "grok",
393 product: "Grok Build",
394 command: "grok",
395 args: &[],
396 prompt_args: &["-p"],
397 model_args: &["-m", "{model}"],
398 effort_args: &["--reasoning-effort", "{effort}"],
399 model_hint: "xAI Grok model ID, such as grok-4.7.",
400 home_environment: &[("GROK_HOME", ".grok")],
401 fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
402 removed_environment: &["GROK_*", "XAI_API_KEY"],
403 full_permission_args: &["--always-approve"],
405 full_permission_environment: &[],
406 search_dirs: &[".grok/bin"],
407 login: Login::Command(&["login"]),
408 status: Status::Stored(KeyStore::Grok {
409 auth: ".grok/auth.json",
410 config: ".grok/config.toml",
411 }),
412 status_summary: StatusSummary::ExitStatus,
413 logout: Logout::Command(&["logout"]),
414 output: OutputFormat::Text,
416 resume: Resume::Unsupported,
419 conversation_files: None,
420 transport: Transport::Process,
421 acp: Some(AcpLaunch {
423 command: "grok",
424 args: &["agent", "{full}", "stdio"],
425 full_args: &["--always-approve"],
426 full_mode: None,
427 full_environment: &[],
428 }),
429 },
430 AdapterDescriptor {
431 name: "dsh",
432 product: "DeepSeek Harness",
433 command: "dsh",
434 args: &["--profile", "headless"],
435 prompt_args: &[],
436 model_args: &[],
437 effort_args: &[],
438 model_hint: "Model ID in the form this agent's CLI accepts.",
439 home_environment: &[("DSH_HOME", ".dsh")],
440 fixed_environment: &[],
441 removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
442 full_permission_args: &[],
444 full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
445 search_dirs: &[],
446 login: Login::ApiKey(DSH_STORE),
447 status: Status::Stored(DSH_STORE),
448 status_summary: StatusSummary::ExitStatus,
449 logout: Logout::Stored(DSH_STORE),
450 output: OutputFormat::Text,
451 resume: Resume::Unsupported,
453 conversation_files: None,
454 transport: Transport::Process,
455 acp: Some(AcpLaunch {
458 command: "dsh",
459 args: &["--profile", "acp"],
460 full_args: &[],
461 full_mode: None,
462 full_environment: &[],
463 }),
464 },
465 AdapterDescriptor {
466 name: "pi",
467 product: "pi",
468 command: "pi",
469 args: &["-p"],
470 prompt_args: &[],
471 model_args: &["--model", "{model}"],
472 effort_args: &["--thinking", "{effort}"],
473 model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
474 home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
475 fixed_environment: &[],
476 removed_environment: &["PI_*"],
477 full_permission_args: &[],
479 full_permission_environment: &[],
480 search_dirs: &[],
481 login: Login::Interactive {
482 args: &[],
483 hint: "run /login and choose a provider, then /quit",
484 },
485 status: Status::Stored(PI_STORE),
486 status_summary: StatusSummary::ExitStatus,
487 logout: Logout::Stored(PI_STORE),
488 output: OutputFormat::PiJson,
489 resume: Resume::Supported {
491 start: &["--session-id", "{session}"],
492 subcommand: &[],
493 options: &["--session-id", "{session}"],
494 positional: &[],
495 },
496 conversation_files: Some(ConversationFiles {
497 dir: ".pi/agent/sessions",
498 extension: "jsonl",
499 }),
500 transport: Transport::Process,
501 acp: None,
503 },
504 AdapterDescriptor {
505 name: "scv",
506 product: "SCV",
507 command: "scv",
508 args: &["server", "--stdio"],
509 prompt_args: &[],
510 model_args: &[],
512 effort_args: &[],
513 model_hint: "Model ID for the nested SCV's provider; applies to a new conversation only.",
514 home_environment: &[],
517 fixed_environment: &[],
518 removed_environment: &[],
519 full_permission_args: &[],
521 full_permission_environment: &[],
522 search_dirs: &[".cargo/bin"],
524 login: Login::Import,
525 status: Status::Stored(SCV_STORE),
526 status_summary: StatusSummary::ExitStatus,
527 logout: Logout::Stored(SCV_STORE),
528 output: OutputFormat::Text,
529 resume: Resume::Unsupported,
530 conversation_files: None,
531 transport: Transport::ScvProtocol,
532 acp: None,
533 },
534];
535
536pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
538 ADAPTERS.iter().find(|adapter| adapter.name == name)
539}
540
541pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
545 let Some(variable) = variable.to_str() else {
546 return false;
547 };
548 variable.ends_with("_API_KEY")
549 || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
550 || ADAPTERS
551 .iter()
552 .flat_map(|adapter| adapter.removed_environment)
553 .any(|rule| match rule.strip_suffix('*') {
554 Some(prefix) => variable.starts_with(prefix),
555 None => variable == *rule,
556 })
557}
558
559pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
562 let signed_out = "not signed in".to_owned();
563 match summary {
564 StatusSummary::ClaudeJson => {
565 let first = serde_json::Deserializer::from_str(output)
567 .into_iter::<serde_json::Value>()
568 .next();
569 let Some(Ok(value)) = first else {
570 return if succeeded {
571 "signed in".into()
572 } else {
573 signed_out
574 };
575 };
576 if value.get("loggedIn").and_then(serde_json::Value::as_bool) != Some(true) {
577 return signed_out;
578 }
579 let method = match value.get("authMethod").and_then(serde_json::Value::as_str) {
580 Some("claude.ai") => "Claude account",
581 Some("api_key" | "apiKey" | "console") => "API key",
582 Some("oauth_token" | "oauthToken") => "OAuth token",
583 _ => "other method",
584 };
585 match value
586 .get("subscriptionType")
587 .and_then(serde_json::Value::as_str)
588 .filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
589 {
590 Some(plan) => format!("signed in ({method}, {plan})"),
591 None => format!("signed in ({method})"),
592 }
593 }
594 StatusSummary::CodexText => {
595 let lower = output.to_ascii_lowercase();
596 if !succeeded || lower.contains("not logged in") {
597 signed_out
598 } else if lower.contains("api key") {
599 "signed in (API key)".into()
600 } else if lower.contains("chatgpt") {
601 "signed in (ChatGPT account)".into()
602 } else {
603 "signed in".into()
604 }
605 }
606 StatusSummary::ExitStatus => {
607 if succeeded {
608 "signed in".into()
609 } else {
610 signed_out
611 }
612 }
613 }
614}
615
616pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
619 if command.contains('/') {
620 let path = Path::new(command);
621 return path.is_file().then(|| path.to_path_buf());
622 }
623 std::env::join_paths(search_dirs)
624 .ok()
625 .and_then(|dirs| {
626 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
627 which::which_in(command, Some(dirs), cwd).ok()
628 })
629 .or_else(|| which::which(command).ok())
630}
631
632pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
634 adapter
635 .search_dirs
636 .iter()
637 .chain(USER_BIN_DIRS)
638 .map(|dir| home.join(dir))
639 .collect()
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 #[test]
647 fn descriptors_are_unique_and_self_consistent() {
648 let mut names: Vec<_> = ADAPTERS.iter().map(|adapter| adapter.name).collect();
649 names.sort_unstable();
650 names.dedup();
651 assert_eq!(names.len(), ADAPTERS.len());
652 for adapter in ADAPTERS {
653 assert!(
654 adapter.model_args.is_empty()
655 || adapter.model_args.iter().any(|arg| arg.contains("{model}")),
656 "{}",
657 adapter.name
658 );
659 assert!(
660 adapter.effort_args.is_empty()
661 || adapter
662 .effort_args
663 .iter()
664 .any(|arg| arg.contains("{effort}")),
665 "{}",
666 adapter.name
667 );
668 if let Resume::Supported {
669 start,
670 subcommand,
671 options,
672 positional,
673 } = adapter.resume
674 {
675 let names_session =
676 |args: &[&str]| args.iter().any(|arg| arg.contains("{session}"));
677 assert!(start.is_empty() || names_session(start), "{}", adapter.name);
678 assert!(
679 names_session(options) || names_session(positional),
680 "{}",
681 adapter.name
682 );
683 assert!(!names_session(subcommand), "{}", adapter.name);
684 assert!(adapter.conversation_files.is_some(), "{}", adapter.name);
685 }
686 for (variable, _) in adapter
688 .home_environment
689 .iter()
690 .chain(adapter.fixed_environment)
691 {
692 assert!(!variable.ends_with("_API_KEY"), "{variable}");
693 }
694 for store in [
696 match adapter.status {
697 Status::Stored(store) => Some(store),
698 Status::Command(_) => None,
699 },
700 match adapter.logout {
701 Logout::Stored(store) => Some(store),
702 Logout::Command(_) => None,
703 },
704 match adapter.login {
705 Login::ApiKey(store) => Some(store),
706 _ => None,
707 },
708 ]
709 .into_iter()
710 .flatten()
711 {
712 let paths = match store {
713 KeyStore::Grok { auth, config } => vec![auth, config],
714 KeyStore::DshRefs { path, .. } => vec![path],
715 KeyStore::Pi { dir } => vec![dir],
716 KeyStore::Scv { .. } => vec![],
718 };
719 for path in paths {
720 assert!(
721 adapter
722 .home_environment
723 .iter()
724 .any(|(_, home)| !home.is_empty() && path.starts_with(home)),
725 "{}: {path}",
726 adapter.name
727 );
728 }
729 }
730 }
731 }
732
733 #[test]
734 fn status_summaries_never_echo_accounts_or_keys() {
735 let claude = r#"{"loggedIn":true,"authMethod":"claude.ai","email":"me@example.com","orgName":"me@example.com's Organization","subscriptionType":"max"}"#;
736 assert_eq!(
737 summarize_status(StatusSummary::ClaudeJson, true, claude),
738 "signed in (Claude account, max)"
739 );
740 assert_eq!(
741 summarize_status(
742 StatusSummary::ClaudeJson,
743 true,
744 r#"{"loggedIn":true,"authMethod":"api_key","subscriptionType":"me@example.com"}"#
745 ),
746 "signed in (API key)"
747 );
748 assert_eq!(
749 summarize_status(StatusSummary::ClaudeJson, false, r#"{"loggedIn":false}"#),
750 "not signed in"
751 );
752 assert_eq!(
753 summarize_status(
754 StatusSummary::ClaudeJson,
755 true,
756 "{\"loggedIn\":true,\"authMethod\":\"claude.ai\"}\n\nsome stderr"
757 ),
758 "signed in (Claude account)"
759 );
760 assert_eq!(
761 summarize_status(
762 StatusSummary::CodexText,
763 true,
764 "Logged in using an API key - sk-proj-***abcd"
765 ),
766 "signed in (API key)"
767 );
768 assert_eq!(
769 summarize_status(StatusSummary::CodexText, true, "Logged in using ChatGPT"),
770 "signed in (ChatGPT account)"
771 );
772 assert_eq!(
773 summarize_status(StatusSummary::CodexText, false, "Not logged in"),
774 "not signed in"
775 );
776 for adapter in ADAPTERS {
777 if let Status::Command(_) = adapter.status {
778 assert_ne!(
779 adapter.status_summary,
780 StatusSummary::ExitStatus,
781 "{}",
782 adapter.name
783 );
784 }
785 }
786 }
787
788 #[test]
789 fn removal_covers_every_adapter_and_generic_api_keys() {
790 for removed in [
791 "OPENAI_API_KEY",
792 "CLAUDE_CONFIG_DIR",
793 "GROK_HOME",
794 "GROK_AUTH",
795 "XAI_API_KEY",
796 "DSH_HOME",
797 "DSH_PERMISSION_MODE",
798 "DEEPSEEK_BASE_URL",
799 "PI_CODING_AGENT_DIR",
800 "OPENROUTER_API_KEY",
801 "SCV_CONFIG",
802 ] {
803 assert!(is_removed_agent_variable(OsStr::new(removed)), "{removed}");
804 }
805 for kept in ["PATH", "HOME", "LANG", "GH_TOKEN", "GROKKING", "PIPX_HOME"] {
806 assert!(!is_removed_agent_variable(OsStr::new(kept)), "{kept}");
807 }
808 }
809
810 #[test]
811 fn executables_resolve_from_per_user_directories_before_path() {
812 let dir = tempfile::tempdir().unwrap();
813 let bin = dir.path().join(".grok/bin");
814 std::fs::create_dir_all(&bin).unwrap();
815 let name = "scv-test-agent-only-in-home";
816 let executable = bin.join(name);
817 std::fs::write(&executable, "#!/bin/sh\n").unwrap();
818 #[cfg(unix)]
819 {
820 use std::os::unix::fs::PermissionsExt;
821 std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
822 }
823 let grok = adapter("grok").unwrap();
824 let dirs = adapter_search_dirs(grok, dir.path());
825 assert!(dirs.contains(&dir.path().join(".local/bin")));
826 assert_eq!(
827 resolve_agent_executable(name, &dirs),
828 Some(executable.clone())
829 );
830 assert_eq!(resolve_agent_executable(name, &[]), None);
831 let shadow = bin.join("sh");
833 std::fs::write(&shadow, "#!/bin/sh\n").unwrap();
834 #[cfg(unix)]
835 {
836 use std::os::unix::fs::PermissionsExt;
837 std::fs::set_permissions(&shadow, std::fs::Permissions::from_mode(0o755)).unwrap();
838 }
839 assert_eq!(resolve_agent_executable("sh", &dirs), Some(shadow));
840 assert!(resolve_agent_executable("sh", &[]).is_some());
841 assert_eq!(
842 resolve_agent_executable(executable.to_str().unwrap(), &[]),
843 Some(executable)
844 );
845 }
846}