Skip to content
Merged
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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@ If your IDE doesn't have a plugin marketplace, or you'd rather pin the install t
```bash
# Default — auto-detect installed IDEs (~/.{claude,cursor,codebuddy,codex}/)
# and install for each one found. Falls back to claude if none detected.
npx -y @tencent-rtc/trtc-agent-skills add
npx -y @tencent-rtc/trtc-agent-skills@latest add

# Force install for every supported IDE (even ones you don't have)
npx -y @tencent-rtc/trtc-agent-skills add --ide all
npx -y @tencent-rtc/trtc-agent-skills add@latest --ide all

# Install only for one specific IDE
npx -y @tencent-rtc/trtc-agent-skills add --ide cursor
npx -y @tencent-rtc/trtc-agent-skills add@latest --ide cursor

# Wipe a previous install before re-installing
npx -y @tencent-rtc/trtc-agent-skills add --clean
npx -y @tencent-rtc/trtc-agent-skills add@latest --clean
```

---
Expand Down
8 changes: 4 additions & 4 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,16 @@
```bash
# 默认 — 自动检测已安装的 IDE(~/.{claude,cursor,codebuddy,codex}/)
# 为每一个检测到的 IDE 都安装好;都没检测到时回退到 claude
npx -y @tencent-rtc/trtc-agent-skills add
npx -y @tencent-rtc/trtc-agent-skills@latest add

# 强制为所有支持的 IDE 都装一份(即使你本机没装那个 IDE)
npx -y @tencent-rtc/trtc-agent-skills add --ide all
npx -y @tencent-rtc/trtc-agent-skills@latest add --ide all

# 只为某个指定的 IDE 安装
npx -y @tencent-rtc/trtc-agent-skills add --ide cursor
npx -y @tencent-rtc/trtc-agent-skills@latest add --ide cursor

# 重装前先清理旧的安装
npx -y @tencent-rtc/trtc-agent-skills add --clean
npx -y @tencent-rtc/trtc-agent-skills@latest add --clean
```

---
Expand Down
100 changes: 69 additions & 31 deletions bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,29 +94,31 @@ const MCP_SERVER_ENTRY = "@tencent-rtc/skill-tool@latest";
// The original hooks.json uses ${CLAUDE_PLUGIN_ROOT} / ${CODEBUDDY_PLUGIN_ROOT}
// placeholders that get expanded by the IDE in plugin mode; in npx mode we
// materialize them to absolute paths under the IDE's settings dir.
// cursor: hooks-cursor.json is rewritten + merged into ~/.cursor/hooks.json
// (USER-LEVEL — Cursor doesn't load project-level hooks). The
// cursor: hooks-cursor.json is rewritten + merged into <root>/.cursor/hooks.json
// (project-level — Cursor supports both project and user-level hooks.json).
// cursor-adapter.py is copied to <root>/.cursor/hooks/ and its hardcoded
// $HOME/.cursor/plugins/local/... reference is rewritten to the actual path.
const HOOKS_TARGETS = {
claude: {
hooksDir: ".claude/hooks",
// claude/codebuddy/codex hooks.json points hook commands directly at
// ${PLUGIN_ROOT}/skills/.../guardrails/xxx.py — there is nothing to copy
// into a hooks/ dir for these IDEs, so we leave hooksDir undefined and
// skip the copy step entirely. This keeps .{ide}/hooks/ free for other
// skill packages to use without us clobbering it.
settingsFile: ".claude/settings.json",
sourceConfig: "hooks.json",
rootPlaceholder: "${CLAUDE_PLUGIN_ROOT}",
rootRewrite: ".claude",
fallbackPlaceholder: "${CODEBUDDY_PLUGIN_ROOT}",
},
codebuddy: {
hooksDir: ".codebuddy/hooks",
settingsFile: ".codebuddy/settings.json",
sourceConfig: "hooks.json",
rootPlaceholder: "${CODEBUDDY_PLUGIN_ROOT}",
rootRewrite: ".codebuddy",
fallbackPlaceholder: "${CLAUDE_PLUGIN_ROOT}",
},
codex: {
hooksDir: ".codex/hooks",
// Codex loads hooks from <repo>/.codex/hooks.json (or ~/.codex/hooks.json)
// — NOT from .agents/settings.json. See https://developers.openai.com/codex/hooks
settingsFile: ".codex/hooks.json",
Expand All @@ -126,9 +128,13 @@ const HOOKS_TARGETS = {
fallbackPlaceholder: "${CODEBUDDY_PLUGIN_ROOT}",
},
cursor: {
hooksDir: ".cursor/hooks",
// ⚠ user-level — Cursor only loads ~/.cursor/hooks.json, not project-level.
settingsFile: path.join(os.homedir(), ".cursor", "hooks.json"),
// Namespace under .cursor/hooks/trtc-agent-skills/ so we never collide
// with another skill's hooks/ contents. cursor-adapter.py auto-detects
// PLUGIN_ROOT by walking up to the nearest dir containing skills/, so
// this nested location still resolves correctly.
hooksDir: ".cursor/hooks/trtc-agent-skills",
hooksFiles: ["cursor-adapter.py"],
settingsFile: ".cursor/hooks.json",
sourceConfig: "hooks-cursor.json",
// The hardcoded path string we need to rewrite in hooks-cursor.json.
cursorAdapterPlaceholder: "$HOME/.cursor/plugins/local/trtc-agent-skills/hooks/cursor-adapter.py",
Expand Down Expand Up @@ -311,9 +317,24 @@ function cleanSkills(skillsRootAbs) {
// also wipe a co-located knowledge-base copy if present
const kb = path.join(path.dirname(skillsRootAbs), "knowledge-base");
if (fs.existsSync(kb)) { rmrf(kb); }
// also wipe a co-located hooks/ copy if present (npx-mode hook scripts)
// Hooks cleanup: only remove our own files, never rmrf the whole hooks/
// dir — another skill package may be sharing it.
const hooks = path.join(path.dirname(skillsRootAbs), "hooks");
if (fs.existsSync(hooks)) { rmrf(hooks); }
if (fs.existsSync(hooks)) {
// 1) preferred current layout: hooks/trtc-agent-skills/
rmrf(path.join(hooks, "trtc-agent-skills"));
// 2) legacy layout: hooks/<file> at the top level. Only remove files we
// know we shipped; leave anything else (other skills, user scripts).
const LEGACY_FILES = ["cursor-adapter.py", "hooks.json", "hooks-cursor.json"];
for (const f of LEGACY_FILES) {
const p = path.join(hooks, f);
if (fs.existsSync(p) && fs.statSync(p).isFile()) rmrf(p);
}
// If hooks/ is now empty, remove it; otherwise leave it for other owners.
try {
if (fs.readdirSync(hooks).length === 0) rmrf(hooks);
} catch { /* ignore */ }
}
return wiped;
}

Expand Down Expand Up @@ -345,9 +366,9 @@ function cleanAiInstructions(ideList, resolvedRoot) {
}
}

// Strip our hook entries from each IDE's settings.json. We tag entries with
// Strip our hook entries from each IDE's settings file. We tag entries with
// __trtc_agent_skills__ so we can filter precisely without disturbing the
// user's own hook entries (relevant for cursor's user-level hooks.json).
// user's own hook entries.
function cleanHooksSettings(ideList, resolvedRoot) {
for (const ide of ideList) {
const target = HOOKS_TARGETS[ide];
Expand Down Expand Up @@ -433,29 +454,37 @@ function rewriteHooksContent(content, target, ideAbsRoot) {
// We need the resulting JSON string to evaluate to a shell-quoted path so
// project paths with spaces don't break shell parsing — that means
// emitting `\"<abs>\"` (JSON-escaped quotes) into the string.
const cursorAdapterAbs = path.join(ideAbsRoot, "hooks", "cursor-adapter.py");
const cursorAdapterAbs = path.join(ideAbsRoot, "hooks", "trtc-agent-skills", "cursor-adapter.py");
const replacement = `\\"${cursorAdapterAbs}\\"`;
out = out.split(target.cursorAdapterPlaceholder).join(replacement);
}
return out;
}

// Copy the hooks/ source directory into <root>/.{ide}/hooks/ so the dispatched
// scripts (cursor-adapter.py + the underlying guardrail scripts referenced by
// hooks.json) sit next to the IDE's skills/.
// Copy only the hook files this IDE actually needs into a namespaced subdir
// (.cursor/hooks/trtc-agent-skills/), so we never wipe a sibling skill's
// hooks/ contents. IDEs whose hook commands point straight at skills/ (claude,
// codebuddy, codex) declare no hooksDir and skip this step entirely.
function copyHooksDir(target, resolvedRoot) {
if (!target.hooksDir) return null;
const dest = path.join(resolvedRoot, target.hooksDir);
rmrf(dest);
copyRecursive(HOOKS_SRC, dest);
ensureDir(dest);
const files = target.hooksFiles && target.hooksFiles.length
? target.hooksFiles
: fs.readdirSync(HOOKS_SRC).filter(f => !f.endsWith(".json"));
for (const f of files) {
const src = path.join(HOOKS_SRC, f);
if (fs.existsSync(src)) copyRecursive(src, path.join(dest, f));
}
return dest;
}

// Merge the rewritten hook config into the IDE's settings file. The settings
// file may already contain unrelated user state (permissions, MCP servers,
// other hooks); we only own the `hooks` key. For Cursor's user-level
// ~/.cursor/hooks.json we merge per-event arrays so a previously-installed
// project's adapter path gets replaced by ours but the user's own hook
// entries (if any) are preserved.
// other hooks); we only own the `hooks` key. We merge per-event arrays so
// a previously-installed project's adapter path gets replaced but the user's
// own hook entries (if any) are preserved.
function mergeHooksConfig(target, resolvedRoot, ideAbsRoot) {
const srcPath = path.join(HOOKS_SRC, target.sourceConfig);
if (!fs.existsSync(srcPath)) return null;
Expand Down Expand Up @@ -487,9 +516,7 @@ function mergeHooksConfig(target, resolvedRoot, ideAbsRoot) {
const incomingHooks = parsed.hooks || {};
if (!existing.hooks || typeof existing.hooks !== "object") existing.hooks = {};

// Marker used inside user-level cursor hooks.json to identify our entries
// when multiple projects install. Tagged on each individual hook entry so
// a future uninstall can filter precisely.
// Marker to identify our entries so a future uninstall can filter precisely.
const tagged = (entry) => {
if (entry && typeof entry === "object") {
return Object.assign({}, entry, { __trtc_agent_skills__: true });
Expand Down Expand Up @@ -521,7 +548,7 @@ function mergeHooksConfig(target, resolvedRoot, ideAbsRoot) {
};

// Preserve / propagate top-level keys that the IDE expects (e.g. cursor
// requires `"version": 1` at the root of ~/.cursor/hooks.json or it rejects
// requires `"version": 1` at the root of .cursor/hooks.json or it rejects
// the file with "Config version must be a number"). Only copy keys we don't
// already own (hooks, __trtc_agent_skills__) to avoid clobbering the user's
// unrelated state.
Expand All @@ -539,16 +566,27 @@ function installHooks(ideList, resolvedRoot) {
const target = HOOKS_TARGETS[ide];
if (!target) continue;

const ideAbsRoot = path.join(resolvedRoot, path.dirname(target.hooksDir));
// ideAbsRoot is "<resolvedRoot>/.{ide}" — the directory that holds skills/,
// hooks/ (when used), settings.json. Derive it from settingsFile so it
// doesn't depend on the optional hooksDir.
const settingsRel = target.settingsFile;
const ideRelRoot = path.isAbsolute(settingsRel)
? path.dirname(settingsRel)
: settingsRel.split(path.sep)[0];
const ideAbsRoot = path.isAbsolute(settingsRel)
? path.dirname(settingsRel)
: path.join(resolvedRoot, ideRelRoot);

const hooksDest = copyHooksDir(target, resolvedRoot);
console.log(c.green(" ✓ ") + `${ide} hooks → ${hooksDest}/`);
if (hooksDest) {
console.log(c.green(" ✓ ") + `${ide} hooks → ${hooksDest}/`);
} else {
console.log(c.dim(` ✓ ${ide} hooks: no files needed (commands point at skills/)`));
}

const merged = mergeHooksConfig(target, resolvedRoot, ideAbsRoot);
if (merged) {
const isUserLevel = path.isAbsolute(target.settingsFile);
const prefix = isUserLevel ? c.yellow(" ⚠ ") : c.green(" ✓ ");
const note = isUserLevel ? c.dim(" (user-level — affects all cursor projects)") : "";
console.log(`${prefix}${ide} hooks settings → ${merged.settingsPath} ${c.dim(`(${merged.eventCount} events)`)}${note}`);
console.log(c.green(" ✓ ") + `${ide} hooks settings → ${merged.settingsPath} ${c.dim(`(${merged.eventCount} events)`)}`);
}
}
}
Expand Down
26 changes: 21 additions & 5 deletions hooks/cursor-adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,9 @@
firing. Empirically (Cursor 3.3.8) `stop` does
fire reliably; the original "stop never fires"
observation was caused by hooks not being loaded
at all, not by the `stop` event itself. See
install instructions in the README for the
documented user-level `~/.cursor/hooks.json`
install path — plugin-level hooks declared in
at all, not by the `stop` event itself. npx
installs to project-level `.cursor/hooks.json`;
plugin-level hooks declared in
.cursor-plugin/plugin.json are NOT loaded by
current Cursor versions.)

Expand All @@ -62,7 +61,24 @@
from pathlib import Path

ADAPTER_DIR = Path(__file__).resolve().parent
PLUGIN_ROOT = ADAPTER_DIR.parent # plugin install root


def _find_plugin_root(start: Path) -> Path:
# Walk up until we find a directory that contains skills/. This makes the
# adapter location-independent: it works whether it lives at
# <plugin>/hooks/cursor-adapter.py (Cursor plugin install + the original
# npx layout) or under a namespaced subdir like
# <plugin>/hooks/trtc-agent-skills/cursor-adapter.py (current npx layout,
# chosen so multiple skill packages can co-exist under .cursor/hooks/).
# Falls back to the old `.parent` behaviour if no `skills/` is found, so
# we never regress an existing install.
for candidate in (start, *start.parents):
if (candidate / "skills").is_dir():
return candidate
return start.parent


PLUGIN_ROOT = _find_plugin_root(ADAPTER_DIR)


# Optional debug logging — only writes when TRTC_HOOK_DEBUG_LOG is set to a
Expand Down
67 changes: 67 additions & 0 deletions tests/unit/test_cursor_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,73 @@ def test_cursor_project_dir_forwarded_as_claude_project_dir(self):
self.assertEqual(trace["env_CLAUDE_PROJECT_DIR"], "/some/project")


class TestPluginRootResolution(unittest.TestCase):
"""Adapter must locate PLUGIN_ROOT correctly under both layouts:

A) Original: <plugin>/hooks/cursor-adapter.py (PLUGIN_ROOT = <plugin>)
B) Namespaced: <plugin>/hooks/trtc-agent-skills/cursor-adapter.py (PLUGIN_ROOT = <plugin>)

Layout B is what the npx installer now uses so multiple skill packages can
co-exist under .cursor/hooks/. If PLUGIN_ROOT lands one level too low under
layout B, every DISPATCH path (`skills/.../guardrails/...`) misses and the
adapter silently fail-opens — Cursor users would see nothing at all.
Anchor that contract here.
"""

def _build(self, adapter_rel: Path) -> Path:
tmp = Path(tempfile.mkdtemp(prefix="trtc-adapter-loc-")).resolve()
adapter = tmp / adapter_rel
adapter.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(ADAPTER_SRC, adapter)
# Plant a stub the adapter will dispatch to. Path is the same regardless
# of where the adapter lives — relative to PLUGIN_ROOT, which we expect
# to be `tmp` in both layouts.
rel_script = DISPATCH["trtc-prepare-ui"]
stub = tmp / rel_script
stub.parent.mkdir(parents=True, exist_ok=True)
stub.write_text(textwrap.dedent(f"""\
#!/usr/bin/env python3
import os, sys, json
with open({json.dumps(str(tmp / 'hook-trace.json'))}, 'w') as f:
json.dump({{"env_CLAUDE_PLUGIN_ROOT": os.environ.get("CLAUDE_PLUGIN_ROOT", "")}}, f)
sys.exit(0)
"""))
stub.chmod(0o755)
return tmp, adapter

def _run(self, adapter: Path):
return subprocess.run(
["python3", str(adapter), "trtc-prepare-ui"],
input="",
capture_output=True,
text=True,
env=os.environ.copy(),
)

def test_layout_a_hooks_top_level(self):
tmp, adapter = self._build(Path("hooks") / "cursor-adapter.py")
try:
result = self._run(adapter)
self.assertEqual(result.returncode, 0, msg=result.stderr)
trace = json.loads((tmp / "hook-trace.json").read_text())
self.assertEqual(trace["env_CLAUDE_PLUGIN_ROOT"], str(tmp))
finally:
shutil.rmtree(tmp, ignore_errors=True)

def test_layout_b_hooks_namespaced_subdir(self):
tmp, adapter = self._build(
Path("hooks") / "trtc-agent-skills" / "cursor-adapter.py"
)
try:
result = self._run(adapter)
self.assertEqual(result.returncode, 0, msg=result.stderr)
trace = json.loads((tmp / "hook-trace.json").read_text())
# The whole point: PLUGIN_ROOT must still be `tmp`, NOT `tmp/hooks`.
self.assertEqual(trace["env_CLAUDE_PLUGIN_ROOT"], str(tmp))
finally:
shutil.rmtree(tmp, ignore_errors=True)


class TestExitCodeMapping(CursorAdapterTestBase):

def test_inner_exit_2_becomes_cursor_deny_envelope_and_exit_2(self):
Expand Down