use std::{
ffi::OsStr,
path::{Path, PathBuf},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Login {
Command(&'static [&'static str]),
Interactive {
args: &'static [&'static str],
hint: &'static str,
},
ApiKey(KeyStore),
Import,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Status {
Command(&'static [&'static str]),
Stored(KeyStore),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
Text,
ClaudeStreamJson,
CodexJsonl,
PiJson,
}
impl OutputFormat {
pub fn args(self) -> &'static [&'static str] {
match self {
Self::Text => &[],
Self::ClaudeStreamJson => &["--output-format", "stream-json", "--verbose"],
Self::CodexJsonl => &["--json"],
Self::PiJson => &["--mode", "json"],
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
Process,
ScvProtocol,
}
impl Transport {
pub fn is_live(self) -> bool {
!matches!(self, Self::Process)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AcpLaunch {
pub command: &'static str,
pub args: &'static [&'static str],
pub full_args: &'static [&'static str],
pub full_mode: Option<&'static str>,
}
pub fn acp_args(launch: &AcpLaunch, full: bool) -> Vec<String> {
let mut args = Vec::with_capacity(launch.args.len() + launch.full_args.len());
for arg in launch.args {
if *arg == "{full}" {
if full {
args.extend(launch.full_args.iter().map(|arg| (*arg).to_owned()));
}
} else {
args.push((*arg).to_owned());
}
}
args
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resume {
Unsupported,
Supported {
start: &'static [&'static str],
subcommand: &'static [&'static str],
options: &'static [&'static str],
positional: &'static [&'static str],
},
}
impl Resume {
pub fn is_supported(self) -> bool {
matches!(self, Self::Supported { .. })
}
pub fn assigns_id(self) -> bool {
matches!(self, Self::Supported { start, .. } if !start.is_empty())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ConversationFiles {
pub dir: &'static str,
pub extension: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StatusSummary {
ClaudeJson,
CodexText,
ExitStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Logout {
Command(&'static [&'static str]),
Stored(KeyStore),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeyStore {
Grok {
auth: &'static str,
config: &'static str,
},
DshRefs {
path: &'static str,
variable: &'static str,
},
Pi { dir: &'static str },
Scv { config: &'static str },
}
#[derive(Debug, Clone, Copy)]
pub struct AdapterDescriptor {
pub name: &'static str,
pub product: &'static str,
pub command: &'static str,
pub args: &'static [&'static str],
pub prompt_args: &'static [&'static str],
pub model_args: &'static [&'static str],
pub effort_args: &'static [&'static str],
pub model_hint: &'static str,
pub home_environment: &'static [(&'static str, &'static str)],
pub fixed_environment: &'static [(&'static str, &'static str)],
pub removed_environment: &'static [&'static str],
pub full_permission_args: &'static [&'static str],
pub full_permission_environment: &'static [(&'static str, &'static str)],
pub search_dirs: &'static [&'static str],
pub login: Login,
pub status: Status,
pub status_summary: StatusSummary,
pub logout: Logout,
pub output: OutputFormat,
pub resume: Resume,
pub conversation_files: Option<ConversationFiles>,
pub transport: Transport,
pub acp: Option<AcpLaunch>,
}
const USER_BIN_DIRS: &[&str] = &[".local/bin"];
const COMMON_REMOVED_ENVIRONMENT: &[&str] = &[
"SCV_CONFIG",
"SCV_MODEL",
"SCV_PROVIDER",
"SCV_BASE_URL",
"SCV_API_KEY_ENV",
"GEMINI_API_KEY",
"GOOGLE_API_KEY",
"AZURE_OPENAI_API_KEY",
"AZURE_OPENAI_ENDPOINT",
];
const PI_STORE: KeyStore = KeyStore::Pi { dir: ".pi/agent" };
const SCV_STORE: KeyStore = KeyStore::Scv {
config: "config.toml",
};
const DSH_STORE: KeyStore = KeyStore::DshRefs {
path: ".dsh/.credentials.yaml",
variable: "DEEPSEEK_API_KEY",
};
pub const ADAPTERS: &[AdapterDescriptor] = &[
AdapterDescriptor {
name: "claude",
product: "Claude Code",
command: "claude",
args: &["-p"],
prompt_args: &[],
model_args: &["--model", "{model}"],
effort_args: &["--effort", "{effort}"],
model_hint: "Claude model alias or ID, such as sonnet or opus.",
home_environment: &[],
fixed_environment: &[],
removed_environment: &[
"ANTHROPIC_API_KEY",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"CLAUDE_CONFIG_DIR",
],
full_permission_args: &["--permission-mode", "bypassPermissions"],
full_permission_environment: &[],
search_dirs: &[],
login: Login::Command(&["auth", "login"]),
status: Status::Command(&["auth", "status"]),
status_summary: StatusSummary::ClaudeJson,
logout: Logout::Command(&["auth", "logout"]),
output: OutputFormat::ClaudeStreamJson,
resume: Resume::Supported {
start: &["--session-id", "{session}"],
subcommand: &[],
options: &["--resume", "{session}"],
positional: &[],
},
conversation_files: Some(ConversationFiles {
dir: ".claude/projects",
extension: "jsonl",
}),
transport: Transport::Process,
acp: Some(AcpLaunch {
command: "claude-agent-acp",
args: &[],
full_args: &[],
full_mode: Some("bypassPermissions"),
}),
},
AdapterDescriptor {
name: "codex",
product: "Codex",
command: "codex",
args: &["exec"],
prompt_args: &[],
model_args: &["-m", "{model}"],
effort_args: &["-c", "model_reasoning_effort=\"{effort}\""],
model_hint: "OpenAI model ID from the Codex configuration; not a Claude alias.",
home_environment: &[("CODEX_HOME", "")],
fixed_environment: &[],
removed_environment: &[
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENAI_ORG_ID",
"OPENAI_PROJECT_ID",
"CODEX_API_KEY",
"CODEX_BASE_URL",
],
full_permission_args: &[
"--dangerously-bypass-approvals-and-sandbox",
"-c",
"web_search=\"live\"",
],
full_permission_environment: &[],
search_dirs: &[],
login: Login::Command(&["login"]),
status: Status::Command(&["login", "status"]),
status_summary: StatusSummary::CodexText,
logout: Logout::Command(&["logout"]),
output: OutputFormat::CodexJsonl,
resume: Resume::Supported {
start: &[],
subcommand: &["resume"],
options: &[],
positional: &["{session}"],
},
conversation_files: Some(ConversationFiles {
dir: "sessions",
extension: "jsonl",
}),
transport: Transport::Process,
acp: Some(AcpLaunch {
command: "codex-acp",
args: &[],
full_args: &[],
full_mode: Some("agent-full-access"),
}),
},
AdapterDescriptor {
name: "grok",
product: "Grok Build",
command: "grok",
args: &[],
prompt_args: &["-p"],
model_args: &["-m", "{model}"],
effort_args: &["--reasoning-effort", "{effort}"],
model_hint: "xAI Grok model ID, such as grok-4.7.",
home_environment: &[("GROK_HOME", ".grok")],
fixed_environment: &[("GROK_DISABLE_AUTOUPDATER", "1")],
removed_environment: &["GROK_*", "XAI_API_KEY"],
full_permission_args: &["--always-approve"],
full_permission_environment: &[],
search_dirs: &[".grok/bin"],
login: Login::Command(&["login"]),
status: Status::Stored(KeyStore::Grok {
auth: ".grok/auth.json",
config: ".grok/config.toml",
}),
status_summary: StatusSummary::ExitStatus,
logout: Logout::Command(&["logout"]),
output: OutputFormat::Text,
resume: Resume::Unsupported,
conversation_files: None,
transport: Transport::Process,
acp: Some(AcpLaunch {
command: "grok",
args: &["agent", "{full}", "stdio"],
full_args: &["--always-approve"],
full_mode: None,
}),
},
AdapterDescriptor {
name: "dsh",
product: "DeepSeek Harness",
command: "dsh",
args: &["--profile", "headless"],
prompt_args: &[],
model_args: &[],
effort_args: &[],
model_hint: "Model ID in the form this agent's CLI accepts.",
home_environment: &[("DSH_HOME", ".dsh")],
fixed_environment: &[],
removed_environment: &["DSH_*", "DEEPSEEK_API_KEY", "DEEPSEEK_BASE_URL"],
full_permission_args: &[],
full_permission_environment: &[("DSH_PERMISSION_MODE", "danger-full-access")],
search_dirs: &[],
login: Login::ApiKey(DSH_STORE),
status: Status::Stored(DSH_STORE),
status_summary: StatusSummary::ExitStatus,
logout: Logout::Stored(DSH_STORE),
output: OutputFormat::Text,
resume: Resume::Unsupported,
conversation_files: None,
transport: Transport::Process,
acp: Some(AcpLaunch {
command: "dsh",
args: &["--profile", "acp"],
full_args: &[],
full_mode: None,
}),
},
AdapterDescriptor {
name: "pi",
product: "pi",
command: "pi",
args: &["-p"],
prompt_args: &[],
model_args: &["--model", "{model}"],
effort_args: &["--thinking", "{effort}"],
model_hint: "pi model pattern or provider/id; the SCV-configured endpoint is provider scv.",
home_environment: &[("PI_CODING_AGENT_DIR", ".pi/agent")],
fixed_environment: &[],
removed_environment: &["PI_*"],
full_permission_args: &[],
full_permission_environment: &[],
search_dirs: &[],
login: Login::Interactive {
args: &[],
hint: "run /login and choose a provider, then /quit",
},
status: Status::Stored(PI_STORE),
status_summary: StatusSummary::ExitStatus,
logout: Logout::Stored(PI_STORE),
output: OutputFormat::PiJson,
resume: Resume::Supported {
start: &["--session-id", "{session}"],
subcommand: &[],
options: &["--session-id", "{session}"],
positional: &[],
},
conversation_files: Some(ConversationFiles {
dir: ".pi/agent/sessions",
extension: "jsonl",
}),
transport: Transport::Process,
acp: None,
},
AdapterDescriptor {
name: "scv",
product: "SCV",
command: "scv",
args: &["server", "--stdio"],
prompt_args: &[],
model_args: &[],
effort_args: &[],
model_hint: "Model ID for the nested SCV's provider; applies to a new conversation only.",
home_environment: &[],
fixed_environment: &[],
removed_environment: &[],
full_permission_args: &[],
full_permission_environment: &[],
search_dirs: &[".cargo/bin"],
login: Login::Import,
status: Status::Stored(SCV_STORE),
status_summary: StatusSummary::ExitStatus,
logout: Logout::Stored(SCV_STORE),
output: OutputFormat::Text,
resume: Resume::Unsupported,
conversation_files: None,
transport: Transport::ScvProtocol,
acp: None,
},
];
pub fn adapter(name: &str) -> Option<&'static AdapterDescriptor> {
ADAPTERS.iter().find(|adapter| adapter.name == name)
}
pub fn is_removed_agent_variable(variable: &OsStr) -> bool {
let Some(variable) = variable.to_str() else {
return false;
};
variable.ends_with("_API_KEY")
|| COMMON_REMOVED_ENVIRONMENT.contains(&variable)
|| ADAPTERS
.iter()
.flat_map(|adapter| adapter.removed_environment)
.any(|rule| match rule.strip_suffix('*') {
Some(prefix) => variable.starts_with(prefix),
None => variable == *rule,
})
}
pub fn summarize_status(summary: StatusSummary, succeeded: bool, output: &str) -> String {
let signed_out = "not signed in".to_owned();
match summary {
StatusSummary::ClaudeJson => {
let first = serde_json::Deserializer::from_str(output)
.into_iter::<serde_json::Value>()
.next();
let Some(Ok(value)) = first else {
return if succeeded {
"signed in".into()
} else {
signed_out
};
};
if value.get("loggedIn").and_then(serde_json::Value::as_bool) != Some(true) {
return signed_out;
}
let method = match value.get("authMethod").and_then(serde_json::Value::as_str) {
Some("claude.ai") => "Claude account",
Some("api_key" | "apiKey" | "console") => "API key",
Some("oauth_token" | "oauthToken") => "OAuth token",
_ => "other method",
};
match value
.get("subscriptionType")
.and_then(serde_json::Value::as_str)
.filter(|plan| ["free", "pro", "max", "team", "enterprise"].contains(plan))
{
Some(plan) => format!("signed in ({method}, {plan})"),
None => format!("signed in ({method})"),
}
}
StatusSummary::CodexText => {
let lower = output.to_ascii_lowercase();
if !succeeded || lower.contains("not logged in") {
signed_out
} else if lower.contains("api key") {
"signed in (API key)".into()
} else if lower.contains("chatgpt") {
"signed in (ChatGPT account)".into()
} else {
"signed in".into()
}
}
StatusSummary::ExitStatus => {
if succeeded {
"signed in".into()
} else {
signed_out
}
}
}
}
pub fn resolve_agent_executable(command: &str, search_dirs: &[PathBuf]) -> Option<PathBuf> {
if command.contains('/') {
let path = Path::new(command);
return path.is_file().then(|| path.to_path_buf());
}
std::env::join_paths(search_dirs)
.ok()
.and_then(|dirs| {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
which::which_in(command, Some(dirs), cwd).ok()
})
.or_else(|| which::which(command).ok())
}
pub fn adapter_search_dirs(adapter: &AdapterDescriptor, home: &Path) -> Vec<PathBuf> {
adapter
.search_dirs
.iter()
.chain(USER_BIN_DIRS)
.map(|dir| home.join(dir))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn descriptors_are_unique_and_self_consistent() {
let mut names: Vec<_> = ADAPTERS.iter().map(|adapter| adapter.name).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), ADAPTERS.len());
for adapter in ADAPTERS {
assert!(
adapter.model_args.is_empty()
|| adapter.model_args.iter().any(|arg| arg.contains("{model}")),
"{}",
adapter.name
);
assert!(
adapter.effort_args.is_empty()
|| adapter
.effort_args
.iter()
.any(|arg| arg.contains("{effort}")),
"{}",
adapter.name
);
if let Resume::Supported {
start,
subcommand,
options,
positional,
} = adapter.resume
{
let names_session =
|args: &[&str]| args.iter().any(|arg| arg.contains("{session}"));
assert!(start.is_empty() || names_session(start), "{}", adapter.name);
assert!(
names_session(options) || names_session(positional),
"{}",
adapter.name
);
assert!(!names_session(subcommand), "{}", adapter.name);
assert!(adapter.conversation_files.is_some(), "{}", adapter.name);
}
for (variable, _) in adapter
.home_environment
.iter()
.chain(adapter.fixed_environment)
{
assert!(!variable.ends_with("_API_KEY"), "{variable}");
}
for store in [
match adapter.status {
Status::Stored(store) => Some(store),
Status::Command(_) => None,
},
match adapter.logout {
Logout::Stored(store) => Some(store),
Logout::Command(_) => None,
},
match adapter.login {
Login::ApiKey(store) => Some(store),
_ => None,
},
]
.into_iter()
.flatten()
{
let paths = match store {
KeyStore::Grok { auth, config } => vec![auth, config],
KeyStore::DshRefs { path, .. } => vec![path],
KeyStore::Pi { dir } => vec![dir],
KeyStore::Scv { .. } => vec![],
};
for path in paths {
assert!(
adapter
.home_environment
.iter()
.any(|(_, home)| !home.is_empty() && path.starts_with(home)),
"{}: {path}",
adapter.name
);
}
}
}
}
#[test]
fn status_summaries_never_echo_accounts_or_keys() {
let claude = r#"{"loggedIn":true,"authMethod":"claude.ai","email":"me@example.com","orgName":"me@example.com's Organization","subscriptionType":"max"}"#;
assert_eq!(
summarize_status(StatusSummary::ClaudeJson, true, claude),
"signed in (Claude account, max)"
);
assert_eq!(
summarize_status(
StatusSummary::ClaudeJson,
true,
r#"{"loggedIn":true,"authMethod":"api_key","subscriptionType":"me@example.com"}"#
),
"signed in (API key)"
);
assert_eq!(
summarize_status(StatusSummary::ClaudeJson, false, r#"{"loggedIn":false}"#),
"not signed in"
);
assert_eq!(
summarize_status(
StatusSummary::ClaudeJson,
true,
"{\"loggedIn\":true,\"authMethod\":\"claude.ai\"}\n\nsome stderr"
),
"signed in (Claude account)"
);
assert_eq!(
summarize_status(
StatusSummary::CodexText,
true,
"Logged in using an API key - sk-proj-***abcd"
),
"signed in (API key)"
);
assert_eq!(
summarize_status(StatusSummary::CodexText, true, "Logged in using ChatGPT"),
"signed in (ChatGPT account)"
);
assert_eq!(
summarize_status(StatusSummary::CodexText, false, "Not logged in"),
"not signed in"
);
for adapter in ADAPTERS {
if let Status::Command(_) = adapter.status {
assert_ne!(
adapter.status_summary,
StatusSummary::ExitStatus,
"{}",
adapter.name
);
}
}
}
#[test]
fn removal_covers_every_adapter_and_generic_api_keys() {
for removed in [
"OPENAI_API_KEY",
"CLAUDE_CONFIG_DIR",
"GROK_HOME",
"GROK_AUTH",
"XAI_API_KEY",
"DSH_HOME",
"DSH_PERMISSION_MODE",
"DEEPSEEK_BASE_URL",
"PI_CODING_AGENT_DIR",
"OPENROUTER_API_KEY",
"SCV_CONFIG",
] {
assert!(is_removed_agent_variable(OsStr::new(removed)), "{removed}");
}
for kept in ["PATH", "HOME", "LANG", "GH_TOKEN", "GROKKING", "PIPX_HOME"] {
assert!(!is_removed_agent_variable(OsStr::new(kept)), "{kept}");
}
}
#[test]
fn executables_resolve_from_per_user_directories_before_path() {
let dir = tempfile::tempdir().unwrap();
let bin = dir.path().join(".grok/bin");
std::fs::create_dir_all(&bin).unwrap();
let name = "scv-test-agent-only-in-home";
let executable = bin.join(name);
std::fs::write(&executable, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let grok = adapter("grok").unwrap();
let dirs = adapter_search_dirs(grok, dir.path());
assert!(dirs.contains(&dir.path().join(".local/bin")));
assert_eq!(
resolve_agent_executable(name, &dirs),
Some(executable.clone())
);
assert_eq!(resolve_agent_executable(name, &[]), None);
let shadow = bin.join("sh");
std::fs::write(&shadow, "#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&shadow, std::fs::Permissions::from_mode(0o755)).unwrap();
}
assert_eq!(resolve_agent_executable("sh", &dirs), Some(shadow));
assert!(resolve_agent_executable("sh", &[]).is_some());
assert_eq!(
resolve_agent_executable(executable.to_str().unwrap(), &[]),
Some(executable)
);
}
}