Skip to main content

coding_agent_hooks/agents/
mod.rs

1//! Multi-agent support — agent identification, tool name normalization, and
2//! protocol implementations.
3//!
4//! This module provides:
5//!
6//! - [`AgentKind`] — enum identifying each supported agent
7//! - Canonical tool alias table — maps agent-native tool names to internal names
8//! - Permission mode resolution — normalizes agent-specific mode strings
9//! - Per-agent [`HookProtocol`](crate::protocol::HookProtocol) implementations
10
11pub mod amazonq;
12pub mod claude;
13pub mod codex;
14pub mod copilot;
15pub mod gemini;
16pub mod opencode;
17
18use std::fmt;
19use std::str::FromStr;
20
21/// Identifies which coding agent is calling.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
24pub enum AgentKind {
25    Claude,
26    Gemini,
27    Codex,
28    #[cfg_attr(feature = "clap", value(name = "amazonq"))]
29    AmazonQ,
30    #[cfg_attr(feature = "clap", value(name = "opencode"))]
31    OpenCode,
32    Copilot,
33}
34
35impl fmt::Display for AgentKind {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            AgentKind::Claude => write!(f, "claude"),
39            AgentKind::Gemini => write!(f, "gemini"),
40            AgentKind::Codex => write!(f, "codex"),
41            AgentKind::AmazonQ => write!(f, "amazonq"),
42            AgentKind::OpenCode => write!(f, "opencode"),
43            AgentKind::Copilot => write!(f, "copilot"),
44        }
45    }
46}
47
48impl FromStr for AgentKind {
49    type Err = String;
50
51    fn from_str(s: &str) -> Result<Self, Self::Err> {
52        match s.to_lowercase().as_str() {
53            "claude" => Ok(AgentKind::Claude),
54            "gemini" => Ok(AgentKind::Gemini),
55            "codex" => Ok(AgentKind::Codex),
56            "amazonq" | "amazon-q" | "amazon_q" => Ok(AgentKind::AmazonQ),
57            "opencode" | "open-code" => Ok(AgentKind::OpenCode),
58            "copilot" => Ok(AgentKind::Copilot),
59            _ => Err(format!("unknown agent: {s}")),
60        }
61    }
62}
63
64// ---------------------------------------------------------------------------
65// Canonical permission mode table
66// ---------------------------------------------------------------------------
67
68/// Maps agent-specific permission mode strings to canonical modes.
69///
70/// Canonical modes: "default", "plan", "edit", "unrestricted".
71/// Unknown modes pass through as-is so agent-specific extensions still work.
72struct ModeAlias {
73    canonical: &'static str,
74    agent_names: &'static [(AgentKind, &'static str)],
75}
76
77const MODE_ALIASES: &[ModeAlias] = &[
78    ModeAlias {
79        canonical: "default",
80        agent_names: &[(AgentKind::Claude, "default")],
81    },
82    ModeAlias {
83        canonical: "plan",
84        agent_names: &[(AgentKind::Claude, "plan")],
85    },
86    ModeAlias {
87        canonical: "edit",
88        agent_names: &[(AgentKind::Claude, "edit")],
89    },
90    ModeAlias {
91        canonical: "unrestricted",
92        agent_names: &[(AgentKind::Claude, "dangerously_skip_permissions")],
93    },
94];
95
96/// Given an agent's native permission mode string, return the canonical mode.
97///
98/// Case-insensitive. Returns the original string unchanged if no mapping exists.
99pub fn resolve_permission_mode<'a>(agent: AgentKind, native_mode: &'a str) -> &'a str {
100    let lower = native_mode.to_lowercase();
101    for alias in MODE_ALIASES {
102        for &(a, name) in alias.agent_names {
103            if a == agent && name.to_lowercase() == lower {
104                return alias.canonical;
105            }
106        }
107    }
108    native_mode
109}
110
111// ---------------------------------------------------------------------------
112// Canonical tool alias table
113// ---------------------------------------------------------------------------
114
115/// Maps a canonical name and agent-native names to a single internal name.
116///
117/// Internal names use Claude Code's tool names (e.g. "Bash", "Read") since
118/// they are already embedded throughout policy engines and permission logic.
119struct ToolAlias {
120    /// User-facing canonical name (e.g. "shell", "read").
121    canonical: &'static str,
122    /// Internal name used by the policy engine (Claude-style, e.g. "Bash").
123    internal: &'static str,
124    /// Agent-specific native names that map to this tool.
125    agent_names: &'static [(AgentKind, &'static str)],
126}
127
128/// The ONE source of truth for tool name mappings across all agents.
129///
130/// Each entry is curated case-by-case. Tools that don't have a clean
131/// cross-agent equivalent are NOT in this table — they stay agent-specific.
132const TOOL_ALIASES: &[ToolAlias] = &[
133    ToolAlias {
134        canonical: "shell",
135        internal: "Bash",
136        agent_names: &[
137            (AgentKind::Claude, "Bash"),
138            (AgentKind::Gemini, "run_shell_command"),
139            (AgentKind::Codex, "shell"),
140            (AgentKind::AmazonQ, "execute_bash"),
141            (AgentKind::OpenCode, "bash"),
142            (AgentKind::Copilot, "bash"),
143        ],
144    },
145    ToolAlias {
146        canonical: "read",
147        internal: "Read",
148        agent_names: &[
149            (AgentKind::Claude, "Read"),
150            (AgentKind::Gemini, "read_file"),
151            (AgentKind::AmazonQ, "fs_read"),
152            (AgentKind::OpenCode, "read"),
153            (AgentKind::Copilot, "view"),
154        ],
155    },
156    ToolAlias {
157        canonical: "write",
158        internal: "Write",
159        agent_names: &[
160            (AgentKind::Claude, "Write"),
161            (AgentKind::Gemini, "write_file"),
162            (AgentKind::AmazonQ, "fs_write"),
163            (AgentKind::OpenCode, "write"),
164        ],
165    },
166    ToolAlias {
167        canonical: "edit",
168        internal: "Edit",
169        agent_names: &[
170            (AgentKind::Claude, "Edit"),
171            (AgentKind::Gemini, "replace"),
172            (AgentKind::OpenCode, "edit"),
173            (AgentKind::Copilot, "edit"),
174        ],
175    },
176    ToolAlias {
177        canonical: "glob",
178        internal: "Glob",
179        agent_names: &[
180            (AgentKind::Claude, "Glob"),
181            (AgentKind::Gemini, "glob"),
182            (AgentKind::OpenCode, "glob"),
183        ],
184    },
185    ToolAlias {
186        canonical: "grep",
187        internal: "Grep",
188        agent_names: &[
189            (AgentKind::Claude, "Grep"),
190            (AgentKind::Gemini, "grep_search"),
191            (AgentKind::OpenCode, "grep"),
192        ],
193    },
194    ToolAlias {
195        canonical: "web_fetch",
196        internal: "WebFetch",
197        agent_names: &[
198            (AgentKind::Claude, "WebFetch"),
199            (AgentKind::Gemini, "web_fetch"),
200            (AgentKind::OpenCode, "webfetch"),
201        ],
202    },
203    ToolAlias {
204        canonical: "web_search",
205        internal: "WebSearch",
206        agent_names: &[
207            (AgentKind::Claude, "WebSearch"),
208            (AgentKind::Gemini, "google_web_search"),
209            (AgentKind::Codex, "web_search"),
210            (AgentKind::OpenCode, "websearch"),
211        ],
212    },
213];
214
215/// Given an agent's native tool name, return the internal (Claude-style) name.
216///
217/// Case-insensitive. Returns the original name unchanged if no mapping exists
218/// (unknown/agent-specific tools pass through as-is).
219pub fn resolve_tool_name(agent: AgentKind, native_name: &str) -> &str {
220    let lower = native_name.to_lowercase();
221    for alias in TOOL_ALIASES {
222        for &(a, name) in alias.agent_names {
223            if a == agent && name.to_lowercase() == lower {
224                return alias.internal;
225            }
226        }
227    }
228    native_name
229}
230
231/// Given a canonical name (e.g. "shell"), return the internal name (e.g. "Bash").
232///
233/// Case-insensitive. Used by the policy compiler to resolve user-facing aliases.
234pub fn canonical_to_internal(clash_name: &str) -> Option<&'static str> {
235    let lower = clash_name.to_lowercase();
236    TOOL_ALIASES
237        .iter()
238        .find(|a| a.canonical.to_lowercase() == lower)
239        .map(|a| a.internal)
240}
241
242/// Resolve any tool name to its internal form.
243///
244/// Accepts canonical names ("shell"), internal names ("Bash"), or any
245/// agent-native name ("run_shell_command"). Case-insensitive.
246/// Returns the internal name if found, or None if unrecognized.
247pub fn resolve_any_to_internal(name: &str) -> Option<&'static str> {
248    let lower = name.to_lowercase();
249    for alias in TOOL_ALIASES {
250        if alias.canonical.to_lowercase() == lower {
251            return Some(alias.internal);
252        }
253        if alias.internal.to_lowercase() == lower {
254            return Some(alias.internal);
255        }
256        for &(_, agent_name) in alias.agent_names {
257            if agent_name.to_lowercase() == lower {
258                return Some(alias.internal);
259            }
260        }
261    }
262    None
263}
264
265/// Given an internal name (e.g. "Bash"), return the canonical name (e.g. "shell").
266///
267/// Case-insensitive.
268pub fn internal_to_canonical(internal_name: &str) -> Option<&'static str> {
269    let lower = internal_name.to_lowercase();
270    TOOL_ALIASES
271        .iter()
272        .find(|a| a.internal.to_lowercase() == lower)
273        .map(|a| a.canonical)
274}
275
276/// Return the best user-facing display name for a tool.
277///
278/// Prefers the canonical name ("shell") if one exists, otherwise returns
279/// the internal name as-is.
280pub fn display_name(internal_name: &str) -> &str {
281    internal_to_canonical(internal_name).unwrap_or(internal_name)
282}
283
284/// Given an internal name and target agent, return the agent's native tool name.
285///
286/// Used for formatting output in the agent's expected vocabulary.
287pub fn internal_to_agent(agent: AgentKind, internal_name: &str) -> Option<&'static str> {
288    let lower = internal_name.to_lowercase();
289    for alias in TOOL_ALIASES {
290        if alias.internal.to_lowercase() == lower {
291            for &(a, name) in alias.agent_names {
292                if a == agent {
293                    return Some(name);
294                }
295            }
296        }
297    }
298    None
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn resolve_claude_bash() {
307        assert_eq!(resolve_tool_name(AgentKind::Claude, "Bash"), "Bash");
308    }
309
310    #[test]
311    fn resolve_gemini_shell() {
312        assert_eq!(
313            resolve_tool_name(AgentKind::Gemini, "run_shell_command"),
314            "Bash"
315        );
316    }
317
318    #[test]
319    fn resolve_codex_shell() {
320        assert_eq!(resolve_tool_name(AgentKind::Codex, "shell"), "Bash");
321    }
322
323    #[test]
324    fn resolve_amazonq_bash() {
325        assert_eq!(
326            resolve_tool_name(AgentKind::AmazonQ, "execute_bash"),
327            "Bash"
328        );
329    }
330
331    #[test]
332    fn resolve_case_insensitive() {
333        assert_eq!(resolve_tool_name(AgentKind::Claude, "bash"), "Bash");
334        assert_eq!(resolve_tool_name(AgentKind::Claude, "BASH"), "Bash");
335        assert_eq!(
336            resolve_tool_name(AgentKind::Gemini, "RUN_SHELL_COMMAND"),
337            "Bash"
338        );
339    }
340
341    #[test]
342    fn resolve_unknown_passthrough() {
343        assert_eq!(
344            resolve_tool_name(AgentKind::Claude, "SomeCustomTool"),
345            "SomeCustomTool"
346        );
347    }
348
349    #[test]
350    fn canonical_to_internal_works() {
351        assert_eq!(canonical_to_internal("shell"), Some("Bash"));
352        assert_eq!(canonical_to_internal("read"), Some("Read"));
353        assert_eq!(canonical_to_internal("SHELL"), Some("Bash"));
354        assert_eq!(canonical_to_internal("unknown"), None);
355    }
356
357    #[test]
358    fn internal_to_canonical_works() {
359        assert_eq!(internal_to_canonical("Bash"), Some("shell"));
360        assert_eq!(internal_to_canonical("Read"), Some("read"));
361        assert_eq!(internal_to_canonical("UnknownTool"), None);
362    }
363
364    #[test]
365    fn internal_to_agent_works() {
366        assert_eq!(
367            internal_to_agent(AgentKind::Gemini, "Bash"),
368            Some("run_shell_command")
369        );
370        assert_eq!(
371            internal_to_agent(AgentKind::AmazonQ, "Read"),
372            Some("fs_read")
373        );
374        assert_eq!(internal_to_agent(AgentKind::Codex, "Glob"), None);
375    }
376
377    #[test]
378    fn resolve_any_canonical() {
379        assert_eq!(resolve_any_to_internal("shell"), Some("Bash"));
380        assert_eq!(resolve_any_to_internal("read"), Some("Read"));
381    }
382
383    #[test]
384    fn resolve_any_internal() {
385        assert_eq!(resolve_any_to_internal("Bash"), Some("Bash"));
386        assert_eq!(resolve_any_to_internal("bash"), Some("Bash"));
387        assert_eq!(resolve_any_to_internal("BASH"), Some("Bash"));
388    }
389
390    #[test]
391    fn resolve_any_agent_native() {
392        assert_eq!(resolve_any_to_internal("run_shell_command"), Some("Bash"));
393        assert_eq!(resolve_any_to_internal("execute_bash"), Some("Bash"));
394        assert_eq!(resolve_any_to_internal("fs_read"), Some("Read"));
395    }
396
397    #[test]
398    fn resolve_any_unknown() {
399        assert_eq!(resolve_any_to_internal("CustomTool"), None);
400    }
401
402    #[test]
403    fn resolve_mode_claude_default() {
404        assert_eq!(
405            resolve_permission_mode(AgentKind::Claude, "default"),
406            "default"
407        );
408    }
409
410    #[test]
411    fn resolve_mode_claude_plan() {
412        assert_eq!(resolve_permission_mode(AgentKind::Claude, "plan"), "plan");
413    }
414
415    #[test]
416    fn resolve_mode_claude_dangerously_skip() {
417        assert_eq!(
418            resolve_permission_mode(AgentKind::Claude, "dangerously_skip_permissions"),
419            "unrestricted"
420        );
421    }
422
423    #[test]
424    fn resolve_mode_case_insensitive() {
425        assert_eq!(
426            resolve_permission_mode(AgentKind::Claude, "DANGEROUSLY_SKIP_PERMISSIONS"),
427            "unrestricted"
428        );
429    }
430
431    #[test]
432    fn resolve_mode_unknown_passthrough() {
433        assert_eq!(
434            resolve_permission_mode(AgentKind::Claude, "custom_mode"),
435            "custom_mode"
436        );
437    }
438
439    #[test]
440    fn resolve_mode_other_agents_default() {
441        assert_eq!(
442            resolve_permission_mode(AgentKind::Gemini, "default"),
443            "default"
444        );
445        assert_eq!(
446            resolve_permission_mode(AgentKind::Codex, "default"),
447            "default"
448        );
449    }
450
451    #[test]
452    fn all_aliases_have_consistent_internal_names() {
453        let claude_names: Vec<&str> = TOOL_ALIASES
454            .iter()
455            .flat_map(|a| {
456                a.agent_names
457                    .iter()
458                    .filter(|(ak, _)| *ak == AgentKind::Claude)
459            })
460            .map(|(_, name)| *name)
461            .collect();
462        for alias in TOOL_ALIASES {
463            assert!(
464                claude_names.contains(&alias.internal),
465                "internal name '{}' for canonical '{}' is not a Claude tool name",
466                alias.internal,
467                alias.canonical
468            );
469        }
470    }
471}