diff --git a/README.md b/README.md index 9814d0f..85c6c6b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ uvx agent-strace replay 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 gemini # Gemini CLI extension under ~/.gemini/extensions +agent-strace setup --cli cursor # Cursor project hooks in .cursor/hooks.json agent-strace list # list sessions agent-strace replay # replay the latest ``` @@ -147,7 +148,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 or --cli gemini for other CLIs +agent-strace setup # 2. add hooks to Claude Code; use --cli codex, gemini, or cursor 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 0757d13..6398c08 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|all] [--no-redact] [--global] +agent-strace setup [--cli claude|codex|gemini|cursor|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 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` 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. ### `import` ``` diff --git a/docs/integrations.md b/docs/integrations.md index 06eb7c8..8e9e228 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -57,6 +57,7 @@ Use setup-generated hooks when the agent CLI has its own lifecycle hook system. | Claude Code | `agent-strace setup --cli claude` | Session start/end, user prompts, assistant responses, tool calls/results | | 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 | 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 2f48c2c..a031aad 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -6,7 +6,7 @@ Three ways to capture agent sessions. Pick the one that matches your agent. ## Option 1: CLI hooks (recommended) -Captures everything: user prompts, assistant responses, and every tool call (Bash, Edit, Write, Read, Agent, Grep, Glob, WebFetch, WebSearch, all MCP tools). +Captures the lifecycle events exposed by each CLI: user prompts, assistant responses, and hook-visible tool calls or edits. Claude Code exposes broad tool coverage; Cursor coverage depends on the native events Cursor emits. ```bash # Generate Claude Code hooks config @@ -18,6 +18,9 @@ agent-strace setup --cli codex # Install Gemini CLI extension hooks agent-strace setup --cli gemini +# Install Cursor project hooks +agent-strace setup --cli cursor + # Configure all supported hook integrations agent-strace setup --cli all @@ -98,6 +101,34 @@ Set `GEMINI_CONFIG_DIR` to install into a different Gemini config directory. The Gemini sends one JSON object to each command hook on stdin. agent-strace records `session_id`, `tool_name`, `tool_input`, `tool_response`, `prompt`, and `prompt_response` into the same `.agent-traces/` session store used by Claude Code and Codex. +### Cursor hooks + +`agent-strace setup --cli cursor` writes a project-local Cursor hooks file: + +``` +.cursor/ +└── hooks.json +``` + +Set `CURSOR_CONFIG_DIR` to write the file somewhere else. The generated config registers Cursor-native prompt, shell, file-edit, assistant-response, session-start, and session-end command hooks: + +```json +{ + "version": 1, + "hooks": { + "sessionStart": [{ "type": "command", "command": "agent-strace hook --provider cursor session-start" }], + "beforeSubmitPrompt": [{ "type": "command", "command": "agent-strace hook --provider cursor before-submit-prompt" }], + "beforeShellExecution": [{ "type": "command", "command": "agent-strace hook --provider cursor before-shell-execution" }], + "afterShellExecution": [{ "type": "command", "command": "agent-strace hook --provider cursor after-shell-execution" }], + "afterFileEdit": [{ "type": "command", "command": "agent-strace hook --provider cursor after-file-edit" }], + "afterAgentResponse": [{ "type": "command", "command": "agent-strace hook --provider cursor after-agent-response" }], + "sessionEnd": [{ "type": "command", "command": "agent-strace hook --provider cursor session-end" }] + } +} +``` + +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. + ### 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 9a3d3c0..e2a8e25 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.78.0" +__version__ = "0.79.0" diff --git a/src/agent_trace/cli.py b/src/agent_trace/cli.py index 4c4e7c3..0972080 100644 --- a/src/agent_trace/cli.py +++ b/src/agent_trace/cli.py @@ -681,6 +681,43 @@ def _gemini_hooks_config(args: argparse.Namespace) -> dict: } +def _cursor_hooks_config(args: argparse.Namespace) -> dict: + cmd_prefix = _hook_command_prefix(args, provider="cursor") + return { + "version": 1, + "hooks": { + "sessionStart": [{ + "type": "command", + "command": f"{cmd_prefix} session-start", + }], + "beforeSubmitPrompt": [{ + "type": "command", + "command": f"{cmd_prefix} before-submit-prompt", + }], + "beforeShellExecution": [{ + "type": "command", + "command": f"{cmd_prefix} before-shell-execution", + }], + "afterShellExecution": [{ + "type": "command", + "command": f"{cmd_prefix} after-shell-execution", + }], + "afterFileEdit": [{ + "type": "command", + "command": f"{cmd_prefix} after-file-edit", + }], + "afterAgentResponse": [{ + "type": "command", + "command": f"{cmd_prefix} after-agent-response", + }], + "sessionEnd": [{ + "type": "command", + "command": f"{cmd_prefix} session-end", + }], + }, + } + + def _gemini_extension_manifest() -> dict: return { "name": "agent-strace", @@ -705,6 +742,18 @@ def _write_gemini_extension(args: argparse.Namespace) -> tuple[Path, Path]: return manifest_path, hooks_path +def _cursor_config_dir() -> Path: + return Path(os.environ.get("CURSOR_CONFIG_DIR", ".cursor")).expanduser() + + +def _write_cursor_hooks_config(args: argparse.Namespace) -> Path: + config_dir = _cursor_config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + hooks_path = config_dir / "hooks.json" + hooks_path.write_text(json.dumps(_cursor_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" @@ -720,6 +769,9 @@ def cmd_setup(args: argparse.Namespace) -> None: f"Wrote Gemini CLI extension manifest: {manifest_path}\n" f"Wrote Gemini CLI hooks config: {hooks_path}\n" ) + if cli in ("cursor", "all"): + hooks_path = _write_cursor_hooks_config(args) + sys.stderr.write(f"Wrote Cursor hooks config: {hooks_path}\n") for idx, (name, path, config) in enumerate(configs): if idx: @@ -729,10 +781,14 @@ def cmd_setup(args: argparse.Namespace) -> None: if cli == "gemini": 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") sys.stderr.write( - "\nThis captures full agent sessions: user prompts, assistant " - "responses, and hook-visible tool calls.\n" + "\nThis captures hook-visible agent sessions: user prompts, assistant " + "responses, and tool calls or edits exposed by the provider.\n" + "For Cursor, MCP proxy tracing still captures MCP calls; native hooks " + "capture only the prompt, shell, file-edit, and response events Cursor emits.\n" "Replay with: agent-strace replay\n" ) @@ -873,7 +929,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"], default="claude", + p_hook.add_argument("--provider", choices=["claude", "codex", "gemini", "cursor"], 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") @@ -891,7 +947,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", "all"], default="claude", + p_setup.add_argument("--cli", choices=["claude", "codex", "gemini", "cursor", "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 de238f0..e901f54 100644 --- a/src/agent_trace/hooks.py +++ b/src/agent_trace/hooks.py @@ -62,17 +62,20 @@ _CLAUDE_SESSION_ID_ENV = "AGENT_TRACE_CLAUDE_SESSION_ID" _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" _PROVIDER_ENV = { "claude": _CLAUDE_SESSION_ID_ENV, "codex": _CODEX_SESSION_ID_ENV, "gemini": _GEMINI_SESSION_ID_ENV, + "cursor": _CURSOR_SESSION_ID_ENV, } _PROVIDER_AGENT = { "claude": "claude-code", "codex": "openai-codex", "gemini": "gemini-cli", + "cursor": "cursor-agent", } @@ -203,7 +206,7 @@ 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"): + if provider not in ("codex", "gemini", "cursor"): return data if event in {"pre-tool", "post-tool", "post-tool-failure"}: @@ -212,14 +215,25 @@ def _normalise_payload(input_data: dict, provider: str, event: str) -> 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 "") + command = data.get("command") + if command and not data.get("tool_name"): + data.setdefault("tool_name", "shell") + data.setdefault("tool_input", {"command": command}) + if data.get("file_path") or data.get("path"): + data.setdefault("tool_name", data.get("tool_name") or "file_edit") + data.setdefault("tool_input", { + "file_path": data.get("file_path") or data.get("path"), + }) 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 == "gemini": + if provider in ("gemini", "cursor"): if event == "session-start": data.setdefault("source", data.get("hook_event_name", "startup")) if event == "user-prompt": - data.setdefault("prompt", data.get("user_prompt") or data.get("input", {}).get("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 "") if event == "stop": data.setdefault("last_assistant_message", data.get("prompt_response", "")) @@ -408,6 +422,34 @@ def handle_stop(input_data: dict, provider: str = "claude") -> None: ) +def handle_file_write(input_data: dict, provider: str = "claude") -> None: + """Handle provider file-edit hooks as file_write events.""" + store = _get_store() + session_id = _resolve_session_id(input_data, provider=provider) + if not session_id: + return + + redact = _should_redact() + data = { + "path": input_data.get("file_path") or input_data.get("path") or input_data.get("uri") or "", + } + for key in ("diff", "patch", "change_summary", "turn_id", "permission_mode"): + if input_data.get(key) not in (None, ""): + data[key] = input_data.get(key) + if redact: + data = redact_data(data) + + _write_event( + store, + session_id, + TraceEvent( + event_type=EventType.FILE_WRITE, + session_id=session_id, + data=data, + ), + ) + + def handle_post_tool(input_data: dict, failed: bool = False, provider: str = "claude") -> None: """Handle PostToolUse / PostToolUseFailure hook event.""" store = _get_store() @@ -419,7 +461,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") and not failed: + if provider in ("codex", "gemini", "cursor") and not failed: if isinstance(tool_output, dict): exit_code = tool_output.get("exit_code") failed = ( @@ -509,7 +551,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] \n") + sys.stderr.write("Usage: agent-strace hook [--provider claude|codex|gemini|cursor] \n") sys.exit(1) aliases = { @@ -520,6 +562,11 @@ def hook_main(args: list[str]) -> None: "before-agent": "user-prompt", "before-prompt": "user-prompt", "after-agent": "stop", + "before-submit-prompt": "user-prompt", + "before-shell-execution": "pre-tool", + "after-shell-execution": "post-tool", + "after-file-edit": "file-write", + "after-agent-response": "stop", } event = aliases.get(rest[0], rest[0]) input_data = _normalise_payload(_read_stdin(), provider, event) @@ -531,6 +578,7 @@ def hook_main(args: list[str]) -> None: "post-tool": lambda d: handle_post_tool(d, failed=False, provider=provider), "post-tool-failure": lambda d: handle_post_tool(d, failed=True, provider=provider), "user-prompt": lambda d: handle_user_prompt(d, provider=provider), + "file-write": lambda d: handle_file_write(d, provider=provider), "stop": lambda d: handle_stop(d, provider=provider), } diff --git a/tests/test_cursor_hooks.py b/tests/test_cursor_hooks.py new file mode 100644 index 0000000..4120bb4 --- /dev/null +++ b/tests/test_cursor_hooks.py @@ -0,0 +1,197 @@ +"""Tests for Cursor 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, + handle_file_write, + handle_post_tool, + handle_pre_tool, + handle_session_start, + handle_stop, + handle_user_prompt, + hook_main, +) +from agent_trace.models import EventType +from agent_trace.store import TraceStore + + +class TestCursorHooks(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + os.environ["AGENT_TRACE_DIR"] = self.tmpdir + os.environ.pop("AGENT_TRACE_CURSOR_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_CURSOR_SESSION_ID", None) + + def test_cursor_session_start_creates_session(self): + handle_session_start({ + "session_id": "cursorsession123456789", + "source": "startup", + "model": "cursor-agent", + "cwd": "/work/repo", + }, provider="cursor") + + session_id = _read_active_session(provider="cursor") + store = TraceStore(self.tmpdir) + meta = store.load_meta(session_id) + events = store.load_events(session_id) + + self.assertEqual(meta.agent_name, "cursor-agent") + self.assertIn("cursor-agent", meta.command) + self.assertEqual(events[0].event_type, EventType.SESSION_START) + self.assertEqual(events[0].data["provider"], "cursor") + self.assertEqual(events[0].data["mode"], "cursor-agent-hooks") + + def test_cursor_shell_execution_is_linked(self): + handle_session_start({"session_id": "cursorshell123456", "source": "startup"}, provider="cursor") + session_id = _read_active_session(provider="cursor") + + handle_pre_tool({ + "session_id": "cursorshell123456", + "tool_name": "shell", + "tool_input": {"command": "pytest tests/"}, + }, provider="cursor") + handle_post_tool({ + "session_id": "cursorshell123456", + "tool_name": "shell", + "tool_response": {"exit_code": 0, "output": "ok"}, + }, provider="cursor") + + events = TraceStore(self.tmpdir).load_events(session_id) + 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(calls[0].data["arguments"]["command"], "pytest tests/") + self.assertEqual(results[0].parent_id, calls[0].event_id) + + def test_cursor_prompt_response_and_file_edit(self): + handle_session_start({"session_id": "cursorprompt12345", "source": "startup"}, provider="cursor") + session_id = _read_active_session(provider="cursor") + + handle_user_prompt({ + "session_id": "cursorprompt12345", + "prompt": "Refactor parser", + }, provider="cursor") + handle_file_write({ + "session_id": "cursorprompt12345", + "file_path": "src/parser.py", + "diff": "+new line", + }, provider="cursor") + handle_stop({ + "session_id": "cursorprompt12345", + "last_assistant_message": "Refactor complete.", + }, provider="cursor") + + events = TraceStore(self.tmpdir).load_events(session_id) + prompts = [event for event in events if event.event_type == EventType.USER_PROMPT] + writes = [event for event in events if event.event_type == EventType.FILE_WRITE] + responses = [event for event in events if event.event_type == EventType.ASSISTANT_RESPONSE] + self.assertEqual(prompts[0].data["prompt"], "Refactor parser") + self.assertEqual(writes[0].data["path"], "src/parser.py") + self.assertIn("Refactor", responses[0].data["text"]) + + def test_hook_main_accepts_cursor_aliases(self): + handle_session_start({"session_id": "cursoralias12345", "source": "startup"}, provider="cursor") + session_id = _read_active_session(provider="cursor") + + shell_payload = json.dumps({ + "session_id": "cursoralias12345", + "command": "python -m pytest", + }) + with patch.object(sys, "stdin", io.StringIO(shell_payload)): + hook_main(["--provider", "cursor", "before-shell-execution"]) + + result_payload = json.dumps({ + "session_id": "cursoralias12345", + "command": "python -m pytest", + "tool_response": {"exit_code": 1, "output": "failed"}, + }) + with patch.object(sys, "stdin", io.StringIO(result_payload)): + hook_main(["--provider", "cursor", "after-shell-execution"]) + + payload = json.dumps({ + "session_id": "cursoralias12345", + "file_path": "src/app.py", + "diff": "+x", + }) + with patch.object(sys, "stdin", io.StringIO(payload)): + hook_main(["--provider", "cursor", "after-file-edit"]) + + events = TraceStore(self.tmpdir).load_events(session_id) + calls = [event for event in events if event.event_type == EventType.TOOL_CALL] + errors = [event for event in events if event.event_type == EventType.ERROR] + writes = [event for event in events if event.event_type == EventType.FILE_WRITE] + self.assertEqual(calls[0].data["tool_name"], "shell") + self.assertEqual(calls[0].data["arguments"]["command"], "python -m pytest") + self.assertEqual(errors[0].parent_id, calls[0].event_id) + self.assertEqual(writes[0].data["path"], "src/app.py") + + +class TestCursorSetup(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + os.environ["CURSOR_CONFIG_DIR"] = self.tmpdir + + def tearDown(self): + os.environ.pop("CURSOR_CONFIG_DIR", None) + + def test_setup_cli_cursor_writes_hooks_json(self): + args = argparse.Namespace( + redact=False, + no_redact=False, + global_config=False, + cli="cursor", + ) + + 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.json" + hooks = json.loads(hooks_path.read_text()) + printed_hooks = json.loads(out.getvalue()) + + self.assertEqual(hooks["version"], 1) + self.assertIn("beforeSubmitPrompt", hooks["hooks"]) + self.assertIn("beforeShellExecution", hooks["hooks"]) + self.assertIn("afterFileEdit", hooks["hooks"]) + self.assertEqual( + hooks["hooks"]["afterFileEdit"][0]["command"], + "agent-strace hook --provider cursor after-file-edit", + ) + self.assertEqual(printed_hooks, hooks) + self.assertIn("Cursor hooks config", err.getvalue()) + + def test_setup_cli_all_includes_cursor(self): + args = argparse.Namespace( + redact=False, + no_redact=False, + global_config=False, + cli="all", + ) + + out = io.StringIO() + err = io.StringIO() + with patch.object(sys, "stdout", out), patch.object(sys, "stderr", err): + cmd_setup(args) + + self.assertTrue((Path(self.tmpdir) / "hooks.json").exists()) + self.assertIn("agent-strace hook --provider codex user-prompt", out.getvalue()) + self.assertIn("Cursor hooks config", err.getvalue()) + + +if __name__ == "__main__": + unittest.main()