Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions clash_starlark/stdlib/builtin.star
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ claude = {
"AskUserQuestion",
"EnterPlanMode",
"ExitPlanMode",
"ReportIntent",
"Skill",
"ToolSearch",
"EnterWorktree",
Expand Down
92 changes: 67 additions & 25 deletions coding-agent-hooks/src/agents/copilot.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
//! GitHub Copilot CLI hook protocol implementation.
//!
//! Copilot uses "approve"/"deny" decisions.
//! Copilot CLI uses `permissionDecision` with values "allow"/"deny"/"ask",
//! matching the same protocol as Claude Code hooks.

use anyhow::Result;
use serde_json::Value;

use super::{AgentKind, resolve_tool_name};
use crate::input::ToolUseHookInput;
use crate::protocol::{HookProtocol, json_str};
use crate::protocol::{HookProtocol, json_str_any, json_value_any};

pub struct CopilotProtocol;

Expand All @@ -17,48 +18,66 @@ impl HookProtocol for CopilotProtocol {
}

fn parse_tool_use(&self, raw: &Value) -> Result<ToolUseHookInput> {
let tool_name = json_str(raw, "tool_name").to_string();
// Copilot CLI sends camelCase fields (toolName, sessionId, toolArgs)
// while Claude Code sends snake_case (tool_name, session_id, tool_input).
// Accept both forms for robustness.
let tool_name = json_str_any(raw, &["toolName", "tool_name"]).to_string();
let original = tool_name.clone();
let resolved = resolve_tool_name(AgentKind::Copilot, &tool_name).to_string();

// Copilot sends toolArgs as a JSON string; parse it into a Value.
let tool_input = json_value_any(raw, &["tool_input", "toolArgs"])
.and_then(|v| match v {
Value::String(s) => serde_json::from_str(&s).ok(),
other => Some(other),
})
.unwrap_or(Value::Object(serde_json::Map::new()));

Ok(ToolUseHookInput {
session_id: json_str(raw, "session_id").to_string(),
transcript_path: json_str(raw, "transcript_path").to_string(),
cwd: json_str(raw, "cwd").to_string(),
session_id: json_str_any(raw, &["sessionId", "session_id"]).to_string(),
transcript_path: json_str_any(raw, &["transcript_path"]).to_string(),
cwd: json_str_any(raw, &["cwd"]).to_string(),
permission_mode: "default".to_string(),
hook_event_name: json_str(raw, "hook_event_name").to_string(),
hook_event_name: json_str_any(raw, &["hook_event_name"]).to_string(),
tool_name: resolved,
tool_input: raw
.get("tool_input")
.cloned()
.unwrap_or(Value::Object(serde_json::Map::new())),
tool_use_id: None,
tool_response: raw.get("tool_response").cloned(),
tool_input,
tool_use_id: raw
.get("toolCallId")
.and_then(|v| v.as_str())
.map(String::from),
tool_response: json_value_any(raw, &["tool_response", "toolResponse"]),
agent: Some(AgentKind::Copilot),
original_tool_name: Some(original),
})
}

// Copilot uses "approve"/"deny"
// Copilot CLI uses the same permissionDecision protocol as Claude Code.
fn format_allow(
&self,
reason: Option<&str>,
_context: Option<&str>,
_updated_input: Option<Value>,
) -> Value {
let mut output = serde_json::json!({ "decision": "approve" });
let mut output = serde_json::json!({ "permissionDecision": "allow" });
if let Some(r) = reason {
output["reason"] = Value::String(r.to_string());
output["permissionDecisionReason"] = Value::String(r.to_string());
}
output
}

fn format_deny(&self, reason: &str, _context: Option<&str>) -> Value {
serde_json::json!({ "decision": "deny", "reason": reason })
serde_json::json!({
"permissionDecision": "deny",
"permissionDecisionReason": reason
})
}

fn format_ask(&self, _reason: Option<&str>, _context: Option<&str>) -> Value {
serde_json::json!({ "decision": "approve" })
fn format_ask(&self, reason: Option<&str>, _context: Option<&str>) -> Value {
let mut output = serde_json::json!({ "permissionDecision": "ask" });
if let Some(r) = reason {
output["permissionDecisionReason"] = Value::String(r.to_string());
}
output
}
}

Expand All @@ -67,7 +86,7 @@ mod tests {
use super::*;

#[test]
fn parse_copilot_bash() {
fn parse_copilot_bash_snake_case() {
let raw = serde_json::json!({
"session_id": "cp-123",
"cwd": "/home/user",
Expand All @@ -77,18 +96,41 @@ mod tests {
});
let input = CopilotProtocol.parse_tool_use(&raw).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.session_id, "cp-123");
}

#[test]
fn parse_copilot_bash_camel_case() {
let raw = serde_json::json!({
"sessionId": "cp-456",
"cwd": "/home/user",
"toolName": "bash",
"toolArgs": "{\"command\":\"echo hello\"}"
});
let input = CopilotProtocol.parse_tool_use(&raw).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.session_id, "cp-456");
// toolArgs is a JSON string that should be parsed into tool_input
assert_eq!(input.tool_input["command"], "echo hello");
}

#[test]
fn format_allow_copilot() {
assert_eq!(
CopilotProtocol.format_allow(None, None, None)["decision"],
"approve"
);
let out = CopilotProtocol.format_allow(None, None, None);
assert_eq!(out["permissionDecision"], "allow");
}

#[test]
fn format_deny_copilot() {
assert_eq!(CopilotProtocol.format_deny("no", None)["decision"], "deny");
let out = CopilotProtocol.format_deny("blocked", None);
assert_eq!(out["permissionDecision"], "deny");
assert_eq!(out["permissionDecisionReason"], "blocked");
}

#[test]
fn format_ask_copilot() {
let out = CopilotProtocol.format_ask(Some("needs approval"), None);
assert_eq!(out["permissionDecision"], "ask");
assert_eq!(out["permissionDecisionReason"], "needs approval");
}
}
72 changes: 72 additions & 0 deletions coding-agent-hooks/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[
(AgentKind::Gemini, "write_file"),
(AgentKind::AmazonQ, "fs_write"),
(AgentKind::OpenCode, "write"),
(AgentKind::Copilot, "create"),
],
},
ToolAlias {
Expand All @@ -180,6 +181,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[
(AgentKind::Claude, "Glob"),
(AgentKind::Gemini, "glob"),
(AgentKind::OpenCode, "glob"),
(AgentKind::Copilot, "glob"),
],
},
ToolAlias {
Expand All @@ -189,6 +191,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[
(AgentKind::Claude, "Grep"),
(AgentKind::Gemini, "grep_search"),
(AgentKind::OpenCode, "grep"),
(AgentKind::Copilot, "grep"),
],
},
ToolAlias {
Expand All @@ -198,6 +201,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[
(AgentKind::Claude, "WebFetch"),
(AgentKind::Gemini, "web_fetch"),
(AgentKind::OpenCode, "webfetch"),
(AgentKind::Copilot, "web_fetch"),
],
},
ToolAlias {
Expand All @@ -208,6 +212,65 @@ const TOOL_ALIASES: &[ToolAlias] = &[
(AgentKind::Gemini, "google_web_search"),
(AgentKind::Codex, "web_search"),
(AgentKind::OpenCode, "websearch"),
(AgentKind::Copilot, "web_search"),
],
},
ToolAlias {
canonical: "ask",
internal: "AskUserQuestion",
agent_names: &[
(AgentKind::Claude, "AskUserQuestion"),
(AgentKind::Copilot, "ask_user"),
],
},
ToolAlias {
canonical: "report_intent",
internal: "ReportIntent",
agent_names: &[(AgentKind::Copilot, "report_intent")],
},
ToolAlias {
canonical: "task_output",
internal: "TaskOutput",
agent_names: &[
(AgentKind::Claude, "TaskOutput"),
(AgentKind::Copilot, "task_complete"),
],
},
ToolAlias {
canonical: "task_create",
internal: "TaskCreate",
agent_names: &[
(AgentKind::Claude, "TaskCreate"),
(AgentKind::Copilot, "task"),
],
},
ToolAlias {
canonical: "task_get",
internal: "TaskGet",
agent_names: &[
(AgentKind::Claude, "TaskGet"),
(AgentKind::Copilot, "read_agent"),
],
},
ToolAlias {
canonical: "task_list",
internal: "TaskList",
agent_names: &[
(AgentKind::Claude, "TaskList"),
(AgentKind::Copilot, "list_agents"),
],
},
ToolAlias {
canonical: "task_stop",
internal: "TaskStop",
agent_names: &[(AgentKind::Claude, "TaskStop")],
},
ToolAlias {
canonical: "task_update",
internal: "TaskUpdate",
agent_names: &[
(AgentKind::Claude, "TaskUpdate"),
(AgentKind::Copilot, "write_agent"),
],
},
];
Expand Down Expand Up @@ -460,6 +523,15 @@ mod tests {
.map(|(_, name)| *name)
.collect();
for alias in TOOL_ALIASES {
let has_claude_entry = alias
.agent_names
.iter()
.any(|(ak, _)| *ak == AgentKind::Claude);
// Aliases that don't have a Claude entry are agent-specific
// (e.g. ReportIntent is Copilot-only) and don't need this check.
if !has_claude_entry {
continue;
}
assert!(
claude_names.contains(&alias.internal),
"internal name '{}' for canonical '{}' is not a Claude tool name",
Expand Down