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(crate) 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
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub struct AcpLaunch {
83 pub command: &'static str,
85 pub(crate) args: &'static [&'static str],
88 pub(crate) full_args: &'static [&'static str],
89 pub full_mode: Option<&'static str>,
92 pub full_environment: &'static [(&'static str, &'static str)],
95}
96
97pub fn acp_args(launch: &AcpLaunch, full: bool) -> Vec<String> {
99 let mut args = Vec::with_capacity(launch.args.len() + launch.full_args.len());
100 for arg in launch.args {
101 if *arg == "{full}" {
102 if full {
103 args.extend(launch.full_args.iter().map(|arg| (*arg).to_owned()));
104 }
105 } else {
106 args.push((*arg).to_owned());
107 }
108 }
109 args
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum Resume {
116 Unsupported,
118 Supported {
119 start: &'static [&'static str],
122 subcommand: &'static [&'static str],
125 options: &'static [&'static str],
127 positional: &'static [&'static str],
130 },
131}
132
133impl Resume {
134 pub(crate) fn is_supported(self) -> bool {
135 matches!(self, Self::Supported { .. })
136 }
137
138 pub(crate) fn assigns_id(self) -> bool {
140 matches!(self, Self::Supported { start, .. } if !start.is_empty())
141 }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub struct ConversationFiles {
148 pub(crate) dir: &'static str,
149 pub(crate) extension: &'static str,
150}
151
152#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub enum StatusSummary {
156 ClaudeJson,
158 CodexText,
160 ExitStatus,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum Logout {
167 Command(&'static [&'static str]),
168 Stored(KeyStore),
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq)]
174pub enum KeyStore {
175 Grok {
178 auth: &'static str,
179 config: &'static str,
180 },
181 DshRefs {
183 path: &'static str,
184 variable: &'static str,
185 },
186 Pi { dir: &'static str },
189 Scv { config: &'static str },
192}
193
194#[derive(Debug, Clone, Copy)]
195pub struct AdapterDescriptor {
196 pub name: &'static str,
198 pub product: &'static str,
200 pub(crate) offers: &'static str,
203 pub command: &'static str,
204 pub args: &'static [&'static str],
205 pub prompt_args: &'static [&'static str],
208 pub model_args: &'static [&'static str],
209 pub effort_args: &'static [&'static str],
210 pub model_hint: &'static str,
212 pub home_environment: &'static [(&'static str, &'static str)],
215 pub fixed_environment: &'static [(&'static str, &'static str)],
217 pub(crate) removed_environment: &'static [&'static str],
220 pub full_permission_args: &'static [&'static str],
225 pub full_permission_environment: &'static [(&'static str, &'static str)],
227 pub(crate) search_dirs: &'static [&'static str],
232 pub login: Login,
233 pub status: Status,
234 pub status_summary: StatusSummary,
236 pub logout: Logout,
237 pub output: OutputFormat,
239 pub resume: Resume,
241 pub conversation_files: Option<ConversationFiles>,
243 pub credential_files: &'static [&'static str],
246 pub transport: Transport,
248 pub acp: Option<AcpLaunch>,
252}
253
254const USER_BIN_DIRS: &[&str] = &[".local/bin"];
256
257const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
261 "SCV_CONFIG",
262 "SCV_MODEL",
263 "SCV_PROVIDER",
264 "SCV_BASE_URL",
265 "SCV_API_KEY_ENV",
266 "GEMINI_API_KEY",
267 "GOOGLE_API_KEY",
268 "AZURE_OPENAI_API_KEY",
269 "AZURE_OPENAI_ENDPOINT",
270];
271
272const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
273const SCV_STORE: KeyStore = KeyStore::Scv {
274 config: "config.toml",
275};
276const DSH_STORE: KeyStore = KeyStore::DshRefs {
277 path: ".dsh/.credentials.yaml",
278 variable: "DEEPSEEK_API_KEY",
279};
280
281pub const ADAPTERS: &[AdapterDescriptor] = &[
282 AdapterDescriptor {
283 name: "claude",
284 product: "Claude Code",
285 offers: "Anthropic's coding agent; it reads, edits, and runs code in a project and can search and fetch the web",
286 command: "claude",
287 args: &["-p"],
288 prompt_args: &[],
289 model_args: &["--model", "{model}"],
290 effort_args: &["--effort", "{effort}"],
291 model_hint: "Claude model alias or ID, such as sonnet or opus.",
292 home_environment: &[],
293 fixed_environment: &[],
294 removed_environment: &[
295 "ANTHROPIC_API_KEY",
296 "ANTHROPIC_BASE_URL",
297 "ANTHROPIC_AUTH_TOKEN",
298 "CLAUDE_CODE_OAUTH_TOKEN",
299 "CLAUDE_CONFIG_DIR",
300 ],
301 full_permission_args: &["--permission-mode", "bypassPermissions"],
303 full_permission_environment: &[],
304 search_dirs: &[],
305 login: Login::Command(&["auth", "login"]),
306 status: Status::Command(&["auth", "status"]),
307 status_summary: StatusSummary::ClaudeJson,
308 logout: Logout::Command(&["auth", "logout"]),
309 output: OutputFormat::ClaudeStreamJson,
310 resume: Resume::Supported {
312 start: &["--session-id", "{session}"],
313 subcommand: &[],
314 options: &["--resume", "{session}"],
315 positional: &[],
316 },
317 conversation_files: Some(ConversationFiles {
318 dir: ".claude/projects",
319 extension: "jsonl",
320 }),
321 credential_files: &[".claude/.credentials.json"],
322 transport: Transport::Process,
323 acp: Some(AcpLaunch {
326 command: "claude-agent-acp",
327 args: &[],
328 full_args: &[],
329 full_mode: Some("bypassPermissions"),
330 full_environment: &[],
331 }),
332 },
333 AdapterDescriptor {
334 name: "codex",
335 product: "Codex",
336 offers: "OpenAI's coding agent; it reads, edits, and runs code in a project, with live web search under full permissions",
337 command: "codex",
338 args: &["exec"],
339 prompt_args: &[],
340 model_args: &["-m", "{model}"],
341 effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
342 model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
343 home_environment: &[("CODEX_HOME", "")],
344 fixed_environment: &[],
345 removed_environment: &[
346 "OPENAI_API_KEY",
347 "OPENAI_BASE_URL",
348 "OPENAI_ORG_ID",
349 "OPENAI_PROJECT_ID",
350 "CODEX_API_KEY",
351 "CODEX_BASE_URL",
352 "CODEX_CONFIG",
353 ],
354 full_permission_args: &[
356 "--dangerously-bypass-approvals-and-sandbox",
357 "-c",
358 "web_search=\"live\"",
359 ],
360 full_permission_environment: &[],
361 search_dirs: &[],
362 login: Login::Command(&["login"]),
363 status: Status::Command(&["login", "status"]),
364 status_summary: StatusSummary::CodexText,
365 logout: Logout::Command(&["logout"]),
366 output: OutputFormat::CodexJsonl,
367 resume: Resume::Supported {
370 start: &[],
371 subcommand: &["resume"],
372 options: &[],
373 positional: &["{session}"],
374 },
375 conversation_files: Some(ConversationFiles {
376 dir: "sessions",
377 extension: "jsonl",
378 }),
379 credential_files: &["auth.json"],
380 transport: Transport::Process,
381 acp: Some(AcpLaunch {
387 command: "codex-acp",
388 args: &[],
389 full_args: &[],
390 full_mode: Some("agent-full-access"),
391 full_environment: &[("CODEX_CONFIG", r#"{"web_search":"live"}"#)],
392 }),
393 },
394 AdapterDescriptor {
395 name: "grok",
396 product: "Grok Build",
397 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",
398 command: "grok",
399 args: &[],
400 prompt_args: &["-p"],
401 model_args: &["-m", "{model}"],
402 effort_args: &["--reasoning-effort", "{effort}"],
403 model_hint: "xAI Grok model ID, such as grok-4.7.",
404 home_environment: &[("GROK_HOME", ".grok")],
405 fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
406 removed_environment: &["GROK_*", "XAI_API_KEY"],
407 full_permission_args: &["--always-approve"],
409 full_permission_environment: &[],
410 search_dirs: &[".grok/bin"],
411 login: Login::Command(&["login"]),
412 status: Status::Stored(KeyStore::Grok {
413 auth: ".grok/auth.json",
414 config: ".grok/config.toml",
415 }),
416 status_summary: StatusSummary::ExitStatus,
417 logout: Logout::Command(&["logout"]),
418 output: OutputFormat::Text,
420 resume: Resume::Unsupported,
423 conversation_files: None,
424 credential_files: &[".grok/auth.json", ".grok/config.toml"],
425 transport: Transport::Process,
426 acp: Some(AcpLaunch {
428 command: "grok",
429 args: &["agent", "{full}", "stdio"],
430 full_args: &["--always-approve"],
431 full_mode: None,
432 full_environment: &[],
433 }),
434 },
435 AdapterDescriptor {
436 name: "dsh",
437 product: "DeepSeek Harness",
438 offers: "a coding agent on DeepSeek models; it reads, edits, and runs code in a project",
439 command: "dsh",
440 args: &["--profile", "headless"],
441 prompt_args: &[],
442 model_args: &[],
443 effort_args: &[],
444 model_hint: "Model ID in the form this agent's CLI accepts.",
445 home_environment: &[("DSH_HOME", ".dsh")],
446 fixed_environment: &[],
447 removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
448 full_permission_args: &[],
450 full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
451 search_dirs: &[],
452 login: Login::ApiKey(DSH_STORE),
453 status: Status::Stored(DSH_STORE),
454 status_summary: StatusSummary::ExitStatus,
455 logout: Logout::Stored(DSH_STORE),
456 output: OutputFormat::Text,
457 resume: Resume::Unsupported,
459 conversation_files: None,
460 credential_files: &[".dsh/.credentials.yaml"],
461 transport: Transport::Process,
462 acp: Some(AcpLaunch {
465 command: "dsh",
466 args: &["--profile", "acp"],
467 full_args: &[],
468 full_mode: None,
469 full_environment: &[],
470 }),
471 },
472 AdapterDescriptor {
473 name: "pi",
474 product: "pi",
475 offers: "a minimal coding agent (read, write, edit, bash) that can run on SCV's own model endpoint; it has no web search",
476 command: "pi",
477 args: &["-p"],
478 prompt_args: &[],
479 model_args: &["--model", "{model}"],
480 effort_args: &["--thinking", "{effort}"],
481 model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
482 home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
483 fixed_environment: &[],
484 removed_environment: &["PI_*"],
485 full_permission_args: &[],
487 full_permission_environment: &[],
488 search_dirs: &[],
489 login: Login::Interactive {
490 args: &[],
491 hint: "run /login and choose a provider, then /quit",
492 },
493 status: Status::Stored(PI_STORE),
494 status_summary: StatusSummary::ExitStatus,
495 logout: Logout::Stored(PI_STORE),
496 output: OutputFormat::PiJson,
497 resume: Resume::Supported {
499 start: &["--session-id", "{session}"],
500 subcommand: &[],
501 options: &["--session-id", "{session}"],
502 positional: &[],
503 },
504 conversation_files: Some(ConversationFiles {
505 dir: ".pi/agent/sessions",
506 extension: "jsonl",
507 }),
508 credential_files: &[".pi/agent/auth.json", ".pi/agent/models.json"],
509 transport: Transport::Process,
510 acp: None,
512 },
513 AdapterDescriptor {
514 name: "scv",
515 product: "SCV",
516 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",
517 command: "scv",
518 args: &["server", "--stdio"],
519 prompt_args: &[],
520 model_args: &[],
522 effort_args: &[],
523 model_hint: "Model ID for the nested SCV's provider; applies to a new conversation only.",
524 home_environment: &[],
527 fixed_environment: &[],
528 removed_environment: &[],
529 full_permission_args: &[],
531 full_permission_environment: &[],
532 search_dirs: &[".cargo/bin"],
534 login: Login::Import,
535 status: Status::Stored(SCV_STORE),
536 status_summary: StatusSummary::ExitStatus,
537 logout: Logout::Stored(SCV_STORE),
538 output: OutputFormat::Text,
539 resume: Resume::Unsupported,
540 conversation_files: None,
541 credential_files: &["config.toml"],
542 transport: Transport::ScvProtocol,
543 acp: None,
544 },
545];
546
547pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
549 ADAPTERS.iter().find(|adapter| adapter.name == name)
550}
551
552pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
556 let Some(variable) = variable.to_str() else {
557 return false;
558 };
559 variable.ends_with("_API_KEY")
560 || COMMON_REMOVED_ENVIRONMENT.contains(&variable)
561 || ADAPTERS
562 .iter()
563 .flat_map(|adapter| adapter.removed_environment)
564 .any(|rule| match rule.strip_suffix('*') {
565 Some(prefix) => variable.starts_with(prefix),
566 None => variable == *rule,
567 })
568}
569
570pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
573 let signed_out = "not signed in".to_owned();
574 match summary {
575 StatusSummary::ClaudeJson => {
576 let first = serde_json::Deserializer::from_str(output)
578 .into_iter::<serde_json::Value>()
579 .next();
580 let Some(Ok(value)) = first else {
581 return if succeeded {
582 "signed in".into()
583 } else {
584 signed_out
585 };
586 };
587 if value.get("loggedIn").and_then(serde_json::Value::as_bool) != Some(true) {
588 return signed_out;
589 }
590 let method = match value.get("authMethod").and_then(serde_json::Value::as_str) {
591 Some("claude.ai") => "Claude account",
592 Some("api_key" | "apiKey" | "console") => "API key",
593 Some("oauth_token" | "oauthToken") => "OAuth token",
594 _ => "other method",
595 };
596 match value
597 .get("subscriptionType")
598 .and_then(serde_json::Value::as_str)
599 .filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
600 {
601 Some(plan) => format!("signed in ({method}, {plan})"),
602 None => format!("signed in ({method})"),
603 }
604 }
605 StatusSummary::CodexText => {
606 let lower = output.to_ascii_lowercase();
607 if !succeeded || lower.contains("not logged in") {
608 signed_out
609 } else if lower.contains("api key") {
610 "signed in (API key)".into()
611 } else if lower.contains("chatgpt") {
612 "signed in (ChatGPT account)".into()
613 } else {
614 "signed in".into()
615 }
616 }
617 StatusSummary::ExitStatus => {
618 if succeeded {
619 "signed in".into()
620 } else {
621 signed_out
622 }
623 }
624 }
625}
626
627pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
630 if command.contains('/') {
631 let path = Path::new(command);
632 return path.is_file().then(|| path.to_path_buf());
633 }
634 std::env::join_paths(search_dirs)
635 .ok()
636 .and_then(|dirs| {
637 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
638 which::which_in(command, Some(dirs), cwd).ok()
639 })
640 .or_else(|| which::which(command).ok())
641}
642
643pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
645 adapter
646 .search_dirs
647 .iter()
648 .chain(USER_BIN_DIRS)
649 .map(|dir| home.join(dir))
650 .collect()
651}
652
653#[cfg(test)]
654mod tests;