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 Unchecked,
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum OutputFormat {
46 Text,
48 ClaudeStreamJson,
51 CodexJsonl,
54 PiJson,
57 GeminiStreamJson,
63 CursorStreamJson,
66 AmpStreamJson,
68}
69
70impl OutputFormat {
71 pub(crate) fn args(self) -> &'static [&'static str] {
73 match self {
74 Self::Text => &[],
75 Self::ClaudeStreamJson => &["--output-format", "stream-json", "--verbose"],
76 Self::CodexJsonl => &["--json"],
77 Self::PiJson => &["--mode", "json"],
78 Self::GeminiStreamJson | Self::CursorStreamJson => &["--output-format", "stream-json"],
79 Self::AmpStreamJson => &["--stream-json"],
80 }
81 }
82
83 pub(crate) fn claude_like(self) -> bool {
86 matches!(
87 self,
88 Self::ClaudeStreamJson | Self::CursorStreamJson | Self::AmpStreamJson
89 )
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum Transport {
96 Process,
99 ScvProtocol,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub struct AcpLaunch {
109 pub command: &'static str,
111 pub(crate) args: &'static [&'static str],
114 pub(crate) full_args: &'static [&'static str],
115 pub full_mode: Option<&'static str>,
120 pub full_environment: &'static [(&'static str, &'static str)],
123 pub session_options: bool,
128 pub no_session_options: bool,
133}
134
135pub fn acp_args(launch: &AcpLaunch, full: bool) -> Vec<String> {
137 let mut args = Vec::with_capacity(launch.args.len() + launch.full_args.len());
138 for arg in launch.args {
139 if *arg == "{full}" {
140 if full {
141 args.extend(launch.full_args.iter().map(|arg| (*arg).to_owned()));
142 }
143 } else {
144 args.push((*arg).to_owned());
145 }
146 }
147 args
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum Takeover {
156 Unsupported,
158 Refused(&'static str),
161 Supported {
167 args: &'static [&'static str],
168 full_args: &'static [&'static str],
169 },
170}
171
172const CLAUDE_TAKEOVER: &str = "Claude Code's own terminal asks before some actions even with its \
174 prompts off (deleting files, publishing), and keys typed there would answer those prompts \
175 instead of SCV's approval gate";
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
180pub enum Resume {
181 Unsupported,
183 Supported {
184 start: &'static [&'static str],
187 subcommand: &'static [&'static str],
190 options: &'static [&'static str],
192 positional: &'static [&'static str],
195 },
196}
197
198impl Resume {
199 pub(crate) fn is_supported(self) -> bool {
200 matches!(self, Self::Supported { .. })
201 }
202
203 pub(crate) fn assigns_id(self) -> bool {
205 matches!(self, Self::Supported { start, .. } if !start.is_empty())
206 }
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
212pub struct ConversationFiles {
213 pub(crate) dir: &'static str,
214 pub(crate) extension: &'static str,
215 pub(crate) id_prefix: Option<usize>,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
224pub enum StatusSummary {
225 CodexText,
227 ExitStatus,
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq)]
233pub enum Logout {
234 Command(&'static [&'static str]),
235 Stored(KeyStore),
237 Interactive {
239 args: &'static [&'static str],
240 hint: &'static str,
241 },
242}
243
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246pub enum KeyStore {
247 ClaudeApiKey {
250 key: &'static str,
251 credentials: &'static str,
252 },
253 ClaudeAccount {
255 credentials: &'static str,
256 config: &'static str,
257 },
258 Grok {
261 auth: &'static str,
262 config: &'static str,
263 },
264 DshRefs {
266 path: &'static str,
267 variable: &'static str,
268 },
269 Pi { dir: &'static str },
272 Scv { config: &'static str },
275 Gemini { dir: &'static str },
280}
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub enum ModeReport {
286 ClaudeInit,
289 CodexRollout,
293 None,
296}
297
298pub const CLAUDE_DENY_RULES: &[&str] = &[
305 "Bash(git push --force origin main:*)",
306 "Bash(git push -f origin main:*)",
307 "Bash(git push --force-with-lease origin main:*)",
308 "Bash(git push --force origin HEAD:main:*)",
309 "Bash(git push -f origin HEAD:main:*)",
310 "Bash(git push --force-with-lease origin HEAD:main:*)",
311 "Bash(git push origin --force main:*)",
312 "Bash(git push origin -f main:*)",
313 "Bash(git push origin --force-with-lease main:*)",
314 "Bash(git push origin main --force:*)",
315 "Bash(git push origin main -f:*)",
316 "Bash(git push origin main --force-with-lease:*)",
317 "Bash(git push origin +main:*)",
318 "Bash(git push origin +HEAD:main:*)",
319 "Bash(cargo publish:*)",
320];
321
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub enum AgentAuth {
325 None,
326 ClaudeApiKey,
327 ClaudeSubscription,
328}
329
330#[derive(Debug, Clone, Copy)]
331pub struct AdapterDescriptor {
332 pub name: &'static str,
334 pub family: &'static str,
336 pub auth: AgentAuth,
338 pub product: &'static str,
340 pub(crate) offers: &'static str,
343 pub command: &'static str,
344 pub args: &'static [&'static str],
345 pub prompt_args: &'static [&'static str],
348 pub model_args: &'static [&'static str],
349 pub effort_args: &'static [&'static str],
350 pub model_hint: &'static str,
352 pub home_environment: &'static [(&'static str, &'static str)],
355 pub fixed_environment: &'static [(&'static str, &'static str)],
357 pub(crate) removed_environment: &'static [&'static str],
360 pub full_permission_args: &'static [&'static str],
365 pub full_permission_environment: &'static [(&'static str, &'static str)],
367 pub mode_report: ModeReport,
369 pub(crate) search_dirs: &'static [&'static str],
374 pub login: Login,
375 pub status: Status,
376 pub status_summary: StatusSummary,
378 pub logout: Logout,
379 pub output: OutputFormat,
381 pub resume: Resume,
383 pub conversation_files: Option<ConversationFiles>,
385 pub credential_files: &'static [&'static str],
388 pub transport: Transport,
390 pub acp: Option<AcpLaunch>,
394 pub cli_turns: bool,
398 pub takeover: Takeover,
400}
401
402impl AdapterDescriptor {
403 pub fn mode_switch(&self) -> bool {
407 !self.full_permission_args.is_empty()
408 || !self.full_permission_environment.is_empty()
409 || self.acp.is_some_and(|launch| {
410 launch.full_mode.is_some()
411 || !launch.full_args.is_empty()
412 || !launch.full_environment.is_empty()
413 })
414 }
415}
416
417const USER_BIN_DIRS: &[&str] = &[".local/bin"];
419
420const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
424 "SCV_CONFIG",
425 "SCV_MODEL",
426 "SCV_PROVIDER",
427 "SCV_BASE_URL",
428 "SCV_API_KEY_ENV",
429 "GEMINI_API_KEY",
430 "GOOGLE_API_KEY",
431 "AZURE_OPENAI_API_KEY",
432 "AZURE_OPENAI_ENDPOINT",
433 "AWS_ACCESS_KEY_ID",
438 "AWS_SECRET_ACCESS_KEY",
439 "AWS_SESSION_TOKEN",
440 "AWS_BEARER_TOKEN_BEDROCK",
441];
442
443const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
444const SCV_STORE: KeyStore = KeyStore::Scv {
445 config: "config.toml",
446};
447const DSH_STORE: KeyStore = KeyStore::DshRefs {
448 path: ".dsh/.credentials.yaml",
449 variable: "DEEPSEEK_API_KEY",
450};
451const GEMINI_STORE: KeyStore = KeyStore::Gemini { dir: ".gemini" };
452const CLAUDE_KEY_STORE: KeyStore = KeyStore::ClaudeApiKey {
453 key: ".anthropic-api-key",
454 credentials: ".claude/.credentials.json",
455};
456const CLAUDE_ACCOUNT_STORE: KeyStore = KeyStore::ClaudeAccount {
457 credentials: ".claude/.credentials.json",
458 config: ".claude.json",
459};
460
461const CLAUDE_BASE: AdapterDescriptor = AdapterDescriptor {
462 name: "claude",
463 family: "claude",
464 auth: AgentAuth::None,
465 product: "Claude Code",
466 offers: "Anthropic's coding agent; it reads, edits, and runs code in a project and can search and fetch the web",
467 command: "claude",
468 args: &["-p"],
469 prompt_args: &[],
470 model_args: &["--model", "{model}"],
471 effort_args: &["--effort", "{effort}"],
472 model_hint: "Claude model alias or ID, such as sonnet or opus.",
473 home_environment: &[],
474 fixed_environment: &[],
475 removed_environment: &[
476 "ANTHROPIC_API_KEY",
477 "ANTHROPIC_BASE_URL",
478 "ANTHROPIC_AUTH_TOKEN",
479 "CLAUDE_CODE_OAUTH_TOKEN",
480 "CLAUDE_CONFIG_DIR",
481 ],
482 full_permission_args: &["--permission-mode", "bypassPermissions"],
483 full_permission_environment: &[],
484 mode_report: ModeReport::ClaudeInit,
485 search_dirs: &[],
486 login: Login::Command(&[]),
487 status: Status::Stored(CLAUDE_KEY_STORE),
488 status_summary: StatusSummary::ExitStatus,
489 logout: Logout::Command(&[]),
490 output: OutputFormat::ClaudeStreamJson,
491 resume: Resume::Supported {
492 start: &["--session-id", "{session}"],
493 subcommand: &[],
494 options: &["--resume", "{session}"],
495 positional: &[],
496 },
497 conversation_files: Some(ConversationFiles {
498 dir: ".claude/projects",
499 extension: "jsonl",
500 id_prefix: None,
501 }),
502 credential_files: &[".anthropic-api-key", ".claude/.credentials.json"],
503 transport: Transport::Process,
504 acp: Some(AcpLaunch {
505 command: "claude-agent-acp",
506 args: &[],
507 full_args: &[],
508 full_mode: Some("bypassPermissions"),
509 full_environment: &[],
510 session_options: false,
511 no_session_options: false,
512 }),
513 cli_turns: true,
514 takeover: Takeover::Refused(CLAUDE_TAKEOVER),
515};
516
517pub const ADAPTERS: &[AdapterDescriptor] = &[
518 AdapterDescriptor {
519 name: "claude",
520 family: "claude",
521 auth: AgentAuth::ClaudeApiKey,
522 product: "Claude Code (API key)",
523 offers: "Anthropic's coding agent; it reads, edits, and runs code in a project and can search and fetch the web; billed per token to the Anthropic API key SCV holds for it",
524 login: Login::ApiKey(CLAUDE_KEY_STORE),
525 status: Status::Stored(CLAUDE_KEY_STORE),
526 logout: Logout::Stored(CLAUDE_KEY_STORE),
527 acp: Some(AcpLaunch {
528 command: "claude-agent-acp",
529 args: &["--hide-claude-auth"],
530 full_args: &[],
531 full_mode: Some("bypassPermissions"),
532 full_environment: &[],
533 session_options: false,
534 no_session_options: false,
535 }),
536 ..CLAUDE_BASE
537 },
538 AdapterDescriptor {
539 name: "claude-ai",
540 family: "claude",
541 auth: AgentAuth::ClaudeSubscription,
542 product: "Claude Code (Claude subscription)",
543 offers: "Anthropic's coding agent; it reads, edits, and runs code in a project and can search and fetch the web; runs on the owner's Claude subscription",
544 login: Login::Command(&["auth", "login", "--claudeai"]),
545 status: Status::Stored(CLAUDE_ACCOUNT_STORE),
546 logout: Logout::Command(&["auth", "logout"]),
547 credential_files: &[".claude/.credentials.json"],
548 acp: Some(AcpLaunch {
549 command: "claude-agent-acp",
550 args: &[],
551 full_args: &[],
552 full_mode: Some("bypassPermissions"),
553 full_environment: &[],
554 session_options: false,
555 no_session_options: false,
556 }),
557 ..CLAUDE_BASE
558 },
559 AdapterDescriptor {
560 name: "codex",
561 family: "codex",
562 auth: AgentAuth::None,
563 product: "Codex",
564 offers: "OpenAI's coding agent; it reads, edits, and runs code in a project, with live web search under full permissions",
565 command: "codex",
566 args: &["exec"],
567 prompt_args: &[],
568 model_args: &["-m", "{model}"],
569 effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
570 model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
571 home_environment: &[("CODEX_HOME", "")],
572 fixed_environment: &[],
573 removed_environment: &[
574 "OPENAI_API_KEY",
575 "OPENAI_BASE_URL",
576 "OPENAI_ORG_ID",
577 "OPENAI_PROJECT_ID",
578 "CODEX_API_KEY",
579 "CODEX_BASE_URL",
580 "CODEX_CONFIG",
581 ],
582 full_permission_args: &[
584 "--dangerously-bypass-approvals-and-sandbox",
585 "-c",
586 "web_search=\"live\"",
587 ],
588 full_permission_environment: &[],
589 mode_report: ModeReport::CodexRollout,
590 search_dirs: &[],
591 login: Login::Command(&["login"]),
592 status: Status::Command(&["login", "status"]),
593 status_summary: StatusSummary::CodexText,
594 logout: Logout::Command(&["logout"]),
595 output: OutputFormat::CodexJsonl,
596 resume: Resume::Supported {
599 start: &[],
600 subcommand: &["resume"],
601 options: &[],
602 positional: &["{session}"],
603 },
604 conversation_files: Some(ConversationFiles {
605 dir: "sessions",
606 extension: "jsonl",
607 id_prefix: None,
608 }),
609 credential_files: &["auth.json"],
610 transport: Transport::Process,
611 acp: Some(AcpLaunch {
617 command: "codex-acp",
618 args: &[],
619 full_args: &[],
620 full_mode: Some("agent-full-access"),
621 full_environment: &[("CODEX_CONFIG", r#"{"web_search":"live"}"#)],
622 session_options: false,
623 no_session_options: false,
624 }),
625 cli_turns: true,
626 takeover: Takeover::Supported {
630 args: &["resume", "{session}"],
631 full_args: &[
632 "--dangerously-bypass-approvals-and-sandbox",
633 "-c",
634 "web_search=\"live\"",
635 ],
636 },
637 },
638 AdapterDescriptor {
639 name: "grok",
640 family: "grok",
641 auth: AgentAuth::None,
642 product: "Grok Build",
643 offers: "xAI's coding agent; it reads, edits, and runs code in a project and has live web and X search. Call it when another agent declined or refused a request, including a safety or guardrail refusal",
644 command: "grok",
645 args: &[],
646 prompt_args: &["-p"],
647 model_args: &["-m", "{model}"],
648 effort_args: &["--reasoning-effort", "{effort}"],
649 model_hint: "xAI Grok model ID, such as grok-4.7.",
650 home_environment: &[("GROK_HOME", ".grok")],
651 fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
652 removed_environment: &["GROK_*", "XAI_API_KEY"],
653 full_permission_args: &["--always-approve"],
655 full_permission_environment: &[],
656 mode_report: ModeReport::None,
657 search_dirs: &[".grok/bin"],
658 login: Login::Command(&["login"]),
659 status: Status::Stored(KeyStore::Grok {
660 auth: ".grok/auth.json",
661 config: ".grok/config.toml",
662 }),
663 status_summary: StatusSummary::ExitStatus,
664 logout: Logout::Command(&["logout"]),
665 output: OutputFormat::Text,
667 resume: Resume::Unsupported,
670 conversation_files: None,
671 credential_files: &[".grok/auth.json", ".grok/config.toml"],
672 transport: Transport::Process,
673 acp: Some(AcpLaunch {
675 command: "grok",
676 args: &["agent", "{full}", "stdio"],
677 full_args: &["--always-approve"],
678 full_mode: None,
679 full_environment: &[],
680 session_options: false,
681 no_session_options: false,
682 }),
683 cli_turns: true,
684 takeover: Takeover::Unsupported,
685 },
686 AdapterDescriptor {
687 name: "dsh",
688 family: "dsh",
689 auth: AgentAuth::None,
690 product: "DeepSeek Harness",
691 offers: "a coding agent on DeepSeek models; it reads, edits, and runs code in a project",
692 command: "dsh",
693 args: &["--profile", "headless"],
694 prompt_args: &[],
695 model_args: &[],
696 effort_args: &[],
697 model_hint: "provider/model as its ACP server lists them, such as deepseek-official/deepseek-v4-pro.",
698 home_environment: &[("DSH_HOME", ".dsh")],
699 fixed_environment: &[],
700 removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
701 full_permission_args: &[],
703 full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
704 mode_report: ModeReport::None,
705 search_dirs: &[],
706 login: Login::ApiKey(DSH_STORE),
707 status: Status::Stored(DSH_STORE),
708 status_summary: StatusSummary::ExitStatus,
709 logout: Logout::Stored(DSH_STORE),
710 output: OutputFormat::Text,
711 resume: Resume::Unsupported,
713 conversation_files: None,
714 credential_files: &[".dsh/.credentials.yaml"],
715 transport: Transport::Process,
716 acp: Some(AcpLaunch {
721 command: "dsh",
722 args: &["--profile", "acp"],
723 full_args: &[],
724 full_mode: None,
725 full_environment: &[],
726 session_options: true,
727 no_session_options: false,
728 }),
729 cli_turns: true,
730 takeover: Takeover::Unsupported,
731 },
732 AdapterDescriptor {
733 name: "pi",
734 family: "pi",
735 auth: AgentAuth::None,
736 product: "pi",
737 offers: "a minimal coding agent (read, write, edit, bash) that can run on SCV's own model endpoint; it has no web search",
738 command: "pi",
739 args: &["-p"],
740 prompt_args: &[],
741 model_args: &["--model", "{model}"],
742 effort_args: &["--thinking", "{effort}"],
743 model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
744 home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
745 fixed_environment: &[],
746 removed_environment: &["PI_*"],
747 full_permission_args: &[],
749 full_permission_environment: &[],
750 mode_report: ModeReport::None,
751 search_dirs: &[],
752 login: Login::Interactive {
753 args: &[],
754 hint: "run /login and choose a provider, then /quit",
755 },
756 status: Status::Stored(PI_STORE),
757 status_summary: StatusSummary::ExitStatus,
758 logout: Logout::Stored(PI_STORE),
759 output: OutputFormat::PiJson,
760 resume: Resume::Supported {
762 start: &["--session-id", "{session}"],
763 subcommand: &[],
764 options: &["--session-id", "{session}"],
765 positional: &[],
766 },
767 conversation_files: Some(ConversationFiles {
768 dir: ".pi/agent/sessions",
769 extension: "jsonl",
770 id_prefix: None,
771 }),
772 credential_files: &[".pi/agent/auth.json", ".pi/agent/models.json"],
773 transport: Transport::Process,
774 acp: None,
776 cli_turns: true,
777 takeover: Takeover::Unsupported,
778 },
779 AdapterDescriptor {
780 name: "gemini",
781 family: "gemini",
782 auth: AgentAuth::None,
783 product: "Gemini CLI",
784 offers: "Google's coding agent; it reads, edits, and runs code in a project and has Google Search",
785 command: "gemini",
786 args: &[],
787 prompt_args: &["-p"],
790 model_args: &["-m", "{model}"],
791 effort_args: &[],
793 model_hint: "Gemini model ID or alias, such as gemini-2.5-pro, pro, flash, or auto; it takes no effort.",
794 home_environment: &[("GEMINI_CLI_HOME", "")],
796 fixed_environment: &[("GEMINI_FORCE_FILE_STORAGE", "true")],
799 removed_environment: &[
800 "GEMINI_*",
801 "GOOGLE_GENAI_*",
802 "GOOGLE_GEMINI_BASE_URL",
803 "GOOGLE_VERTEX_BASE_URL",
804 "GOOGLE_CLOUD_*",
805 "GOOGLE_APPLICATION_CREDENTIALS",
806 ],
807 full_permission_args: &["--approval-mode", "yolo", "--skip-trust"],
814 full_permission_environment: &[],
815 mode_report: ModeReport::None,
817 search_dirs: &[".npm-global/bin"],
819 login: Login::Interactive {
820 args: &[],
821 hint: "run /auth and choose Sign in with Google or Use Gemini API Key, then /quit",
822 },
823 status: Status::Stored(GEMINI_STORE),
825 status_summary: StatusSummary::ExitStatus,
826 logout: Logout::Stored(GEMINI_STORE),
827 output: OutputFormat::GeminiStreamJson,
828 resume: Resume::Supported {
832 start: &["--session-id", "{session}"],
833 subcommand: &[],
834 options: &["--resume", "{session}"],
835 positional: &[],
836 },
837 conversation_files: Some(ConversationFiles {
839 dir: ".gemini/tmp",
840 extension: "jsonl",
841 id_prefix: Some(8),
842 }),
843 credential_files: &[
844 ".gemini/oauth_creds.json",
845 ".gemini/gemini-credentials.json",
846 ".gemini/.env",
847 ],
848 transport: Transport::Process,
849 acp: Some(AcpLaunch {
856 command: "gemini",
857 args: &["{full}", "--acp"],
858 full_args: &["--approval-mode", "yolo", "--skip-trust"],
859 full_mode: None,
860 full_environment: &[],
861 session_options: false,
862 no_session_options: true,
863 }),
864 cli_turns: true,
865 takeover: Takeover::Unsupported,
866 },
867 AdapterDescriptor {
868 name: "qwen",
869 family: "qwen",
870 auth: AgentAuth::None,
871 product: "Qwen Code",
872 offers: "Alibaba's coding agent on Qwen models; it reads, edits, and runs code in a project",
873 command: "qwen",
874 args: &[],
875 prompt_args: &["-p"],
876 model_args: &["-m", "{model}"],
877 effort_args: &[],
878 model_hint: "Qwen model ID, such as qwen3-coder-plus.",
879 home_environment: &[],
880 fixed_environment: &[],
881 removed_environment: &["QWEN_*", "DASHSCOPE_*"],
882 full_permission_args: &["--yolo"],
883 full_permission_environment: &[],
884 mode_report: ModeReport::None,
885 search_dirs: &[".npm-global/bin"],
886 login: Login::Interactive {
887 args: &[],
888 hint: "choose how to sign in (Qwen OAuth, or an OpenAI-compatible key), then /quit",
889 },
890 status: Status::Unchecked,
891 status_summary: StatusSummary::ExitStatus,
892 logout: Logout::Interactive {
893 args: &[],
894 hint: "sign out with its own command (/auth), then /quit",
895 },
896 output: OutputFormat::Text,
898 resume: Resume::Unsupported,
899 conversation_files: None,
900 credential_files: &[".qwen/oauth_creds.json", ".qwen/.env"],
901 transport: Transport::Process,
902 acp: Some(AcpLaunch {
904 command: "qwen",
905 args: &["{full}", "--experimental-acp"],
906 full_args: &["--yolo"],
907 full_mode: None,
908 full_environment: &[],
909 session_options: false,
910 no_session_options: false,
911 }),
912 cli_turns: true,
913 takeover: Takeover::Unsupported,
914 },
915 AdapterDescriptor {
916 name: "opencode",
917 family: "opencode",
918 auth: AgentAuth::None,
919 product: "OpenCode",
920 offers: "an open-source coding agent that runs on many model providers; it reads, edits, and runs code in a project",
921 command: "opencode",
922 args: &["run"],
923 prompt_args: &[],
924 model_args: &["-m", "{model}"],
925 effort_args: &[],
926 model_hint: "provider/model as OpenCode names it, such as anthropic/claude-sonnet-4-5.",
927 home_environment: &[],
928 fixed_environment: &[],
929 removed_environment: &["OPENCODE_*"],
930 full_permission_args: &[],
932 full_permission_environment: &[],
933 mode_report: ModeReport::None,
934 search_dirs: &[".opencode/bin", ".npm-global/bin"],
935 login: Login::Command(&["auth", "login"]),
936 status: Status::Unchecked,
937 status_summary: StatusSummary::ExitStatus,
938 logout: Logout::Command(&["auth", "logout"]),
939 output: OutputFormat::Text,
940 resume: Resume::Unsupported,
941 conversation_files: None,
942 credential_files: &["data/opencode/auth.json"],
943 transport: Transport::Process,
944 acp: Some(AcpLaunch {
946 command: "opencode",
947 args: &["acp"],
948 full_args: &[],
949 full_mode: None,
950 full_environment: &[],
951 session_options: false,
952 no_session_options: false,
953 }),
954 cli_turns: true,
955 takeover: Takeover::Unsupported,
956 },
957 AdapterDescriptor {
958 name: "copilot",
959 family: "copilot",
960 auth: AgentAuth::None,
961 product: "GitHub Copilot CLI",
962 offers: "GitHub's coding agent; it reads, edits, and runs code in a project and knows GitHub",
963 command: "copilot",
964 args: &[],
965 prompt_args: &["-p"],
966 model_args: &["--model", "{model}"],
967 effort_args: &[],
968 model_hint: "a model Copilot CLI offers, such as claude-sonnet-4.5 or gpt-5.",
969 home_environment: &[],
970 fixed_environment: &[],
971 removed_environment: &["COPILOT_*"],
972 full_permission_args: &["--allow-all-tools"],
974 full_permission_environment: &[],
975 mode_report: ModeReport::None,
976 search_dirs: &[".npm-global/bin"],
977 login: Login::Interactive {
978 args: &[],
979 hint: "run /login, then /exit",
980 },
981 status: Status::Unchecked,
982 status_summary: StatusSummary::ExitStatus,
983 logout: Logout::Interactive {
984 args: &[],
985 hint: "run /logout, then /exit",
986 },
987 output: OutputFormat::Text,
988 resume: Resume::Unsupported,
990 conversation_files: None,
991 credential_files: &[],
992 transport: Transport::Process,
993 acp: None,
995 cli_turns: true,
996 takeover: Takeover::Unsupported,
997 },
998 AdapterDescriptor {
999 name: "cursor",
1000 family: "cursor",
1001 auth: AgentAuth::None,
1002 product: "Cursor CLI",
1003 offers: "Cursor's coding agent; it reads, edits, and runs code in a project",
1004 command: "cursor-agent",
1005 args: &["-p"],
1006 prompt_args: &[],
1007 model_args: &["--model", "{model}"],
1008 effort_args: &[],
1009 model_hint: "a model Cursor offers, such as sonnet-4.5 or gpt-5.",
1010 home_environment: &[],
1011 fixed_environment: &[],
1012 removed_environment: &["CURSOR_*"],
1013 full_permission_args: &["--force"],
1015 full_permission_environment: &[],
1016 mode_report: ModeReport::None,
1017 search_dirs: &[],
1018 login: Login::Command(&["login"]),
1019 status: Status::Unchecked,
1020 status_summary: StatusSummary::ExitStatus,
1021 logout: Logout::Command(&["logout"]),
1022 output: OutputFormat::CursorStreamJson,
1023 resume: Resume::Supported {
1025 start: &[],
1026 subcommand: &[],
1027 options: &["--resume", "{session}"],
1028 positional: &[],
1029 },
1030 conversation_files: None,
1031 credential_files: &[],
1032 transport: Transport::Process,
1033 acp: None,
1034 cli_turns: true,
1035 takeover: Takeover::Unsupported,
1036 },
1037 AdapterDescriptor {
1038 name: "amp",
1039 family: "amp",
1040 auth: AgentAuth::None,
1041 product: "Amp",
1042 offers: "Sourcegraph's coding agent; it reads, edits, and runs code in a project and can search the web",
1043 command: "amp",
1044 args: &[],
1045 prompt_args: &["-x"],
1046 model_args: &[],
1048 effort_args: &[],
1049 model_hint: "Amp chooses its own model; omit model.",
1050 home_environment: &[],
1051 fixed_environment: &[],
1052 removed_environment: &["AMP_*"],
1053 full_permission_args: &["--dangerously-allow-all"],
1054 full_permission_environment: &[],
1055 mode_report: ModeReport::None,
1056 search_dirs: &[".amp/bin", ".npm-global/bin"],
1057 login: Login::Command(&["login"]),
1058 status: Status::Unchecked,
1059 status_summary: StatusSummary::ExitStatus,
1060 logout: Logout::Command(&["logout"]),
1061 output: OutputFormat::AmpStreamJson,
1062 resume: Resume::Unsupported,
1064 conversation_files: None,
1065 credential_files: &[],
1066 transport: Transport::Process,
1067 acp: None,
1068 cli_turns: true,
1069 takeover: Takeover::Unsupported,
1070 },
1071 AdapterDescriptor {
1072 name: "kimi",
1073 family: "kimi",
1074 auth: AgentAuth::None,
1075 product: "Kimi Code",
1076 offers: "Moonshot AI's coding agent on Kimi models; it reads, edits, and runs code in a project",
1077 command: "kimi",
1078 args: &[],
1079 prompt_args: &[],
1080 model_args: &[],
1081 effort_args: &[],
1082 model_hint: "a model its ACP server lists.",
1083 home_environment: &[],
1084 fixed_environment: &[],
1085 removed_environment: &["KIMI_*", "MOONSHOT_*"],
1086 full_permission_args: &[],
1087 full_permission_environment: &[],
1088 mode_report: ModeReport::None,
1089 search_dirs: &[".kimi-code/bin"],
1090 login: Login::Interactive {
1091 args: &[],
1092 hint: "run /login, then /exit (`-- login` runs `kimi login` instead: a device code, with `--region mainland-cn` or `--region global`)",
1093 },
1094 status: Status::Unchecked,
1095 status_summary: StatusSummary::ExitStatus,
1096 logout: Logout::Interactive {
1097 args: &[],
1098 hint: "sign out with its own command, then /exit",
1099 },
1100 output: OutputFormat::Text,
1101 resume: Resume::Unsupported,
1102 conversation_files: None,
1103 credential_files: &[],
1104 transport: Transport::Process,
1105 acp: Some(AcpLaunch {
1108 command: "kimi",
1109 args: &["acp"],
1110 full_args: &[],
1111 full_mode: None,
1112 full_environment: &[],
1113 session_options: true,
1114 no_session_options: false,
1115 }),
1116 cli_turns: false,
1117 takeover: Takeover::Unsupported,
1118 },
1119 AdapterDescriptor {
1120 name: "scv",
1121 family: "scv",
1122 auth: AgentAuth::None,
1123 product: "SCV",
1124 offers: "a nested SCV session with its own context and tools; suited to a self-contained sub-task kept out of this conversation's context, or work in another project",
1125 command: "scv",
1126 args: &["server", "--stdio", "--policy", "on-risk"],
1129 prompt_args: &[],
1130 model_args: &[],
1132 effort_args: &[],
1133 model_hint: "Model ID for the nested SCV's provider; applies to a new conversation only.",
1134 home_environment: &[],
1137 fixed_environment: &[],
1138 removed_environment: &[],
1139 full_permission_args: &[],
1141 full_permission_environment: &[],
1142 mode_report: ModeReport::None,
1143 search_dirs: &[".cargo/bin"],
1145 login: Login::Import,
1146 status: Status::Stored(SCV_STORE),
1147 status_summary: StatusSummary::ExitStatus,
1148 logout: Logout::Stored(SCV_STORE),
1149 output: OutputFormat::Text,
1150 resume: Resume::Unsupported,
1151 conversation_files: None,
1152 credential_files: &["config.toml"],
1153 transport: Transport::ScvProtocol,
1154 acp: None,
1155 cli_turns: true,
1156 takeover: Takeover::Unsupported,
1157 },
1158];
1159
1160pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
1162 ADAPTERS.iter().find(|adapter| adapter.name == name)
1163}
1164
1165pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
1169 let Some(variable) = variable.to_str() else {
1170 return false;
1171 };
1172 variable.ends_with("_API_KEY")
1173 || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
1174 || ADAPTERS
1175 .iter()
1176 .flat_map(|adapter| adapter.removed_environment)
1177 .any(|rule| match rule.strip_suffix('*') {
1178 Some(prefix) => variable.starts_with(prefix),
1179 None => variable == *rule,
1180 })
1181}
1182
1183pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
1186 let signed_out = "not signed in".to_owned();
1187 match summary {
1188 StatusSummary::CodexText => {
1189 let lower = output.to_ascii_lowercase();
1190 if !succeeded || lower.contains("not logged in") {
1191 signed_out
1192 } else if lower.contains("api key") {
1193 "signed in (API key)".into()
1194 } else if lower.contains("chatgpt") {
1195 "signed in (ChatGPT account)".into()
1196 } else {
1197 "signed in".into()
1198 }
1199 }
1200 StatusSummary::ExitStatus => {
1201 if succeeded {
1202 "signed in".into()
1203 } else {
1204 signed_out
1205 }
1206 }
1207 }
1208}
1209
1210pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
1213 if command.contains('/') {
1214 let path = Path::new(command);
1215 return path.is_file().then(|| path.to_path_buf());
1216 }
1217 std::env::join_paths(search_dirs)
1218 .ok()
1219 .and_then(|dirs| {
1220 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
1221 which::which_in(command, Some(dirs), cwd).ok()
1222 })
1223 .or_else(|| which::which(command).ok())
1224}
1225
1226pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
1228 adapter
1229 .search_dirs
1230 .iter()
1231 .chain(USER_BIN_DIRS)
1232 .map(|dir| home.join(dir))
1233 .collect()
1234}
1235
1236#[cfg(test)]
1237mod tests;