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,
62 CursorStreamJson,
65 AmpStreamJson,
67}
68
69impl OutputFormat {
70 pub(crate) fn args(self) -> &'static [&'static str] {
72 match self {
73 Self::Text => &[],
74 Self::ClaudeStreamJson => &["--output-format", "stream-json", "--verbose"],
75 Self::CodexJsonl => &["--json"],
76 Self::PiJson => &["--mode", "json"],
77 Self::GeminiStreamJson | Self::CursorStreamJson => &["--output-format", "stream-json"],
78 Self::AmpStreamJson => &["--stream-json"],
79 }
80 }
81
82 pub(crate) fn claude_like(self) -> bool {
85 matches!(
86 self,
87 Self::ClaudeStreamJson | Self::CursorStreamJson | Self::AmpStreamJson
88 )
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum Transport {
95 Process,
98 ScvProtocol,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub struct AcpLaunch {
108 pub command: &'static str,
110 pub(crate) args: &'static [&'static str],
113 pub(crate) full_args: &'static [&'static str],
114 pub full_mode: Option<&'static str>,
119 pub full_environment: &'static [(&'static str, &'static str)],
122 pub session_options: bool,
127}
128
129pub fn acp_args(launch: &AcpLaunch, full: bool) -> Vec<String> {
131 let mut args = Vec::with_capacity(launch.args.len() + launch.full_args.len());
132 for arg in launch.args {
133 if *arg == "{full}" {
134 if full {
135 args.extend(launch.full_args.iter().map(|arg| (*arg).to_owned()));
136 }
137 } else {
138 args.push((*arg).to_owned());
139 }
140 }
141 args
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub enum Takeover {
150 Unsupported,
152 Refused(&'static str),
155 Supported {
161 args: &'static [&'static str],
162 full_args: &'static [&'static str],
163 },
164}
165
166const CLAUDE_TAKEOVER: &str = "Claude Code's own terminal asks before some actions even with its \
168 prompts off (deleting files, publishing), and keys typed there would answer those prompts \
169 instead of SCV's approval gate";
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum Resume {
175 Unsupported,
177 Supported {
178 start: &'static [&'static str],
181 subcommand: &'static [&'static str],
184 options: &'static [&'static str],
186 positional: &'static [&'static str],
189 },
190}
191
192impl Resume {
193 pub(crate) fn is_supported(self) -> bool {
194 matches!(self, Self::Supported { .. })
195 }
196
197 pub(crate) fn assigns_id(self) -> bool {
199 matches!(self, Self::Supported { start, .. } if !start.is_empty())
200 }
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub struct ConversationFiles {
207 pub(crate) dir: &'static str,
208 pub(crate) extension: &'static str,
209}
210
211#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum StatusSummary {
215 CodexText,
217 ExitStatus,
219}
220
221#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223pub enum Logout {
224 Command(&'static [&'static str]),
225 Stored(KeyStore),
227 Interactive {
229 args: &'static [&'static str],
230 hint: &'static str,
231 },
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum KeyStore {
237 ClaudeApiKey {
240 key: &'static str,
241 credentials: &'static str,
242 },
243 ClaudeAccount {
245 credentials: &'static str,
246 config: &'static str,
247 },
248 Grok {
251 auth: &'static str,
252 config: &'static str,
253 },
254 DshRefs {
256 path: &'static str,
257 variable: &'static str,
258 },
259 Pi { dir: &'static str },
262 Scv { config: &'static str },
265}
266
267#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub enum ModeReport {
271 ClaudeInit,
274 CodexRollout,
278 None,
281}
282
283pub const CLAUDE_DENY_RULES: &[&str] = &[
290 "Bash(git push --force origin main:*)",
291 "Bash(git push -f origin main:*)",
292 "Bash(git push --force-with-lease origin main:*)",
293 "Bash(git push --force origin HEAD:main:*)",
294 "Bash(git push -f origin HEAD:main:*)",
295 "Bash(git push --force-with-lease origin HEAD:main:*)",
296 "Bash(git push origin --force main:*)",
297 "Bash(git push origin -f main:*)",
298 "Bash(git push origin --force-with-lease main:*)",
299 "Bash(git push origin main --force:*)",
300 "Bash(git push origin main -f:*)",
301 "Bash(git push origin main --force-with-lease:*)",
302 "Bash(git push origin +main:*)",
303 "Bash(git push origin +HEAD:main:*)",
304 "Bash(cargo publish:*)",
305];
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
309pub enum AgentAuth {
310 None,
311 ClaudeApiKey,
312 ClaudeSubscription,
313}
314
315#[derive(Debug, Clone, Copy)]
316pub struct AdapterDescriptor {
317 pub name: &'static str,
319 pub family: &'static str,
321 pub auth: AgentAuth,
323 pub product: &'static str,
325 pub(crate) offers: &'static str,
328 pub command: &'static str,
329 pub args: &'static [&'static str],
330 pub prompt_args: &'static [&'static str],
333 pub model_args: &'static [&'static str],
334 pub effort_args: &'static [&'static str],
335 pub model_hint: &'static str,
337 pub home_environment: &'static [(&'static str, &'static str)],
340 pub fixed_environment: &'static [(&'static str, &'static str)],
342 pub(crate) removed_environment: &'static [&'static str],
345 pub full_permission_args: &'static [&'static str],
350 pub full_permission_environment: &'static [(&'static str, &'static str)],
352 pub mode_report: ModeReport,
354 pub(crate) search_dirs: &'static [&'static str],
359 pub login: Login,
360 pub status: Status,
361 pub status_summary: StatusSummary,
363 pub logout: Logout,
364 pub output: OutputFormat,
366 pub resume: Resume,
368 pub conversation_files: Option<ConversationFiles>,
370 pub credential_files: &'static [&'static str],
373 pub transport: Transport,
375 pub acp: Option<AcpLaunch>,
379 pub cli_turns: bool,
383 pub takeover: Takeover,
385}
386
387impl AdapterDescriptor {
388 pub fn mode_switch(&self) -> bool {
392 !self.full_permission_args.is_empty()
393 || !self.full_permission_environment.is_empty()
394 || self.acp.is_some_and(|launch| {
395 launch.full_mode.is_some()
396 || !launch.full_args.is_empty()
397 || !launch.full_environment.is_empty()
398 })
399 }
400}
401
402const USER_BIN_DIRS: &[&str] = &[".local/bin"];
404
405const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
409 "SCV_CONFIG",
410 "SCV_MODEL",
411 "SCV_PROVIDER",
412 "SCV_BASE_URL",
413 "SCV_API_KEY_ENV",
414 "GEMINI_API_KEY",
415 "GOOGLE_API_KEY",
416 "AZURE_OPENAI_API_KEY",
417 "AZURE_OPENAI_ENDPOINT",
418 "AWS_ACCESS_KEY_ID",
423 "AWS_SECRET_ACCESS_KEY",
424 "AWS_SESSION_TOKEN",
425 "AWS_BEARER_TOKEN_BEDROCK",
426];
427
428const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
429const SCV_STORE: KeyStore = KeyStore::Scv {
430 config: "config.toml",
431};
432const DSH_STORE: KeyStore = KeyStore::DshRefs {
433 path: ".dsh/.credentials.yaml",
434 variable: "DEEPSEEK_API_KEY",
435};
436const CLAUDE_KEY_STORE: KeyStore = KeyStore::ClaudeApiKey {
437 key: ".anthropic-api-key",
438 credentials: ".claude/.credentials.json",
439};
440const CLAUDE_ACCOUNT_STORE: KeyStore = KeyStore::ClaudeAccount {
441 credentials: ".claude/.credentials.json",
442 config: ".claude.json",
443};
444
445const CLAUDE_BASE: AdapterDescriptor = AdapterDescriptor {
446 name: "claude",
447 family: "claude",
448 auth: AgentAuth::None,
449 product: "Claude Code",
450 offers: "Anthropic's coding agent; it reads, edits, and runs code in a project and can search and fetch the web",
451 command: "claude",
452 args: &["-p"],
453 prompt_args: &[],
454 model_args: &["--model", "{model}"],
455 effort_args: &["--effort", "{effort}"],
456 model_hint: "Claude model alias or ID, such as sonnet or opus.",
457 home_environment: &[],
458 fixed_environment: &[],
459 removed_environment: &[
460 "ANTHROPIC_API_KEY",
461 "ANTHROPIC_BASE_URL",
462 "ANTHROPIC_AUTH_TOKEN",
463 "CLAUDE_CODE_OAUTH_TOKEN",
464 "CLAUDE_CONFIG_DIR",
465 ],
466 full_permission_args: &["--permission-mode", "bypassPermissions"],
467 full_permission_environment: &[],
468 mode_report: ModeReport::ClaudeInit,
469 search_dirs: &[],
470 login: Login::Command(&[]),
471 status: Status::Stored(CLAUDE_KEY_STORE),
472 status_summary: StatusSummary::ExitStatus,
473 logout: Logout::Command(&[]),
474 output: OutputFormat::ClaudeStreamJson,
475 resume: Resume::Supported {
476 start: &["--session-id", "{session}"],
477 subcommand: &[],
478 options: &["--resume", "{session}"],
479 positional: &[],
480 },
481 conversation_files: Some(ConversationFiles {
482 dir: ".claude/projects",
483 extension: "jsonl",
484 }),
485 credential_files: &[".anthropic-api-key", ".claude/.credentials.json"],
486 transport: Transport::Process,
487 acp: Some(AcpLaunch {
488 command: "claude-agent-acp",
489 args: &[],
490 full_args: &[],
491 full_mode: Some("bypassPermissions"),
492 full_environment: &[],
493 session_options: false,
494 }),
495 cli_turns: true,
496 takeover: Takeover::Refused(CLAUDE_TAKEOVER),
497};
498
499pub const ADAPTERS: &[AdapterDescriptor] = &[
500 AdapterDescriptor {
501 name: "claude",
502 family: "claude",
503 auth: AgentAuth::ClaudeApiKey,
504 product: "Claude Code (API key)",
505 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",
506 login: Login::ApiKey(CLAUDE_KEY_STORE),
507 status: Status::Stored(CLAUDE_KEY_STORE),
508 logout: Logout::Stored(CLAUDE_KEY_STORE),
509 acp: Some(AcpLaunch {
510 command: "claude-agent-acp",
511 args: &["--hide-claude-auth"],
512 full_args: &[],
513 full_mode: Some("bypassPermissions"),
514 full_environment: &[],
515 session_options: false,
516 }),
517 ..CLAUDE_BASE
518 },
519 AdapterDescriptor {
520 name: "claude-ai",
521 family: "claude",
522 auth: AgentAuth::ClaudeSubscription,
523 product: "Claude Code (Claude subscription)",
524 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",
525 login: Login::Command(&["auth", "login", "--claudeai"]),
526 status: Status::Stored(CLAUDE_ACCOUNT_STORE),
527 logout: Logout::Command(&["auth", "logout"]),
528 credential_files: &[".claude/.credentials.json"],
529 acp: Some(AcpLaunch {
530 command: "claude-agent-acp",
531 args: &[],
532 full_args: &[],
533 full_mode: Some("bypassPermissions"),
534 full_environment: &[],
535 session_options: false,
536 }),
537 ..CLAUDE_BASE
538 },
539 AdapterDescriptor {
540 name: "codex",
541 family: "codex",
542 auth: AgentAuth::None,
543 product: "Codex",
544 offers: "OpenAI's coding agent; it reads, edits, and runs code in a project, with live web search under full permissions",
545 command: "codex",
546 args: &["exec"],
547 prompt_args: &[],
548 model_args: &["-m", "{model}"],
549 effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
550 model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
551 home_environment: &[("CODEX_HOME", "")],
552 fixed_environment: &[],
553 removed_environment: &[
554 "OPENAI_API_KEY",
555 "OPENAI_BASE_URL",
556 "OPENAI_ORG_ID",
557 "OPENAI_PROJECT_ID",
558 "CODEX_API_KEY",
559 "CODEX_BASE_URL",
560 "CODEX_CONFIG",
561 ],
562 full_permission_args: &[
564 "--dangerously-bypass-approvals-and-sandbox",
565 "-c",
566 "web_search=\"live\"",
567 ],
568 full_permission_environment: &[],
569 mode_report: ModeReport::CodexRollout,
570 search_dirs: &[],
571 login: Login::Command(&["login"]),
572 status: Status::Command(&["login", "status"]),
573 status_summary: StatusSummary::CodexText,
574 logout: Logout::Command(&["logout"]),
575 output: OutputFormat::CodexJsonl,
576 resume: Resume::Supported {
579 start: &[],
580 subcommand: &["resume"],
581 options: &[],
582 positional: &["{session}"],
583 },
584 conversation_files: Some(ConversationFiles {
585 dir: "sessions",
586 extension: "jsonl",
587 }),
588 credential_files: &["auth.json"],
589 transport: Transport::Process,
590 acp: Some(AcpLaunch {
596 command: "codex-acp",
597 args: &[],
598 full_args: &[],
599 full_mode: Some("agent-full-access"),
600 full_environment: &[("CODEX_CONFIG", r#"{"web_search":"live"}"#)],
601 session_options: false,
602 }),
603 cli_turns: true,
604 takeover: Takeover::Supported {
608 args: &["resume", "{session}"],
609 full_args: &[
610 "--dangerously-bypass-approvals-and-sandbox",
611 "-c",
612 "web_search=\"live\"",
613 ],
614 },
615 },
616 AdapterDescriptor {
617 name: "grok",
618 family: "grok",
619 auth: AgentAuth::None,
620 product: "Grok Build",
621 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",
622 command: "grok",
623 args: &[],
624 prompt_args: &["-p"],
625 model_args: &["-m", "{model}"],
626 effort_args: &["--reasoning-effort", "{effort}"],
627 model_hint: "xAI Grok model ID, such as grok-4.7.",
628 home_environment: &[("GROK_HOME", ".grok")],
629 fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
630 removed_environment: &["GROK_*", "XAI_API_KEY"],
631 full_permission_args: &["--always-approve"],
633 full_permission_environment: &[],
634 mode_report: ModeReport::None,
635 search_dirs: &[".grok/bin"],
636 login: Login::Command(&["login"]),
637 status: Status::Stored(KeyStore::Grok {
638 auth: ".grok/auth.json",
639 config: ".grok/config.toml",
640 }),
641 status_summary: StatusSummary::ExitStatus,
642 logout: Logout::Command(&["logout"]),
643 output: OutputFormat::Text,
645 resume: Resume::Unsupported,
648 conversation_files: None,
649 credential_files: &[".grok/auth.json", ".grok/config.toml"],
650 transport: Transport::Process,
651 acp: Some(AcpLaunch {
653 command: "grok",
654 args: &["agent", "{full}", "stdio"],
655 full_args: &["--always-approve"],
656 full_mode: None,
657 full_environment: &[],
658 session_options: false,
659 }),
660 cli_turns: true,
661 takeover: Takeover::Unsupported,
662 },
663 AdapterDescriptor {
664 name: "dsh",
665 family: "dsh",
666 auth: AgentAuth::None,
667 product: "DeepSeek Harness",
668 offers: "a coding agent on DeepSeek models; it reads, edits, and runs code in a project",
669 command: "dsh",
670 args: &["--profile", "headless"],
671 prompt_args: &[],
672 model_args: &[],
673 effort_args: &[],
674 model_hint: "provider/model as its ACP server lists them, such as deepseek-official/deepseek-v4-pro.",
675 home_environment: &[("DSH_HOME", ".dsh")],
676 fixed_environment: &[],
677 removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
678 full_permission_args: &[],
680 full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
681 mode_report: ModeReport::None,
682 search_dirs: &[],
683 login: Login::ApiKey(DSH_STORE),
684 status: Status::Stored(DSH_STORE),
685 status_summary: StatusSummary::ExitStatus,
686 logout: Logout::Stored(DSH_STORE),
687 output: OutputFormat::Text,
688 resume: Resume::Unsupported,
690 conversation_files: None,
691 credential_files: &[".dsh/.credentials.yaml"],
692 transport: Transport::Process,
693 acp: Some(AcpLaunch {
698 command: "dsh",
699 args: &["--profile", "acp"],
700 full_args: &[],
701 full_mode: None,
702 full_environment: &[],
703 session_options: true,
704 }),
705 cli_turns: true,
706 takeover: Takeover::Unsupported,
707 },
708 AdapterDescriptor {
709 name: "pi",
710 family: "pi",
711 auth: AgentAuth::None,
712 product: "pi",
713 offers: "a minimal coding agent (read, write, edit, bash) that can run on SCV's own model endpoint; it has no web search",
714 command: "pi",
715 args: &["-p"],
716 prompt_args: &[],
717 model_args: &["--model", "{model}"],
718 effort_args: &["--thinking", "{effort}"],
719 model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
720 home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
721 fixed_environment: &[],
722 removed_environment: &["PI_*"],
723 full_permission_args: &[],
725 full_permission_environment: &[],
726 mode_report: ModeReport::None,
727 search_dirs: &[],
728 login: Login::Interactive {
729 args: &[],
730 hint: "run /login and choose a provider, then /quit",
731 },
732 status: Status::Stored(PI_STORE),
733 status_summary: StatusSummary::ExitStatus,
734 logout: Logout::Stored(PI_STORE),
735 output: OutputFormat::PiJson,
736 resume: Resume::Supported {
738 start: &["--session-id", "{session}"],
739 subcommand: &[],
740 options: &["--session-id", "{session}"],
741 positional: &[],
742 },
743 conversation_files: Some(ConversationFiles {
744 dir: ".pi/agent/sessions",
745 extension: "jsonl",
746 }),
747 credential_files: &[".pi/agent/auth.json", ".pi/agent/models.json"],
748 transport: Transport::Process,
749 acp: None,
751 cli_turns: true,
752 takeover: Takeover::Unsupported,
753 },
754 AdapterDescriptor {
755 name: "gemini",
756 family: "gemini",
757 auth: AgentAuth::None,
758 product: "Gemini CLI",
759 offers: "Google's coding agent; it reads, edits, and runs code in a project and has Google Search",
760 command: "gemini",
761 args: &[],
762 prompt_args: &["-p"],
763 model_args: &["-m", "{model}"],
764 effort_args: &[],
765 model_hint: "Gemini model ID, such as gemini-2.5-pro.",
766 home_environment: &[],
767 fixed_environment: &[],
768 removed_environment: &[
769 "GEMINI_*",
770 "GOOGLE_GENAI_*",
771 "GOOGLE_CLOUD_PROJECT",
772 "GOOGLE_CLOUD_LOCATION",
773 "GOOGLE_APPLICATION_CREDENTIALS",
774 ],
775 full_permission_args: &["--yolo"],
777 full_permission_environment: &[],
778 mode_report: ModeReport::None,
779 search_dirs: &[".npm-global/bin"],
780 login: Login::Interactive {
781 args: &[],
782 hint: "choose how to sign in (Login with Google, or a Gemini API key), then /quit",
783 },
784 status: Status::Unchecked,
785 status_summary: StatusSummary::ExitStatus,
786 logout: Logout::Interactive {
787 args: &[],
788 hint: "sign out with its own command (/auth), then /quit",
789 },
790 output: OutputFormat::GeminiStreamJson,
791 resume: Resume::Unsupported,
794 conversation_files: None,
795 credential_files: &[".gemini/oauth_creds.json", ".gemini/.env"],
796 transport: Transport::Process,
797 acp: Some(AcpLaunch {
799 command: "gemini",
800 args: &["{full}", "--experimental-acp"],
801 full_args: &["--yolo"],
802 full_mode: None,
803 full_environment: &[],
804 session_options: false,
805 }),
806 cli_turns: true,
807 takeover: Takeover::Unsupported,
808 },
809 AdapterDescriptor {
810 name: "qwen",
811 family: "qwen",
812 auth: AgentAuth::None,
813 product: "Qwen Code",
814 offers: "Alibaba's coding agent on Qwen models; it reads, edits, and runs code in a project",
815 command: "qwen",
816 args: &[],
817 prompt_args: &["-p"],
818 model_args: &["-m", "{model}"],
819 effort_args: &[],
820 model_hint: "Qwen model ID, such as qwen3-coder-plus.",
821 home_environment: &[],
822 fixed_environment: &[],
823 removed_environment: &["QWEN_*", "DASHSCOPE_*"],
824 full_permission_args: &["--yolo"],
825 full_permission_environment: &[],
826 mode_report: ModeReport::None,
827 search_dirs: &[".npm-global/bin"],
828 login: Login::Interactive {
829 args: &[],
830 hint: "choose how to sign in (Qwen OAuth, or an OpenAI-compatible key), then /quit",
831 },
832 status: Status::Unchecked,
833 status_summary: StatusSummary::ExitStatus,
834 logout: Logout::Interactive {
835 args: &[],
836 hint: "sign out with its own command (/auth), then /quit",
837 },
838 output: OutputFormat::Text,
840 resume: Resume::Unsupported,
841 conversation_files: None,
842 credential_files: &[".qwen/oauth_creds.json", ".qwen/.env"],
843 transport: Transport::Process,
844 acp: Some(AcpLaunch {
846 command: "qwen",
847 args: &["{full}", "--experimental-acp"],
848 full_args: &["--yolo"],
849 full_mode: None,
850 full_environment: &[],
851 session_options: false,
852 }),
853 cli_turns: true,
854 takeover: Takeover::Unsupported,
855 },
856 AdapterDescriptor {
857 name: "opencode",
858 family: "opencode",
859 auth: AgentAuth::None,
860 product: "OpenCode",
861 offers: "an open-source coding agent that runs on many model providers; it reads, edits, and runs code in a project",
862 command: "opencode",
863 args: &["run"],
864 prompt_args: &[],
865 model_args: &["-m", "{model}"],
866 effort_args: &[],
867 model_hint: "provider/model as OpenCode names it, such as anthropic/claude-sonnet-4-5.",
868 home_environment: &[],
869 fixed_environment: &[],
870 removed_environment: &["OPENCODE_*"],
871 full_permission_args: &[],
873 full_permission_environment: &[],
874 mode_report: ModeReport::None,
875 search_dirs: &[".opencode/bin", ".npm-global/bin"],
876 login: Login::Command(&["auth", "login"]),
877 status: Status::Unchecked,
878 status_summary: StatusSummary::ExitStatus,
879 logout: Logout::Command(&["auth", "logout"]),
880 output: OutputFormat::Text,
881 resume: Resume::Unsupported,
882 conversation_files: None,
883 credential_files: &["data/opencode/auth.json"],
884 transport: Transport::Process,
885 acp: Some(AcpLaunch {
887 command: "opencode",
888 args: &["acp"],
889 full_args: &[],
890 full_mode: None,
891 full_environment: &[],
892 session_options: false,
893 }),
894 cli_turns: true,
895 takeover: Takeover::Unsupported,
896 },
897 AdapterDescriptor {
898 name: "copilot",
899 family: "copilot",
900 auth: AgentAuth::None,
901 product: "GitHub Copilot CLI",
902 offers: "GitHub's coding agent; it reads, edits, and runs code in a project and knows GitHub",
903 command: "copilot",
904 args: &[],
905 prompt_args: &["-p"],
906 model_args: &["--model", "{model}"],
907 effort_args: &[],
908 model_hint: "a model Copilot CLI offers, such as claude-sonnet-4.5 or gpt-5.",
909 home_environment: &[],
910 fixed_environment: &[],
911 removed_environment: &["COPILOT_*"],
912 full_permission_args: &["--allow-all-tools"],
914 full_permission_environment: &[],
915 mode_report: ModeReport::None,
916 search_dirs: &[".npm-global/bin"],
917 login: Login::Interactive {
918 args: &[],
919 hint: "run /login, then /exit",
920 },
921 status: Status::Unchecked,
922 status_summary: StatusSummary::ExitStatus,
923 logout: Logout::Interactive {
924 args: &[],
925 hint: "run /logout, then /exit",
926 },
927 output: OutputFormat::Text,
928 resume: Resume::Unsupported,
930 conversation_files: None,
931 credential_files: &[],
932 transport: Transport::Process,
933 acp: None,
935 cli_turns: true,
936 takeover: Takeover::Unsupported,
937 },
938 AdapterDescriptor {
939 name: "cursor",
940 family: "cursor",
941 auth: AgentAuth::None,
942 product: "Cursor CLI",
943 offers: "Cursor's coding agent; it reads, edits, and runs code in a project",
944 command: "cursor-agent",
945 args: &["-p"],
946 prompt_args: &[],
947 model_args: &["--model", "{model}"],
948 effort_args: &[],
949 model_hint: "a model Cursor offers, such as sonnet-4.5 or gpt-5.",
950 home_environment: &[],
951 fixed_environment: &[],
952 removed_environment: &["CURSOR_*"],
953 full_permission_args: &["--force"],
955 full_permission_environment: &[],
956 mode_report: ModeReport::None,
957 search_dirs: &[],
958 login: Login::Command(&["login"]),
959 status: Status::Unchecked,
960 status_summary: StatusSummary::ExitStatus,
961 logout: Logout::Command(&["logout"]),
962 output: OutputFormat::CursorStreamJson,
963 resume: Resume::Supported {
965 start: &[],
966 subcommand: &[],
967 options: &["--resume", "{session}"],
968 positional: &[],
969 },
970 conversation_files: None,
971 credential_files: &[],
972 transport: Transport::Process,
973 acp: None,
974 cli_turns: true,
975 takeover: Takeover::Unsupported,
976 },
977 AdapterDescriptor {
978 name: "amp",
979 family: "amp",
980 auth: AgentAuth::None,
981 product: "Amp",
982 offers: "Sourcegraph's coding agent; it reads, edits, and runs code in a project and can search the web",
983 command: "amp",
984 args: &[],
985 prompt_args: &["-x"],
986 model_args: &[],
988 effort_args: &[],
989 model_hint: "Amp chooses its own model; omit model.",
990 home_environment: &[],
991 fixed_environment: &[],
992 removed_environment: &["AMP_*"],
993 full_permission_args: &["--dangerously-allow-all"],
994 full_permission_environment: &[],
995 mode_report: ModeReport::None,
996 search_dirs: &[".amp/bin", ".npm-global/bin"],
997 login: Login::Command(&["login"]),
998 status: Status::Unchecked,
999 status_summary: StatusSummary::ExitStatus,
1000 logout: Logout::Command(&["logout"]),
1001 output: OutputFormat::AmpStreamJson,
1002 resume: Resume::Unsupported,
1004 conversation_files: None,
1005 credential_files: &[],
1006 transport: Transport::Process,
1007 acp: None,
1008 cli_turns: true,
1009 takeover: Takeover::Unsupported,
1010 },
1011 AdapterDescriptor {
1012 name: "kimi",
1013 family: "kimi",
1014 auth: AgentAuth::None,
1015 product: "Kimi Code",
1016 offers: "Moonshot AI's coding agent on Kimi models; it reads, edits, and runs code in a project",
1017 command: "kimi",
1018 args: &[],
1019 prompt_args: &[],
1020 model_args: &[],
1021 effort_args: &[],
1022 model_hint: "a model its ACP server lists.",
1023 home_environment: &[],
1024 fixed_environment: &[],
1025 removed_environment: &["KIMI_*", "MOONSHOT_*"],
1026 full_permission_args: &[],
1027 full_permission_environment: &[],
1028 mode_report: ModeReport::None,
1029 search_dirs: &[".kimi-code/bin"],
1030 login: Login::Interactive {
1031 args: &[],
1032 hint: "run /login, then /exit (`-- login` runs `kimi login` instead: a device code, with `--region mainland-cn` or `--region global`)",
1033 },
1034 status: Status::Unchecked,
1035 status_summary: StatusSummary::ExitStatus,
1036 logout: Logout::Interactive {
1037 args: &[],
1038 hint: "sign out with its own command, then /exit",
1039 },
1040 output: OutputFormat::Text,
1041 resume: Resume::Unsupported,
1042 conversation_files: None,
1043 credential_files: &[],
1044 transport: Transport::Process,
1045 acp: Some(AcpLaunch {
1048 command: "kimi",
1049 args: &["acp"],
1050 full_args: &[],
1051 full_mode: None,
1052 full_environment: &[],
1053 session_options: true,
1054 }),
1055 cli_turns: false,
1056 takeover: Takeover::Unsupported,
1057 },
1058 AdapterDescriptor {
1059 name: "scv",
1060 family: "scv",
1061 auth: AgentAuth::None,
1062 product: "SCV",
1063 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",
1064 command: "scv",
1065 args: &["server", "--stdio", "--policy", "on-risk"],
1068 prompt_args: &[],
1069 model_args: &[],
1071 effort_args: &[],
1072 model_hint: "Model ID for the nested SCV's provider; applies to a new conversation only.",
1073 home_environment: &[],
1076 fixed_environment: &[],
1077 removed_environment: &[],
1078 full_permission_args: &[],
1080 full_permission_environment: &[],
1081 mode_report: ModeReport::None,
1082 search_dirs: &[".cargo/bin"],
1084 login: Login::Import,
1085 status: Status::Stored(SCV_STORE),
1086 status_summary: StatusSummary::ExitStatus,
1087 logout: Logout::Stored(SCV_STORE),
1088 output: OutputFormat::Text,
1089 resume: Resume::Unsupported,
1090 conversation_files: None,
1091 credential_files: &["config.toml"],
1092 transport: Transport::ScvProtocol,
1093 acp: None,
1094 cli_turns: true,
1095 takeover: Takeover::Unsupported,
1096 },
1097];
1098
1099pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
1101 ADAPTERS.iter().find(|adapter| adapter.name == name)
1102}
1103
1104pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
1108 let Some(variable) = variable.to_str() else {
1109 return false;
1110 };
1111 variable.ends_with("_API_KEY")
1112 || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
1113 || ADAPTERS
1114 .iter()
1115 .flat_map(|adapter| adapter.removed_environment)
1116 .any(|rule| match rule.strip_suffix('*') {
1117 Some(prefix) => variable.starts_with(prefix),
1118 None => variable == *rule,
1119 })
1120}
1121
1122pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
1125 let signed_out = "not signed in".to_owned();
1126 match summary {
1127 StatusSummary::CodexText => {
1128 let lower = output.to_ascii_lowercase();
1129 if !succeeded || lower.contains("not logged in") {
1130 signed_out
1131 } else if lower.contains("api key") {
1132 "signed in (API key)".into()
1133 } else if lower.contains("chatgpt") {
1134 "signed in (ChatGPT account)".into()
1135 } else {
1136 "signed in".into()
1137 }
1138 }
1139 StatusSummary::ExitStatus => {
1140 if succeeded {
1141 "signed in".into()
1142 } else {
1143 signed_out
1144 }
1145 }
1146 }
1147}
1148
1149pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
1152 if command.contains('/') {
1153 let path = Path::new(command);
1154 return path.is_file().then(|| path.to_path_buf());
1155 }
1156 std::env::join_paths(search_dirs)
1157 .ok()
1158 .and_then(|dirs| {
1159 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
1160 which::which_in(command, Some(dirs), cwd).ok()
1161 })
1162 .or_else(|| which::which(command).ok())
1163}
1164
1165pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
1167 adapter
1168 .search_dirs
1169 .iter()
1170 .chain(USER_BIN_DIRS)
1171 .map(|dir| home.join(dir))
1172 .collect()
1173}
1174
1175#[cfg(test)]
1176mod tests;