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>,
117 pub full_environment: &'static [(&'static str, &'static str)],
120 pub session_options: bool,
125}
126
127pub fn acp_args(launch: &AcpLaunch, full: bool) -> Vec<String> {
129 let mut args = Vec::with_capacity(launch.args.len() + launch.full_args.len());
130 for arg in launch.args {
131 if *arg == "{full}" {
132 if full {
133 args.extend(launch.full_args.iter().map(|arg| (*arg).to_owned()));
134 }
135 } else {
136 args.push((*arg).to_owned());
137 }
138 }
139 args
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum Takeover {
148 Unsupported,
150 Refused(&'static str),
153 Supported {
160 args: &'static [&'static str],
161 full_args: &'static [&'static str],
162 },
163}
164
165const CLAUDE_TAKEOVER: &str = "Claude Code's own terminal asks before some actions even with its \
167 prompts off (deleting files, publishing), and keys typed there would answer those prompts \
168 instead of SCV's approval gate";
169
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
173pub enum Resume {
174 Unsupported,
176 Supported {
177 start: &'static [&'static str],
180 subcommand: &'static [&'static str],
183 options: &'static [&'static str],
185 positional: &'static [&'static str],
188 },
189}
190
191impl Resume {
192 pub(crate) fn is_supported(self) -> bool {
193 matches!(self, Self::Supported { .. })
194 }
195
196 pub(crate) fn assigns_id(self) -> bool {
198 matches!(self, Self::Supported { start, .. } if !start.is_empty())
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
205pub struct ConversationFiles {
206 pub(crate) dir: &'static str,
207 pub(crate) extension: &'static str,
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq)]
213pub enum StatusSummary {
214 CodexText,
216 ExitStatus,
218}
219
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
222pub enum Logout {
223 Command(&'static [&'static str]),
224 Stored(KeyStore),
226 Interactive {
228 args: &'static [&'static str],
229 hint: &'static str,
230 },
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum KeyStore {
236 ClaudeApiKey {
239 key: &'static str,
240 credentials: &'static str,
241 },
242 ClaudeAccount {
244 credentials: &'static str,
245 config: &'static str,
246 },
247 Grok {
250 auth: &'static str,
251 config: &'static str,
252 },
253 DshRefs {
255 path: &'static str,
256 variable: &'static str,
257 },
258 Pi { dir: &'static str },
261 Scv { config: &'static str },
264}
265
266#[derive(Debug, Clone, Copy, PartialEq, Eq)]
268pub enum AgentAuth {
269 None,
270 ClaudeApiKey,
271 ClaudeSubscription,
272}
273
274#[derive(Debug, Clone, Copy)]
275pub struct AdapterDescriptor {
276 pub name: &'static str,
278 pub family: &'static str,
280 pub auth: AgentAuth,
282 pub product: &'static str,
284 pub(crate) offers: &'static str,
287 pub command: &'static str,
288 pub args: &'static [&'static str],
289 pub prompt_args: &'static [&'static str],
292 pub model_args: &'static [&'static str],
293 pub effort_args: &'static [&'static str],
294 pub model_hint: &'static str,
296 pub home_environment: &'static [(&'static str, &'static str)],
299 pub fixed_environment: &'static [(&'static str, &'static str)],
301 pub(crate) removed_environment: &'static [&'static str],
304 pub full_permission_args: &'static [&'static str],
309 pub full_permission_environment: &'static [(&'static str, &'static str)],
311 pub(crate) search_dirs: &'static [&'static str],
316 pub login: Login,
317 pub status: Status,
318 pub status_summary: StatusSummary,
320 pub logout: Logout,
321 pub output: OutputFormat,
323 pub resume: Resume,
325 pub conversation_files: Option<ConversationFiles>,
327 pub credential_files: &'static [&'static str],
330 pub transport: Transport,
332 pub acp: Option<AcpLaunch>,
336 pub cli_turns: bool,
340 pub takeover: Takeover,
342}
343
344const USER_BIN_DIRS: &[&str] = &[".local/bin"];
346
347const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
351 "SCV_CONFIG",
352 "SCV_MODEL",
353 "SCV_PROVIDER",
354 "SCV_BASE_URL",
355 "SCV_API_KEY_ENV",
356 "GEMINI_API_KEY",
357 "GOOGLE_API_KEY",
358 "AZURE_OPENAI_API_KEY",
359 "AZURE_OPENAI_ENDPOINT",
360 "AWS_ACCESS_KEY_ID",
365 "AWS_SECRET_ACCESS_KEY",
366 "AWS_SESSION_TOKEN",
367 "AWS_BEARER_TOKEN_BEDROCK",
368];
369
370const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
371const SCV_STORE: KeyStore = KeyStore::Scv {
372 config: "config.toml",
373};
374const DSH_STORE: KeyStore = KeyStore::DshRefs {
375 path: ".dsh/.credentials.yaml",
376 variable: "DEEPSEEK_API_KEY",
377};
378const CLAUDE_KEY_STORE: KeyStore = KeyStore::ClaudeApiKey {
379 key: ".anthropic-api-key",
380 credentials: ".claude/.credentials.json",
381};
382const CLAUDE_ACCOUNT_STORE: KeyStore = KeyStore::ClaudeAccount {
383 credentials: ".claude/.credentials.json",
384 config: ".claude.json",
385};
386
387const CLAUDE_BASE: AdapterDescriptor = AdapterDescriptor {
388 name: "claude",
389 family: "claude",
390 auth: AgentAuth::None,
391 product: "Claude Code",
392 offers: "Anthropic's coding agent; it reads, edits, and runs code in a project and can search and fetch the web",
393 command: "claude",
394 args: &["-p"],
395 prompt_args: &[],
396 model_args: &["--model", "{model}"],
397 effort_args: &["--effort", "{effort}"],
398 model_hint: "Claude model alias or ID, such as sonnet or opus.",
399 home_environment: &[],
400 fixed_environment: &[],
401 removed_environment: &[
402 "ANTHROPIC_API_KEY",
403 "ANTHROPIC_BASE_URL",
404 "ANTHROPIC_AUTH_TOKEN",
405 "CLAUDE_CODE_OAUTH_TOKEN",
406 "CLAUDE_CONFIG_DIR",
407 ],
408 full_permission_args: &["--permission-mode", "bypassPermissions"],
409 full_permission_environment: &[],
410 search_dirs: &[],
411 login: Login::Command(&[]),
412 status: Status::Stored(CLAUDE_KEY_STORE),
413 status_summary: StatusSummary::ExitStatus,
414 logout: Logout::Command(&[]),
415 output: OutputFormat::ClaudeStreamJson,
416 resume: Resume::Supported {
417 start: &["--session-id", "{session}"],
418 subcommand: &[],
419 options: &["--resume", "{session}"],
420 positional: &[],
421 },
422 conversation_files: Some(ConversationFiles {
423 dir: ".claude/projects",
424 extension: "jsonl",
425 }),
426 credential_files: &[".anthropic-api-key", ".claude/.credentials.json"],
427 transport: Transport::Process,
428 acp: Some(AcpLaunch {
429 command: "claude-agent-acp",
430 args: &[],
431 full_args: &[],
432 full_mode: Some("bypassPermissions"),
433 full_environment: &[],
434 session_options: false,
435 }),
436 cli_turns: true,
437 takeover: Takeover::Refused(CLAUDE_TAKEOVER),
438};
439
440pub const ADAPTERS: &[AdapterDescriptor] = &[
441 AdapterDescriptor {
442 name: "claude",
443 family: "claude",
444 auth: AgentAuth::ClaudeApiKey,
445 product: "Claude Code (API key)",
446 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",
447 login: Login::ApiKey(CLAUDE_KEY_STORE),
448 status: Status::Stored(CLAUDE_KEY_STORE),
449 logout: Logout::Stored(CLAUDE_KEY_STORE),
450 acp: Some(AcpLaunch {
451 command: "claude-agent-acp",
452 args: &["--hide-claude-auth"],
453 full_args: &[],
454 full_mode: Some("bypassPermissions"),
455 full_environment: &[],
456 session_options: false,
457 }),
458 ..CLAUDE_BASE
459 },
460 AdapterDescriptor {
461 name: "claude-ai",
462 family: "claude",
463 auth: AgentAuth::ClaudeSubscription,
464 product: "Claude Code (Claude subscription)",
465 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",
466 login: Login::Command(&["auth", "login", "--claudeai"]),
467 status: Status::Stored(CLAUDE_ACCOUNT_STORE),
468 logout: Logout::Command(&["auth", "logout"]),
469 credential_files: &[".claude/.credentials.json"],
470 acp: Some(AcpLaunch {
471 command: "claude-agent-acp",
472 args: &[],
473 full_args: &[],
474 full_mode: Some("bypassPermissions"),
475 full_environment: &[],
476 session_options: false,
477 }),
478 ..CLAUDE_BASE
479 },
480 AdapterDescriptor {
481 name: "codex",
482 family: "codex",
483 auth: AgentAuth::None,
484 product: "Codex",
485 offers: "OpenAI's coding agent; it reads, edits, and runs code in a project, with live web search under full permissions",
486 command: "codex",
487 args: &["exec"],
488 prompt_args: &[],
489 model_args: &["-m", "{model}"],
490 effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
491 model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
492 home_environment: &[("CODEX_HOME", "")],
493 fixed_environment: &[],
494 removed_environment: &[
495 "OPENAI_API_KEY",
496 "OPENAI_BASE_URL",
497 "OPENAI_ORG_ID",
498 "OPENAI_PROJECT_ID",
499 "CODEX_API_KEY",
500 "CODEX_BASE_URL",
501 "CODEX_CONFIG",
502 ],
503 full_permission_args: &[
505 "--dangerously-bypass-approvals-and-sandbox",
506 "-c",
507 "web_search=\"live\"",
508 ],
509 full_permission_environment: &[],
510 search_dirs: &[],
511 login: Login::Command(&["login"]),
512 status: Status::Command(&["login", "status"]),
513 status_summary: StatusSummary::CodexText,
514 logout: Logout::Command(&["logout"]),
515 output: OutputFormat::CodexJsonl,
516 resume: Resume::Supported {
519 start: &[],
520 subcommand: &["resume"],
521 options: &[],
522 positional: &["{session}"],
523 },
524 conversation_files: Some(ConversationFiles {
525 dir: "sessions",
526 extension: "jsonl",
527 }),
528 credential_files: &["auth.json"],
529 transport: Transport::Process,
530 acp: Some(AcpLaunch {
536 command: "codex-acp",
537 args: &[],
538 full_args: &[],
539 full_mode: Some("agent-full-access"),
540 full_environment: &[("CODEX_CONFIG", r#"{"web_search":"live"}"#)],
541 session_options: false,
542 }),
543 cli_turns: true,
544 takeover: Takeover::Supported {
548 args: &["resume", "{session}"],
549 full_args: &[
550 "--dangerously-bypass-approvals-and-sandbox",
551 "-c",
552 "web_search=\"live\"",
553 ],
554 },
555 },
556 AdapterDescriptor {
557 name: "grok",
558 family: "grok",
559 auth: AgentAuth::None,
560 product: "Grok Build",
561 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",
562 command: "grok",
563 args: &[],
564 prompt_args: &["-p"],
565 model_args: &["-m", "{model}"],
566 effort_args: &["--reasoning-effort", "{effort}"],
567 model_hint: "xAI Grok model ID, such as grok-4.7.",
568 home_environment: &[("GROK_HOME", ".grok")],
569 fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
570 removed_environment: &["GROK_*", "XAI_API_KEY"],
571 full_permission_args: &["--always-approve"],
573 full_permission_environment: &[],
574 search_dirs: &[".grok/bin"],
575 login: Login::Command(&["login"]),
576 status: Status::Stored(KeyStore::Grok {
577 auth: ".grok/auth.json",
578 config: ".grok/config.toml",
579 }),
580 status_summary: StatusSummary::ExitStatus,
581 logout: Logout::Command(&["logout"]),
582 output: OutputFormat::Text,
584 resume: Resume::Unsupported,
587 conversation_files: None,
588 credential_files: &[".grok/auth.json", ".grok/config.toml"],
589 transport: Transport::Process,
590 acp: Some(AcpLaunch {
592 command: "grok",
593 args: &["agent", "{full}", "stdio"],
594 full_args: &["--always-approve"],
595 full_mode: None,
596 full_environment: &[],
597 session_options: false,
598 }),
599 cli_turns: true,
600 takeover: Takeover::Unsupported,
601 },
602 AdapterDescriptor {
603 name: "dsh",
604 family: "dsh",
605 auth: AgentAuth::None,
606 product: "DeepSeek Harness",
607 offers: "a coding agent on DeepSeek models; it reads, edits, and runs code in a project",
608 command: "dsh",
609 args: &["--profile", "headless"],
610 prompt_args: &[],
611 model_args: &[],
612 effort_args: &[],
613 model_hint: "provider/model as its ACP server lists them, such as deepseek-official/deepseek-v4-pro.",
614 home_environment: &[("DSH_HOME", ".dsh")],
615 fixed_environment: &[],
616 removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
617 full_permission_args: &[],
619 full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
620 search_dirs: &[],
621 login: Login::ApiKey(DSH_STORE),
622 status: Status::Stored(DSH_STORE),
623 status_summary: StatusSummary::ExitStatus,
624 logout: Logout::Stored(DSH_STORE),
625 output: OutputFormat::Text,
626 resume: Resume::Unsupported,
628 conversation_files: None,
629 credential_files: &[".dsh/.credentials.yaml"],
630 transport: Transport::Process,
631 acp: Some(AcpLaunch {
636 command: "dsh",
637 args: &["--profile", "acp"],
638 full_args: &[],
639 full_mode: None,
640 full_environment: &[],
641 session_options: true,
642 }),
643 cli_turns: true,
644 takeover: Takeover::Unsupported,
645 },
646 AdapterDescriptor {
647 name: "pi",
648 family: "pi",
649 auth: AgentAuth::None,
650 product: "pi",
651 offers: "a minimal coding agent (read, write, edit, bash) that can run on SCV's own model endpoint; it has no web search",
652 command: "pi",
653 args: &["-p"],
654 prompt_args: &[],
655 model_args: &["--model", "{model}"],
656 effort_args: &["--thinking", "{effort}"],
657 model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
658 home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
659 fixed_environment: &[],
660 removed_environment: &["PI_*"],
661 full_permission_args: &[],
663 full_permission_environment: &[],
664 search_dirs: &[],
665 login: Login::Interactive {
666 args: &[],
667 hint: "run /login and choose a provider, then /quit",
668 },
669 status: Status::Stored(PI_STORE),
670 status_summary: StatusSummary::ExitStatus,
671 logout: Logout::Stored(PI_STORE),
672 output: OutputFormat::PiJson,
673 resume: Resume::Supported {
675 start: &["--session-id", "{session}"],
676 subcommand: &[],
677 options: &["--session-id", "{session}"],
678 positional: &[],
679 },
680 conversation_files: Some(ConversationFiles {
681 dir: ".pi/agent/sessions",
682 extension: "jsonl",
683 }),
684 credential_files: &[".pi/agent/auth.json", ".pi/agent/models.json"],
685 transport: Transport::Process,
686 acp: None,
688 cli_turns: true,
689 takeover: Takeover::Unsupported,
690 },
691 AdapterDescriptor {
692 name: "gemini",
693 family: "gemini",
694 auth: AgentAuth::None,
695 product: "Gemini CLI",
696 offers: "Google's coding agent; it reads, edits, and runs code in a project and has Google Search",
697 command: "gemini",
698 args: &[],
699 prompt_args: &["-p"],
700 model_args: &["-m", "{model}"],
701 effort_args: &[],
702 model_hint: "Gemini model ID, such as gemini-2.5-pro.",
703 home_environment: &[],
704 fixed_environment: &[],
705 removed_environment: &[
706 "GEMINI_*",
707 "GOOGLE_GENAI_*",
708 "GOOGLE_CLOUD_PROJECT",
709 "GOOGLE_CLOUD_LOCATION",
710 "GOOGLE_APPLICATION_CREDENTIALS",
711 ],
712 full_permission_args: &["--yolo"],
714 full_permission_environment: &[],
715 search_dirs: &[".npm-global/bin"],
716 login: Login::Interactive {
717 args: &[],
718 hint: "choose how to sign in (Login with Google, or a Gemini API key), then /quit",
719 },
720 status: Status::Unchecked,
721 status_summary: StatusSummary::ExitStatus,
722 logout: Logout::Interactive {
723 args: &[],
724 hint: "sign out with its own command (/auth), then /quit",
725 },
726 output: OutputFormat::GeminiStreamJson,
727 resume: Resume::Unsupported,
730 conversation_files: None,
731 credential_files: &[".gemini/oauth_creds.json", ".gemini/.env"],
732 transport: Transport::Process,
733 acp: Some(AcpLaunch {
735 command: "gemini",
736 args: &["{full}", "--experimental-acp"],
737 full_args: &["--yolo"],
738 full_mode: None,
739 full_environment: &[],
740 session_options: false,
741 }),
742 cli_turns: true,
743 takeover: Takeover::Unsupported,
744 },
745 AdapterDescriptor {
746 name: "qwen",
747 family: "qwen",
748 auth: AgentAuth::None,
749 product: "Qwen Code",
750 offers: "Alibaba's coding agent on Qwen models; it reads, edits, and runs code in a project",
751 command: "qwen",
752 args: &[],
753 prompt_args: &["-p"],
754 model_args: &["-m", "{model}"],
755 effort_args: &[],
756 model_hint: "Qwen model ID, such as qwen3-coder-plus.",
757 home_environment: &[],
758 fixed_environment: &[],
759 removed_environment: &["QWEN_*", "DASHSCOPE_*"],
760 full_permission_args: &["--yolo"],
761 full_permission_environment: &[],
762 search_dirs: &[".npm-global/bin"],
763 login: Login::Interactive {
764 args: &[],
765 hint: "choose how to sign in (Qwen OAuth, or an OpenAI-compatible key), then /quit",
766 },
767 status: Status::Unchecked,
768 status_summary: StatusSummary::ExitStatus,
769 logout: Logout::Interactive {
770 args: &[],
771 hint: "sign out with its own command (/auth), then /quit",
772 },
773 output: OutputFormat::Text,
775 resume: Resume::Unsupported,
776 conversation_files: None,
777 credential_files: &[".qwen/oauth_creds.json", ".qwen/.env"],
778 transport: Transport::Process,
779 acp: Some(AcpLaunch {
781 command: "qwen",
782 args: &["{full}", "--experimental-acp"],
783 full_args: &["--yolo"],
784 full_mode: None,
785 full_environment: &[],
786 session_options: false,
787 }),
788 cli_turns: true,
789 takeover: Takeover::Unsupported,
790 },
791 AdapterDescriptor {
792 name: "opencode",
793 family: "opencode",
794 auth: AgentAuth::None,
795 product: "OpenCode",
796 offers: "an open-source coding agent that runs on many model providers; it reads, edits, and runs code in a project",
797 command: "opencode",
798 args: &["run"],
799 prompt_args: &[],
800 model_args: &["-m", "{model}"],
801 effort_args: &[],
802 model_hint: "provider/model as OpenCode names it, such as anthropic/claude-sonnet-4-5.",
803 home_environment: &[],
804 fixed_environment: &[],
805 removed_environment: &["OPENCODE_*"],
806 full_permission_args: &[],
808 full_permission_environment: &[],
809 search_dirs: &[".opencode/bin", ".npm-global/bin"],
810 login: Login::Command(&["auth", "login"]),
811 status: Status::Unchecked,
812 status_summary: StatusSummary::ExitStatus,
813 logout: Logout::Command(&["auth", "logout"]),
814 output: OutputFormat::Text,
815 resume: Resume::Unsupported,
816 conversation_files: None,
817 credential_files: &["data/opencode/auth.json"],
818 transport: Transport::Process,
819 acp: Some(AcpLaunch {
821 command: "opencode",
822 args: &["acp"],
823 full_args: &[],
824 full_mode: None,
825 full_environment: &[],
826 session_options: false,
827 }),
828 cli_turns: true,
829 takeover: Takeover::Unsupported,
830 },
831 AdapterDescriptor {
832 name: "copilot",
833 family: "copilot",
834 auth: AgentAuth::None,
835 product: "GitHub Copilot CLI",
836 offers: "GitHub's coding agent; it reads, edits, and runs code in a project and knows GitHub",
837 command: "copilot",
838 args: &[],
839 prompt_args: &["-p"],
840 model_args: &["--model", "{model}"],
841 effort_args: &[],
842 model_hint: "a model Copilot CLI offers, such as claude-sonnet-4.5 or gpt-5.",
843 home_environment: &[],
844 fixed_environment: &[],
845 removed_environment: &["COPILOT_*"],
846 full_permission_args: &["--allow-all-tools"],
848 full_permission_environment: &[],
849 search_dirs: &[".npm-global/bin"],
850 login: Login::Interactive {
851 args: &[],
852 hint: "run /login, then /exit",
853 },
854 status: Status::Unchecked,
855 status_summary: StatusSummary::ExitStatus,
856 logout: Logout::Interactive {
857 args: &[],
858 hint: "run /logout, then /exit",
859 },
860 output: OutputFormat::Text,
861 resume: Resume::Unsupported,
863 conversation_files: None,
864 credential_files: &[],
865 transport: Transport::Process,
866 acp: None,
868 cli_turns: true,
869 takeover: Takeover::Unsupported,
870 },
871 AdapterDescriptor {
872 name: "cursor",
873 family: "cursor",
874 auth: AgentAuth::None,
875 product: "Cursor CLI",
876 offers: "Cursor's coding agent; it reads, edits, and runs code in a project",
877 command: "cursor-agent",
878 args: &["-p"],
879 prompt_args: &[],
880 model_args: &["--model", "{model}"],
881 effort_args: &[],
882 model_hint: "a model Cursor offers, such as sonnet-4.5 or gpt-5.",
883 home_environment: &[],
884 fixed_environment: &[],
885 removed_environment: &["CURSOR_*"],
886 full_permission_args: &["--force"],
888 full_permission_environment: &[],
889 search_dirs: &[],
890 login: Login::Command(&["login"]),
891 status: Status::Unchecked,
892 status_summary: StatusSummary::ExitStatus,
893 logout: Logout::Command(&["logout"]),
894 output: OutputFormat::CursorStreamJson,
895 resume: Resume::Supported {
897 start: &[],
898 subcommand: &[],
899 options: &["--resume", "{session}"],
900 positional: &[],
901 },
902 conversation_files: None,
903 credential_files: &[],
904 transport: Transport::Process,
905 acp: None,
906 cli_turns: true,
907 takeover: Takeover::Unsupported,
908 },
909 AdapterDescriptor {
910 name: "amp",
911 family: "amp",
912 auth: AgentAuth::None,
913 product: "Amp",
914 offers: "Sourcegraph's coding agent; it reads, edits, and runs code in a project and can search the web",
915 command: "amp",
916 args: &[],
917 prompt_args: &["-x"],
918 model_args: &[],
920 effort_args: &[],
921 model_hint: "Amp chooses its own model; omit model.",
922 home_environment: &[],
923 fixed_environment: &[],
924 removed_environment: &["AMP_*"],
925 full_permission_args: &["--dangerously-allow-all"],
926 full_permission_environment: &[],
927 search_dirs: &[".amp/bin", ".npm-global/bin"],
928 login: Login::Command(&["login"]),
929 status: Status::Unchecked,
930 status_summary: StatusSummary::ExitStatus,
931 logout: Logout::Command(&["logout"]),
932 output: OutputFormat::AmpStreamJson,
933 resume: Resume::Unsupported,
935 conversation_files: None,
936 credential_files: &[],
937 transport: Transport::Process,
938 acp: None,
939 cli_turns: true,
940 takeover: Takeover::Unsupported,
941 },
942 AdapterDescriptor {
943 name: "kimi",
944 family: "kimi",
945 auth: AgentAuth::None,
946 product: "Kimi Code",
947 offers: "Moonshot AI's coding agent on Kimi models; it reads, edits, and runs code in a project",
948 command: "kimi",
949 args: &[],
950 prompt_args: &[],
951 model_args: &[],
952 effort_args: &[],
953 model_hint: "a model its ACP server lists.",
954 home_environment: &[],
955 fixed_environment: &[],
956 removed_environment: &["KIMI_*", "MOONSHOT_*"],
957 full_permission_args: &[],
958 full_permission_environment: &[],
959 search_dirs: &[".kimi-code/bin"],
960 login: Login::Interactive {
961 args: &[],
962 hint: "run /login, then /exit (`-- login` runs `kimi login` instead: a device code, with `--region mainland-cn` or `--region global`)",
963 },
964 status: Status::Unchecked,
965 status_summary: StatusSummary::ExitStatus,
966 logout: Logout::Interactive {
967 args: &[],
968 hint: "sign out with its own command, then /exit",
969 },
970 output: OutputFormat::Text,
971 resume: Resume::Unsupported,
972 conversation_files: None,
973 credential_files: &[],
974 transport: Transport::Process,
975 acp: Some(AcpLaunch {
978 command: "kimi",
979 args: &["acp"],
980 full_args: &[],
981 full_mode: None,
982 full_environment: &[],
983 session_options: true,
984 }),
985 cli_turns: false,
986 takeover: Takeover::Unsupported,
987 },
988 AdapterDescriptor {
989 name: "scv",
990 family: "scv",
991 auth: AgentAuth::None,
992 product: "SCV",
993 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",
994 command: "scv",
995 args: &["server", "--stdio"],
996 prompt_args: &[],
997 model_args: &[],
999 effort_args: &[],
1000 model_hint: "Model ID for the nested SCV's provider; applies to a new conversation only.",
1001 home_environment: &[],
1004 fixed_environment: &[],
1005 removed_environment: &[],
1006 full_permission_args: &[],
1008 full_permission_environment: &[],
1009 search_dirs: &[".cargo/bin"],
1011 login: Login::Import,
1012 status: Status::Stored(SCV_STORE),
1013 status_summary: StatusSummary::ExitStatus,
1014 logout: Logout::Stored(SCV_STORE),
1015 output: OutputFormat::Text,
1016 resume: Resume::Unsupported,
1017 conversation_files: None,
1018 credential_files: &["config.toml"],
1019 transport: Transport::ScvProtocol,
1020 acp: None,
1021 cli_turns: true,
1022 takeover: Takeover::Unsupported,
1023 },
1024];
1025
1026pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
1028 ADAPTERS.iter().find(|adapter| adapter.name == name)
1029}
1030
1031pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
1035 let Some(variable) = variable.to_str() else {
1036 return false;
1037 };
1038 variable.ends_with("_API_KEY")
1039 || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
1040 || ADAPTERS
1041 .iter()
1042 .flat_map(|adapter| adapter.removed_environment)
1043 .any(|rule| match rule.strip_suffix('*') {
1044 Some(prefix) => variable.starts_with(prefix),
1045 None => variable == *rule,
1046 })
1047}
1048
1049pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
1052 let signed_out = "not signed in".to_owned();
1053 match summary {
1054 StatusSummary::CodexText => {
1055 let lower = output.to_ascii_lowercase();
1056 if !succeeded || lower.contains("not logged in") {
1057 signed_out
1058 } else if lower.contains("api key") {
1059 "signed in (API key)".into()
1060 } else if lower.contains("chatgpt") {
1061 "signed in (ChatGPT account)".into()
1062 } else {
1063 "signed in".into()
1064 }
1065 }
1066 StatusSummary::ExitStatus => {
1067 if succeeded {
1068 "signed in".into()
1069 } else {
1070 signed_out
1071 }
1072 }
1073 }
1074}
1075
1076pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
1079 if command.contains('/') {
1080 let path = Path::new(command);
1081 return path.is_file().then(|| path.to_path_buf());
1082 }
1083 std::env::join_paths(search_dirs)
1084 .ok()
1085 .and_then(|dirs| {
1086 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
1087 which::which_in(command, Some(dirs), cwd).ok()
1088 })
1089 .or_else(|| which::which(command).ok())
1090}
1091
1092pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
1094 adapter
1095 .search_dirs
1096 .iter()
1097 .chain(USER_BIN_DIRS)
1098 .map(|dir| home.join(dir))
1099 .collect()
1100}
1101
1102#[cfg(test)]
1103mod tests;