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