diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json
index 111a0f4..e99add7 100644
--- a/.cursor-plugin/plugin.json
+++ b/.cursor-plugin/plugin.json
@@ -5,5 +5,5 @@
"repository": "https://github.com/Tencent-RTC/agent-skills",
"rules": ".cursor/rules/",
"skills": "./skills/",
- "hooks": "./hooks/hooks.json"
+ "hooks": "./hooks/hooks-cursor.json"
}
diff --git a/README.md b/README.md
index dff4364..0b10a28 100644
--- a/README.md
+++ b/README.md
@@ -119,9 +119,9 @@ The skill activates automatically when you mention TRTC or describe a real-time
| | What it does | Example prompts |
|---|---|---|
-| **Get started** | Guides you through demo setup, SDK integration, troubleshooting, or adding a new feature — step by step | *"I want to add video conferencing to my web app"* · *"I'm getting error 6206 when users join"* · *"Conference is working — now I want to add screen sharing"* |
-| **Scenario walkthrough** | Loads a complete feature scenario and walks you through each capability in order, with code and checkpoints | *"Walk me through building a complete conference room from scratch"* · *"Guide me through a 1-on-1 video consultation end to end"* |
-| **Docs & lookup** | Answers factual questions from the official knowledge base with cited sources | *"What does error code 6206 mean?"* · *"How much does Conference cost per participant minute?"* · *"What's the max number of participants?"* |
+| **Get started** | Guides you through demo setup, SDK integration, troubleshooting, or adding a new feature — step by step | • *"I want to add video conferencing to my web app"*
• *"I'm getting error 6206 when users join"*
• *"Conference is working — now I want to add screen sharing"* |
+| **Scenario walkthrough** | Loads a complete feature scenario and walks you through each capability in order, with code and checkpoints | • *"Walk me through building a complete conference room from scratch"*
• *"Guide me through a 1-on-1 video consultation end to end"* |
+| **Docs & lookup** | Answers factual questions from the official knowledge base with cited sources | • *"What does error code 6206 mean?"*
• *"How much does Conference cost per participant minute?"*
• *"What's the max number of participants?"* |
The skill saves your progress in the project. If you close the tool and come back later, it picks up where you left off.
diff --git a/README.zh.md b/README.zh.md
index 7b58f33..b02917d 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -119,9 +119,9 @@ codex mcp add tencent-rtc --env SDKAPPID=YOUR_SDKAPPID --env SECRETKEY=YOUR_SECR
| | 功能说明 | 示例 |
|---|---|---|
-| **快速上手** | 引导你跑通 Demo、从零集成、排查错误或添加新功能 | *"我想在 Web 应用里加视频会议"* · *"用户进房报错 6206"* · *"会议已接入,现在想加屏幕共享"* |
-| **场景引导** | 加载完整场景,按顺序逐步实现每个能力,每步附代码和验证 | *"我想搭建一个会议应用"* · *"我想用 Conference 搭建一个医疗问诊场景"* |
-| **文档查询** | 从官方知识库检索事实性问题,每个答案附来源引用 | *"错误码 6206 是什么意思?"* · *"Conference 按分钟怎么计费?"* · *"会议室最多支持多少人?"* |
+| **快速上手** | 引导你跑通 Demo、从零集成、排查错误或添加新功能 | • *"我想在 Web 应用里加视频会议"*
• *"用户进房报错 6206"*
• *"会议已接入,现在想加屏幕共享"* |
+| **场景引导** | 加载完整场景,按顺序逐步实现每个能力,每步附代码和验证 | • *"我想搭建一个会议应用"*
• *"我想用 Conference 搭建一个医疗问诊场景"* |
+| **文档查询** | 从官方知识库检索事实性问题,每个答案附来源引用 | • *"错误码 6206 是什么意思?"*
• *"Conference 按分钟怎么计费?"*
• *"会议室最多支持多少人?"* |
Skill 会在项目中保存你的进度。关掉工具下次回来,可以从上次中断的地方继续,不需要重新复述你在做什么。
diff --git a/hooks/cursor-adapter.py b/hooks/cursor-adapter.py
new file mode 100755
index 0000000..69f0b63
--- /dev/null
+++ b/hooks/cursor-adapter.py
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""cursor-adapter.py — Translate Cursor hook protocol to existing Claude-format guardrails.
+
+Wired from hooks-cursor.json. Each Cursor hook event runs:
+
+ python3 ./hooks/cursor-adapter.py
+
+This script:
+ 1. Reads the Cursor hook envelope from stdin (camelCase fields).
+ 2. Normalizes it to the {tool_name, tool_input} shape the existing
+ guardrail scripts in skills/*/guardrails/ expect.
+ 3. Sets CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR env vars so the
+ existing scripts find sessions and shared libs unchanged.
+ 4. Spawns the underlying script and propagates stdout/stderr.
+ 5. Translates exit codes:
+ Claude Code: 0=allow, 1=fail, 2=block
+ Cursor: 0=success, 2=deny (with JSON), other=fail-open
+ We map Claude exit 1 and exit 2 -> Cursor deny (exit 2 + JSON on stdout).
+
+Fail-open principle: if anything goes wrong inside the adapter (missing
+script, malformed stdin, dispatch key unknown), we exit 0 silently so a
+broken adapter never blocks the user's workflow.
+
+Cursor event mapping (see hooks-cursor.json):
+ sessionStart -> trtc-prepare-ui
+ beforeReadFile -> gate-slice-read
+ preToolUse -> gate-slice-write (filtered to Write/Edit inside)
+ afterFileEdit -> verify-ui-post-write, verify-slice-must
+ afterAgentResponse -> stop-apply-evidence, trtc-verify-ui, verify-apply-project
+ (Claude Code's `Stop` event fires per agent loop
+ iteration end — the same semantic as Cursor's
+ `afterAgentResponse`. Cursor's documented `stop`
+ event was verified to never fire as of 2026-06,
+ so we use afterAgentResponse instead.
+
+ IMPORTANT: Cursor treats afterAgentResponse as a
+ pure observer event. Our deny envelope is
+ received and logged in Cursor's Hooks Output
+ panel ("Hook 1 blocked action") but does NOT
+ surface to the user (no chat warning) or to the
+ AI (no self-correction). Tested with
+ user_message and followup_message fields — both
+ ignored. This is a Cursor platform limitation,
+ not an adapter issue. Until Cursor implements
+ enforcement on afterAgentResponse, the 3
+ end-of-task guardrails act as audit logs only.)
+
+To enable verbose tracing during development, set the env var
+TRTC_HOOK_DEBUG_LOG=/path/to/file.log; see _dbg() below. The default is
+silent (no log file written) so production installs don't accumulate logs.
+"""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+ADAPTER_DIR = Path(__file__).resolve().parent
+PLUGIN_ROOT = ADAPTER_DIR.parent # plugin install root
+
+
+# Optional debug logging — only writes when TRTC_HOOK_DEBUG_LOG is set to a
+# path. Useful for diagnosing "why didn't my hook fire" without modifying
+# the script.
+def _dbg(msg: str) -> None:
+ log_path = os.environ.get("TRTC_HOOK_DEBUG_LOG")
+ if not log_path:
+ return
+ try:
+ import datetime as _dt
+ with open(log_path, "a") as f:
+ f.write(f"[{_dt.datetime.now().isoformat(timespec='milliseconds')}] {msg}\n")
+ except Exception:
+ pass
+
+
+# Dispatch table: dispatch_key -> relative script path under PLUGIN_ROOT.
+DISPATCH = {
+ "trtc-prepare-ui": "skills/trtc/room-builder/guardrails/trtc_prepare_ui.py",
+ "gate-slice-read": "skills/trtc-topic/guardrails/gate_slice_read.py",
+ "gate-slice-write": "skills/trtc-topic/guardrails/gate_slice_write.py",
+ "verify-ui-post-write": "skills/trtc/room-builder/guardrails/verify_ui_post_write.sh",
+ "verify-slice-must": "skills/trtc-apply/guardrails/verify_slice_must_rules.py",
+ "stop-apply-evidence": "skills/trtc-topic/guardrails/stop_require_apply_evidence.py",
+ "trtc-verify-ui": "skills/trtc/room-builder/guardrails/trtc_verify_ui.py",
+ "verify-apply-project": "skills/trtc-apply/guardrails/verify_apply_project.py",
+}
+
+# Dispatch keys whose underlying scripts read JSON from stdin.
+# Other keys are CLI-only and will receive empty stdin.
+STDIN_KEYS = {
+ "gate-slice-read",
+ "gate-slice-write",
+ "verify-ui-post-write",
+ "verify-slice-must",
+}
+
+
+def _silent_allow() -> None:
+ """Fail-open: never block the user because the adapter itself failed."""
+ _dbg(" EXIT=0 (silent allow)")
+ sys.exit(0)
+
+
+def _read_cursor_payload() -> dict:
+ raw = sys.stdin.read()
+ if not raw or not raw.strip():
+ return {}
+ try:
+ data = json.loads(raw)
+ return data if isinstance(data, dict) else {}
+ except (ValueError, TypeError):
+ return {}
+
+
+def _translate_payload(key: str, cursor: dict) -> dict:
+ """Map Cursor's per-event payload to the {tool_name, tool_input} envelope
+ the existing guardrails expect.
+
+ The existing scripts read either:
+ - {"tool_name": "Read", "tool_input": {"file_path": "..."}}
+ - {"tool_name": "Write|Edit", "tool_input": {"file_path": "..."}}
+ """
+ if key == "gate-slice-read":
+ # Cursor's beforeReadFile payload: {file_path, content, attachments, ...}
+ file_path = cursor.get("file_path") or cursor.get("filePath")
+ return {"tool_name": "Read", "tool_input": {"file_path": file_path}}
+
+ if key == "gate-slice-write":
+ # Cursor's preToolUse payload: {tool_name, tool_input, tool_use_id, cwd, ...}
+ # Cursor only emits "Write" (no separate "Edit"); existing script
+ # accepts both. Pass through unchanged when tool_name is Write/Edit;
+ # silent allow for unrelated tools.
+ tool_name = cursor.get("tool_name") or cursor.get("toolName")
+ if tool_name not in ("Write", "Edit"):
+ return {} # caller will silent-allow
+ tool_input = cursor.get("tool_input") or cursor.get("toolInput") or {}
+ return {"tool_name": tool_name, "tool_input": tool_input}
+
+ if key in ("verify-ui-post-write", "verify-slice-must"):
+ # Cursor's afterFileEdit payload: {file_path, edits}
+ file_path = cursor.get("file_path") or cursor.get("filePath")
+ return {"tool_name": "Edit", "tool_input": {"file_path": file_path}}
+
+ # CLI-only events (sessionStart, afterAgentResponse) get empty stdin —
+ # they read state from session files and env vars, not stdin.
+ return {}
+
+
+def _emit_cursor_deny(message: str) -> None:
+ """Cursor deny protocol: JSON on stdout + exit 2.
+
+ Fields populated:
+ - permission: "deny" — tells Cursor this action should be blocked.
+ - user_message: surfaced in Cursor's UI when the platform supports it
+ on the current event. Verified to be ignored on afterAgentResponse
+ (Cursor only logs the block in the Output panel there); honored on
+ preToolUse / beforeReadFile.
+ - agent_message: shown to the agent so it can self-correct on hooks
+ that run before the agent's loop ends (preToolUse, beforeReadFile).
+
+ Note: `followup_message` was tested and is NOT honored on
+ afterAgentResponse (Cursor 3.4.20). Per Cursor docs it's only meaningful
+ on the `stop` event, which itself does not fire in current Cursor
+ versions — so there's no reliable channel to make Cursor auto-submit a
+ correction message back to the agent after it has already responded.
+ """
+ _dbg(f" EXIT=2 (deny) message={message[:160]!r}")
+ msg = message or "Blocked by TRTC guardrail."
+ payload = {
+ "permission": "deny",
+ "user_message": msg,
+ "agent_message": msg,
+ }
+ sys.stdout.write(json.dumps(payload))
+ sys.stdout.flush()
+ sys.exit(2)
+
+
+def main(argv: list[str]) -> None:
+ key = argv[1] if len(argv) >= 2 else None
+ _dbg(
+ f"ENTER key={key!r} "
+ f"cursor_project_dir={os.environ.get('CURSOR_PROJECT_DIR', '?')!r}"
+ )
+ if not key:
+ _silent_allow()
+
+ # Read stdin first so debug logs see Cursor's hook_event_name even when
+ # the dispatch key is unknown or the script is missing.
+ cursor_payload = _read_cursor_payload()
+ _dbg(
+ f" hook_event_name={cursor_payload.get('hook_event_name', '?')!r} "
+ f"keys={sorted(cursor_payload.keys()) if cursor_payload else []}"
+ )
+
+ rel_script = DISPATCH.get(key)
+ if not rel_script:
+ # Unknown dispatch key — never block on adapter misconfiguration.
+ _silent_allow()
+
+ script = PLUGIN_ROOT / rel_script
+ if not script.exists():
+ # Script missing (e.g., skill not installed). Same convention the
+ # existing hooks.json uses ("[ ! -f X ] || python3 X").
+ _silent_allow()
+
+ claude_payload = _translate_payload(key, cursor_payload)
+
+ # gate-slice-write returns {} when tool isn't Write/Edit -> silent allow.
+ if key == "gate-slice-write" and not claude_payload:
+ _silent_allow()
+
+ # Build env: surface Cursor's project dir as CLAUDE_PROJECT_DIR so
+ # existing scripts that key off CLAUDE_PROJECT_DIR keep working.
+ env = dict(os.environ)
+ cursor_project_dir = env.get("CURSOR_PROJECT_DIR") or cursor_payload.get("cwd")
+ if cursor_project_dir and not env.get("CLAUDE_PROJECT_DIR"):
+ env["CLAUDE_PROJECT_DIR"] = cursor_project_dir
+ env["CLAUDE_PLUGIN_ROOT"] = str(PLUGIN_ROOT)
+
+ runner = ["bash", str(script)] if script.suffix == ".sh" else ["python3", str(script)]
+ stdin_data = json.dumps(claude_payload) if key in STDIN_KEYS else ""
+
+ try:
+ proc = subprocess.run(
+ runner,
+ input=stdin_data,
+ text=True,
+ env=env,
+ capture_output=True,
+ timeout=30,
+ )
+ except (subprocess.TimeoutExpired, OSError):
+ _silent_allow()
+
+ # Forward whatever the inner script wrote.
+ if proc.stdout:
+ sys.stderr.write(proc.stdout) # inner stdout -> stderr so we don't
+ # corrupt our Cursor JSON channel
+ if proc.stderr:
+ sys.stderr.write(proc.stderr)
+
+ rc = proc.returncode
+ _dbg(f" inner_rc={rc}")
+ if rc == 0:
+ sys.exit(0)
+ if rc in (1, 2):
+ # Translate fail/block into Cursor's deny envelope. Use stderr
+ # content as the agent-facing message so the existing scripts'
+ # human-readable explanations reach the user.
+ msg = (proc.stderr or proc.stdout or "").strip()
+ _emit_cursor_deny(msg)
+
+ # Unknown exit code — fail open to mimic Claude Code's permissive default.
+ _silent_allow()
+
+
+if __name__ == "__main__":
+ main(sys.argv)
diff --git a/hooks/hooks-cursor.json b/hooks/hooks-cursor.json
new file mode 100644
index 0000000..40a74bd
--- /dev/null
+++ b/hooks/hooks-cursor.json
@@ -0,0 +1,39 @@
+{
+ "version": 1,
+ "hooks": {
+ "sessionStart": [
+ {
+ "command": "python3 ./hooks/cursor-adapter.py trtc-prepare-ui"
+ }
+ ],
+ "beforeReadFile": [
+ {
+ "command": "python3 ./hooks/cursor-adapter.py gate-slice-read"
+ }
+ ],
+ "preToolUse": [
+ {
+ "command": "python3 ./hooks/cursor-adapter.py gate-slice-write"
+ }
+ ],
+ "afterFileEdit": [
+ {
+ "command": "python3 ./hooks/cursor-adapter.py verify-ui-post-write"
+ },
+ {
+ "command": "python3 ./hooks/cursor-adapter.py verify-slice-must"
+ }
+ ],
+ "afterAgentResponse": [
+ {
+ "command": "python3 ./hooks/cursor-adapter.py stop-apply-evidence"
+ },
+ {
+ "command": "python3 ./hooks/cursor-adapter.py trtc-verify-ui"
+ },
+ {
+ "command": "python3 ./hooks/cursor-adapter.py verify-apply-project"
+ }
+ ]
+ }
+}
diff --git a/tests/unit/test_cursor_adapter.py b/tests/unit/test_cursor_adapter.py
new file mode 100644
index 0000000..109354c
--- /dev/null
+++ b/tests/unit/test_cursor_adapter.py
@@ -0,0 +1,298 @@
+"""Unit tests for hooks/cursor-adapter.py.
+
+Strategy: replace each guardrail script with a tiny stub in a fake plugin
+root, then run the adapter and assert the right script ran with the right
+stdin/env. Same strategy used in tests/unit/test_trtc_*.py — direct
+subprocess invocation, no test-framework magic.
+
+Run with:
+ python3 -m unittest tests.unit.test_cursor_adapter
+
+Pytest also picks these up if available.
+"""
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+import tempfile
+import textwrap
+import unittest
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parent.parent.parent
+ADAPTER_SRC = REPO_ROOT / "hooks" / "cursor-adapter.py"
+
+
+# Mirror of the DISPATCH dict inside cursor-adapter.py — keep in sync.
+DISPATCH = {
+ "trtc-prepare-ui": "skills/trtc/room-builder/guardrails/trtc_prepare_ui.py",
+ "gate-slice-read": "skills/trtc-topic/guardrails/gate_slice_read.py",
+ "gate-slice-write": "skills/trtc-topic/guardrails/gate_slice_write.py",
+ "verify-ui-post-write": "skills/trtc/room-builder/guardrails/verify_ui_post_write.sh",
+ "verify-slice-must": "skills/trtc-apply/guardrails/verify_slice_must_rules.py",
+ "stop-apply-evidence": "skills/trtc-topic/guardrails/stop_require_apply_evidence.py",
+ "trtc-verify-ui": "skills/trtc/room-builder/guardrails/trtc_verify_ui.py",
+ "verify-apply-project": "skills/trtc-apply/guardrails/verify_apply_project.py",
+}
+
+
+class CursorAdapterTestBase(unittest.TestCase):
+ """Builds a fake plugin tree at /plugin/ containing a copy of the
+ real adapter, so adapter's __file__-based PLUGIN_ROOT resolution lands
+ on our temp directory.
+ """
+
+ def setUp(self) -> None:
+ # macOS resolves /var/folders → /private/var/folders; realpath the
+ # tmp dir so what we observe matches what the adapter sees.
+ self.tmp = Path(tempfile.mkdtemp(prefix="trtc-adapter-")).resolve()
+ (self.tmp / "hooks").mkdir(parents=True, exist_ok=True)
+ self.adapter = self.tmp / "hooks" / "cursor-adapter.py"
+ shutil.copy2(ADAPTER_SRC, self.adapter)
+
+ def tearDown(self) -> None:
+ shutil.rmtree(self.tmp, ignore_errors=True)
+
+ # ---- helpers --------------------------------------------------------
+
+ def plant_stub(self, rel_path: str, *, exit_code: int = 0, stderr_text: str = "") -> None:
+ """Write a stub at / that records its stdin/env to
+ /hook-trace.json, prints stderr_text, and exits exit_code.
+ """
+ abs_path = self.tmp / rel_path
+ abs_path.parent.mkdir(parents=True, exist_ok=True)
+ trace_path = self.tmp / "hook-trace.json"
+
+ if abs_path.suffix == ".sh":
+ # Match the .sh script wrapping pattern used in verify_ui_post_write.sh.
+ body = textwrap.dedent(f"""\
+ #!/usr/bin/env bash
+ data=$(cat)
+ python3 - <&2
+ fi
+ exit {exit_code}
+ """)
+ else:
+ body = textwrap.dedent(f"""\
+ #!/usr/bin/env python3
+ import json, os, sys
+ trace = {{
+ "stdin": sys.stdin.read(),
+ "env_CLAUDE_PLUGIN_ROOT": os.environ.get("CLAUDE_PLUGIN_ROOT", ""),
+ "env_CLAUDE_PROJECT_DIR": os.environ.get("CLAUDE_PROJECT_DIR", ""),
+ "argv": sys.argv,
+ }}
+ with open({json.dumps(str(trace_path))}, "w") as f:
+ json.dump(trace, f)
+ if {json.dumps(stderr_text)}:
+ sys.stderr.write({json.dumps(stderr_text)})
+ sys.exit({exit_code})
+ """)
+
+ abs_path.write_text(body)
+ abs_path.chmod(0o755)
+
+ def run_adapter(self, dispatch_key: str, stdin: str = "", extra_env: dict | None = None):
+ env = os.environ.copy()
+ if extra_env:
+ env.update(extra_env)
+ return subprocess.run(
+ ["python3", str(self.adapter), dispatch_key],
+ input=stdin,
+ capture_output=True,
+ text=True,
+ env=env,
+ )
+
+ def read_trace(self) -> dict | None:
+ p = self.tmp / "hook-trace.json"
+ if not p.exists():
+ return None
+ return json.loads(p.read_text())
+
+
+class TestSilentAllow(CursorAdapterTestBase):
+
+ def test_unknown_dispatch_key_is_silent_allow(self):
+ result = self.run_adapter("totally-bogus-key")
+ self.assertEqual(result.returncode, 0)
+
+ def test_missing_target_script_is_silent_allow(self):
+ # Plant nothing — script doesn't exist.
+ result = self.run_adapter(
+ "gate-slice-read",
+ stdin=json.dumps({"file_path": "/x"}),
+ )
+ self.assertEqual(result.returncode, 0)
+
+ def test_malformed_stdin_is_silent_allow(self):
+ self.plant_stub(DISPATCH["gate-slice-read"], exit_code=0)
+ result = self.run_adapter("gate-slice-read", stdin="not json at all")
+ # Adapter still dispatches with empty payload; inner stub exits 0; net 0.
+ self.assertEqual(result.returncode, 0)
+
+ def test_pretooluse_with_non_write_tool_short_circuits(self):
+ # Stub plants exit 2; if adapter wrongly invoked it, we'd see deny.
+ self.plant_stub(DISPATCH["gate-slice-write"], exit_code=2, stderr_text="should not fire")
+ result = self.run_adapter(
+ "gate-slice-write",
+ stdin=json.dumps({"tool_name": "Shell", "tool_input": {"command": "ls"}}),
+ )
+ self.assertEqual(result.returncode, 0)
+ # Trace file should NOT exist — adapter short-circuited before subprocess.
+ self.assertIsNone(self.read_trace())
+
+
+class TestPayloadTranslation(CursorAdapterTestBase):
+
+ def test_before_read_file_translates_to_tool_name_read(self):
+ self.plant_stub(DISPATCH["gate-slice-read"], exit_code=0)
+ result = self.run_adapter(
+ "gate-slice-read",
+ stdin=json.dumps({
+ "file_path": "/repo/knowledge-base/slices/conference/web/login-auth.md",
+ "content": "...",
+ "hook_event_name": "beforeReadFile",
+ }),
+ )
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+
+ trace = self.read_trace()
+ self.assertIsNotNone(trace, "stub did not run")
+ seen = json.loads(trace["stdin"])
+ self.assertEqual(seen["tool_name"], "Read")
+ self.assertEqual(
+ seen["tool_input"]["file_path"],
+ "/repo/knowledge-base/slices/conference/web/login-auth.md",
+ )
+
+ def test_pretooluse_with_write_dispatches_with_normalized_payload(self):
+ self.plant_stub(DISPATCH["gate-slice-write"], exit_code=0)
+ result = self.run_adapter(
+ "gate-slice-write",
+ stdin=json.dumps({
+ "tool_name": "Write",
+ "tool_input": {"file_path": "/proj/src/main.ts"},
+ }),
+ )
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+
+ trace = self.read_trace()
+ self.assertIsNotNone(trace)
+ seen = json.loads(trace["stdin"])
+ self.assertEqual(seen["tool_name"], "Write")
+ self.assertEqual(seen["tool_input"]["file_path"], "/proj/src/main.ts")
+
+ def test_after_file_edit_translates_for_verify_slice_must(self):
+ self.plant_stub(DISPATCH["verify-slice-must"], exit_code=0)
+ result = self.run_adapter(
+ "verify-slice-must",
+ stdin=json.dumps({
+ "file_path": "/proj/src/Room.vue",
+ "edits": [{"old_string": "a", "new_string": "b"}],
+ }),
+ )
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+
+ trace = self.read_trace()
+ self.assertIsNotNone(trace)
+ seen = json.loads(trace["stdin"])
+ self.assertEqual(seen["tool_name"], "Edit")
+ self.assertEqual(seen["tool_input"]["file_path"], "/proj/src/Room.vue")
+
+
+class TestEnvForwarding(CursorAdapterTestBase):
+
+ def test_claude_plugin_root_exported_to_inner_script(self):
+ self.plant_stub(DISPATCH["trtc-prepare-ui"], exit_code=0)
+ result = self.run_adapter("trtc-prepare-ui", stdin="")
+ self.assertEqual(result.returncode, 0, msg=result.stderr)
+
+ trace = self.read_trace()
+ self.assertIsNotNone(trace)
+ self.assertEqual(trace["env_CLAUDE_PLUGIN_ROOT"], str(self.tmp))
+
+ def test_cursor_project_dir_forwarded_as_claude_project_dir(self):
+ self.plant_stub(DISPATCH["trtc-prepare-ui"], exit_code=0)
+ result = self.run_adapter(
+ "trtc-prepare-ui",
+ stdin="",
+ extra_env={"CURSOR_PROJECT_DIR": "/some/project", "CLAUDE_PROJECT_DIR": ""},
+ )
+ self.assertEqual(result.returncode, 0)
+
+ trace = self.read_trace()
+ self.assertIsNotNone(trace)
+ self.assertEqual(trace["env_CLAUDE_PROJECT_DIR"], "/some/project")
+
+
+class TestExitCodeMapping(CursorAdapterTestBase):
+
+ def test_inner_exit_2_becomes_cursor_deny_envelope_and_exit_2(self):
+ self.plant_stub(
+ DISPATCH["gate-slice-read"],
+ exit_code=2,
+ stderr_text="Slice read out of bounds — finish current slice first.",
+ )
+ result = self.run_adapter(
+ "gate-slice-read",
+ stdin=json.dumps({"file_path": "/repo/knowledge-base/slices/conference/web/x.md"}),
+ )
+ self.assertEqual(result.returncode, 2)
+
+ envelope = json.loads(result.stdout)
+ self.assertEqual(envelope["permission"], "deny")
+ # Both user-facing and agent-facing messages should be set so Cursor
+ # can surface the warning in whichever channel the current event
+ # supports (preToolUse blocks the AI; afterAgentResponse logs to
+ # the Output panel and may surface user_message).
+ self.assertIn("Slice read out of bounds", envelope["agent_message"])
+ self.assertIn("Slice read out of bounds", envelope["user_message"])
+
+ def test_inner_exit_1_also_maps_to_deny(self):
+ # verify_slice_must_rules.py and verify_apply_project.py exit 1 on
+ # check failure (not 2). Adapter must still translate to Cursor deny.
+ self.plant_stub(
+ DISPATCH["verify-slice-must"],
+ exit_code=1,
+ stderr_text="MUST-rule check failed",
+ )
+ result = self.run_adapter(
+ "verify-slice-must",
+ stdin=json.dumps({"file_path": "/p/x.vue"}),
+ )
+ self.assertEqual(result.returncode, 2)
+ self.assertIn('"permission":', result.stdout)
+ self.assertIn('"deny"', result.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()