#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ClientId {
ClaudeCode,
Codex,
Cursor,
GeminiCli,
Windsurf,
CopilotCli,
Antigravity,
Goose,
Crush,
RooCode,
Warp,
OpenCode,
}
impl ClientId {
pub const ALL: [Self; 12] = [
Self::ClaudeCode,
Self::Codex,
Self::Cursor,
Self::GeminiCli,
Self::Windsurf,
Self::CopilotCli,
Self::Antigravity,
Self::Goose,
Self::Crush,
Self::RooCode,
Self::Warp,
Self::OpenCode,
];
#[must_use]
pub const fn wire_name(self) -> &'static str {
match self {
Self::ClaudeCode => "claude-code",
Self::Codex => "codex",
Self::Cursor => "cursor",
Self::GeminiCli => "gemini-cli",
Self::Windsurf => "windsurf",
Self::CopilotCli => "copilot-cli",
Self::Antigravity => "antigravity",
Self::Goose => "goose",
Self::Crush => "crush",
Self::RooCode => "roo-code",
Self::Warp => "warp",
Self::OpenCode => "opencode",
}
}
#[must_use]
pub const fn display_name(self) -> &'static str {
match self {
Self::ClaudeCode => "Claude Code",
Self::Codex => "Codex",
Self::Cursor => "Cursor",
Self::GeminiCli => "Gemini CLI",
Self::Windsurf => "Windsurf",
Self::CopilotCli => "Copilot CLI",
Self::Antigravity => "Antigravity",
Self::Goose => "Goose",
Self::Crush => "Crush",
Self::RooCode => "Roo Code",
Self::Warp => "Warp",
Self::OpenCode => "OpenCode",
}
}
#[must_use]
pub fn from_wire(name: &str) -> Option<Self> {
let normalized = name.to_ascii_lowercase();
Some(match normalized.as_str() {
"claude" | "claude-code" | "claude_code" | "claude-cli" => Self::ClaudeCode,
"codex" | "codex-cli" => Self::Codex,
"cursor" | "cursor-agent" => Self::Cursor,
"gemini" | "gemini-cli" | "gemini_cli" => Self::GeminiCli,
"windsurf" => Self::Windsurf,
"copilot" | "copilot-cli" => Self::CopilotCli,
"antigravity" => Self::Antigravity,
"goose" => Self::Goose,
"crush" => Self::Crush,
"roo" | "roo-code" | "roo_code" => Self::RooCode,
"warp" => Self::Warp,
"opencode" | "open-code" | "opencode-cli" => Self::OpenCode,
_ => return None,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wire_names_round_trip_through_from_wire() {
for id in ClientId::ALL {
assert_eq!(
ClientId::from_wire(id.wire_name()),
Some(id),
"wire name {} did not round-trip",
id.wire_name()
);
}
}
#[test]
fn wire_names_are_unique_and_kebab_case() {
let mut seen = std::collections::BTreeSet::new();
for id in ClientId::ALL {
assert!(seen.insert(id.wire_name()), "duplicate {}", id.wire_name());
assert!(
id.wire_name()
.chars()
.all(|c| c.is_ascii_lowercase() || c == '-'),
"{} is not kebab-case",
id.wire_name()
);
}
}
#[test]
fn from_wire_accepts_legacy_alias_spellings() {
let cases: &[(&str, ClientId)] = &[
("claude", ClientId::ClaudeCode),
("claude_code", ClientId::ClaudeCode),
("CLAUDE-CODE", ClientId::ClaudeCode),
("codex-cli", ClientId::Codex),
("cursor-agent", ClientId::Cursor),
("gemini", ClientId::GeminiCli),
("gemini_cli", ClientId::GeminiCli),
("Windsurf", ClientId::Windsurf),
];
for (input, want) in cases {
assert_eq!(ClientId::from_wire(input), Some(*want), "alias {input}");
}
assert_eq!(ClientId::from_wire("definitely-not-a-client"), None);
assert_eq!(ClientId::from_wire(""), None);
}
}