diff --git a/clash_starlark/stdlib/builtin.star b/clash_starlark/stdlib/builtin.star index e105ffd..d34ab63 100644 --- a/clash_starlark/stdlib/builtin.star +++ b/clash_starlark/stdlib/builtin.star @@ -36,6 +36,7 @@ claude = { "AskUserQuestion", "EnterPlanMode", "ExitPlanMode", + "ReportIntent", "Skill", "ToolSearch", "EnterWorktree", diff --git a/coding-agent-hooks/src/agents/copilot.rs b/coding-agent-hooks/src/agents/copilot.rs index 755d04b..92f747c 100644 --- a/coding-agent-hooks/src/agents/copilot.rs +++ b/coding-agent-hooks/src/agents/copilot.rs @@ -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; @@ -17,48 +18,66 @@ impl HookProtocol for CopilotProtocol { } fn parse_tool_use(&self, raw: &Value) -> Result { - 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 { - 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 } } @@ -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", @@ -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"); } } diff --git a/coding-agent-hooks/src/agents/mod.rs b/coding-agent-hooks/src/agents/mod.rs index 7dd2858..90d4d20 100644 --- a/coding-agent-hooks/src/agents/mod.rs +++ b/coding-agent-hooks/src/agents/mod.rs @@ -161,6 +161,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[ (AgentKind::Gemini, "write_file"), (AgentKind::AmazonQ, "fs_write"), (AgentKind::OpenCode, "write"), + (AgentKind::Copilot, "create"), ], }, ToolAlias { @@ -180,6 +181,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[ (AgentKind::Claude, "Glob"), (AgentKind::Gemini, "glob"), (AgentKind::OpenCode, "glob"), + (AgentKind::Copilot, "glob"), ], }, ToolAlias { @@ -189,6 +191,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[ (AgentKind::Claude, "Grep"), (AgentKind::Gemini, "grep_search"), (AgentKind::OpenCode, "grep"), + (AgentKind::Copilot, "grep"), ], }, ToolAlias { @@ -198,6 +201,7 @@ const TOOL_ALIASES: &[ToolAlias] = &[ (AgentKind::Claude, "WebFetch"), (AgentKind::Gemini, "web_fetch"), (AgentKind::OpenCode, "webfetch"), + (AgentKind::Copilot, "web_fetch"), ], }, ToolAlias { @@ -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"), ], }, ]; @@ -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",