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