diff --git a/README.md b/README.md index 85c6c6b..2dc5780 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,10 @@ uvx agent-strace replay ```bash agent-strace setup # Claude Code hooks for ~/.claude/settings.json -agent-strace setup --cli codex # OpenAI Codex hooks for ~/.codex/hooks.json +agent-strace setup --cli codex # OpenAI Codex hooks in ~/.codex/hooks.json agent-strace setup --cli gemini # Gemini CLI extension under ~/.gemini/extensions agent-strace setup --cli cursor # Cursor project hooks in .cursor/hooks.json +agent-strace setup --cli copilot # GitHub Copilot CLI hooks in ~/.copilot/hooks agent-strace list # list sessions agent-strace replay # replay the latest ``` @@ -148,7 +149,7 @@ Install **agent-strace** from the [Extensions panel](https://open-vsx.org/extens ```bash pip install agent-strace # 1. install -agent-strace setup # 2. add hooks to Claude Code; use --cli codex, gemini, or cursor for other CLIs +agent-strace setup # 2. add hooks to Claude Code; use --cli codex, gemini, cursor, or copilot for other CLIs # 3. open project in VS Code — extension activates when .agent-traces/ exists # 4. start Claude Code — status bar appears immediately ``` diff --git a/docs/commands.md b/docs/commands.md index 6398c08..6456b71 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -30,9 +30,9 @@ Capture an MCP HTTP/SSE server session. Listens on `--port` (default: 3100) and ### `setup` ``` -agent-strace setup [--cli claude|codex|gemini|cursor|all] [--no-redact] [--global] +agent-strace setup [--cli claude|codex|gemini|cursor|copilot|all] [--no-redact] [--global] ``` -Print or install hooks config for supported agent CLIs. `--cli claude` prints Claude Code settings JSON for `~/.claude/settings.json`; `--cli codex` prints OpenAI Codex hooks JSON for `~/.codex/hooks.json`; `--cli gemini` writes a Gemini CLI extension under `$GEMINI_CONFIG_DIR/extensions/agent-strace` or `~/.gemini/extensions/agent-strace`; `--cli cursor` writes `.cursor/hooks.json` or `$CURSOR_CONFIG_DIR/hooks.json`; `--cli all` configures all supported CLIs. Secret redaction is enabled by default; use `--no-redact` only for trusted local traces. +Print or install hooks config for supported agent CLIs. `--cli claude` prints Claude Code settings JSON for `~/.claude/settings.json`; `--cli codex` writes `$CODEX_CONFIG_DIR/hooks.json` or `~/.codex/hooks.json`; `--cli gemini` writes a Gemini CLI extension under `$GEMINI_CONFIG_DIR/extensions/agent-strace` or `~/.gemini/extensions/agent-strace`; `--cli cursor` writes `.cursor/hooks.json` or `$CURSOR_CONFIG_DIR/hooks.json`; `--cli copilot` writes `$COPILOT_HOME/hooks/agent-strace.json` or `~/.copilot/hooks/agent-strace.json`; `--cli all` configures all supported CLIs. Secret redaction is enabled by default; use `--no-redact` only for trusted local traces. ### `import` ``` diff --git a/docs/integrations.md b/docs/integrations.md index 8e9e228..50d8901 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -58,6 +58,7 @@ Use setup-generated hooks when the agent CLI has its own lifecycle hook system. | OpenAI Codex | `agent-strace setup --cli codex` | Session start, user prompts, assistant responses, `PreToolUse`/`PostToolUse` tools | | Gemini CLI | `agent-strace setup --cli gemini` | Session start/end, prompts, assistant responses, `BeforeTool`/`AfterTool` tools | | Cursor | `agent-strace setup --cli cursor` | Session start/end, prompts, shell execution, file edits, assistant responses when emitted by Cursor hooks | +| GitHub Copilot CLI | `agent-strace setup --cli copilot` | Session starts, prompts, hook-visible tool calls/results, and stop payloads when emitted by Copilot hooks | All paths write the same event stream under `.agent-traces/`, so replay, timeline, explain, why, watch, export, and audit commands work the same way after capture. diff --git a/docs/setup.md b/docs/setup.md index 225d916..ad5c46b 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -12,7 +12,7 @@ Captures the lifecycle events exposed by each CLI: user prompts, assistant respo # Generate Claude Code hooks config agent-strace setup -# Generate OpenAI Codex hooks config +# Install OpenAI Codex user-level hooks agent-strace setup --cli codex # Install Gemini CLI extension hooks @@ -21,6 +21,9 @@ agent-strace setup --cli gemini # Install Cursor project hooks agent-strace setup --cli cursor +# Install GitHub Copilot CLI user-level hooks +agent-strace setup --cli copilot + # Configure all supported hook integrations agent-strace setup --cli all @@ -57,7 +60,7 @@ agent-strace explain # plain-English summary ### OpenAI Codex hooks -`agent-strace setup --cli codex` prints hooks JSON for `~/.codex/hooks.json`: +`agent-strace setup --cli codex` writes user-level hooks to `$CODEX_CONFIG_DIR/hooks.json` or `~/.codex/hooks.json`, and also prints the JSON: ```json { @@ -135,6 +138,34 @@ Set `CURSOR_CONFIG_DIR` to write the file somewhere else. The generated config r Cursor hook coverage depends on the events Cursor emits. Native hooks capture prompts, shell commands, file edits, and agent responses when available. MCP server tool calls are still captured most reliably through the MCP proxy configuration below. +### GitHub Copilot CLI hooks + +`agent-strace setup --cli copilot` writes user-level Copilot hooks: + +``` +~/.copilot/ +└── hooks/ + └── agent-strace.json +``` + +Set `COPILOT_HOME` to install into a different Copilot config directory. The generated config registers Copilot lifecycle hooks using the VS Code-compatible event names: + +```json +{ + "version": 1, + "hooks": { + "SessionStart": [{ "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot session-start" }] }], + "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot user-prompt" }] }], + "PreToolUse": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot pre-tool" }] }], + "PostToolUse": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot post-tool" }] }], + "PostToolUseFailure": [{ "matcher": ".*", "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot post-tool-failure" }] }], + "AgentStop": [{ "hooks": [{ "type": "command", "command": "agent-strace hook --provider copilot stop" }] }] + } +} +``` + +Copilot sends hook payloads on stdin. agent-strace records session starts, user prompts, and hook-visible tool calls/results. `AgentStop` is registered so sessions can receive stop events when Copilot emits useful payload data; assistant text capture depends on the fields Copilot includes. + ### Import existing sessions Already ran sessions without hooks? Import from Claude Code's native JSONL logs: diff --git a/src/agent_trace/__init__.py b/src/agent_trace/__init__.py index c7f4a3c..37aefb5 100644 --- a/src/agent_trace/__init__.py +++ b/src/agent_trace/__init__.py @@ -1,3 +1,3 @@ """agent-trace: strace for AI agents.""" -__version__ = "0.79.1" +__version__ = "0.80.0" diff --git a/src/agent_trace/cli.py b/src/agent_trace/cli.py index 15ffad4..7635c47 100644 --- a/src/agent_trace/cli.py +++ b/src/agent_trace/cli.py @@ -619,6 +619,54 @@ def _codex_hooks_config(args: argparse.Namespace) -> dict: } +def _copilot_hooks_config(args: argparse.Namespace) -> dict: + cmd_prefix = _hook_command_prefix(args, provider="copilot") + return { + "version": 1, + "hooks": { + "SessionStart": [{ + "hooks": [{ + "type": "command", + "command": f"{cmd_prefix} session-start", + }], + }], + "UserPromptSubmit": [{ + "hooks": [{ + "type": "command", + "command": f"{cmd_prefix} user-prompt", + }], + }], + "PreToolUse": [{ + "matcher": ".*", + "hooks": [{ + "type": "command", + "command": f"{cmd_prefix} pre-tool", + }], + }], + "PostToolUse": [{ + "matcher": ".*", + "hooks": [{ + "type": "command", + "command": f"{cmd_prefix} post-tool", + }], + }], + "PostToolUseFailure": [{ + "matcher": ".*", + "hooks": [{ + "type": "command", + "command": f"{cmd_prefix} post-tool-failure", + }], + }], + "AgentStop": [{ + "hooks": [{ + "type": "command", + "command": f"{cmd_prefix} stop", + }], + }], + } + } + + def _gemini_hooks_config(args: argparse.Namespace) -> dict: cmd_prefix = _hook_command_prefix(args, provider="gemini") return { @@ -730,6 +778,18 @@ def _gemini_config_dir() -> Path: return Path(os.environ.get("GEMINI_CONFIG_DIR", "~/.gemini")).expanduser() +def _codex_config_dir() -> Path: + return Path(os.environ.get("CODEX_CONFIG_DIR", "~/.codex")).expanduser() + + +def _write_codex_hooks_config(args: argparse.Namespace) -> Path: + config_dir = _codex_config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + hooks_path = config_dir / "hooks.json" + hooks_path.write_text(json.dumps(_codex_hooks_config(args), indent=2) + "\n") + return hooks_path + + def _write_gemini_extension(args: argparse.Namespace) -> tuple[Path, Path]: extension_dir = _gemini_config_dir() / "extensions" / "agent-strace" hooks_dir = extension_dir / "hooks" @@ -746,6 +806,10 @@ def _cursor_config_dir() -> Path: return Path(os.environ.get("CURSOR_CONFIG_DIR", ".cursor")).expanduser() +def _copilot_config_dir() -> Path: + return Path(os.environ.get("COPILOT_HOME", "~/.copilot")).expanduser() + + def _write_cursor_hooks_config(args: argparse.Namespace) -> Path: config_dir = _cursor_config_dir() config_dir.mkdir(parents=True, exist_ok=True) @@ -754,6 +818,14 @@ def _write_cursor_hooks_config(args: argparse.Namespace) -> Path: return hooks_path +def _write_copilot_hooks_config(args: argparse.Namespace) -> Path: + hooks_dir = _copilot_config_dir() / "hooks" + hooks_dir.mkdir(parents=True, exist_ok=True) + hooks_path = hooks_dir / "agent-strace.json" + hooks_path.write_text(json.dumps(_copilot_hooks_config(args), indent=2) + "\n") + return hooks_path + + def cmd_setup(args: argparse.Namespace) -> None: """Generate hooks configuration for supported agent CLIs.""" cli = getattr(args, "cli", "claude") or "claude" @@ -762,7 +834,9 @@ def cmd_setup(args: argparse.Namespace) -> None: if cli in ("claude", "all"): configs.append(("Claude Code", "~/.claude/settings.json", _claude_hooks_config(args))) if cli in ("codex", "all"): - configs.append(("OpenAI Codex", "~/.codex/hooks.json", _codex_hooks_config(args))) + hooks_path = _write_codex_hooks_config(args) + sys.stderr.write(f"Wrote OpenAI Codex hooks config: {hooks_path}\n") + configs.append(("OpenAI Codex", str(hooks_path), _codex_hooks_config(args))) if cli in ("gemini", "all"): manifest_path, hooks_path = _write_gemini_extension(args) sys.stderr.write( @@ -772,11 +846,17 @@ def cmd_setup(args: argparse.Namespace) -> None: if cli in ("cursor", "all"): hooks_path = _write_cursor_hooks_config(args) sys.stderr.write(f"Wrote Cursor hooks config: {hooks_path}\n") + if cli in ("copilot", "all"): + hooks_path = _write_copilot_hooks_config(args) + sys.stderr.write(f"Wrote GitHub Copilot hooks config: {hooks_path}\n") for idx, (name, path, config) in enumerate(configs): if idx: sys.stdout.write("\n") - sys.stderr.write(f"Add this to {path} for {name}:\n\n") + if name == "OpenAI Codex": + sys.stderr.write(f"Installed {name} hooks at {path}; JSON:\n\n") + else: + sys.stderr.write(f"Add this to {path} for {name}:\n\n") sys.stdout.write(json.dumps(config, indent=2) + "\n") if cli in ("codex", "all"): @@ -791,6 +871,8 @@ def cmd_setup(args: argparse.Namespace) -> None: sys.stdout.write(json.dumps(_gemini_hooks_config(args), indent=2) + "\n") if cli == "cursor": sys.stdout.write(json.dumps(_cursor_hooks_config(args), indent=2) + "\n") + if cli == "copilot": + sys.stdout.write(json.dumps(_copilot_hooks_config(args), indent=2) + "\n") sys.stderr.write( "\nThis captures hook-visible agent sessions: user prompts, assistant " @@ -937,7 +1019,7 @@ def build_parser() -> argparse.ArgumentParser: # hook (called by agent CLI hooks systems) p_hook = sub.add_parser("hook", help="handle an agent CLI hook event (internal)") - p_hook.add_argument("--provider", choices=["claude", "codex", "gemini", "cursor"], default="claude", + p_hook.add_argument("--provider", choices=["claude", "codex", "gemini", "cursor", "copilot"], default="claude", help="hook provider (default: claude)") p_hook.add_argument("event", nargs="?", help="hook event: session-start, session-end, pre-tool, post-tool, post-tool-failure") @@ -955,7 +1037,7 @@ def build_parser() -> argparse.ArgumentParser: help="disable automatic secret redaction in generated hooks", ) p_setup.add_argument("--global", dest="global_config", action="store_true", help="output config for ~/.claude/settings.json (all projects)") - p_setup.add_argument("--cli", choices=["claude", "codex", "gemini", "cursor", "all"], default="claude", + p_setup.add_argument("--cli", choices=["claude", "codex", "gemini", "cursor", "copilot", "all"], default="claude", help="agent CLI to configure (default: claude)") # import (Claude Code JSONL session logs) diff --git a/src/agent_trace/hooks.py b/src/agent_trace/hooks.py index e901f54..b6ab2eb 100644 --- a/src/agent_trace/hooks.py +++ b/src/agent_trace/hooks.py @@ -1,8 +1,8 @@ """Agent CLI hooks integration. -Captures every tool call supported CLIs make, not just MCP calls. Uses the -Claude Code and OpenAI Codex hooks systems to trace Bash, Edit, Write, Read, -Agent, apply_patch, and all other hook-visible tools. +Captures hook-visible tool calls from supported agent CLIs, not just MCP +calls. Uses provider hook systems such as Claude Code, OpenAI Codex, Gemini, +Cursor, and GitHub Copilot. Usage: # In .claude/settings.json or ~/.claude/settings.json: @@ -63,12 +63,14 @@ _CODEX_SESSION_ID_ENV = "AGENT_TRACE_CODEX_SESSION_ID" _GEMINI_SESSION_ID_ENV = "AGENT_TRACE_GEMINI_SESSION_ID" _CURSOR_SESSION_ID_ENV = "AGENT_TRACE_CURSOR_SESSION_ID" +_COPILOT_SESSION_ID_ENV = "AGENT_TRACE_COPILOT_SESSION_ID" _PROVIDER_ENV = { "claude": _CLAUDE_SESSION_ID_ENV, "codex": _CODEX_SESSION_ID_ENV, "gemini": _GEMINI_SESSION_ID_ENV, "cursor": _CURSOR_SESSION_ID_ENV, + "copilot": _COPILOT_SESSION_ID_ENV, } _PROVIDER_AGENT = { @@ -76,6 +78,7 @@ "codex": "openai-codex", "gemini": "gemini-cli", "cursor": "cursor-agent", + "copilot": "github-copilot", } @@ -206,15 +209,25 @@ def _should_redact() -> bool: def _normalise_payload(input_data: dict, provider: str, event: str) -> dict: """Map provider-specific hook payloads to the Claude-shaped fields.""" data = dict(input_data) - if provider not in ("codex", "gemini", "cursor"): + if provider not in ("codex", "gemini", "cursor", "copilot"): return data + data.setdefault("session_id", data.get("sessionId") or "") + data.setdefault("turn_id", data.get("turnId") or "") + data.setdefault("tool_use_id", data.get("toolUseId") or "") + if event in {"pre-tool", "post-tool", "post-tool-failure"}: tool = data.get("tool") if isinstance(tool, dict): data.setdefault("tool_name", tool.get("name") or tool.get("tool_name") or "") data.setdefault("tool_input", tool.get("input") or tool.get("arguments") or {}) data.setdefault("tool_output", tool.get("output") or tool.get("response") or "") + if data.get("toolName") and not data.get("tool_name"): + data["tool_name"] = data.get("toolName") + if data.get("toolArgs") is not None and "tool_input" not in data: + data["tool_input"] = data.get("toolArgs") + if (data.get("toolResult") is not None or data.get("textResultForLlm") is not None) and "tool_output" not in data: + data["tool_output"] = data.get("toolResult", data.get("textResultForLlm", "")) command = data.get("command") if command and not data.get("tool_name"): data.setdefault("tool_name", "shell") @@ -227,13 +240,13 @@ def _normalise_payload(input_data: dict, provider: str, event: str) -> dict: data.setdefault("tool_input", data.get("input") or data.get("arguments") or {}) data.setdefault("tool_output", data.get("tool_response", data.get("output", ""))) - if provider in ("gemini", "cursor"): + if provider in ("gemini", "cursor", "copilot"): if event == "session-start": data.setdefault("source", data.get("hook_event_name", "startup")) if event == "user-prompt": input_value = data.get("input", {}) prompt = input_value.get("prompt", "") if isinstance(input_value, dict) else input_value - data.setdefault("prompt", data.get("user_prompt") or data.get("prompt") or prompt or "") + data.setdefault("prompt", data.get("user_prompt") or data.get("prompt") or data.get("initialPrompt") or prompt or "") if event == "stop": data.setdefault("last_assistant_message", data.get("prompt_response", "")) @@ -461,7 +474,7 @@ def handle_post_tool(input_data: dict, failed: bool = False, provider: str = "cl tool_name = input_data.get("tool_name", "unknown") tool_output = input_data.get("tool_output", input_data.get("tool_response", "")) - if provider in ("codex", "gemini", "cursor") and not failed: + if provider in ("codex", "gemini", "cursor", "copilot") and not failed: if isinstance(tool_output, dict): exit_code = tool_output.get("exit_code") failed = ( @@ -551,7 +564,7 @@ def hook_main(args: list[str]) -> None: sys.exit(1) if not rest: - sys.stderr.write("Usage: agent-strace hook [--provider claude|codex|gemini|cursor] \n") + sys.stderr.write("Usage: agent-strace hook [--provider claude|codex|gemini|cursor|copilot] \n") sys.exit(1) aliases = { @@ -567,6 +580,14 @@ def hook_main(args: list[str]) -> None: "after-shell-execution": "post-tool", "after-file-edit": "file-write", "after-agent-response": "stop", + "SessionStart": "session-start", + "SessionEnd": "session-end", + "UserPromptSubmit": "user-prompt", + "PreToolUse": "pre-tool", + "PostToolUse": "post-tool", + "PostToolUseFailure": "post-tool-failure", + "AgentStop": "stop", + "agent-stop": "stop", } event = aliases.get(rest[0], rest[0]) input_data = _normalise_payload(_read_stdin(), provider, event) diff --git a/tests/test_codex_hooks.py b/tests/test_codex_hooks.py index 03e15f3..c415a58 100644 --- a/tests/test_codex_hooks.py +++ b/tests/test_codex_hooks.py @@ -7,6 +7,7 @@ import sys import tempfile import unittest +from pathlib import Path from unittest.mock import patch from agent_trace.cli import cmd_setup @@ -158,6 +159,19 @@ def test_hook_main_requires_event_after_provider(self): class TestCodexSetup(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + os.environ["CODEX_CONFIG_DIR"] = self.tmpdir + self.gemini_dir = tempfile.mkdtemp() + os.environ["GEMINI_CONFIG_DIR"] = self.gemini_dir + self.copilot_home = tempfile.mkdtemp() + os.environ["COPILOT_HOME"] = self.copilot_home + + def tearDown(self): + os.environ.pop("CODEX_CONFIG_DIR", None) + os.environ.pop("GEMINI_CONFIG_DIR", None) + os.environ.pop("COPILOT_HOME", None) + def test_setup_cli_codex_outputs_hooks_json(self): args = argparse.Namespace( redact=False, @@ -172,7 +186,10 @@ def test_setup_cli_codex_outputs_hooks_json(self): cmd_setup(args) config = json.loads(out.getvalue()) + written_config = json.loads((Path(self.tmpdir) / "hooks.json").read_text()) err_text = err.getvalue() + self.assertEqual(written_config, config) + self.assertIn("Wrote OpenAI Codex hooks config", err_text) self.assertIn("~/.codex/hooks.json", err_text) self.assertIn("Codex hook checklist", err_text) self.assertIn("~/.codex/hooks/hooks.json", err_text) @@ -203,8 +220,11 @@ def test_setup_cli_all_outputs_claude_and_codex_sections(self): text = out.getvalue() self.assertIn("agent-strace hook user-prompt", text) self.assertIn("agent-strace hook --provider codex user-prompt", text) + self.assertTrue((Path(self.tmpdir) / "hooks.json").exists()) + self.assertTrue((Path(self.copilot_home) / "hooks" / "agent-strace.json").exists()) self.assertIn("~/.claude/settings.json", err.getvalue()) self.assertIn("~/.codex/hooks.json", err.getvalue()) + self.assertIn("GitHub Copilot hooks config", err.getvalue()) self.assertIn("Codex hook checklist", err.getvalue()) diff --git a/tests/test_copilot_hooks.py b/tests/test_copilot_hooks.py new file mode 100644 index 0000000..859ab2d --- /dev/null +++ b/tests/test_copilot_hooks.py @@ -0,0 +1,120 @@ +"""Tests for GitHub Copilot hooks integration.""" + +import argparse +import io +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from agent_trace.cli import cmd_setup +from agent_trace.hooks import _read_active_session, hook_main +from agent_trace.models import EventType +from agent_trace.store import TraceStore + + +class TestCopilotHooks(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + os.environ["AGENT_TRACE_DIR"] = self.tmpdir + os.environ.pop("AGENT_TRACE_COPILOT_SESSION_ID", None) + os.environ.pop("AGENT_TRACE_REDACT", None) + + def tearDown(self): + os.environ.pop("AGENT_TRACE_DIR", None) + os.environ.pop("AGENT_TRACE_COPILOT_SESSION_ID", None) + + def test_copilot_hook_main_normalizes_camel_case_payloads(self): + start_payload = json.dumps({ + "sessionId": "copilotsession123456", + "source": "startup", + "model": "copilot", + }) + with patch.object(sys, "stdin", io.StringIO(start_payload)): + hook_main(["--provider", "copilot", "session-start"]) + + session_id = _read_active_session(provider="copilot") + + prompt_payload = json.dumps({ + "sessionId": "copilotsession123456", + "turnId": "turn_1", + "initialPrompt": "Run the checks", + }) + with patch.object(sys, "stdin", io.StringIO(prompt_payload)): + hook_main(["--provider", "copilot", "user-prompt"]) + + tool_payload = json.dumps({ + "sessionId": "copilotsession123456", + "turnId": "turn_1", + "toolUseId": "tool_1", + "toolName": "terminal", + "toolArgs": {"command": "pytest"}, + }) + with patch.object(sys, "stdin", io.StringIO(tool_payload)): + hook_main(["--provider", "copilot", "pre-tool"]) + + result_payload = json.dumps({ + "sessionId": "copilotsession123456", + "toolUseId": "tool_1", + "toolName": "terminal", + "toolResult": {"exit_code": 0, "output": "ok"}, + }) + with patch.object(sys, "stdin", io.StringIO(result_payload)): + hook_main(["--provider", "copilot", "post-tool"]) + + store = TraceStore(self.tmpdir) + meta = store.load_meta(session_id) + events = store.load_events(session_id) + prompts = [event for event in events if event.event_type == EventType.USER_PROMPT] + calls = [event for event in events if event.event_type == EventType.TOOL_CALL] + results = [event for event in events if event.event_type == EventType.TOOL_RESULT] + + self.assertEqual(meta.agent_name, "github-copilot") + self.assertEqual(events[0].data["provider"], "copilot") + self.assertEqual(prompts[0].data["prompt"], "Run the checks") + self.assertEqual(calls[0].data["tool_name"], "terminal") + self.assertEqual(calls[0].data["arguments"]["command"], "pytest") + self.assertEqual(results[0].parent_id, calls[0].event_id) + + +class TestCopilotSetup(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + os.environ["COPILOT_HOME"] = self.tmpdir + + def tearDown(self): + os.environ.pop("COPILOT_HOME", None) + + def test_setup_cli_copilot_writes_user_hooks_file(self): + args = argparse.Namespace( + redact=False, + no_redact=False, + global_config=False, + cli="copilot", + ) + + out = io.StringIO() + err = io.StringIO() + with patch.object(sys, "stdout", out), patch.object(sys, "stderr", err): + cmd_setup(args) + + hooks_path = Path(self.tmpdir) / "hooks" / "agent-strace.json" + hooks = json.loads(hooks_path.read_text()) + printed_hooks = json.loads(out.getvalue()) + + self.assertEqual(printed_hooks, hooks) + self.assertEqual(hooks["version"], 1) + self.assertIn("SessionStart", hooks["hooks"]) + self.assertIn("PostToolUseFailure", hooks["hooks"]) + self.assertEqual( + hooks["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"], + "agent-strace hook --provider copilot user-prompt", + ) + self.assertIn("GitHub Copilot hooks config", err.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_cursor_hooks.py b/tests/test_cursor_hooks.py index 4120bb4..a2a9c7f 100644 --- a/tests/test_cursor_hooks.py +++ b/tests/test_cursor_hooks.py @@ -143,9 +143,18 @@ class TestCursorSetup(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() os.environ["CURSOR_CONFIG_DIR"] = self.tmpdir + self.codex_dir = tempfile.mkdtemp() + os.environ["CODEX_CONFIG_DIR"] = self.codex_dir + self.gemini_dir = tempfile.mkdtemp() + os.environ["GEMINI_CONFIG_DIR"] = self.gemini_dir + self.copilot_home = tempfile.mkdtemp() + os.environ["COPILOT_HOME"] = self.copilot_home def tearDown(self): os.environ.pop("CURSOR_CONFIG_DIR", None) + os.environ.pop("CODEX_CONFIG_DIR", None) + os.environ.pop("GEMINI_CONFIG_DIR", None) + os.environ.pop("COPILOT_HOME", None) def test_setup_cli_cursor_writes_hooks_json(self): args = argparse.Namespace( @@ -189,6 +198,7 @@ def test_setup_cli_all_includes_cursor(self): cmd_setup(args) self.assertTrue((Path(self.tmpdir) / "hooks.json").exists()) + self.assertTrue((Path(self.copilot_home) / "hooks" / "agent-strace.json").exists()) self.assertIn("agent-strace hook --provider codex user-prompt", out.getvalue()) self.assertIn("Cursor hooks config", err.getvalue()) diff --git a/tests/test_gemini_hooks.py b/tests/test_gemini_hooks.py index a7b1827..48a2660 100644 --- a/tests/test_gemini_hooks.py +++ b/tests/test_gemini_hooks.py @@ -170,9 +170,18 @@ class TestGeminiSetup(unittest.TestCase): def setUp(self): self.tmpdir = tempfile.mkdtemp() os.environ["GEMINI_CONFIG_DIR"] = self.tmpdir + self.codex_dir = tempfile.mkdtemp() + os.environ["CODEX_CONFIG_DIR"] = self.codex_dir + self.cursor_dir = tempfile.mkdtemp() + os.environ["CURSOR_CONFIG_DIR"] = self.cursor_dir + self.copilot_home = tempfile.mkdtemp() + os.environ["COPILOT_HOME"] = self.copilot_home def tearDown(self): os.environ.pop("GEMINI_CONFIG_DIR", None) + os.environ.pop("CODEX_CONFIG_DIR", None) + os.environ.pop("CURSOR_CONFIG_DIR", None) + os.environ.pop("COPILOT_HOME", None) def test_setup_cli_gemini_writes_extension_files(self): args = argparse.Namespace( @@ -221,8 +230,12 @@ def test_setup_cli_all_includes_gemini_extension(self): extension_dir = Path(self.tmpdir) / "extensions" / "agent-strace" self.assertTrue((extension_dir / "gemini-extension.json").exists()) self.assertTrue((extension_dir / "hooks" / "hooks.json").exists()) + self.assertTrue((Path(self.codex_dir) / "hooks.json").exists()) + self.assertTrue((Path(self.cursor_dir) / "hooks.json").exists()) + self.assertTrue((Path(self.copilot_home) / "hooks" / "agent-strace.json").exists()) self.assertIn("agent-strace hook --provider codex user-prompt", out.getvalue()) self.assertIn("Gemini CLI extension", err.getvalue()) + self.assertIn("GitHub Copilot hooks config", err.getvalue()) if __name__ == "__main__":