use anyhow::{bail, Result};
use serde_json::Value;
pub const AGENTS: &[&str] = &["claude", "codex", "auggie", "hermes", "opencode"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
Claude,
Codex,
Auggie,
Hermes,
Opencode,
}
impl Agent {
pub const ALL: [Agent; 5] = [
Agent::Claude,
Agent::Codex,
Agent::Auggie,
Agent::Hermes,
Agent::Opencode,
];
pub fn parse(name: &str) -> Result<Self> {
match name {
"claude" => Ok(Agent::Claude),
"codex" => Ok(Agent::Codex),
"auggie" => Ok(Agent::Auggie),
"hermes" => Ok(Agent::Hermes),
"opencode" => Ok(Agent::Opencode),
unknown => {
let candidates = nearest_agents(unknown).join(", ");
bail!("unknown agent '{unknown}'; did you mean: {candidates}?")
}
}
}
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Agent::Claude => "claude",
Agent::Codex => "codex",
Agent::Auggie => "auggie",
Agent::Hermes => "hermes",
Agent::Opencode => "opencode",
}
}
}
#[derive(Debug, Clone)]
pub struct FileWrite {
pub path: String,
pub content: String,
}
#[derive(Debug, Clone)]
pub struct ToolAction {
pub session: String,
pub files: Vec<FileWrite>,
pub is_stop: bool,
}
pub type ParseOutcome = Result<ToolAction, Option<String>>;
pub fn normalize(agent: Agent, raw: &str) -> ParseOutcome {
let value: Value = serde_json::from_str(raw).map_err(|_| None)?;
match agent {
Agent::Claude => normalize_claude_family(&value),
Agent::Codex => normalize_codex(&value),
Agent::Auggie => normalize_auggie(&value),
Agent::Hermes => normalize_hermes(&value),
Agent::Opencode => normalize_opencode(&value),
}
}
fn normalize_claude_family(value: &Value) -> ParseOutcome {
let session = session_from(value, "session_id");
let Some(tool_input) = value.get("tool_input") else {
if value.get("stop_hook_active").is_some() {
return Ok(ToolAction {
session,
files: vec![],
is_stop: true,
});
}
return Err(None);
};
let path = tool_input
.get("file_path")
.and_then(Value::as_str)
.map(relativize)
.ok_or(None)?;
let well_formed = value.get("tool_name").and_then(Value::as_str).is_some();
let content = tool_input.get("content").and_then(Value::as_str);
match (well_formed, content) {
(true, Some(content)) => Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: content.to_owned(),
}],
is_stop: false,
}),
_ => Err(Some(path)),
}
}
fn normalize_codex(value: &Value) -> ParseOutcome {
if value
.get("tool_input")
.and_then(|input| input.get("file_path"))
.is_some()
{
return normalize_claude_family(value);
}
let session = session_from(value, "session_id");
let Some(tool_input) = value.get("tool_input") else {
if value.get("stop_hook_active").is_some() {
return Ok(ToolAction {
session,
files: vec![],
is_stop: true,
});
}
return Err(None);
};
let command = tool_input
.get("command")
.and_then(Value::as_str)
.ok_or(None)?;
let files = parse_apply_patch(command);
if files.is_empty() {
return Err(None);
}
Ok(ToolAction {
session,
files,
is_stop: false,
})
}
fn parse_apply_patch(command: &str) -> Vec<FileWrite> {
let mut files = Vec::new();
let mut current_path: Option<String> = None;
let mut current_content = String::new();
for line in command.lines() {
let file_marker = line
.strip_prefix("*** Add File: ")
.or_else(|| line.strip_prefix("*** Update File: "));
if let Some(path) = file_marker {
if let Some(done) = current_path.take() {
files.push(FileWrite {
path: done,
content: std::mem::take(&mut current_content),
});
}
current_path = Some(relativize(path.trim()));
} else if line.starts_with("*** End Patch") {
if let Some(done) = current_path.take() {
files.push(FileWrite {
path: done,
content: std::mem::take(&mut current_content),
});
}
} else if current_path.is_some() {
if let Some(added) = line.strip_prefix('+') {
current_content.push_str(added);
current_content.push('\n');
}
}
}
if let Some(done) = current_path.take() {
files.push(FileWrite {
path: done,
content: current_content,
});
}
files
}
fn normalize_auggie(value: &Value) -> ParseOutcome {
let session = session_from(value, "conversation_id");
let Some(tool_input) = value.get("tool_input") else {
return Err(None);
};
let path = tool_input
.get("path")
.or_else(|| tool_input.get("file_path"))
.and_then(Value::as_str)
.map(relativize)
.ok_or(None)?;
let well_formed = value.get("tool_name").and_then(Value::as_str).is_some();
let content = tool_input
.get("file_content")
.or_else(|| tool_input.get("new_str_1"))
.or_else(|| tool_input.get("content"))
.and_then(Value::as_str);
match (well_formed, content) {
(true, Some(content)) => Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: content.to_owned(),
}],
is_stop: false,
}),
_ => Err(Some(path)),
}
}
fn normalize_hermes(value: &Value) -> ParseOutcome {
let session = session_from(value, "session_id");
let Some(tool_input) = value.get("tool_input") else {
return Err(None);
};
let path = tool_input
.get("path")
.or_else(|| tool_input.get("file_path"))
.and_then(Value::as_str)
.map(relativize)
.ok_or(None)?;
let well_formed = value.get("tool_name").and_then(Value::as_str).is_some();
let content = tool_input.get("content").and_then(Value::as_str);
match (well_formed, content) {
(true, Some(content)) => Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: content.to_owned(),
}],
is_stop: false,
}),
_ => Err(Some(path)),
}
}
fn normalize_opencode(value: &Value) -> ParseOutcome {
let session = session_from(value, "sessionID");
let Some(args) = value.get("args") else {
return Err(None);
};
let path = args
.get("filePath")
.and_then(Value::as_str)
.map(relativize)
.ok_or(None)?;
let well_formed = value.get("tool").and_then(Value::as_str).is_some();
let content = args.get("content").and_then(Value::as_str);
match (well_formed, content) {
(true, Some(content)) => Ok(ToolAction {
session,
files: vec![FileWrite {
path,
content: content.to_owned(),
}],
is_stop: false,
}),
_ => Err(Some(path)),
}
}
fn relativize(path: &str) -> String {
let candidate = std::path::Path::new(path);
if candidate.is_relative() {
return path.to_owned();
}
let Ok(cwd) = std::env::current_dir() else {
return path.to_owned();
};
if let Ok(stripped) = candidate.strip_prefix(&cwd) {
return stripped.to_string_lossy().into_owned();
}
if let Ok(canonical_cwd) = cwd.canonicalize() {
if let Ok(stripped) = candidate.strip_prefix(&canonical_cwd) {
return stripped.to_string_lossy().into_owned();
}
}
path.to_owned()
}
fn session_from(value: &Value, key: &str) -> String {
value
.get(key)
.and_then(Value::as_str)
.unwrap_or("anonymous-session")
.to_owned()
}
fn nearest_agents(unknown: &str) -> Vec<&'static str> {
let mut scored: Vec<(usize, &'static str)> = AGENTS
.iter()
.map(|&agent| (levenshtein(unknown, agent), agent))
.collect();
scored.sort_unstable();
scored.truncate(2);
scored.into_iter().map(|(_, agent)| agent).collect()
}
fn levenshtein(a: &str, b: &str) -> usize {
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
let mut previous: Vec<usize> = (0..=b_chars.len()).collect();
let mut current = vec![0usize; b_chars.len() + 1];
for (i, &a_char) in a_chars.iter().enumerate() {
current[0] = i + 1;
for (j, &b_char) in b_chars.iter().enumerate() {
let substitution = usize::from(a_char != b_char);
current[j + 1] = (previous[j] + substitution)
.min(previous[j + 1] + 1)
.min(current[j] + 1);
}
std::mem::swap(&mut previous, &mut current);
}
previous[b_chars.len()]
}