From 3f47a348a4324bc94ae1dba616cd47410eb7efd5 Mon Sep 17 00:00:00 2001 From: jinku Date: Mon, 11 May 2026 20:03:48 -0700 Subject: [PATCH] Release 1.2.1 with native plugin hooks --- .codex-plugin/plugin.json | 3 +- CHANGELOG.md | 7 + README.md | 79 +-- internal-skills/cli-runtime/runtime.md | 4 +- internal-skills/review-runtime/runtime.md | 8 +- package-lock.json | 4 +- package.json | 2 +- scripts/claude-companion.mjs | 236 +++++++- scripts/install-hooks.mjs | 225 +------- scripts/installer-cli.mjs | 263 ++++++--- scripts/lib/codex-config.mjs | 84 ++- scripts/lib/installed-skill-paths.mjs | 31 - scripts/lib/render.mjs | 1 + scripts/local-plugin-install.mjs | 595 +------------------- skills/adversarial-review/SKILL.md | 16 +- skills/cancel/SKILL.md | 4 +- skills/rescue/SKILL.md | 8 +- skills/result/SKILL.md | 4 +- skills/review/SKILL.md | 16 +- skills/setup/SKILL.md | 10 +- skills/status/SKILL.md | 4 +- tests/e2e/codex-skills-e2e.test.mjs | 163 +++--- tests/install-hooks.test.mjs | 155 ++--- tests/installer-cli.test.mjs | 479 +++++----------- tests/integration/claude-companion.test.mjs | 180 ++++++ tests/render.test.mjs | 8 + tests/skills-contracts.test.mjs | 62 +- 27 files changed, 1061 insertions(+), 1590 deletions(-) delete mode 100644 scripts/lib/installed-skill-paths.mjs diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f6183cc..7377466 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "cc", - "version": "1.2.0", + "version": "1.2.1", "description": "Claude Code Plugin for Codex. Delegate code reviews, investigations, and tracked tasks to Claude Code from inside Codex.", "author": { "name": "Sendbird, Inc.", @@ -18,6 +18,7 @@ "agent" ], "skills": "./skills/", + "hooks": "./hooks/hooks.json", "interface": { "displayName": "Claude Code", "shortDescription": "Claude Code Plugin for Codex", diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bab30d..0be9b8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## v1.2.1 + +- Switch marketplace installs to Codex native plugin hooks: bundled hooks now load from `hooks/hooks.json` in the active plugin cache with `$PLUGIN_ROOT` instead of writing managed global hook commands into `~/.codex/hooks.json`. +- Remove the local checkout/stable-root install path from the supported install flow. The installer now uses `marketplace/add` + `plugin/install`, cleans stale `~/.codex/plugins/cc` state, and enables `[features].hooks` plus `[features].plugin_hooks`. +- Update public skills to resolve the active plugin root from their `SKILL.md` path, so marketplace cache installs run the matching companion code after plugin updates. +- Refresh README, setup, installer, and E2E coverage around the marketplace/cache-only install path, native hook feature-gate repair, and `$cc:setup` trust repair for this plugin's hook hashes. + ## v1.2.0 - Default the Claude model for `review`, `adversarial-review`, and `rescue`/`task` to `opus` (resolved to the 1M-context variant `claude-opus-4-7[1m]`) with `xhigh` effort. The `sonnet` alias resolves to `claude-sonnet-4-6[1m]` and defaults to `high` effort; `haiku` stays on `claude-haiku-4-5` with effort unset. `--model` and `--effort` remain user-overridable; `xhigh` is now a first-class effort level and `max` is reserved for users who explicitly opt in. diff --git a/README.md b/README.md index bf39c4e..d7200aa 100644 --- a/README.md +++ b/README.md @@ -43,36 +43,23 @@ It follows the shape of [openai/codex-plugin-cc](https://github.com/openai/codex ### 1. Install -Use one of these install paths, in this order: - -1. **Sendbird marketplace (preferred)** - ```bash - codex marketplace add sendbird/codex-marketplace - ``` - Then install `cc` from the Sendbird marketplace inside Codex, and run `$cc:setup` once. Marketplace/plugin install places the plugin, but this plugin still owns global hook setup and repair. - -2. **`npx` installer** - ```bash - npx cc-plugin-codex install - ``` - This is the cross-platform path we test on every release. - -3. **Local checkout install** - ```bash - git clone https://github.com/sendbird/cc-plugin-codex.git ~/.codex/plugins/cc - cd ~/.codex/plugins/cc - node scripts/local-plugin-install.mjs install --plugin-root ~/.codex/plugins/cc - ``` - After install, run `$cc:setup`. - -The `npx` installer: -- Copies the plugin to `~/.codex/plugins/cc` -- Activates the plugin through Codex app-server when available -- Falls back to config-based activation on older Codex builds -- Enables `codex_hooks = true` -- Installs lifecycle, review-gate, and unread-result hooks - -On Windows, prefer either the Sendbird marketplace path or the `npx` path. The shell-script installer below is POSIX-only. +Install from the Sendbird marketplace: + +```bash +codex marketplace add sendbird/codex-marketplace +``` + +Then install `cc` from the Sendbird marketplace inside Codex, and run `$cc:setup` once. + +`cc-plugin-codex` uses Codex native plugin hooks. The active plugin copy lives under Codex's plugin cache, and hook commands resolve through `$PLUGIN_ROOT`; there is no separate local checkout install. + +The optional `npx` helper runs the same marketplace/cache install path and enables the required Codex feature gates: + +```bash +npx cc-plugin-codex install +``` + +On Windows, prefer the Sendbird marketplace path or the `npx` helper. The shell-script helper below is POSIX-only. Codex CLI's official guidance still treats Windows support as experimental and recommends a WSL workspace for the best Codex experience. Claude Code supports both native Windows and WSL. > **Prerequisites:** Node.js 18+, Codex with hook support, and `claude` CLI installed and authenticated. @@ -232,8 +219,8 @@ $cc:setup --enable-review-gate # turn on stop-time review gate $cc:setup --disable-review-gate # turn it off ``` -Setup checks Claude Code availability, hook installation, and review-gate state. If hooks are missing, it reinstalls them. If Claude Code isn't installed, it offers to install it. -This is also the repair path for marketplace-installed copies of the plugin: marketplace install can place the plugin, but `$cc:setup` is what confirms `codex_hooks = true` and installs the managed global hooks if they are missing. +Setup checks Claude Code availability, native plugin hook feature gates, and review-gate state. If Claude Code isn't installed, it offers to install it. +This is also the repair path for marketplace-installed copies of the plugin: `$cc:setup` confirms `[features].hooks = true` and `[features].plugin_hooks = true`, then trusts this plugin's current native hook hashes so Codex loads the bundled hooks from the active plugin cache. ## Background Jobs @@ -296,7 +283,7 @@ The review gate is an **optional** stop-time hook. When enabled, pressing Ctrl+C - **Tracked job ownership** — background jobs track unread/viewed state and session ownership, with safe PID-validated cleanup on session exit. - **Built-in background notify** — rescue and review flows can now wake the parent thread and point directly to `$cc:result ` instead of relying only on later polling. - **Unread-result nudges** — completed background jobs are still surfaced in your next prompt as a reliable fallback. -- **Idempotent installer** — manages plugin files, hooks, and config in a single atomic step. Safe to re-run for updates. Falls back gracefully on older Codex builds. +- **Idempotent installer** — installs through Codex's marketplace/cache path and enables native hook feature gates. Safe to re-run for updates. ## Install Variants @@ -314,9 +301,9 @@ Then install `cc` from the Sendbird marketplace inside Codex, and run: $cc:setup ``` -Marketplace/plugin install places the plugin, but it does **not** install this plugin's managed global hooks for you. `$cc:setup` is the repair/install step that confirms `codex_hooks = true` and installs hooks when they are missing. +Marketplace/plugin install places the plugin under Codex's plugin cache. `$cc:setup` verifies Claude Code, confirms `[features].hooks = true` plus `[features].plugin_hooks = true`, and trusts the current `hooks/hooks.json` hook hashes from the active plugin cache. -### npx +### npx helper ```bash npx cc-plugin-codex install @@ -328,21 +315,7 @@ After install, run: $cc:setup ``` -### Local checkout - -```bash -git clone https://github.com/sendbird/cc-plugin-codex.git ~/.codex/plugins/cc -cd ~/.codex/plugins/cc -node scripts/local-plugin-install.mjs install --plugin-root ~/.codex/plugins/cc -``` - -After install, run: - -```text -$cc:setup -``` - -`local-plugin-install.mjs` expects `--plugin-root` to be the managed install directory itself. If you want to install from an arbitrary checkout path, use `npx cc-plugin-codex install` instead. +The helper adds the Sendbird marketplace, installs `cc` through Codex app-server, enables native hook feature gates, and removes stale global hook entries from older installs. ### Shell script (POSIX-only) @@ -358,7 +331,7 @@ $cc:setup ### Update -Re-run the install command — it's idempotent. +Re-run the marketplace update/install flow or the `npx` helper — both are idempotent. ```bash npx cc-plugin-codex update @@ -379,10 +352,10 @@ claude auth login ``` **Commands not recognized in Codex** -Re-run install. If your Codex build doesn't support `plugin/install`, the installer falls back to config-based activation and generates skill wrapper files automatically. You'll see a warning in the install output. +Re-run install and restart Codex. This plugin expects Codex plugin support and no longer installs local skill-wrapper fallbacks. **Hooks not firing** -Check that `codex_hooks = true` is set in `~/.codex/config.toml` under `[features]`. Run `$cc:setup` to verify and auto-repair. +Check that `hooks = true` and `plugin_hooks = true` are set in `~/.codex/config.toml` under `[features]`. Run `$cc:setup` to verify and auto-repair the feature gates plus this plugin's hook trust hashes, then restart Codex if those flags were just changed. **A background job finished but I did not get the result nudge** Use: diff --git a/internal-skills/cli-runtime/runtime.md b/internal-skills/cli-runtime/runtime.md index c638ec2..a54c81e 100644 --- a/internal-skills/cli-runtime/runtime.md +++ b/internal-skills/cli-runtime/runtime.md @@ -2,10 +2,10 @@ Use this document only inside the rescue forwarding worker spawned by `$cc:rescue` as defined in `../../skills/rescue/SKILL.md`. This is an internal execution contract, not a public skill. It owns execution and routing. It does not own prompt rewriting beyond deciding when to consult the prompt-shaping reference. -The public rescue skill already resolved the installed plugin root. Reuse that installed copy path here. Do not derive a new runtime path from this document, any cache directory, or the current working tree. +The public rescue skill already resolved the active plugin root from its `SKILL.md` path. Reuse that path here. Do not derive a new runtime path from this document or the current working tree. Primary helper: -- `node "/scripts/claude-companion.mjs" task ...` +- `node "/scripts/claude-companion.mjs" task ...` Execution rules: - The rescue subagent is a forwarder, not an operator. Launch exactly one `task` command and return that stdout unchanged. diff --git a/internal-skills/review-runtime/runtime.md b/internal-skills/review-runtime/runtime.md index a26b2c9..692c6e3 100644 --- a/internal-skills/review-runtime/runtime.md +++ b/internal-skills/review-runtime/runtime.md @@ -2,17 +2,17 @@ Use this document only when the main Codex thread or a built-in forwarding child is executing a Claude Code `review` or `adversarial-review` command. This is an internal runtime reference, not a public skill. It captures the exact companion-command contract and the foreground/background execution boundary. -The public skill already resolved the installed plugin root. Reuse that installed copy path here. Do not derive a new runtime path from this document, any cache directory, or the current working tree. +The public skill already resolved the active plugin root from its `SKILL.md` path. Reuse that path here. Do not derive a new runtime path from this document or the current working tree. Primary helper: -- `node "/scripts/claude-companion.mjs" review ...` -- `node "/scripts/claude-companion.mjs" adversarial-review ...` +- `node "/scripts/claude-companion.mjs" review ...` +- `node "/scripts/claude-companion.mjs" adversarial-review ...` Execution boundary: - Foreground review stays on the main Codex thread. Do not satisfy foreground review through a review subagent, a generic review-runner role, or any background worker abstraction. - Background review uses exactly one built-in forwarding child through `spawn_agent`. - Never satisfy either mode with raw `claude`, `claude-code`, `claude review`, hand-rolled `bash -lc ...claude...`, or detached companion shell backgrounding. -- If the installed companion command fails, surface that failure instead of improvising a different executor. +- If the resolved companion command fails, surface that failure instead of improvising a different executor. Foreground contract: - Strip `--wait` and `--background` before building the companion command. diff --git a/package-lock.json b/package-lock.json index dadb9cb..01a287a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "cc-plugin-codex", - "version": "1.1.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cc-plugin-codex", - "version": "1.1.0", + "version": "1.2.1", "license": "Apache-2.0", "bin": { "cc-plugin-codex": "scripts/installer-cli.mjs" diff --git a/package.json b/package.json index 12adf18..29a40f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "cc-plugin-codex", - "version": "1.2.0", + "version": "1.2.1", "description": "Claude Code Plugin for Codex by Sendbird", "type": "module", "author": { diff --git a/scripts/claude-companion.mjs b/scripts/claude-companion.mjs index 414feb5..053b549 100644 --- a/scripts/claude-companion.mjs +++ b/scripts/claude-companion.mjs @@ -60,6 +60,11 @@ import { resolveReviewTarget } from "./lib/git.mjs"; import { binaryAvailable, getProcessIdentity } from "./lib/process.mjs"; +import { callCodexAppServer } from "./lib/codex-app-server.mjs"; +import { + ensureNativePluginHooksEnabled, + nativePluginHooksStatus, +} from "./lib/codex-config.mjs"; import { loadPromptTemplate, interpolateTemplate } from "./lib/prompts.mjs"; import { parseStructuredOutput } from "./lib/structured-output.mjs"; import { @@ -334,20 +339,200 @@ function readOutputSchema(schemaPath) { // Readiness checks // --------------------------------------------------------------------------- +function readCodexConfig() { + if (!fs.existsSync(CODEX_CONFIG_TOML)) { + return ""; + } + return fs.readFileSync(CODEX_CONFIG_TOML, "utf8"); +} + +function writeCodexConfig(content) { + fs.mkdirSync(path.dirname(CODEX_CONFIG_TOML), { recursive: true }); + fs.writeFileSync(CODEX_CONFIG_TOML, content, "utf8"); +} + +function configureNativePluginHooks() { + const existing = readCodexConfig(); + const { changed, content } = ensureNativePluginHooksEnabled(existing); + if (changed || !fs.existsSync(CODEX_CONFIG_TOML)) { + writeCodexConfig(content); + } + return changed; +} + +function currentPluginCacheInstallInfo() { + const cacheRoot = path.join(CODEX_DIR, "plugins", "cache"); + const relativePath = path.relative(cacheRoot, ROOT_DIR); + if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + return null; + } + const [marketplaceName, pluginName, version] = relativePath + .split(path.sep) + .filter(Boolean); + if (!marketplaceName || pluginName !== "cc" || !version) { + return null; + } + return { + marketplaceName, + pluginName, + version, + pluginId: `${pluginName}@${marketplaceName}`, + }; +} + +function shouldRepairPluginHookTrust() { + return ( + Boolean(currentPluginCacheInstallInfo()) || + process.env.CC_PLUGIN_CODEX_FORCE_HOOK_TRUST === "1" + ); +} + +function pathIsInsideRoot(filePath) { + if (typeof filePath !== "string" || !filePath) { + return false; + } + const relativePath = path.relative(ROOT_DIR, path.resolve(filePath)); + return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); +} + +function isCurrentPluginHook(hook, pluginInfo) { + if (!hook || typeof hook !== "object") { + return false; + } + if (String(hook.source || "").toLowerCase() !== "plugin") { + return false; + } + if (pluginInfo?.pluginId && hook.pluginId !== pluginInfo.pluginId) { + return false; + } + if (pluginInfo == null && typeof hook.pluginId === "string" && !hook.pluginId.startsWith("cc@")) { + return false; + } + return pathIsInsideRoot(hook.sourcePath); +} + +function hookNeedsTrust(hook) { + const trustStatus = String(hook?.trustStatus || "").toLowerCase(); + return trustStatus === "untrusted" || trustStatus === "modified"; +} + +async function repairNativePluginHookTrust(cwd) { + const pluginInfo = currentPluginCacheInstallInfo(); + if (!shouldRepairPluginHookTrust()) { + return { + attempted: false, + ready: true, + detail: "not running from an installed Codex plugin cache", + }; + } + + let response; + try { + response = await callCodexAppServer({ + cwd, + method: "hooks/list", + params: { cwds: [cwd] }, + }); + } catch (error) { + return { + attempted: true, + ready: false, + detail: `unable to inspect native plugin hooks: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + const entries = Array.isArray(response?.data) ? response.data : []; + const hooks = entries.flatMap((entry) => (Array.isArray(entry?.hooks) ? entry.hooks : [])); + const pluginHooks = hooks.filter((hook) => isCurrentPluginHook(hook, pluginInfo)); + const untrustedHooks = pluginHooks.filter( + (hook) => hookNeedsTrust(hook) && typeof hook.key === "string" && hook.currentHash + ); + + if (pluginHooks.length === 0) { + return { + attempted: true, + ready: false, + found: 0, + trusted: 0, + detail: "no native plugin hooks were reported for this plugin", + }; + } + if (untrustedHooks.length === 0) { + return { + attempted: true, + ready: true, + found: pluginHooks.length, + trusted: 0, + detail: `native plugin hooks already trusted (${pluginHooks.length})`, + }; + } + + const value = Object.fromEntries( + untrustedHooks.map((hook) => [ + hook.key, + { + trusted_hash: hook.currentHash, + }, + ]) + ); + + try { + await callCodexAppServer({ + cwd, + method: "config/batchWrite", + params: { + edits: [ + { + keyPath: "hooks.state", + value, + mergeStrategy: "upsert", + }, + ], + filePath: null, + expectedVersion: null, + reloadUserConfig: true, + }, + }); + } catch (error) { + return { + attempted: true, + ready: false, + found: pluginHooks.length, + trusted: 0, + detail: `unable to trust native plugin hooks: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + return { + attempted: true, + ready: true, + found: pluginHooks.length, + trusted: untrustedHooks.length, + detail: `trusted ${untrustedHooks.length} native plugin hooks`, + }; +} + function checkHooksStatus() { - const hooksFile = path.join(CODEX_DIR, "hooks.json"); - if (!fs.existsSync(hooksFile)) { - return { installed: false, detail: "hooks.json not found — run install-hooks.mjs" }; + const bundledHooksFile = path.join(ROOT_DIR, "hooks", "hooks.json"); + if (!fs.existsSync(bundledHooksFile)) { + return { + installed: false, + detail: `plugin-bundled hooks file missing at ${bundledHooksFile}`, + }; } - const content = fs.readFileSync(hooksFile, "utf8"); - if ( - content.includes("session-lifecycle-hook.mjs") && - content.includes("stop-review-gate-hook.mjs") && - content.includes("unread-result-hook.mjs") - ) { - return { installed: true, detail: "Codex hooks installed" }; + + const status = nativePluginHooksStatus(readCodexConfig()); + if (status.installed) { + return { installed: true, detail: "native Codex plugin hooks enabled" }; } - return { installed: false, detail: "hooks.json exists but Codex hooks not found — run install-hooks.mjs" }; + return { + installed: false, + detail: `native Codex plugin hooks disabled: missing ${status.missing.join(", ")}`, + }; } function ensureClaudeReady(cwd) { @@ -364,7 +549,7 @@ function ensureClaudeReady(cwd) { } } -function buildSetupReport(cwd, actionsTaken = []) { +function buildSetupReport(cwd, actionsTaken = [], hookTrust = null) { const workspaceRoot = resolveWorkspaceRoot(cwd); const nodeStatus = binaryAvailable("node", ["--version"], { cwd }); const claudeStatus = getClaudeAvailability(cwd); @@ -380,7 +565,10 @@ function buildSetupReport(cwd, actionsTaken = []) { nextSteps.push("Run `claude auth login`."); } if (!hooksStatus.installed) { - nextSteps.push("Run `node scripts/install-hooks.mjs` to install Codex hooks."); + nextSteps.push("Run `$cc:setup` again after enabling native Codex plugin hooks."); + } + if (hookTrust?.ready === false) { + nextSteps.push("Open `/hooks` and trust this plugin's hooks manually, then rerun `$cc:setup`."); } if (!config.stopReviewGate) { nextSteps.push( @@ -393,11 +581,13 @@ function buildSetupReport(cwd, actionsTaken = []) { nodeStatus.available && claudeStatus.available && authStatus.loggedIn && - hooksStatus.installed, + hooksStatus.installed && + hookTrust?.ready !== false, node: nodeStatus, claude: claudeStatus, auth: authStatus, hooks: hooksStatus, + hookTrust, reviewGateEnabled: Boolean(config.stopReviewGate), actionsTaken, nextSteps @@ -408,7 +598,7 @@ function buildSetupReport(cwd, actionsTaken = []) { // setup // --------------------------------------------------------------------------- -function handleSetup(argv) { +async function handleSetup(argv) { const { options } = parseCommandInput(argv, { valueOptions: ["cwd"], booleanOptions: ["json", "enable-review-gate", "disable-review-gate"] @@ -422,6 +612,18 @@ function handleSetup(argv) { const workspaceRoot = resolveCommandWorkspace(options); const actionsTaken = []; + if (configureNativePluginHooks()) { + actionsTaken.push( + "Enabled native Codex plugin hooks via [features].hooks and [features].plugin_hooks." + ); + actionsTaken.push("Restart Codex if this session started before the feature change."); + } + + const hookTrust = await repairNativePluginHookTrust(cwd); + if (hookTrust.trusted > 0) { + actionsTaken.push(`Trusted ${hookTrust.trusted} native Codex plugin hooks.`); + } + if (options["enable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", true); actionsTaken.push(`Enabled the stop-time review gate for ${workspaceRoot}.`); @@ -430,7 +632,7 @@ function handleSetup(argv) { actionsTaken.push(`Disabled the stop-time review gate for ${workspaceRoot}.`); } - const finalReport = buildSetupReport(cwd, actionsTaken); + const finalReport = buildSetupReport(cwd, actionsTaken, hookTrust); outputResult( options.json ? finalReport : renderSetupReport(finalReport), options.json @@ -1832,7 +2034,7 @@ async function main() { switch (subcommand) { case "setup": - handleSetup(argv); + await handleSetup(argv); break; case "review": await handleReview(argv); diff --git a/scripts/install-hooks.mjs b/scripts/install-hooks.mjs index 1558203..20186c2 100644 --- a/scripts/install-hooks.mjs +++ b/scripts/install-hooks.mjs @@ -6,41 +6,29 @@ */ /** - * install-hooks.mjs — Installs plugin hooks. + * install-hooks.mjs — Legacy compatibility wrapper. * - * Steps: - * 1. Read hooks/hooks.json from plugin dir (resolve relative to import.meta.url) - * 2. Replace $PLUGIN_ROOT with absolute path to plugin directory - * 3. Read existing ~/.codex/hooks.json (or empty {hooks:{}}) - * 4. For each event type, append new hooks (don't overwrite existing) - * 5. Write merged result - * 6. Ensure ~/.codex/config.toml has codex_hooks = true + * Native Codex plugin hooks are loaded from hooks/hooks.json in the plugin cache. + * This script now only enables the required feature gates and removes stale + * global hook entries from older cc-plugin-codex installs. */ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { ensureCodexHooksEnabled } from "./lib/codex-config.mjs"; +import { ensureNativePluginHooksEnabled } from "./lib/codex-config.mjs"; import { resolveCodexHome } from "./lib/codex-paths.mjs"; +import { removeManagedHooks } from "./lib/managed-global-integration.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = path.resolve(SCRIPT_DIR, ".."); -const PLUGIN_HOOKS_FILE = path.join(PLUGIN_ROOT, "hooks", "hooks.json"); const CODEX_DIR = resolveCodexHome(); -const CODEX_HOOKS_FILE = path.join(CODEX_DIR, "hooks.json"); const CODEX_CONFIG_TOML = path.join(CODEX_DIR, "config.toml"); // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -function readJsonFile(filePath) { - if (!fs.existsSync(filePath)) { - return null; - } - return JSON.parse(fs.readFileSync(filePath, "utf8")); -} - function readTextFile(filePath) { if (!fs.existsSync(filePath)) { return null; @@ -53,214 +41,29 @@ function writeTextFile(filePath, content) { fs.writeFileSync(filePath, content, "utf8"); } -function writeJsonFile(filePath, data) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + "\n", "utf8"); -} - -function configureCodexHooks() { +function configureNativePluginHooks() { const existing = readTextFile(CODEX_CONFIG_TOML) ?? ""; - const { changed, content } = ensureCodexHooksEnabled(existing); + const { changed, content } = ensureNativePluginHooksEnabled(existing); if (changed || !fs.existsSync(CODEX_CONFIG_TOML)) { writeTextFile(CODEX_CONFIG_TOML, content); } return changed; } -function escapeShellArgument(value) { - const text = String(value); - if (process.platform === "win32") { - return `"${text.replace(/"/g, '""')}"`; - } - return `'${text.replace(/'/g, `'\\''`)}'`; -} - -function resolvePluginSubpath(relativePath) { - const normalized = String(relativePath ?? ""); - if (!normalized || path.isAbsolute(normalized)) { - throw new Error(`Invalid plugin-relative path: ${normalized}`); - } - const resolved = path.resolve(PLUGIN_ROOT, normalized); - const pluginRootWithSep = `${PLUGIN_ROOT}${path.sep}`; - if (resolved !== PLUGIN_ROOT && !resolved.startsWith(pluginRootWithSep)) { - throw new Error(`Refusing to resolve path outside the plugin root: ${normalized}`); - } - return resolved; -} - -function normalizeCommandForComparison(command) { - return String(command) - .replace(/\\(?=["'])/g, "") - .replace(/["']/g, "") - .replace(/\s+/g, " ") - .trim(); -} - -function resolvePluginRoot(text) { - return text.replace(/\$PLUGIN_ROOT/g, PLUGIN_ROOT); -} - -function resolveHookCommand(command) { - return command.replace(/"\$PLUGIN_ROOT\/([^"]+)"/g, (_, relativePath) => - escapeShellArgument(resolvePluginSubpath(relativePath)) - ); -} - -function deepReplacePlaceholders(obj) { - if (typeof obj === "string") { - return resolvePluginRoot(obj); - } - if (Array.isArray(obj)) { - return obj.map(deepReplacePlaceholders); - } - if (obj && typeof obj === "object") { - const result = {}; - for (const [key, value] of Object.entries(obj)) { - if (key === "command" && typeof value === "string") { - result[key] = resolveHookCommand(value); - continue; - } - result[key] = deepReplacePlaceholders(value); - } - return result; - } - return obj; -} - -/** - * Check if a hook entry is a duplicate of an existing one. - * Two hook entries are considered duplicates if they have the same - * command string (after placeholder resolution). - */ -function isDuplicateHookEntry(existing, candidate) { - const existingHooks = existing.hooks ?? []; - const candidateHooks = candidate.hooks ?? []; - - if (candidateHooks.length === 0) return false; - - // Check if any candidate hook command already exists in existing hooks - for (const ch of candidateHooks) { - if (!ch.command) continue; - for (const eh of existingHooks) { - if ( - eh.type === ch.type && - normalizeCommandForComparison(eh.command) === - normalizeCommandForComparison(ch.command) - ) { - return true; - } - } - } - return false; -} - -function dedupeHookEntries(entries) { - const seen = new Set(); - const deduped = []; - - for (const entry of entries) { - const hooks = entry?.hooks ?? []; - const matcher = entry?.matcher ?? ""; - const signature = hooks - .map((hook) => - [ - hook?.type ?? "", - normalizeCommandForComparison(hook?.command ?? ""), - matcher, - ].join("|") - ) - .join("||"); - - if (seen.has(signature)) { - continue; - } - - seen.add(signature); - deduped.push(entry); - } - - return deduped; -} - // --------------------------------------------------------------------------- // Main // --------------------------------------------------------------------------- function main() { - // Step 1: Read plugin hooks template - const pluginHooksRaw = readJsonFile(PLUGIN_HOOKS_FILE); - if (!pluginHooksRaw || !pluginHooksRaw.hooks) { - console.error("Error: Could not read plugin hooks template at", PLUGIN_HOOKS_FILE); - process.exit(1); - } - - // Step 2: Replace $PLUGIN_ROOT with actual path - const pluginHooks = deepReplacePlaceholders(pluginHooksRaw); - console.log(`Plugin root resolved to: ${PLUGIN_ROOT}`); - - // Step 3: Read existing hooks.json (or create empty) - let existingHooks = readJsonFile(CODEX_HOOKS_FILE); - if (!existingHooks) { - existingHooks = { hooks: {} }; - console.log("No existing hooks.json found, creating new one."); - } else { - console.log(`Found existing hooks.json at ${CODEX_HOOKS_FILE}`); - } - - if (!existingHooks.hooks) { - existingHooks.hooks = {}; - } - - for (const [eventType, entries] of Object.entries(existingHooks.hooks)) { - if (Array.isArray(entries)) { - existingHooks.hooks[eventType] = dedupeHookEntries(entries); - } - } - - // Step 4: Merge — for each event type, append new hooks without overwriting - let addedCount = 0; - let skippedCount = 0; + const nativeHooksChanged = configureNativePluginHooks(); + removeManagedHooks(PLUGIN_ROOT); - for (const [eventType, entries] of Object.entries(pluginHooks.hooks)) { - if (!Array.isArray(entries)) continue; - - if (!existingHooks.hooks[eventType]) { - existingHooks.hooks[eventType] = []; - } - - for (const entry of entries) { - // Check for duplicates - const alreadyExists = existingHooks.hooks[eventType].some((existing) => - isDuplicateHookEntry(existing, entry) - ); - - if (alreadyExists) { - skippedCount++; - console.log(` [skip] ${eventType}: hook already exists`); - } else { - existingHooks.hooks[eventType].push(entry); - addedCount++; - console.log(` [add] ${eventType}: added hook entry`); - } - } - } - - // Step 5: Write merged result - writeJsonFile(CODEX_HOOKS_FILE, existingHooks); - console.log(`\nWrote ${CODEX_HOOKS_FILE}`); - console.log(` Added: ${addedCount} hook entries`); - console.log(` Skipped: ${skippedCount} duplicate entries`); - - // Step 6: Ensure config.toml enables codex_hooks - const codexHooksChanged = configureCodexHooks(); - if (codexHooksChanged) { - console.log("\nEnabled codex_hooks in ~/.codex/config.toml."); + if (nativeHooksChanged) { + console.log("Enabled native Codex plugin hooks in ~/.codex/config.toml."); } else { - console.log("\nCodex hooks are enabled in config.toml. Ready to go."); + console.log("Native Codex plugin hooks are already enabled."); } - - console.log(""); - console.log("Codex hooks installation complete."); + console.log("Codex now loads this plugin's hooks from hooks/hooks.json in the active plugin cache."); } main(); diff --git a/scripts/installer-cli.mjs b/scripts/installer-cli.mjs index 1507f9b..8ba2540 100755 --- a/scripts/installer-cli.mjs +++ b/scripts/installer-cli.mjs @@ -10,31 +10,29 @@ import os from "node:os"; import path from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; -import { spawnSync } from "node:child_process"; -import { samePath, resolveCodexHome } from "./lib/codex-paths.mjs"; -import { materializeInstalledSkillPaths } from "./lib/installed-skill-paths.mjs"; -import { listManagedPluginCacheEntries } from "./lib/plugin-identity.mjs"; +import { callCodexAppServer } from "./lib/codex-app-server.mjs"; +import { ensureNativePluginHooksEnabled } from "./lib/codex-config.mjs"; +import { resolveCodexHome } from "./lib/codex-paths.mjs"; +import { + LEGACY_MARKETPLACE_NAME, + listManagedPluginCacheEntries, + pluginIdForMarketplace, + PLUGIN_NAME, +} from "./lib/plugin-identity.mjs"; +import { + cleanupManagedGlobalIntegrations, + removeManagedSkillWrappers, +} from "./lib/managed-global-integration.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PACKAGE_ROOT = path.resolve(SCRIPT_DIR, ".."); const CODEX_HOME = resolveCodexHome(); -const INSTALL_DIR = path.join(CODEX_HOME, "plugins", "cc"); -const INCLUDED_PATHS = [ - ".codex-plugin", - "CHANGELOG.md", - "LICENSE", - "NOTICE", - "README.md", - "agents", - "assets", - "hooks", - "internal-skills", - "package.json", - "prompts", - "schemas", - "scripts", - "skills", -]; +const HOME_DIR = os.homedir(); +const CODEX_CONFIG_FILE = path.join(CODEX_HOME, "config.toml"); +const LEGACY_INSTALL_DIR = path.join(CODEX_HOME, "plugins", PLUGIN_NAME); +const PERSONAL_MARKETPLACE_FILE = path.join(HOME_DIR, ".agents", "plugins", "marketplace.json"); +const DEFAULT_MARKETPLACE_NAME = "sendbird"; +const DEFAULT_MARKETPLACE_SOURCE = "sendbird/codex-marketplace"; function usage() { console.error("Usage: cc-plugin-codex "); @@ -49,9 +47,16 @@ function parseArgs(argv) { return { command }; } -function ensureEmptyDir(dirPath) { - fs.rmSync(dirPath, { recursive: true, force: true }); - fs.mkdirSync(dirPath, { recursive: true }); +function readText(filePath) { + if (!fs.existsSync(filePath)) { + return ""; + } + return fs.readFileSync(filePath, "utf8"); +} + +function writeText(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, content, "utf8"); } function removeIfEmpty(dirPath) { @@ -63,94 +68,194 @@ function removeIfEmpty(dirPath) { } } -function copyDistribution(sourceRoot, destinationRoot) { - ensureEmptyDir(destinationRoot); - for (const relativePath of INCLUDED_PATHS) { - const sourcePath = path.join(sourceRoot, relativePath); - if (!fs.existsSync(sourcePath)) { - continue; - } - const destinationPath = path.join(destinationRoot, relativePath); - fs.mkdirSync(path.dirname(destinationPath), { recursive: true }); - fs.cpSync(sourcePath, destinationPath, { recursive: true }); +function resolveInstallerMarketplaceConfig() { + const marketplaceName = + process.env.CC_PLUGIN_CODEX_MARKETPLACE_NAME?.trim() || DEFAULT_MARKETPLACE_NAME; + const source = + process.env.CC_PLUGIN_CODEX_MARKETPLACE_SOURCE?.trim() || DEFAULT_MARKETPLACE_SOURCE; + const refName = process.env.CC_PLUGIN_CODEX_MARKETPLACE_REF?.trim() || null; + const sparsePaths = (process.env.CC_PLUGIN_CODEX_MARKETPLACE_SPARSE_PATHS ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + + return { + marketplaceName, + source, + refName, + sparsePaths: sparsePaths.length > 0 ? sparsePaths : null, + }; +} + +function configureNativePluginHooks() { + const existing = readText(CODEX_CONFIG_FILE); + const { changed, content } = ensureNativePluginHooksEnabled(existing); + if (changed || !fs.existsSync(CODEX_CONFIG_FILE)) { + writeText(CODEX_CONFIG_FILE, content); } + return changed; } -function stageInstall(sourceRoot, installDir) { - if (samePath(sourceRoot, installDir)) { +function removePersonalMarketplaceCcEntries() { + if (!fs.existsSync(PERSONAL_MARKETPLACE_FILE)) { + return; + } + const parsed = JSON.parse(fs.readFileSync(PERSONAL_MARKETPLACE_FILE, "utf8")); + if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.plugins)) { + return; + } + const nextPlugins = parsed.plugins.filter((plugin) => plugin?.name !== PLUGIN_NAME); + if (nextPlugins.length === parsed.plugins.length) { return; } + if (nextPlugins.length === 0) { + fs.rmSync(PERSONAL_MARKETPLACE_FILE, { force: true }); + removeIfEmpty(path.dirname(PERSONAL_MARKETPLACE_FILE)); + removeIfEmpty(path.dirname(path.dirname(PERSONAL_MARKETPLACE_FILE))); + return; + } + parsed.plugins = nextPlugins; + writeText(PERSONAL_MARKETPLACE_FILE, `${JSON.stringify(parsed, null, 2)}\n`); +} - const stagingParent = path.dirname(installDir); - fs.mkdirSync(stagingParent, { recursive: true }); - const stagingDir = fs.mkdtempSync(path.join(stagingParent, ".cc-staging-")); +function normalizeTrailingNewline(text) { + return `${String(text).replace(/\s*$/, "")}\n`; +} - try { - copyDistribution(sourceRoot, stagingDir); - materializeInstalledSkillPaths(stagingDir, installDir); - fs.rmSync(installDir, { recursive: true, force: true }); - fs.renameSync(stagingDir, installDir); - } catch (error) { - fs.rmSync(stagingDir, { recursive: true, force: true }); - throw error; +function removeManagedPluginConfigSections() { + if (!fs.existsSync(CODEX_CONFIG_FILE)) { + return; + } + const lines = fs.readFileSync(CODEX_CONFIG_FILE, "utf8").split("\n"); + const kept = []; + let skip = false; + let changed = false; + + for (const line of lines) { + const trimmed = line.trim(); + if (skip && trimmed.startsWith("[")) { + skip = false; + } + if (!skip && /^\[\s*plugins\s*\.\s*["']?cc@([^"'\]]+)["']?\s*\]\s*(?:#.*)?$/i.test(trimmed)) { + skip = true; + changed = true; + continue; + } + if (!skip) { + kept.push(line); + } } + + if (changed) { + writeText(CODEX_CONFIG_FILE, normalizeTrailingNewline(kept.join("\n").replace(/\n{3,}/g, "\n\n"))); + } +} + +function cleanupLegacyLocalInstall() { + cleanupManagedGlobalIntegrations(LEGACY_INSTALL_DIR); + cleanupManagedGlobalIntegrations(PACKAGE_ROOT); + removeManagedSkillWrappers(); + removePersonalMarketplaceCcEntries(); + fs.rmSync(LEGACY_INSTALL_DIR, { recursive: true, force: true }); +} + +async function addMarketplaceThroughCodex({ source, refName, sparsePaths }) { + const params = { source }; + if (refName) { + params.refName = refName; + } + if (sparsePaths && sparsePaths.length > 0) { + params.sparsePaths = sparsePaths; + } + + return await callCodexAppServer({ + cwd: PACKAGE_ROOT, + method: "marketplace/add", + params, + }); +} + +async function installPluginThroughCodex(marketplacePath) { + await callCodexAppServer({ + cwd: path.dirname(marketplacePath), + method: "plugin/install", + params: { + marketplacePath, + pluginName: PLUGIN_NAME, + forceRemoteSync: false, + }, + }); } -function runLocalInstaller(installDir, command) { - const installerPath = path.join(installDir, "scripts", "local-plugin-install.mjs"); - const result = spawnSync(process.execPath, [installerPath, command, "--plugin-root", installDir], { - stdio: "inherit", - env: { - ...process.env, - HOME: process.env.HOME || os.homedir(), - USERPROFILE: process.env.USERPROFILE || process.env.HOME || os.homedir(), - CC_PLUGIN_CODEX_SKILLS_MATERIALIZED: "1", +async function uninstallPluginThroughCodex(marketplaceName) { + await callCodexAppServer({ + cwd: CODEX_HOME, + method: "plugin/uninstall", + params: { + pluginId: pluginIdForMarketplace(marketplaceName), + forceRemoteSync: false, }, }); +} + +async function installOrUpdate() { + const marketplaceConfig = resolveInstallerMarketplaceConfig(); + const hooksChanged = configureNativePluginHooks(); + cleanupLegacyLocalInstall(); + + const marketplace = await addMarketplaceThroughCodex(marketplaceConfig); + const marketplacePath = path.join( + marketplace.installedRoot, + ".agents", + "plugins", + "marketplace.json" + ); + await installPluginThroughCodex(marketplacePath); - if (result.status !== 0) { - process.exit(result.status ?? 1); + console.log(`Installed ${PLUGIN_NAME} from ${marketplaceConfig.source} into the Codex plugin cache.`); + if (hooksChanged) { + console.log("Enabled [features].hooks and [features].plugin_hooks in ~/.codex/config.toml."); + console.log("Restart Codex to make newly enabled native plugin hooks active in existing sessions."); } } -function installOrUpdate() { - stageInstall(PACKAGE_ROOT, INSTALL_DIR); - runLocalInstaller(INSTALL_DIR, "install"); - console.log(`Plugin files installed to ${INSTALL_DIR}`); -} +async function uninstall() { + const marketplaceConfig = resolveInstallerMarketplaceConfig(); + cleanupLegacyLocalInstall(); -function uninstall() { - if (fs.existsSync(path.join(INSTALL_DIR, "scripts", "local-plugin-install.mjs"))) { - runLocalInstaller(INSTALL_DIR, "uninstall"); - } else if (fs.existsSync(PACKAGE_ROOT)) { - // Uninstall can still run from a package tarball even if the installed tree is gone. - stageInstall(PACKAGE_ROOT, INSTALL_DIR); - runLocalInstaller(INSTALL_DIR, "uninstall"); + for (const marketplaceName of [ + marketplaceConfig.marketplaceName, + DEFAULT_MARKETPLACE_NAME, + LEGACY_MARKETPLACE_NAME, + ]) { + try { + await uninstallPluginThroughCodex(marketplaceName); + } catch { + // Continue local cleanup across historical install modes. + } } + removeManagedPluginConfigSections(); - fs.rmSync(INSTALL_DIR, { recursive: true, force: true }); for (const cacheEntry of listManagedPluginCacheEntries(CODEX_HOME)) { fs.rmSync(cacheEntry.cachePath, { recursive: true, force: true }); } - const pluginsDir = path.dirname(INSTALL_DIR); + const cacheDir = path.join(CODEX_HOME, "plugins", "cache"); - if (fs.existsSync(pluginsDir) && fs.readdirSync(pluginsDir).length === 0) { - fs.rmdirSync(pluginsDir); - } if (fs.existsSync(cacheDir)) { for (const marketplaceName of fs.readdirSync(cacheDir)) { - removeIfEmpty(path.join(cacheDir, marketplaceName, "cc")); + removeIfEmpty(path.join(cacheDir, marketplaceName, PLUGIN_NAME)); removeIfEmpty(path.join(cacheDir, marketplaceName)); } removeIfEmpty(cacheDir); } - console.log(`Plugin files removed from ${INSTALL_DIR}`); + + console.log(`Uninstalled ${PLUGIN_NAME} from Codex plugin cache and removed legacy local installs.`); } const { command } = parseArgs(process.argv.slice(2)); if (command === "install" || command === "update") { - installOrUpdate(); + await installOrUpdate(); } else { - uninstall(); + await uninstall(); } diff --git a/scripts/lib/codex-config.mjs b/scripts/lib/codex-config.mjs index 8ea0fc6..908ff93 100644 --- a/scripts/lib/codex-config.mjs +++ b/scripts/lib/codex-config.mjs @@ -7,21 +7,27 @@ function normalizeTrailingNewline(text) { return `${String(text).replace(/\s*$/, "")}\n`; } -export function ensureCodexHooksEnabled(content) { +const REQUIRED_NATIVE_HOOK_FEATURES = ["hooks", "plugin_hooks"]; + +export function ensureNativePluginHooksEnabled(content) { const lines = String(content ?? "").split("\n"); const next = []; let inFeatures = false; let foundFeatures = false; - let foundCodexHooks = false; + const found = new Set(); let changed = false; for (const line of lines) { const trimmed = line.trim(); if (trimmed.startsWith("[") && trimmed.endsWith("]")) { - if (inFeatures && !foundCodexHooks) { - next.push("codex_hooks = true"); - foundCodexHooks = true; - changed = true; + if (inFeatures) { + for (const key of REQUIRED_NATIVE_HOOK_FEATURES) { + if (!found.has(key)) { + next.push(`${key} = true`); + found.add(key); + changed = true; + } + } } inFeatures = trimmed === "[features]"; foundFeatures ||= inFeatures; @@ -29,30 +35,42 @@ export function ensureCodexHooksEnabled(content) { continue; } - if (inFeatures && /^codex_hooks\s*=/.test(trimmed)) { - foundCodexHooks = true; - if (trimmed !== "codex_hooks = true") { - next.push("codex_hooks = true"); + if (inFeatures) { + const featureMatch = trimmed.match(/^([A-Za-z0-9_.-]+)\s*=/); + const featureKey = featureMatch?.[1] ?? null; + if (featureKey === "codex_hooks") { changed = true; - } else { - next.push(line); + continue; + } + if (REQUIRED_NATIVE_HOOK_FEATURES.includes(featureKey)) { + found.add(featureKey); + if (trimmed !== `${featureKey} = true`) { + next.push(`${featureKey} = true`); + changed = true; + } else { + next.push(line); + } + continue; } - continue; } next.push(line); } - if (inFeatures && !foundCodexHooks) { - next.push("codex_hooks = true"); - changed = true; + if (inFeatures) { + for (const key of REQUIRED_NATIVE_HOOK_FEATURES) { + if (!found.has(key)) { + next.push(`${key} = true`); + changed = true; + } + } } if (!foundFeatures) { if (next.length > 0 && next[next.length - 1].trim() !== "") { next.push(""); } - next.push("[features]", "codex_hooks = true"); + next.push("[features]", ...REQUIRED_NATIVE_HOOK_FEATURES.map((key) => `${key} = true`)); changed = true; } @@ -61,3 +79,35 @@ export function ensureCodexHooksEnabled(content) { content: normalizeTrailingNewline(next.join("\n").replace(/\n{3,}/g, "\n\n")), }; } + +export function nativePluginHooksStatus(content) { + const enabled = new Map(REQUIRED_NATIVE_HOOK_FEATURES.map((key) => [key, false])); + let inFeatures = false; + + for (const line of String(content ?? "").split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + inFeatures = trimmed === "[features]"; + continue; + } + if (!inFeatures) { + continue; + } + const match = trimmed.match(/^([A-Za-z0-9_.-]+)\s*=\s*(true|false)\s*(?:#.*)?$/i); + if (!match) { + continue; + } + const key = match[1] === "codex_hooks" ? "hooks" : match[1]; + if (enabled.has(key)) { + enabled.set(key, match[2].toLowerCase() === "true"); + } + } + + const missing = [...enabled.entries()] + .filter(([, value]) => !value) + .map(([key]) => key); + return { + installed: missing.length === 0, + missing, + }; +} diff --git a/scripts/lib/installed-skill-paths.mjs b/scripts/lib/installed-skill-paths.mjs deleted file mode 100644 index 405163f..0000000 --- a/scripts/lib/installed-skill-paths.mjs +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Copyright 2026 Sendbird, Inc. - * SPDX-License-Identifier: Apache-2.0 - */ -import fs from "node:fs"; -import path from "node:path"; - -import { normalizePathSlashes } from "./codex-paths.mjs"; - -export function materializeInstalledSkillPaths(skillTreeRoot, installedRoot = skillTreeRoot) { - const normalizedPluginRoot = normalizePathSlashes(installedRoot); - const skillsRoot = path.join(skillTreeRoot, "skills"); - if (!fs.existsSync(skillsRoot)) { - return; - } - - for (const skillName of fs.readdirSync(skillsRoot, { withFileTypes: true })) { - if (!skillName.isDirectory()) { - continue; - } - const skillPath = path.join(skillsRoot, skillName.name, "SKILL.md"); - if (!fs.existsSync(skillPath)) { - continue; - } - const original = fs.readFileSync(skillPath, "utf8"); - const rewritten = original.replaceAll("", normalizedPluginRoot); - if (rewritten !== original) { - fs.writeFileSync(skillPath, rewritten, "utf8"); - } - } -} diff --git a/scripts/lib/render.mjs b/scripts/lib/render.mjs index 26a0738..5407dbb 100644 --- a/scripts/lib/render.mjs +++ b/scripts/lib/render.mjs @@ -233,6 +233,7 @@ export function renderSetupReport(report) { `- claude: ${report.claude.detail}`, `- auth: ${report.auth.detail}`, `- hooks: ${report.hooks.detail}`, + ...(report.hookTrust ? [`- hook trust: ${report.hookTrust.detail}`] : []), `- review gate: ${report.reviewGateEnabled ? "enabled" : "disabled"}`, "", ]; diff --git a/scripts/local-plugin-install.mjs b/scripts/local-plugin-install.mjs index 246cea4..bab893a 100644 --- a/scripts/local-plugin-install.mjs +++ b/scripts/local-plugin-install.mjs @@ -5,589 +5,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import process from "node:process"; -import { spawnSync } from "node:child_process"; -import { fileURLToPath } from "node:url"; -import { callCodexAppServer } from "./lib/codex-app-server.mjs"; -import { ensureCodexHooksEnabled } from "./lib/codex-config.mjs"; -import { normalizePathSlashes, resolveCodexHome, samePath } from "./lib/codex-paths.mjs"; -import { materializeInstalledSkillPaths } from "./lib/installed-skill-paths.mjs"; -import { - parseManagedPluginSections, - getPreferredMarketplaceName, - LEGACY_MARKETPLACE_NAME, - pluginConfigHeader, - pluginIdForMarketplace, - PLUGIN_NAME, -} from "./lib/plugin-identity.mjs"; -import { - cleanupManagedGlobalIntegrations, - resolveManagedMarketplacePluginPath, - removeManagedSkillWrappers, -} from "./lib/managed-global-integration.mjs"; - -const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); -const DEFAULT_PLUGIN_ROOT = path.resolve(SCRIPT_DIR, ".."); -const MARKETPLACE_DISPLAY_NAME = "Local Plugins"; -const HOME_DIR = os.homedir(); -const CODEX_HOME = resolveCodexHome(); -const MARKETPLACE_FILE = path.join(HOME_DIR, ".agents", "plugins", "marketplace.json"); -const CODEX_CONFIG_FILE = path.join(CODEX_HOME, "config.toml"); -const CODEX_SKILLS_DIR = path.join(CODEX_HOME, "skills"); -const CODEX_PROMPTS_DIR = path.join(CODEX_HOME, "prompts"); -const INSTALLED_PLUGIN_ROOT = path.join(CODEX_HOME, "plugins", PLUGIN_NAME); -const EXPORTED_SKILLS = [ - "review", - "adversarial-review", - "rescue", - "status", - "result", - "cancel", - "setup", -]; -function resolveInstallerMarketplaceConfig() { - const configuredName = - process.env.CC_PLUGIN_CODEX_MARKETPLACE_NAME?.trim() || - getPreferredMarketplaceName(LEGACY_MARKETPLACE_NAME); - const source = process.env.CC_PLUGIN_CODEX_MARKETPLACE_SOURCE?.trim() || null; - const refName = process.env.CC_PLUGIN_CODEX_MARKETPLACE_REF?.trim() || null; - const sparsePaths = (process.env.CC_PLUGIN_CODEX_MARKETPLACE_SPARSE_PATHS ?? "") - .split(",") - .map((value) => value.trim()) - .filter(Boolean); - - return { - marketplaceName: configuredName, - source, - refName, - sparsePaths: sparsePaths.length > 0 ? sparsePaths : null, - }; -} - -function usage() { - console.error( - "Usage: node scripts/local-plugin-install.mjs " + - "[--plugin-root ] [--skip-hook-install]" - ); - process.exit(1); -} - -function parseArgs(argv) { - const args = [...argv]; - const command = args.shift(); - if (!command || !["install", "uninstall"].includes(command)) { - usage(); - } - - let pluginRoot = DEFAULT_PLUGIN_ROOT; - let skipHookInstall = false; - - while (args.length > 0) { - const arg = args.shift(); - if (arg === "--plugin-root") { - const next = args.shift(); - if (!next) usage(); - pluginRoot = path.resolve(next); - continue; - } - if (arg === "--skip-hook-install") { - skipHookInstall = true; - continue; - } - usage(); - } - - return { command, pluginRoot, skipHookInstall }; -} - -function ensureParentDir(filePath) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); -} - -function readText(filePath) { - if (!fs.existsSync(filePath)) { - return null; - } - return fs.readFileSync(filePath, "utf8"); -} - -function writeText(filePath, content) { - ensureParentDir(filePath); - fs.writeFileSync(filePath, content, "utf8"); -} - -function normalizeTrailingNewline(text) { - return `${text.replace(/\s*$/, "")}\n`; -} - -function assertSupportedPluginRoot(pluginRoot) { - if (samePath(pluginRoot, INSTALLED_PLUGIN_ROOT)) { - return; - } - - throw new Error( - `Unsupported --plugin-root ${pluginRoot}. ` + - `For a local checkout install, clone the plugin into ${INSTALLED_PLUGIN_ROOT} and rerun this script there, ` + - `or use \`npx cc-plugin-codex install\` from any checkout.` - ); -} - -function formatWrapperName(skillName) { - return `${PLUGIN_NAME}-${skillName}`; -} - -function formatSkillInvocationName(skillName) { - return `${PLUGIN_NAME}:${skillName}`; -} - -function extractFrontmatterField(markdown, fieldName) { - const match = markdown.match(/^---\n([\s\S]*?)\n---\n?/); - if (!match) { - return null; - } - - for (const line of match[1].split("\n")) { - const fieldMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); - if (!fieldMatch) { - continue; - } - if (fieldMatch[1] === fieldName) { - return fieldMatch[2]; - } - } - return null; -} - -function rewriteSkillFrontmatter(markdown, skillName) { - return markdown.replace(/^---\n([\s\S]*?)\n---/, (_whole, body) => { - const nextLines = body.split("\n").map((line) => { - if (line.startsWith("name:")) { - return `name: ${formatSkillInvocationName(skillName)}`; - } - return line; - }); - return `---\n${nextLines.join("\n")}\n---`; - }); -} - -function rewriteSkillBody(markdown, pluginRoot) { - const normalizedPluginRoot = normalizePathSlashes(pluginRoot); - return markdown - .replaceAll("", normalizedPluginRoot) - .replace( - "Resolve `` as two directories above this skill file. The companion entrypoint is:", - "Use the companion entrypoint at:" - ) - .replace( - "Resolve `` as two directories above this skill file, then run:", - "Use the companion entrypoint:" - ) - .replace( - "Resolve `` as two directories above this skill file.", - `Use the installed plugin root at \`${normalizedPluginRoot}\`.` - ) - .replaceAll("", normalizedPluginRoot); -} - -function installCodexSkillWrappers(pluginRoot) { - for (const skillName of EXPORTED_SKILLS) { - const sourceSkillPath = path.join(pluginRoot, "skills", skillName, "SKILL.md"); - const sourceSkill = readText(sourceSkillPath); - if (!sourceSkill) { - throw new Error(`Missing skill source: ${sourceSkillPath}`); - } - - const wrappedSkill = rewriteSkillBody( - rewriteSkillFrontmatter(sourceSkill, skillName), - pluginRoot - ); - const targetSkillPath = path.join( - CODEX_SKILLS_DIR, - formatWrapperName(skillName), - "SKILL.md" - ); - writeText(targetSkillPath, normalizeTrailingNewline(wrappedSkill)); - - const description = extractFrontmatterField(sourceSkill, "description"); - const promptBody = [ - "---", - ...(description ? [`description: ${description}`] : []), - "---", - "", - `Use the $${formatSkillInvocationName(skillName)} skill for this command and follow its instructions exactly.`, - "", - "Treat any text after the prompt name as the raw arguments to pass through.", - "", - "Do not restate the command. Just route to the skill.", - ].join("\n"); - - writeText( - path.join(CODEX_PROMPTS_DIR, `${formatWrapperName(skillName)}.md`), - normalizeTrailingNewline(promptBody) - ); - } -} - -function loadMarketplaceFile(marketplaceName) { - const existing = readText(MARKETPLACE_FILE); - if (!existing) { - return { - name: marketplaceName, - interface: { - displayName: MARKETPLACE_DISPLAY_NAME, - }, - plugins: [], - }; - } - - const parsed = JSON.parse(existing); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - throw new Error(`Invalid marketplace file at ${MARKETPLACE_FILE}`); - } - - if (!Array.isArray(parsed.plugins)) { - parsed.plugins = []; - } - if (!parsed.name) { - parsed.name = marketplaceName; - } - if (!parsed.interface || typeof parsed.interface !== "object") { - parsed.interface = {}; - } - if (!parsed.interface.displayName) { - parsed.interface.displayName = MARKETPLACE_DISPLAY_NAME; - } - return parsed; -} - -function saveMarketplaceFile(data) { - if (!Array.isArray(data.plugins) || data.plugins.length === 0) { - if (fs.existsSync(MARKETPLACE_FILE)) { - fs.rmSync(MARKETPLACE_FILE, { force: true }); - } - return; - } - writeText(MARKETPLACE_FILE, `${JSON.stringify(data, null, 2)}\n`); -} - -function upsertMarketplaceEntry(pluginRoot, marketplaceName) { - const pluginPath = resolveManagedMarketplacePluginPath(pluginRoot); - const marketplace = loadMarketplaceFile(marketplaceName); - const nextEntry = { - name: PLUGIN_NAME, - source: { - source: "local", - path: pluginPath, - }, - policy: { - installation: "INSTALLED_BY_DEFAULT", - authentication: "ON_USE", - }, - category: "Coding", - }; - - const existingIndex = marketplace.plugins.findIndex( - (plugin) => plugin?.name === PLUGIN_NAME - ); - if (existingIndex >= 0) { - marketplace.plugins.splice(existingIndex, 1, nextEntry); - } else { - marketplace.plugins.push(nextEntry); - } - - saveMarketplaceFile(marketplace); -} - -function removeMarketplaceEntry(pluginRoot, marketplaceName) { - const existing = readText(MARKETPLACE_FILE); - if (!existing) { - return; - } - - const marketplace = loadMarketplaceFile(marketplaceName); - marketplace.plugins = marketplace.plugins.filter((plugin) => { - return plugin?.name !== PLUGIN_NAME; - }); - saveMarketplaceFile(marketplace); -} - -function removeTomlSections(content, headers) { - const lines = content.split("\n"); - const kept = []; - let skip = false; - let changed = false; - - for (const line of lines) { - const trimmed = line.trim(); - if (skip && trimmed.startsWith("[")) { - skip = false; - } - if (!skip && headers.has(trimmed)) { - skip = true; - changed = true; - continue; - } - if (!skip) { - kept.push(line); - } - } - - return { - changed, - content: normalizeTrailingNewline( - kept.join("\n").replace(/\n{3,}/g, "\n\n") - ), - }; -} - -function ensurePluginEnabled(content, marketplaceName) { - const pluginHeader = pluginConfigHeader(marketplaceName); - const lines = content.split("\n"); - const next = []; - let inPluginSection = false; - let foundPluginSection = false; - let foundEnabled = false; - let changed = false; - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed.startsWith("[") && trimmed.endsWith("]")) { - if (inPluginSection && !foundEnabled) { - next.push("enabled = true"); - foundEnabled = true; - changed = true; - } - inPluginSection = trimmed === pluginHeader; - foundPluginSection ||= inPluginSection; - next.push(line); - continue; - } - - if (inPluginSection && /^enabled\s*=/.test(trimmed)) { - foundEnabled = true; - if (trimmed !== "enabled = true") { - next.push("enabled = true"); - changed = true; - } else { - next.push(line); - } - continue; - } - - next.push(line); - } - - if (inPluginSection && !foundEnabled) { - next.push("enabled = true"); - changed = true; - } - - if (!foundPluginSection) { - if (next.length > 0 && next[next.length - 1].trim() !== "") { - next.push(""); - } - next.push(pluginHeader, "enabled = true"); - changed = true; - } - - return { - changed, - content: normalizeTrailingNewline(next.join("\n").replace(/\n{3,}/g, "\n\n")), - }; -} - -function readConfigFile() { - return readText(CODEX_CONFIG_FILE) ?? ""; -} - -function writeConfigFile(content) { - writeText(CODEX_CONFIG_FILE, normalizeTrailingNewline(content)); -} - -function removePluginConfigBlock(marketplaceName) { - const existing = readConfigFile(); - const managedHeaders = parseManagedPluginSections(existing).map((section) => - pluginConfigHeader(section.marketplaceName) - ); - const headers = - managedHeaders.length > 0 ? new Set(managedHeaders) : new Set([pluginConfigHeader(marketplaceName)]); - const pluginRemoval = removeTomlSections(existing, headers); - if (pluginRemoval.changed) { - writeConfigFile(pluginRemoval.content); - } -} - -function configureCodexHooks() { - const existing = readConfigFile(); - const { content } = ensureCodexHooksEnabled(existing); - writeConfigFile(content); -} - -function enablePluginThroughConfigFallback(marketplaceName) { - const existing = readConfigFile(); - const { content } = ensurePluginEnabled(existing, marketplaceName); - writeConfigFile(content); -} - -function runInstallHooks(pluginRoot) { - const result = spawnSync(process.execPath, [path.join(pluginRoot, "scripts", "install-hooks.mjs")], { - cwd: pluginRoot, - stdio: "inherit", - env: process.env, - }); - - if (result.status !== 0) { - process.exit(result.status ?? 1); - } -} - -async function installPluginThroughCodex(marketplacePath) { - await callCodexAppServer({ - cwd: path.dirname(marketplacePath), - method: "plugin/install", - params: { - marketplacePath, - pluginName: PLUGIN_NAME, - forceRemoteSync: false, - }, - }); -} - -async function addMarketplaceThroughCodex({ source, refName, sparsePaths }) { - const params = { source }; - if (refName) { - params.refName = refName; - } - if (sparsePaths && sparsePaths.length > 0) { - params.sparsePaths = sparsePaths; - } - - return await callCodexAppServer({ - cwd: INSTALLED_PLUGIN_ROOT, - method: "marketplace/add", - params, - }); -} - -function isCodexInstallFallbackEligible(error) { - const message = error instanceof Error ? error.message : String(error); - return ( - /Method not found/i.test(message) || - /Failed to start .*codex/i.test(message) || - /app-server exited before responding to plugin\/install/i.test(message) || - /app-server timed out waiting for plugin\/install/i.test(message) - ); -} - -function isCodexMarketplaceAddFallbackEligible(error) { - const message = error instanceof Error ? error.message : String(error); - return ( - /Method not found/i.test(message) || - /Failed to start .*codex/i.test(message) || - /app-server exited before responding to marketplace\/add/i.test(message) || - /app-server timed out waiting for marketplace\/add/i.test(message) - ); -} - -async function uninstallPluginThroughCodex(marketplaceName) { - await callCodexAppServer({ - cwd: CODEX_HOME, - method: "plugin/uninstall", - params: { - pluginId: pluginIdForMarketplace(marketplaceName), - forceRemoteSync: false, - }, - }); -} - -export async function install(pluginRoot, skipHookInstall) { - const marketplaceConfig = resolveInstallerMarketplaceConfig(); - assertSupportedPluginRoot(pluginRoot); - if ( - samePath(pluginRoot, INSTALLED_PLUGIN_ROOT) && - process.env.CC_PLUGIN_CODEX_SKILLS_MATERIALIZED !== "1" - ) { - materializeInstalledSkillPaths(pluginRoot); - } - let marketplacePath = MARKETPLACE_FILE; - let usedLegacyMarketplaceFallback = false; - - if (marketplaceConfig.source) { - try { - const result = await addMarketplaceThroughCodex(marketplaceConfig); - marketplacePath = path.join(result.installedRoot, ".agents", "plugins", "marketplace.json"); - } catch (error) { - if (!isCodexMarketplaceAddFallbackEligible(error)) { - throw error; - } - upsertMarketplaceEntry(pluginRoot, marketplaceConfig.marketplaceName); - usedLegacyMarketplaceFallback = true; - const detail = error instanceof Error ? error.message : String(error); - console.warn( - `Warning: Codex marketplace/add unavailable; falling back to a personal marketplace entry. ${detail}` - ); - } - } else { - upsertMarketplaceEntry(pluginRoot, marketplaceConfig.marketplaceName); - usedLegacyMarketplaceFallback = true; - } - - configureCodexHooks(); - let usedFallback = false; - try { - await installPluginThroughCodex(marketplacePath); - removeManagedSkillWrappers(); - } catch (error) { - if (!isCodexInstallFallbackEligible(error)) { - throw error; - } - enablePluginThroughConfigFallback(marketplaceConfig.marketplaceName); - installCodexSkillWrappers(pluginRoot); - usedFallback = true; - const detail = error instanceof Error ? error.message : String(error); - console.warn( - `Warning: Codex plugin/install unavailable; enabled the plugin through config fallback and installed Codex-native cc-* wrappers. ${detail}` - ); - } - if (!skipHookInstall) { - runInstallHooks(pluginRoot); - } - if (usedFallback) { - console.log("Installed using fallback local-plugin activation."); - } - if (usedLegacyMarketplaceFallback && marketplaceConfig.source) { - console.log("Installed using legacy personal marketplace registration."); - } - console.log(`Installed ${PLUGIN_NAME} from ${pluginRoot}`); -} - -export async function uninstall(pluginRoot) { - const marketplaceConfig = resolveInstallerMarketplaceConfig(); - cleanupManagedGlobalIntegrations(pluginRoot); - removeMarketplaceEntry(pluginRoot, marketplaceConfig.marketplaceName); - try { - await uninstallPluginThroughCodex(marketplaceConfig.marketplaceName); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - console.warn( - `Warning: Codex plugin uninstall failed; continuing managed cleanup. ${detail}` - ); - } - removePluginConfigBlock(marketplaceConfig.marketplaceName); - console.log(`Uninstalled ${PLUGIN_NAME} from ${pluginRoot}`); -} - -async function main() { - const { command, pluginRoot, skipHookInstall } = parseArgs(process.argv.slice(2)); - - if (command === "install") { - await install(pluginRoot, skipHookInstall); - } else { - await uninstall(pluginRoot); - } -} - -await main(); +console.error( + [ + "Local checkout installs are no longer supported.", + "Install cc from the Sendbird Codex marketplace so Codex owns the active plugin cache:", + " codex marketplace add sendbird/codex-marketplace", + "Then install `cc` from that marketplace and run `$cc:setup`.", + ].join("\n") +); +process.exit(1); diff --git a/skills/adversarial-review/SKILL.md b/skills/adversarial-review/SKILL.md index e1b22cb..99f25ba 100644 --- a/skills/adversarial-review/SKILL.md +++ b/skills/adversarial-review/SKILL.md @@ -13,8 +13,8 @@ If the user wants Claude Code to go beyond review and perform investigation, val If the user asks for a local review plus a separate Claude background review and then wants the main Codex thread to aggregate the findings and apply fixes, keep the delegated Claude portion on `$cc:review` unless the user explicitly asks for the adversarial angle. Unlike `$cc:review`, this skill accepts custom focus text after the flags. The moment the user wants to steer Claude toward a specific angle or risk question, prefer `$cc:adversarial-review`. -Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: -`node "/scripts/claude-companion.mjs" adversarial-review ...` +Resolve `` as two directories above this `SKILL.md` file. Always run the companion from that active plugin root: +`node "/scripts/claude-companion.mjs" adversarial-review ...` Supported arguments: `--wait`, `--background`, `--base `, `--scope auto|working-tree|branch`, `--model `, `--effort `, plus optional focus text after the flags (defaults: model=opus, effort=xhigh; sonnet defaults to high; haiku has no effort) @@ -56,20 +56,20 @@ Argument handling: Foreground flow: - Run: - `node "/scripts/claude-companion.mjs" adversarial-review --view-state on-success ` + `node "/scripts/claude-companion.mjs" adversarial-review --view-state on-success ` - Foreground adversarial review belongs to the main Codex thread. Do not spawn a review subagent, do not invoke a generic review-runner role, and do not proxy this foreground path through any background worker abstraction. -- Do not fall back to raw `claude`, `claude-code`, `claude review`, `bash -lc ...claude...`, or any other direct Claude CLI syntax when the companion path is available. The foreground syntax contract here is the installed companion command above, not a hand-rolled Claude invocation. -- If the installed companion command fails, surface that failure. Do not silently retry foreground adversarial review through a different CLI shape, a generic review runner, or a custom shell wrapper. +- Do not fall back to raw `claude`, `claude-code`, `claude review`, `bash -lc ...claude...`, or any other direct Claude CLI syntax when the companion path is available. The foreground syntax contract here is the resolved companion command above, not a hand-rolled Claude invocation. +- If the resolved companion command fails, surface that failure. Do not silently retry foreground adversarial review through a different CLI shape, a generic review runner, or a custom shell wrapper. - Present the companion stdout faithfully. - Do not fix anything mentioned in the review output. Background flow: - For background adversarial review, use Codex's built-in `default` subagent instead of a detached background shell command. -- Do not satisfy background adversarial review by using a generic `claude_review_runner`-style helper role, raw Claude CLI, or any other review executor that bypasses the installed companion command. +- Do not satisfy background adversarial review by using a generic `claude_review_runner`-style helper role, raw Claude CLI, or any other review executor that bypasses the resolved companion command. - Never satisfy background adversarial review by running the companion command itself with shell backgrounding such as `&`, `nohup`, detached `spawn`, or any equivalent direct background process launch. - Background here means "spawn the forwarding child via `spawn_agent` and do not wait in the parent turn." The companion adversarial-review command inside that child still runs once, in the foreground, inside the child thread. - Before spawning the built-in child, capture the review job id plus routing context in one call: - `node "/scripts/claude-companion.mjs" background-routing-context --kind review --json` + `node "/scripts/claude-companion.mjs" background-routing-context --kind review --json` - If that helper returns a non-empty `jobId`, pass it into the companion command as an internal `--job-id ` routing flag. - If that helper returns a non-empty `ownerSessionId`, include `--owner-session-id ` in the companion command. - If it returns an empty `ownerSessionId`, omit `--owner-session-id` entirely. Never leave an empty placeholder such as `--owner-session-id --job-id`. @@ -89,7 +89,7 @@ Background flow: - The built-in child must be a pure forwarder. It should: - run exactly one shell command - execute: - `node "/scripts/claude-companion.mjs" adversarial-review --view-state defer ` + `node "/scripts/claude-companion.mjs" adversarial-review --view-state defer ` - run that command as one blocking foreground shell-tool call, not as a background terminal/session - do not request a shell session id, poll a shell session later, or return before the companion command exits - if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call diff --git a/skills/cancel/SKILL.md b/skills/cancel/SKILL.md index 766fb01..86026a2 100644 --- a/skills/cancel/SKILL.md +++ b/skills/cancel/SKILL.md @@ -7,8 +7,8 @@ description: 'Cancel an active tracked Claude Code job in this repository. Args: Use this skill when the user wants to stop an active Claude Code job in this repository. -Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: -`node "/scripts/claude-companion.mjs" cancel $ARGUMENTS` +Resolve `` as two directories above this `SKILL.md` file. Always run the companion from that active plugin root: +`node "/scripts/claude-companion.mjs" cancel $ARGUMENTS` Supported arguments: `[job-id]` diff --git a/skills/rescue/SKILL.md b/skills/rescue/SKILL.md index c8b1c5b..df51fc0 100644 --- a/skills/rescue/SKILL.md +++ b/skills/rescue/SKILL.md @@ -16,8 +16,8 @@ Prefer `$cc:rescue` when the user wants Claude Code to diagnose the issue, valid Do not use rescue for "just review this diff" unless the user also wants follow-through work beyond review findings. Do not use rescue merely because the main Codex thread plans to fix things after combining its own review with a separate Claude review. Rescue is only the right delegation when Claude itself is supposed to investigate, edit, test, or otherwise own the follow-through work. -Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: -`node "/scripts/claude-companion.mjs" task ...` +Resolve `` as two directories above this `SKILL.md` file. Always run the companion from that active plugin root: +`node "/scripts/claude-companion.mjs" task ...` Raw slash-command arguments: `$ARGUMENTS` @@ -42,7 +42,7 @@ Main-thread routing rules: - Default to `--write` unless the user explicitly wants read-only behavior or only review, diagnosis, or research without edits. - If `--resume` or `--resume-last` is present, continue the latest tracked Claude Code task. If `--fresh` is present, start a new task. - If none of `--resume`, `--resume-last`, or `--fresh` is present, first run: - `node "/scripts/claude-companion.mjs" task-resume-candidate --json` + `node "/scripts/claude-companion.mjs" task-resume-candidate --json` - If that helper reports `available: true`, ask the user once whether to continue the current Claude Code thread or start a new one. - Use exactly these two choices: - `Continue current Claude Code thread` @@ -70,7 +70,7 @@ Subagent launch: - Pass only the routing and task arguments that actually belong to `claude-companion.mjs task`. - If the free-text task begins with `/`, preserve it verbatim in the spawned subagent request. Do not strip the slash or rewrite it into a local Codex command. - Before spawning the built-in child, capture the task job id plus routing context in one call: - `node "/scripts/claude-companion.mjs" background-routing-context --kind task --json` + `node "/scripts/claude-companion.mjs" background-routing-context --kind task --json` - If that helper returns a non-empty `ownerSessionId`, include `--owner-session-id ` in the companion command so tracked Claude Code jobs stay attached to the user-facing parent session for `$cc:status` / `$cc:result`. - If it returns an empty `ownerSessionId`, omit `--owner-session-id` entirely. Never leave an empty routing placeholder such as `--owner-session-id --job-id`. - If that helper returns a non-empty `jobId`, pass it into the companion command as an internal `--job-id ` routing flag. diff --git a/skills/result/SKILL.md b/skills/result/SKILL.md index c8cabfa..b6c89e3 100644 --- a/skills/result/SKILL.md +++ b/skills/result/SKILL.md @@ -7,8 +7,8 @@ description: 'Show the stored final output for a finished Claude Code job in thi Use this skill when the user wants the stored final output for a finished Claude Code job. -Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: -`node "/scripts/claude-companion.mjs" result $ARGUMENTS` +Resolve `` as two directories above this `SKILL.md` file. Always run the companion from that active plugin root: +`node "/scripts/claude-companion.mjs" result $ARGUMENTS` Supported arguments: `[job-id]` diff --git a/skills/review/SKILL.md b/skills/review/SKILL.md index a2d05b1..ea5c0af 100644 --- a/skills/review/SKILL.md +++ b/skills/review/SKILL.md @@ -13,8 +13,8 @@ If the user wants Claude Code to investigate, validate by changing code, or actu If the overall request is "you review it too, also ask Claude to review in the background, then you aggregate and fix it", keep the delegated Claude part on `$cc:review` unless the user explicitly asks for a harsher or more adversarial review. `$cc:review` does not accept custom focus text. If the user wants to steer Claude toward a particular angle, question, subsystem, or risk area, that is a signal to use `$cc:adversarial-review` instead. -Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: -`node "/scripts/claude-companion.mjs" review ...` +Resolve `` as two directories above this `SKILL.md` file. Always run the companion from that active plugin root: +`node "/scripts/claude-companion.mjs" review ...` Supported arguments: `--wait`, `--background`, `--base `, `--scope auto|working-tree|branch`, `--model `, `--effort ` (defaults: model=opus, effort=xhigh; sonnet defaults to high; haiku has no effort) @@ -54,20 +54,20 @@ Argument handling: Foreground flow: - Run: - `node "/scripts/claude-companion.mjs" review --view-state on-success ` + `node "/scripts/claude-companion.mjs" review --view-state on-success ` - Foreground review belongs to the main Codex thread. Do not spawn a review subagent, do not invoke a generic review-runner role, and do not proxy this foreground path through any background worker abstraction. -- Do not fall back to raw `claude`, `claude-code`, `claude review`, `bash -lc ...claude...`, or any other direct Claude CLI syntax when the companion path is available. The foreground syntax contract here is the installed companion command above, not a hand-rolled Claude invocation. -- If the installed companion command fails, surface that failure. Do not silently retry foreground review through a different CLI shape, a generic review runner, or a custom shell wrapper. +- Do not fall back to raw `claude`, `claude-code`, `claude review`, `bash -lc ...claude...`, or any other direct Claude CLI syntax when the companion path is available. The foreground syntax contract here is the resolved companion command above, not a hand-rolled Claude invocation. +- If the resolved companion command fails, surface that failure. Do not silently retry foreground review through a different CLI shape, a generic review runner, or a custom shell wrapper. - Present the companion stdout faithfully. - Do not fix anything mentioned in the review output. Background flow: - For background review, use Codex's built-in `default` subagent instead of a detached background shell command. -- Do not satisfy background review by using a generic `claude_review_runner`-style helper role, raw Claude CLI, or any other review executor that bypasses the installed companion command. +- Do not satisfy background review by using a generic `claude_review_runner`-style helper role, raw Claude CLI, or any other review executor that bypasses the resolved companion command. - Never satisfy background review by running the companion command itself with shell backgrounding such as `&`, `nohup`, detached `spawn`, or any equivalent direct background process launch. - Background here means "spawn the forwarding child via `spawn_agent` and do not wait in the parent turn." The companion review command inside that child still runs once, in the foreground, inside the child thread. - Before spawning the built-in child, capture the review job id plus routing context in one call: - `node "/scripts/claude-companion.mjs" background-routing-context --kind review --json` + `node "/scripts/claude-companion.mjs" background-routing-context --kind review --json` - If that helper returns a non-empty `jobId`, pass it into the companion command as an internal `--job-id ` routing flag. - If that helper returns a non-empty `ownerSessionId`, include `--owner-session-id ` in the companion command. - If it returns an empty `ownerSessionId`, omit `--owner-session-id` entirely. Never leave an empty placeholder such as `--owner-session-id --job-id`. @@ -87,7 +87,7 @@ Background flow: - The built-in child must be a pure forwarder. It should: - run exactly one shell command - execute: - `node "/scripts/claude-companion.mjs" review --view-state defer ` + `node "/scripts/claude-companion.mjs" review --view-state defer ` - run that command as one blocking foreground shell-tool call, not as a background terminal/session - do not request a shell session id, poll a shell session later, or return before the companion command exits - if the available shell tool is `exec_command`, call it once in non-interactive mode and wait for command exit in that same call diff --git a/skills/setup/SKILL.md b/skills/setup/SKILL.md index 42c2d43..205ba86 100644 --- a/skills/setup/SKILL.md +++ b/skills/setup/SKILL.md @@ -7,7 +7,7 @@ description: 'Check whether Claude Code CLI is ready in this environment and opt Use this skill when the user wants to verify Claude Code readiness or toggle the review gate. -Do not derive the companion path from this skill file or any cache directory. Always run the installed copy under ``. +Resolve `` as two directories above this `SKILL.md` file. Always run the companion from that active plugin root. Supported arguments: - `--enable-review-gate` @@ -15,15 +15,13 @@ Supported arguments: Workflow: - First run the machine-readable probe: - `node "/scripts/claude-companion.mjs" setup --json $ARGUMENTS` + `node "/scripts/claude-companion.mjs" setup --json $ARGUMENTS` - If it reports that Claude Code is unavailable and `npm` is available, ask whether to install Claude Code now. - If the user agrees, run `npm install -g @anthropic-ai/claude-code` and rerun setup. - If Claude Code is already installed or `npm` is unavailable, do not ask about installation. -- If setup reports missing hooks, run: - `node "/scripts/install-hooks.mjs"` -- After hook installation, rerun the final setup command so the user sees the repaired state immediately. +- If setup reports missing native plugin hook features or hook trust, rerun setup once. The companion repairs `[features].hooks`, `[features].plugin_hooks`, and this plugin's native hook trust hashes itself. - After the decision flow is complete, run the final user-facing command without `--json`: - `node "/scripts/claude-companion.mjs" setup $ARGUMENTS` + `node "/scripts/claude-companion.mjs" setup $ARGUMENTS` Output: - Present the final non-JSON setup output exactly as returned by the companion. diff --git a/skills/status/SKILL.md b/skills/status/SKILL.md index ef910c2..d1a95c9 100644 --- a/skills/status/SKILL.md +++ b/skills/status/SKILL.md @@ -7,8 +7,8 @@ description: 'Show active or recent Claude Code jobs in this repository, or deta Use this skill when the user wants the current state of Claude Code jobs in this repository. -Do not derive the companion path from this skill file or any cache directory. Always run the installed copy: -`node "/scripts/claude-companion.mjs" status $ARGUMENTS` +Resolve `` as two directories above this `SKILL.md` file. Always run the companion from that active plugin root: +`node "/scripts/claude-companion.mjs" status $ARGUMENTS` Supported arguments: `[job-id]`, `--wait`, `--timeout-ms `, `--poll-interval-ms `, `--all` diff --git a/tests/e2e/codex-skills-e2e.test.mjs b/tests/e2e/codex-skills-e2e.test.mjs index 3411325..f40bc0e 100644 --- a/tests/e2e/codex-skills-e2e.test.mjs +++ b/tests/e2e/codex-skills-e2e.test.mjs @@ -16,7 +16,6 @@ const PROJECT_ROOT = path.resolve( ); const COMPANION_SCRIPT = path.join(PROJECT_ROOT, "scripts", "claude-companion.mjs"); const INSTALLER_SCRIPT = path.join(PROJECT_ROOT, "scripts", "installer-cli.mjs"); -const INSTALL_HOOKS_SCRIPT = path.join(PROJECT_ROOT, "scripts", "install-hooks.mjs"); const RESCUE_SKILL_PATH = path.join(PROJECT_ROOT, "skills", "rescue", "SKILL.md"); const REVIEW_SKILL_PATH = path.join(PROJECT_ROOT, "skills", "review", "SKILL.md"); const ADVERSARIAL_REVIEW_SKILL_PATH = path.join( @@ -171,7 +170,7 @@ function createEnvironment() { } function installHooks(testEnv) { - const result = spawnSync(process.execPath, [INSTALL_HOOKS_SCRIPT], { + const result = spawnSync(process.execPath, [path.join(PROJECT_ROOT, "scripts", "install-hooks.mjs")], { cwd: PROJECT_ROOT, env: testEnv.env, encoding: "utf8", @@ -180,28 +179,92 @@ function installHooks(testEnv) { assert.equal(result.status, 0, result.stderr || result.stdout); const hooksFile = path.join(testEnv.codexHome, "hooks.json"); - assert.ok(fs.existsSync(hooksFile), "Codex hooks should be installed"); + const configFile = path.join(testEnv.codexHome, "config.toml"); + const config = fs.readFileSync(configFile, "utf8"); + assert.ok(!fs.existsSync(hooksFile), "native plugin hooks should not install global hooks"); + assert.match(config, /hooks = true/); + assert.match(config, /plugin_hooks = true/); +} + +function createLocalMarketplaceFixture(testEnv) { + const marketplaceRoot = path.join(testEnv.rootDir, "sendbird-marketplace"); + const pluginRoot = path.join(marketplaceRoot, "plugins", "cc"); + fs.rmSync(marketplaceRoot, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(pluginRoot), { recursive: true }); + fs.cpSync(PROJECT_ROOT, pluginRoot, { + recursive: true, + filter: (sourcePath) => { + const relative = path.relative(PROJECT_ROOT, sourcePath); + return ( + relative === "" || + !relative.split(path.sep).some((part) => + [".git", "node_modules", "tasks"].includes(part) + ) + ); + }, + }); + fs.mkdirSync(path.join(marketplaceRoot, ".agents", "plugins"), { recursive: true }); + fs.writeFileSync( + path.join(marketplaceRoot, ".agents", "plugins", "marketplace.json"), + `${JSON.stringify( + { + name: "sendbird", + interface: { displayName: "Sendbird Plugins" }, + plugins: [ + { + name: "cc", + source: { + source: "local", + path: "./plugins/cc", + }, + policy: { + installation: "AVAILABLE", + authentication: "ON_USE", + }, + category: "Coding", + }, + ], + }, + null, + 2 + )}\n`, + "utf8" + ); + return marketplaceRoot; } function installPlugin(testEnv) { + const marketplaceRoot = createLocalMarketplaceFixture(testEnv); const result = spawnSync(process.execPath, [INSTALLER_SCRIPT, "install"], { cwd: PROJECT_ROOT, - env: testEnv.env, + env: { + ...testEnv.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }, encoding: "utf8", }); assert.equal(result.status, 0, result.stderr || result.stdout); - const installDir = path.join(testEnv.codexHome, "plugins", "cc"); - const marketplaceFile = path.join(testEnv.homeDir, ".agents", "plugins", "marketplace.json"); + const cacheParent = path.join(testEnv.codexHome, "plugins", "cache", "sendbird", "cc"); const configFile = path.join(testEnv.codexHome, "config.toml"); + const cacheDir = fs.existsSync(cacheParent) + ? fs + .readdirSync(cacheParent, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(cacheParent, entry.name)) + .find((candidate) => + fs.existsSync(path.join(candidate, "scripts", "installer-cli.mjs")) + ) + : null; assert.ok( - fs.existsSync(path.join(installDir, "scripts", "installer-cli.mjs")), - "installer should copy the plugin into the Codex home" + cacheDir, + "installer should install the plugin into the Codex cache" ); - assert.ok(fs.existsSync(marketplaceFile), "installer should register the plugin in the local marketplace"); assert.ok(fs.existsSync(configFile), "installer should create a Codex config.toml"); + return cacheDir; } function installPluginWithEnv(testEnv, extraEnv = {}) { @@ -1586,60 +1649,6 @@ describe("Codex rescue-skill E2E", () => { }); describe("Codex direct-skill E2E", () => { - it("uses fallback-installed cc-review wrappers when plugin/install is unavailable", async (t) => { - if (!codexAvailable()) { - t.skip("codex CLI is not available in this environment"); - return; - } - - const testEnv = createEnvironment(); - const workspaceDir = path.join(testEnv.rootDir, "fallback-review-workspace"); - fs.mkdirSync(workspaceDir, { recursive: true }); - setupGitWorkspace(workspaceDir); - fs.writeFileSync( - path.join(workspaceDir, "app.js"), - "export function value() {\n return 5;\n}\n", - "utf8" - ); - - const fallbackCodex = createMethodNotFoundCodex(testEnv); - installPluginWithEnv(testEnv, fallbackCodex.env); - assert.ok( - fs.existsSync(path.join(testEnv.codexHome, "skills", "cc-review", "SKILL.md")), - "fallback install should create a Codex-native cc-review wrapper" - ); - - const userRequest = "$cc:review --wait --scope working-tree --model haiku"; - const provider = startDirectSkillProvider({ - userRequest, - expectedNeedles: ["Claude Code Review"], - shellCommands: [ - `node ${JSON.stringify(COMPANION_SCRIPT)} review --view-state on-success --scope working-tree --model haiku`, - ], - cwd: workspaceDir, - }); - testEnv.providerPort = await provider.listen(); - writeConfigToml(testEnv, testEnv.providerPort); - - try { - const execResult = await runCodexExec(testEnv, userRequest, { cwd: workspaceDir }); - - assert.equal(execResult.status, 0, execResult.stderr || execResult.stdout); - const finalMessage = fs.readFileSync(testEnv.outputFile, "utf8"); - assert.match(finalMessage, /Claude Code Review/); - const claudeInvocations = readClaudeInvocations(testEnv.claudeLogFile); - assert.ok( - claudeInvocations.some( - (entry) => entry.args.includes("--model") && entry.args.includes("claude-haiku-4-5") - ), - "fallback-installed wrapper should still route the requested model alias to Claude" - ); - } finally { - await provider.close(); - cleanupEnvironment(testEnv); - } - }); - it("uses the installed plugin review skill without running $cc:setup first", async (t) => { if (!codexAvailable()) { t.skip("codex CLI is not available in this environment"); @@ -1656,14 +1665,15 @@ describe("Codex direct-skill E2E", () => { "utf8" ); - installPlugin(testEnv); + const pluginRoot = installPlugin(testEnv); const userRequest = "$cc:review --wait --scope working-tree --model haiku"; + const companionScript = path.join(pluginRoot, "scripts", "claude-companion.mjs"); const provider = startDirectSkillProvider({ userRequest, expectedNeedles: ["Claude Code Review"], shellCommands: [ - `node ${JSON.stringify(COMPANION_SCRIPT)} review --view-state on-success --scope working-tree --model haiku`, + `node ${JSON.stringify(companionScript)} review --view-state on-success --scope working-tree --model haiku`, ], cwd: workspaceDir, }); @@ -2090,7 +2100,7 @@ describe("Codex direct-skill E2E", () => { } }); - it("auto-installs hooks during $cc:setup when the json probe reports they are missing", async (t) => { + it("repairs native plugin hook feature gates during $cc:setup", async (t) => { if (!codexAvailable()) { t.skip("codex CLI is not available in this environment"); return; @@ -2103,7 +2113,6 @@ describe("Codex direct-skill E2E", () => { expectedNeedles: ["Claude Code Setup"], shellCommands: [ `node ${JSON.stringify(COMPANION_SCRIPT)} setup --json`, - `node ${JSON.stringify(INSTALL_HOOKS_SCRIPT)}`, `node ${JSON.stringify(COMPANION_SCRIPT)} setup`, ], }); @@ -2119,17 +2128,20 @@ describe("Codex direct-skill E2E", () => { assert.equal(execResult.status, 0, execResult.stderr || execResult.stdout); const finalMessage = fs.readFileSync(testEnv.outputFile, "utf8"); assert.match(finalMessage, /Status: ready/i); - assert.match(finalMessage, /hooks: Codex hooks installed/i); + assert.match(finalMessage, /hooks: native Codex plugin hooks enabled/i); const hooksFile = path.join(testEnv.codexHome, "hooks.json"); - assert.ok(fs.existsSync(hooksFile), "setup should install hooks when they are missing"); + const config = fs.readFileSync(path.join(testEnv.codexHome, "config.toml"), "utf8"); + assert.ok(!fs.existsSync(hooksFile), "setup should not install global hooks"); + assert.match(config, /hooks = true/); + assert.match(config, /plugin_hooks = true/); } finally { await provider.close(); cleanupEnvironment(testEnv); } }); - it("auto-installs hooks during $cc:setup --enable-review-gate when the json probe reports they are missing", async (t) => { + it("repairs native plugin hooks during $cc:setup --enable-review-gate", async (t) => { if (!codexAvailable()) { t.skip("codex CLI is not available in this environment"); return; @@ -2142,7 +2154,6 @@ describe("Codex direct-skill E2E", () => { expectedNeedles: ["Claude Code Setup"], shellCommands: [ `node ${JSON.stringify(COMPANION_SCRIPT)} setup --json --enable-review-gate`, - `node ${JSON.stringify(INSTALL_HOOKS_SCRIPT)}`, `node ${JSON.stringify(COMPANION_SCRIPT)} setup --enable-review-gate`, ], }); @@ -2158,15 +2169,15 @@ describe("Codex direct-skill E2E", () => { assert.equal(execResult.status, 0, execResult.stderr || execResult.stdout); const finalMessage = fs.readFileSync(testEnv.outputFile, "utf8"); assert.match(finalMessage, /Status: ready/i); - assert.match(finalMessage, /hooks: Codex hooks installed/i); + assert.match(finalMessage, /hooks: native Codex plugin hooks enabled/i); assert.match(finalMessage, /review gate: enabled/i); assert.match(finalMessage, /Enabled the stop-time review gate/i); const hooksFile = path.join(testEnv.codexHome, "hooks.json"); - assert.ok( - fs.existsSync(hooksFile), - "setup --enable-review-gate should still install hooks when they are missing" - ); + const config = fs.readFileSync(path.join(testEnv.codexHome, "config.toml"), "utf8"); + assert.ok(!fs.existsSync(hooksFile)); + assert.match(config, /hooks = true/); + assert.match(config, /plugin_hooks = true/); } finally { await provider.close(); cleanupEnvironment(testEnv); diff --git a/tests/install-hooks.test.mjs b/tests/install-hooks.test.mjs index cf5e6ac..61b96db 100644 --- a/tests/install-hooks.test.mjs +++ b/tests/install-hooks.test.mjs @@ -34,39 +34,6 @@ function runInstallHooks(homeDir, scriptPath = SCRIPT_PATH, cwd = PROJECT_ROOT) return result; } -function runInstallHooksRaw(homeDir, scriptPath = SCRIPT_PATH, cwd = PROJECT_ROOT) { - return spawnSync(process.execPath, [scriptPath], { - cwd, - env: { - ...process.env, - HOME: homeDir, - USERPROFILE: homeDir, - }, - encoding: "utf8", - }); -} - -function copyInstallFixture(pluginRoot) { - fs.mkdirSync(pluginRoot, { recursive: true }); - fs.cpSync(path.join(PROJECT_ROOT, "hooks"), path.join(pluginRoot, "hooks"), { - recursive: true, - }); - fs.mkdirSync(path.join(pluginRoot, "scripts"), { recursive: true }); - fs.cpSync( - path.join(PROJECT_ROOT, "scripts", "lib"), - path.join(pluginRoot, "scripts", "lib"), - { recursive: true } - ); - fs.copyFileSync( - path.join(PROJECT_ROOT, "scripts", "install-hooks.mjs"), - path.join(pluginRoot, "scripts", "install-hooks.mjs") - ); - fs.copyFileSync( - path.join(PROJECT_ROOT, "scripts", "claude-companion.mjs"), - path.join(pluginRoot, "scripts", "claude-companion.mjs") - ); -} - const tempHomes = []; afterEach(() => { @@ -77,7 +44,7 @@ afterEach(() => { }); describe("install-hooks.mjs", () => { - it("installs hooks into an empty Codex home", () => { + it("enables native plugin hooks in an empty Codex home", () => { const homeDir = makeTempHome(); tempHomes.push(homeDir); @@ -86,30 +53,17 @@ describe("install-hooks.mjs", () => { const hooksFile = path.join(homeDir, ".codex", "hooks.json"); const configFile = path.join(homeDir, ".codex", "config.toml"); - assert.ok(fs.existsSync(hooksFile)); + assert.ok(!fs.existsSync(hooksFile), "native plugin hooks should not write global hooks.json"); assert.ok(fs.existsSync(configFile)); - const hooks = JSON.parse(fs.readFileSync(hooksFile, "utf8")); const config = fs.readFileSync(configFile, "utf8"); - const sessionStartCommand = - hooks.hooks.SessionStart[0].hooks[0].command; - assert.ok(sessionStartCommand.includes(`${PROJECT_ROOT}/hooks/session-lifecycle-hook.mjs`)); - const sessionEndCommand = - hooks.hooks.SessionEnd[0].hooks[0].command; - assert.ok( - sessionEndCommand.includes( - `${PROJECT_ROOT}/hooks/session-lifecycle-hook.mjs' SessionEnd` - ) - ); - const userPromptCommand = - hooks.hooks.UserPromptSubmit[0].hooks[0].command; - assert.ok(userPromptCommand.includes(`${PROJECT_ROOT}/hooks/unread-result-hook.mjs`)); assert.match(config, /\[features\]/); - assert.match(config, /codex_hooks = true/); - assert.ok(result.stdout.includes("Codex hooks installation complete.")); + assert.match(config, /hooks = true/); + assert.match(config, /plugin_hooks = true/); + assert.match(result.stdout, /native Codex plugin hooks/i); }); - it("upgrades an existing false codex_hooks setting to true", () => { + it("upgrades legacy codex_hooks to native hook feature gates", () => { const homeDir = makeTempHome(); tempHomes.push(homeDir); @@ -125,12 +79,13 @@ describe("install-hooks.mjs", () => { const config = fs.readFileSync(path.join(codexDir, "config.toml"), "utf8"); assert.match(config, /\[features\]/); - assert.match(config, /codex_hooks = true/); - assert.doesNotMatch(config, /codex_hooks = false/); - assert.match(result.stdout, /Enabled codex_hooks/i); + assert.match(config, /hooks = true/); + assert.match(config, /plugin_hooks = true/); + assert.doesNotMatch(config, /codex_hooks/); + assert.match(result.stdout, /Enabled native Codex plugin hooks/i); }); - it("does not duplicate semantically identical hook commands when quoting changes", () => { + it("removes stale managed global hook commands", () => { const homeDir = makeTempHome(); tempHomes.push(homeDir); @@ -163,76 +118,42 @@ describe("install-hooks.mjs", () => { runInstallHooks(homeDir); - const hooks = JSON.parse( - fs.readFileSync(path.join(codexDir, "hooks.json"), "utf8") - ); - assert.equal(hooks.hooks.SessionStart.length, 1); + assert.ok(!fs.existsSync(path.join(codexDir, "hooks.json"))); }); - it("shell-escapes installed hook commands when the plugin path contains command substitution syntax", () => { + it("does not remove unrelated global hook commands", () => { const homeDir = makeTempHome(); tempHomes.push(homeDir); - const pluginRoot = path.join(homeDir, "plugin $(touch injected-marker)"); - copyInstallFixture(pluginRoot); - - const scriptPath = path.join(pluginRoot, "scripts", "install-hooks.mjs"); - runInstallHooks(homeDir, scriptPath, pluginRoot); - - const hooksFile = path.join(homeDir, ".codex", "hooks.json"); - const hooks = JSON.parse(fs.readFileSync(hooksFile, "utf8")); - const sessionStartCommand = - hooks.hooks.SessionStart[0].hooks[0].command; - - assert.match( - sessionStartCommand, - /node '\S.*\$\(touch injected-marker\).*session-lifecycle-hook\.mjs'/ - ); - - fs.mkdirSync( - path.join(homeDir, ".codex", "plugins", "cache", "local-plugins", "cc", "local"), - { recursive: true } - ); + const codexDir = path.join(homeDir, ".codex"); + fs.mkdirSync(codexDir, { recursive: true }); fs.writeFileSync( - path.join(homeDir, ".codex", "config.toml"), - '[plugins."cc@local-plugins"]\nenabled = true\n', + path.join(codexDir, "hooks.json"), + JSON.stringify( + { + hooks: { + SessionStart: [ + { + matcher: "", + hooks: [ + { + type: "command", + command: "echo custom-hook", + }, + ], + }, + ], + }, + }, + null, + 2 + ) + "\n", "utf8" ); - const runResult = spawnSync("sh", ["-lc", sessionStartCommand], { - cwd: pluginRoot, - env: { - ...process.env, - HOME: homeDir, - USERPROFILE: homeDir, - }, - encoding: "utf8", - }); - - assert.equal(runResult.status, 0, runResult.stderr || runResult.stdout); - assert.ok( - !fs.existsSync(path.join(pluginRoot, "injected-marker")), - "hook command should not execute command substitution from the plugin path" - ); - - }); - - it("rejects hook templates that resolve outside the plugin root", () => { - const homeDir = makeTempHome(); - tempHomes.push(homeDir); - - const pluginRoot = path.join(homeDir, "plugin-outside-path"); - copyInstallFixture(pluginRoot); - - const hooksFile = path.join(pluginRoot, "hooks", "hooks.json"); - const hooks = JSON.parse(fs.readFileSync(hooksFile, "utf8")); - hooks.hooks.SessionStart[0].hooks[0].command = 'node "$PLUGIN_ROOT/../evil.sh"'; - fs.writeFileSync(hooksFile, JSON.stringify(hooks, null, 2) + "\n", "utf8"); - - const scriptPath = path.join(pluginRoot, "scripts", "install-hooks.mjs"); - const result = runInstallHooksRaw(homeDir, scriptPath, pluginRoot); + runInstallHooks(homeDir); - assert.notEqual(result.status, 0); - assert.match(result.stderr || result.stdout, /outside the plugin root/i); + const hooks = JSON.parse(fs.readFileSync(path.join(codexDir, "hooks.json"), "utf8")); + assert.equal(hooks.hooks.SessionStart[0].hooks[0].command, "echo custom-hook"); }); }); diff --git a/tests/installer-cli.test.mjs b/tests/installer-cli.test.mjs index 5bd0200..ebed4a5 100644 --- a/tests/installer-cli.test.mjs +++ b/tests/installer-cli.test.mjs @@ -772,61 +772,7 @@ afterEach(() => { }); describe("installer-cli", () => { - it("installs into ~/.codex/plugins/cc and registers the plugin in the personal marketplace", () => { - const homeDir = makeTempHome(); - const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir); - copyFixture(sourceRoot); - - runInstaller("install", homeDir, sourceRoot, fakeCodex.env); - - const installDir = path.join(homeDir, ".codex", "plugins", "cc"); - const cacheDir = path.join(homeDir, ".codex", "plugins", "cache", "local-plugins", "cc", "local"); - const marketplaceFile = path.join(homeDir, ".agents", "plugins", "marketplace.json"); - const configFile = path.join(homeDir, ".codex", "config.toml"); - const hooksFile = path.join(homeDir, ".codex", "hooks.json"); - const fallbackSkillPath = path.join(homeDir, ".codex", "skills", "cc-review", "SKILL.md"); - const fallbackPromptPath = path.join(homeDir, ".codex", "prompts", "cc-review.md"); - const installedReviewSkill = path.join(installDir, "skills", "review", "SKILL.md"); - const cachedReviewSkill = path.join(cacheDir, "skills", "review", "SKILL.md"); - const normalizedInstallDir = installDir.replace(/\\/g, "/"); - - assert.ok(fs.existsSync(path.join(installDir, "scripts", "installer-cli.mjs"))); - assert.ok(fs.existsSync(path.join(cacheDir, "skills", "review", "SKILL.md"))); - assert.ok(!fs.existsSync(fallbackSkillPath)); - assert.ok(!fs.existsSync(fallbackPromptPath)); - assert.ok(fs.readFileSync(installedReviewSkill, "utf8").includes(normalizedInstallDir)); - assert.doesNotMatch(fs.readFileSync(installedReviewSkill, "utf8"), //i); - assert.ok(fs.readFileSync(cachedReviewSkill, "utf8").includes(normalizedInstallDir)); - assert.doesNotMatch(fs.readFileSync(cachedReviewSkill, "utf8"), //i); - - const marketplace = JSON.parse(fs.readFileSync(marketplaceFile, "utf8")); - assert.equal(marketplace.plugins[0].name, "cc"); - assert.equal(marketplace.plugins[0].source.path, "./.codex/plugins/cc"); - - const config = fs.readFileSync(configFile, "utf8"); - assert.match(config, /\[plugins\."cc@local-plugins"\]/); - assert.match(config, /enabled = true/); - assert.match(config, /\[features\]/); - assert.match(config, /codex_hooks = true/); - - const hooks = JSON.parse(fs.readFileSync(hooksFile, "utf8")); - const sessionStartCommand = hooks.hooks.SessionStart[0].hooks[0].command; - assert.ok(sessionStartCommand.includes(`${installDir}/hooks/session-lifecycle-hook.mjs`)); - assert.ok(!sessionStartCommand.includes(sourceRoot)); - - const requests = readFakeCodexLog(fakeCodex.logPath); - assert.ok( - requests.some((request) => request.method === "plugin/install"), - "installer should use Codex's official plugin/install path" - ); - assert.ok( - !requests.some((request) => request.method === "marketplace/add"), - "npx install should install the current package directly instead of redirecting through a marketplace by default" - ); - }); - - it("prefers Codex marketplace/add when an official marketplace source is configured", () => { + it("installs through Codex marketplace/add and plugin/install into the plugin cache", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); const fakeCodex = createMarketplaceAwareCodex(homeDir); @@ -842,13 +788,23 @@ describe("installer-cli", () => { const configFile = path.join(homeDir, ".codex", "config.toml"); const config = fs.readFileSync(configFile, "utf8"); const marketplaceFile = path.join(homeDir, ".agents", "plugins", "marketplace.json"); + const hooksFile = path.join(homeDir, ".codex", "hooks.json"); + const legacyInstallDir = path.join(homeDir, ".codex", "plugins", "cc"); + const cacheDir = path.join(homeDir, ".codex", "plugins", "cache", "sendbird", "cc", "local"); + const cachedReviewSkill = path.join(cacheDir, "skills", "review", "SKILL.md"); const requests = readFakeCodexLog(fakeCodex.logPath); const pluginInstallRequest = requests.find((request) => request.method === "plugin/install"); assert.match(config, /\[plugins\."cc@sendbird"\]/); + assert.match(config, /hooks = true/); + assert.match(config, /plugin_hooks = true/); + assert.ok(!fs.existsSync(legacyInstallDir), "installer should not create a stable local plugin root"); + assert.ok(!fs.existsSync(hooksFile), "installer should not write global hooks.json"); + assert.ok(fs.existsSync(cachedReviewSkill)); + assert.match(fs.readFileSync(cachedReviewSkill, "utf8"), /\/scripts\/claude-companion\.mjs/); assert.ok( requests.some((request) => request.method === "marketplace/add"), - "installer should prefer Codex marketplace/add when a marketplace source is configured" + "installer should call Codex marketplace/add" ); assert.equal( pluginInstallRequest?.params?.marketplacePath, @@ -860,58 +816,47 @@ describe("installer-cli", () => { ); }); - it("falls back to the personal marketplace entry when marketplace/add is unavailable", () => { + it("does not fall back to local config activation when marketplace/add is unavailable", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); const fakeCodex = createMethodNotFoundCodex(homeDir); copyFixture(sourceRoot); const marketplaceRoot = copyMarketplaceFixture(sourceRoot); - const result = runInstaller("install", homeDir, sourceRoot, { - ...fakeCodex.env, - CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, - CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", - }); + const result = spawnSync( + process.execPath, + [path.join(sourceRoot, "scripts", "installer-cli.mjs"), "install"], + { + cwd: sourceRoot, + env: { + ...process.env, + HOME: homeDir, + USERPROFILE: homeDir, + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }, + encoding: "utf8", + } + ); - const marketplaceFile = path.join(homeDir, ".agents", "plugins", "marketplace.json"); - const marketplace = JSON.parse(fs.readFileSync(marketplaceFile, "utf8")); const config = fs.readFileSync(path.join(homeDir, ".codex", "config.toml"), "utf8"); - assert.equal(marketplace.name, "sendbird"); - assert.equal(marketplace.plugins[0].name, "cc"); - assert.match(config, /\[plugins\."cc@sendbird"\]/); - assert.match(result.stderr, /marketplace\/add unavailable/i); - assert.match(result.stderr, /config fallback/i); + assert.notEqual(result.status, 0, "marketplace/add failure should fail install"); + assert.doesNotMatch(config, /\[plugins\."cc@sendbird"\]/); + assert.ok(!fs.existsSync(path.join(homeDir, ".codex", "skills", "cc-review", "SKILL.md"))); + assert.ok(!fs.existsSync(path.join(homeDir, ".agents", "plugins", "marketplace.json"))); }); - it("materializes installed skill paths for a direct local checkout install", () => { + it("rejects direct local checkout installs", () => { const homeDir = makeTempHome(); const installDir = path.join(homeDir, ".codex", "plugins", "cc"); - const fakeCodex = createFakeCodex(homeDir); copyFixture(installDir); - runLocalPluginInstaller("install", installDir, homeDir, fakeCodex.env); + const result = runLocalPluginInstallerExpectFailure("install", installDir, homeDir); - const installedReviewSkill = path.join(installDir, "skills", "review", "SKILL.md"); - const skillText = fs.readFileSync(installedReviewSkill, "utf8"); - const normalizedInstallDir = installDir.replace(/\\/g, "/"); - - assert.ok(skillText.includes(normalizedInstallDir)); - assert.doesNotMatch(skillText, //i); - }); - - it("rejects direct source-root installs outside the managed plugin directory", () => { - const homeDir = makeTempHome(); - const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir); - copyFixture(sourceRoot); - - const result = runLocalPluginInstallerExpectFailure("install", sourceRoot, homeDir, fakeCodex.env); - const expectedInstallDir = path.join(homeDir, ".codex", "plugins", "cc").replace(/\\/g, "/"); - - assert.match(result.stderr, /Unsupported --plugin-root/i); - assert.match(result.stderr.replace(/\\/g, "/"), new RegExp(expectedInstallDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); - assert.match(result.stderr, /npx cc-plugin-codex install/i); + assert.match(result.stderr, /Local checkout installs are no longer supported/i); + assert.match(result.stderr, /codex marketplace add sendbird\/codex-marketplace/i); }); it("installs successfully when CODEX_HOME is outside the user's home directory", () => { @@ -919,91 +864,34 @@ describe("installer-cli", () => { const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), "cc-external-codex-home-")); tempHomes.push(codexHome); const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir, codexHome); - copyFixture(sourceRoot); - - runInstaller("install", homeDir, sourceRoot, { ...fakeCodex.env, CODEX_HOME: codexHome }); - - const installDir = path.join(codexHome, "plugins", "cc"); - const marketplaceFile = path.join(homeDir, ".agents", "plugins", "marketplace.json"); - const marketplace = JSON.parse(fs.readFileSync(marketplaceFile, "utf8")); - const expectedPath = `./${path.relative(homeDir, installDir).replace(/\\/g, "/")}`; - - assert.ok(fs.existsSync(path.join(installDir, "scripts", "installer-cli.mjs"))); - assert.equal(marketplace.plugins[0].source.path, expectedPath); - assert.ok(expectedPath.includes("..")); - }); - - it("falls back to config-based activation when plugin/install is unsupported", () => { - const homeDir = makeTempHome(); - const sourceRoot = makeTempSource(); - const fakeCodex = createMethodNotFoundCodex(homeDir); + const fakeCodex = createMarketplaceAwareCodex(homeDir, codexHome); copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); - const result = runInstaller("install", homeDir, sourceRoot, fakeCodex.env); + runInstaller("install", homeDir, sourceRoot, { + ...fakeCodex.env, + CODEX_HOME: codexHome, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }); - const installDir = path.join(homeDir, ".codex", "plugins", "cc"); - const cacheDir = path.join(homeDir, ".codex", "plugins", "cache", "local-plugins", "cc", "local"); - const fallbackSkillPath = path.join(homeDir, ".codex", "skills", "cc-review", "SKILL.md"); - const fallbackPromptPath = path.join(homeDir, ".codex", "prompts", "cc-review.md"); - const configFile = path.join(homeDir, ".codex", "config.toml"); - const hooksFile = path.join(homeDir, ".codex", "hooks.json"); - const config = fs.readFileSync(configFile, "utf8"); - const requests = readFakeCodexLog(fakeCodex.logPath); + const cacheDir = path.join(codexHome, "plugins", "cache", "sendbird", "cc", "local"); - assert.ok(fs.existsSync(path.join(installDir, "scripts", "installer-cli.mjs"))); - assert.ok(fs.existsSync(hooksFile), "fallback install should still install managed hooks"); - assert.match(config, /\[plugins\."cc@local-plugins"\]/); - assert.match(config, /enabled = true/); - assert.ok(!fs.existsSync(cacheDir), "fallback install should still avoid relying on the Codex cache path"); - assert.ok(fs.existsSync(fallbackSkillPath), "fallback install should expose a Codex-native skill wrapper"); - assert.ok(fs.existsSync(fallbackPromptPath), "fallback install should expose a matching prompt wrapper"); - assert.match(fs.readFileSync(fallbackSkillPath, "utf8"), /^---\nname: cc:review\n/m); - assert.match(fs.readFileSync(fallbackPromptPath, "utf8"), /Use the \$cc:review skill/); - assert.ok( - requests.some((request) => request.method === "plugin/install"), - "fallback install should still try plugin/install first" - ); - assert.match(result.stderr, /config fallback/i); + assert.ok(fs.existsSync(path.join(cacheDir, "scripts", "installer-cli.mjs"))); }); - it("falls back to config-based activation when plugin/install hangs", () => { + it("removes stale fallback skill wrappers and legacy global hooks when official install succeeds", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); - const fakeCodex = createHangingCodex(homeDir); - copyFixture(sourceRoot); - - const result = runInstaller("install", homeDir, sourceRoot, fakeCodex.env); - - const installDir = path.join(homeDir, ".codex", "plugins", "cc"); - const fallbackSkillPath = path.join(homeDir, ".codex", "skills", "cc-review", "SKILL.md"); - const configFile = path.join(homeDir, ".codex", "config.toml"); - const hooksFile = path.join(homeDir, ".codex", "hooks.json"); - const config = fs.readFileSync(configFile, "utf8"); - const requests = readFakeCodexLog(fakeCodex.logPath); - - assert.ok(fs.existsSync(path.join(installDir, "scripts", "installer-cli.mjs"))); - assert.ok(fs.existsSync(hooksFile), "timeout fallback install should still install managed hooks"); - assert.match(config, /\[plugins\."cc@local-plugins"\]/); - assert.match(config, /enabled = true/); - assert.ok(fs.existsSync(fallbackSkillPath), "timeout fallback should also install skill wrappers"); - assert.ok( - requests.some((request) => request.method === "plugin/install"), - "timeout fallback install should still try plugin/install first" - ); - assert.match(result.stderr, /timed out waiting for plugin\/install/i); - assert.match(result.stderr, /config fallback/i); - }); - - it("removes stale fallback skill wrappers when official plugin/install succeeds", () => { - const homeDir = makeTempHome(); - const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir); + const fakeCodex = createMarketplaceAwareCodex(homeDir); copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); const staleSkillPath = path.join(homeDir, ".codex", "skills", "cc-review", "SKILL.md"); const stalePromptPath = path.join(homeDir, ".codex", "prompts", "cc-review.md"); const unrelatedSkillPath = path.join(homeDir, ".codex", "skills", "keep-me", "SKILL.md"); + const legacyInstallDir = path.join(homeDir, ".codex", "plugins", "cc"); + const hooksFile = path.join(homeDir, ".codex", "hooks.json"); fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true }); fs.writeFileSync(staleSkillPath, "stale wrapper\n", "utf8"); @@ -1011,9 +899,32 @@ describe("installer-cli", () => { fs.writeFileSync(stalePromptPath, "stale prompt\n", "utf8"); fs.mkdirSync(path.dirname(unrelatedSkillPath), { recursive: true }); fs.writeFileSync(unrelatedSkillPath, "leave me alone\n", "utf8"); + fs.mkdirSync(path.join(legacyInstallDir, "hooks"), { recursive: true }); + fs.writeFileSync(path.join(legacyInstallDir, "hooks", "session-lifecycle-hook.mjs"), "", "utf8"); + fs.writeFileSync( + hooksFile, + JSON.stringify({ + hooks: { + SessionStart: [{ + matcher: "", + hooks: [{ + type: "command", + command: `node "${path.join(legacyInstallDir, "hooks", "session-lifecycle-hook.mjs")}"`, + }], + }], + }, + }, null, 2) + "\n", + "utf8" + ); - runInstaller("install", homeDir, sourceRoot, fakeCodex.env); + runInstaller("install", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }); + assert.ok(!fs.existsSync(legacyInstallDir)); + assert.ok(!fs.existsSync(hooksFile)); assert.ok(!fs.existsSync(staleSkillPath)); assert.ok(!fs.existsSync(stalePromptPath)); assert.ok(fs.existsSync(unrelatedSkillPath), "official install should not remove unrelated user skills"); @@ -1022,8 +933,9 @@ describe("installer-cli", () => { it("uninstalls cleanly while preserving unrelated user config", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir); + const fakeCodex = createMarketplaceAwareCodex(homeDir); copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); const marketplaceDir = path.join(homeDir, ".agents", "plugins"); fs.mkdirSync(marketplaceDir, { recursive: true }); @@ -1083,7 +995,11 @@ describe("installer-cli", () => { "utf8" ); - runInstaller("install", homeDir, sourceRoot, fakeCodex.env); + runInstaller("install", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }); const marketplacePath = path.join(homeDir, ".agents", "plugins", "marketplace.json"); const marketplaceBeforeUninstall = JSON.parse(fs.readFileSync(marketplacePath, "utf8")); @@ -1122,10 +1038,15 @@ describe("installer-cli", () => { it("removes versioned marketplace cache entries during uninstall", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir); + const fakeCodex = createMarketplaceAwareCodex(homeDir); copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); - runInstaller("install", homeDir, sourceRoot, fakeCodex.env); + runInstaller("install", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }); const versionedCacheDir = path.join( homeDir, @@ -1149,13 +1070,18 @@ describe("installer-cli", () => { assert.ok(!fs.existsSync(path.dirname(versionedCacheDir))); }); - it("removes managed hook commands that point at versioned marketplace cache roots", () => { + it("removes legacy managed hook commands that point at versioned marketplace cache roots", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir); + const fakeCodex = createMarketplaceAwareCodex(homeDir); copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); - runInstaller("install", homeDir, sourceRoot, fakeCodex.env); + runInstaller("install", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }); const codexDir = path.join(homeDir, ".codex"); const versionedCacheDir = path.join( @@ -1203,181 +1129,80 @@ describe("installer-cli", () => { assert.ok(!fs.existsSync(hooksFile), "uninstall should remove managed hooks even when they point at a versioned cache root"); }); - it("removes managed hooks before calling Codex plugin/uninstall", () => { + it("removes legacy managed hooks before calling Codex plugin/uninstall", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); const fakeCodex = createUninstallOrderCodex(homeDir); copyFixture(sourceRoot); - - runInstaller("install", homeDir, sourceRoot, fakeCodex.env); - runInstaller("uninstall", homeDir, sourceRoot, fakeCodex.env); - - const inspect = JSON.parse(fs.readFileSync(fakeCodex.inspectPath, "utf8")); - assert.equal( - inspect.managedHooksPresentAtUninstallCall, - false, - "managed hooks should be removed before plugin/uninstall deactivates the plugin config" - ); - }); - - it("self-cleans managed hooks after a Codex-side plugin uninstall", () => { - const homeDir = makeTempHome(); - const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir); - copyFixture(sourceRoot); - - runInstaller("install", homeDir, sourceRoot, fakeCodex.env); - const codexDir = path.join(homeDir, ".codex"); - const installDir = path.join(codexDir, "plugins", "cc"); - const cacheDir = path.join(codexDir, "plugins", "cache", "local-plugins", "cc", "local"); - const configFile = path.join(codexDir, "config.toml"); + const cacheDir = path.join(codexDir, "plugins", "cache", "sendbird", "cc", "local"); const hooksFile = path.join(codexDir, "hooks.json"); - const fallbackSkillPath = path.join(codexDir, "skills", "cc-review", "SKILL.md"); - const fallbackPromptPath = path.join(codexDir, "prompts", "cc-review.md"); - - fs.mkdirSync(path.dirname(fallbackSkillPath), { recursive: true }); - fs.writeFileSync(fallbackSkillPath, "stale fallback skill\n", "utf8"); - fs.mkdirSync(path.dirname(fallbackPromptPath), { recursive: true }); - fs.writeFileSync(fallbackPromptPath, "stale fallback prompt\n", "utf8"); - + fs.mkdirSync(path.join(cacheDir, "hooks"), { recursive: true }); fs.writeFileSync( - configFile, - fs - .readFileSync(configFile, "utf8") - .replace(/\n?\[plugins\."cc@local-plugins"\][\s\S]*?(?=\n\[|$)/, "\n"), + path.join(codexDir, "config.toml"), + '[plugins."cc@sendbird"]\nenabled = true\n', "utf8" ); - fs.rmSync(cacheDir, { recursive: true, force: true }); - - const result = spawnSync( - process.execPath, - [path.join(installDir, "hooks", "session-lifecycle-hook.mjs"), "SessionStart"], - { - cwd: installDir, - env: { - ...process.env, - HOME: homeDir, - USERPROFILE: homeDir, + fs.writeFileSync( + hooksFile, + JSON.stringify({ + hooks: { + SessionStart: [{ + matcher: "", + hooks: [{ + type: "command", + command: `node "${path.join(cacheDir, "hooks", "session-lifecycle-hook.mjs")}"`, + }], + }], }, - input: JSON.stringify({ - cwd: installDir, - session_id: "session-cleanup-test", - hook_event_name: "SessionStart", - }), - encoding: "utf8", - } + }, null, 2) + "\n", + "utf8" ); - assert.equal(result.status, 0, result.stderr || result.stdout); - - const cleanedConfig = fs.readFileSync(configFile, "utf8"); - assert.ok(!fs.existsSync(hooksFile), "cleanup should remove managed global hooks once the plugin is uninstalled"); - assert.ok(!fs.existsSync(fallbackSkillPath), "cleanup should also remove managed fallback skill wrappers"); - assert.ok(!fs.existsSync(fallbackPromptPath), "cleanup should also remove managed fallback prompt wrappers"); + runInstaller("uninstall", homeDir, sourceRoot, fakeCodex.env); - const marketplace = JSON.parse( - fs.readFileSync(path.join(homeDir, ".agents", "plugins", "marketplace.json"), "utf8") - ); + const inspect = JSON.parse(fs.readFileSync(fakeCodex.inspectPath, "utf8")); assert.equal( - marketplace.plugins.filter((plugin) => plugin.name === "cc").length, - 1, - "cleanup should keep the personal marketplace entry so Codex can reinstall the plugin later" + inspect.managedHooksPresentAtUninstallCall, + false, + "managed hooks should be removed before plugin/uninstall deactivates the plugin config" ); }); - it("does not self-clean managed hooks when only the Codex cache disappears", () => { + it("keeps install/update idempotent while refreshing the cached copy", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir); + const fakeCodex = createMarketplaceAwareCodex(homeDir); copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); - runInstaller("install", homeDir, sourceRoot, fakeCodex.env); - - const codexDir = path.join(homeDir, ".codex"); - const installDir = path.join(codexDir, "plugins", "cc"); - const cacheDir = path.join(codexDir, "plugins", "cache", "local-plugins", "cc", "local"); - const hooksFile = path.join(codexDir, "hooks.json"); - fs.rmSync(cacheDir, { recursive: true, force: true }); - - const result = spawnSync( - process.execPath, - [path.join(installDir, "hooks", "session-lifecycle-hook.mjs"), "SessionStart"], - { - cwd: installDir, - env: { - ...process.env, - HOME: homeDir, - USERPROFILE: homeDir, - }, - input: JSON.stringify({ - cwd: installDir, - session_id: "session-cache-miss-test", - hook_event_name: "SessionStart", - }), - encoding: "utf8", - } - ); - - assert.equal(result.status, 0, result.stderr || result.stdout); - assert.ok( - fs.existsSync(hooksFile), - "cache loss alone should not remove managed hooks while the plugin remains enabled" - ); - }); - - it("keeps install/update idempotent while refreshing the installed copy", () => { - const homeDir = makeTempHome(); - const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir); - copyFixture(sourceRoot); + const installEnv = { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }; - runInstaller("install", homeDir, sourceRoot, fakeCodex.env); - runInstaller("install", homeDir, sourceRoot, fakeCodex.env); + runInstaller("install", homeDir, sourceRoot, installEnv); + runInstaller("install", homeDir, sourceRoot, installEnv); - const readmePath = path.join(sourceRoot, "README.md"); + const readmePath = path.join(marketplaceRoot, "plugins", "cc", "README.md"); fs.appendFileSync( readmePath, "\n\n", "utf8" ); - runInstaller("update", homeDir, sourceRoot, fakeCodex.env); + runInstaller("update", homeDir, sourceRoot, installEnv); - const installDir = path.join(homeDir, ".codex", "plugins", "cc"); - const installedReadme = fs.readFileSync(path.join(installDir, "README.md"), "utf8"); - const marketplace = JSON.parse( - fs.readFileSync(path.join(homeDir, ".agents", "plugins", "marketplace.json"), "utf8") - ); + const cacheDir = path.join(homeDir, ".codex", "plugins", "cache", "sendbird", "cc", "local"); + const cachedReadme = fs.readFileSync(path.join(cacheDir, "README.md"), "utf8"); const config = fs.readFileSync(path.join(homeDir, ".codex", "config.toml"), "utf8"); - const hooks = JSON.parse(fs.readFileSync(path.join(homeDir, ".codex", "hooks.json"), "utf8")); - const sessionStartCommands = hooks.hooks.SessionStart.flatMap((entry) => - entry.hooks.map((hook) => hook.command) - ); - const sessionEndCommands = hooks.hooks.SessionEnd.flatMap((entry) => - entry.hooks.map((hook) => hook.command) - ); - assert.match(installedReadme, /installer-cli update regression marker/); + assert.match(cachedReadme, /installer-cli update regression marker/); assert.equal( - marketplace.plugins.filter((plugin) => plugin.name === "cc").length, + countOccurrences(config, /\[plugins\."cc@sendbird"\]/g), 1, - "installer should not duplicate marketplace registrations across install/update runs" - ); - assert.equal( - countOccurrences(config, /\[plugins\."cc@local-plugins"\]/g), - 1, - "installer should keep exactly one local plugin enablement block" - ); - assert.equal( - sessionStartCommands.filter((command) => command.includes("session-lifecycle-hook.mjs")).length, - 1, - "installer should keep a single SessionStart lifecycle hook" - ); - assert.equal( - sessionEndCommands.filter((command) => command.includes("session-lifecycle-hook.mjs")).length, - 1, - "installer should keep a single SessionEnd lifecycle hook" + "installer should keep exactly one Sendbird plugin enablement block" ); }); @@ -1394,33 +1219,25 @@ describe("installer-cli", () => { it("shell installer wrappers install and uninstall the plugin end to end", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); - const fakeCodex = createFakeCodex(homeDir); + const fakeCodex = createMarketplaceAwareCodex(homeDir); copyFixture(sourceRoot); + const marketplaceRoot = copyMarketplaceFixture(sourceRoot); - runShellWrapper("install.sh", homeDir, sourceRoot, fakeCodex.env); + runShellWrapper("install.sh", homeDir, sourceRoot, { + ...fakeCodex.env, + CC_PLUGIN_CODEX_MARKETPLACE_SOURCE: marketplaceRoot, + CC_PLUGIN_CODEX_MARKETPLACE_NAME: "sendbird", + }); - const installDir = path.join(homeDir, ".codex", "plugins", "cc"); - const cacheDir = path.join(homeDir, ".codex", "plugins", "cache", "local-plugins", "cc", "local"); + const cacheDir = path.join(homeDir, ".codex", "plugins", "cache", "sendbird", "cc", "local"); const configFile = path.join(homeDir, ".codex", "config.toml"); - const marketplaceFile = path.join(homeDir, ".agents", "plugins", "marketplace.json"); - assert.ok(fs.existsSync(path.join(installDir, "scripts", "installer-cli.mjs"))); assert.ok(fs.existsSync(path.join(cacheDir, "skills", "review", "SKILL.md"))); assert.ok(fs.existsSync(configFile)); - assert.ok(fs.existsSync(marketplaceFile)); runShellWrapper("uninstall.sh", homeDir, sourceRoot, fakeCodex.env); const config = fs.readFileSync(configFile, "utf8"); - assert.ok(!fs.existsSync(installDir), "shell uninstall should remove the installed plugin copy"); - assert.ok(!fs.existsSync(cacheDir), "shell uninstall should remove the warmed local plugin cache"); - assert.doesNotMatch(config, /\[plugins\."cc@local-plugins"\]/); - if (fs.existsSync(marketplaceFile)) { - const marketplace = JSON.parse(fs.readFileSync(marketplaceFile, "utf8")); - assert.equal( - marketplace.plugins.filter((plugin) => plugin.name === "cc").length, - 0, - "shell uninstall should remove the marketplace registration" - ); - } + assert.ok(!fs.existsSync(cacheDir), "shell uninstall should remove the cached plugin copy"); + assert.doesNotMatch(config, /\[plugins\."cc@sendbird"\]/); }); }); diff --git a/tests/integration/claude-companion.test.mjs b/tests/integration/claude-companion.test.mjs index 297ea1b..528132f 100644 --- a/tests/integration/claude-companion.test.mjs +++ b/tests/integration/claude-companion.test.mjs @@ -164,6 +164,69 @@ function createTestEnvironment() { }; } +function createFakeCodexAppServer(testEnv, hooks) { + const serverPath = path.join(testEnv.rootDir, "fake-codex-app-server.mjs"); + const logPath = path.join(testEnv.rootDir, "fake-codex-app-server.ndjson"); + fs.writeFileSync( + serverPath, + `import fs from "node:fs"; +import readline from "node:readline"; + +const hooks = ${JSON.stringify(hooks, null, 2)}; +const logPath = ${JSON.stringify(logPath)}; +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +function write(message) { + process.stdout.write(JSON.stringify(message) + "\\n"); +} + +rl.on("line", (line) => { + const message = JSON.parse(line); + fs.appendFileSync(logPath, JSON.stringify(message) + "\\n", "utf8"); + if (message.method === "initialize") { + write({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05" } }); + return; + } + if (message.method === "hooks/list") { + write({ + jsonrpc: "2.0", + id: message.id, + result: { + data: (message.params?.cwds || []).map((cwd) => ({ + cwd, + hooks, + warnings: [], + errors: [], + })), + }, + }); + return; + } + if (message.method === "config/batchWrite") { + write({ jsonrpc: "2.0", id: message.id, result: { status: "ok" } }); + return; + } + write({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: "method not found" }, + }); +}); +`, + "utf8" + ); + return { serverPath, logPath }; +} + +function readJsonLines(filePath) { + return fs + .readFileSync(filePath, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + function cleanupTestEnvironment(testEnv) { for (let attempt = 0; attempt < 20; attempt++) { try { @@ -552,6 +615,123 @@ describe("claude-companion integration", () => { } }); + it("setup trusts current native plugin hooks through Codex hooks/list", () => { + const testEnv = createTestEnvironment(); + const sourcePath = path.join(PROJECT_ROOT, "hooks", "hooks.json"); + const hooks = [ + { + key: "cc@sendbird:hooks/hooks.json:session_start:0:0", + eventName: "session_start", + handlerType: "command", + matcher: null, + command: "node session-lifecycle-hook.mjs", + timeoutSec: 600, + statusMessage: null, + sourcePath, + source: "plugin", + pluginId: "cc@sendbird", + displayOrder: 0, + enabled: true, + isManaged: false, + currentHash: "sha256:session", + trustStatus: "untrusted", + }, + { + key: "cc@sendbird:hooks/hooks.json:stop:0:0", + eventName: "stop", + handlerType: "command", + matcher: null, + command: "node stop-review-gate-hook.mjs", + timeoutSec: 600, + statusMessage: null, + sourcePath, + source: "plugin", + pluginId: "cc@sendbird", + displayOrder: 1, + enabled: true, + isManaged: false, + currentHash: "sha256:stop", + trustStatus: "modified", + }, + { + key: "cc@sendbird:hooks/hooks.json:user_prompt_submit:0:0", + eventName: "user_prompt_submit", + handlerType: "command", + matcher: null, + command: "node unread-result-hook.mjs", + timeoutSec: 600, + statusMessage: null, + sourcePath, + source: "plugin", + pluginId: "cc@sendbird", + displayOrder: 2, + enabled: true, + isManaged: false, + currentHash: "sha256:already", + trustStatus: "trusted", + }, + { + key: "other@sendbird:hooks/hooks.json:session_start:0:0", + eventName: "session_start", + handlerType: "command", + matcher: null, + command: "node other.mjs", + timeoutSec: 600, + statusMessage: null, + sourcePath: path.join(testEnv.rootDir, "other", "hooks.json"), + source: "plugin", + pluginId: "other@sendbird", + displayOrder: 3, + enabled: true, + isManaged: false, + currentHash: "sha256:other", + trustStatus: "untrusted", + }, + ]; + const fakeCodex = createFakeCodexAppServer(testEnv, hooks); + + try { + const report = runCompanionJson( + ["setup", "--cwd", testEnv.workspaceDir, "--json"], + { + env: { + ...testEnv.env, + CC_PLUGIN_CODEX_EXECUTABLE: process.execPath, + CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON: JSON.stringify([fakeCodex.serverPath]), + CC_PLUGIN_CODEX_FORCE_HOOK_TRUST: "1", + }, + } + ); + + assert.equal(report.ready, true); + assert.equal(report.hookTrust.ready, true); + assert.equal(report.hookTrust.found, 3); + assert.equal(report.hookTrust.trusted, 2); + assert.match(report.hookTrust.detail, /trusted 2 native plugin hooks/i); + + const requests = readJsonLines(fakeCodex.logPath); + const writeRequest = requests.find((request) => request.method === "config/batchWrite"); + assert.ok(writeRequest, "expected setup to write hook trust state"); + assert.deepEqual(writeRequest.params.edits, [ + { + keyPath: "hooks.state", + value: { + "cc@sendbird:hooks/hooks.json:session_start:0:0": { + trusted_hash: "sha256:session", + }, + "cc@sendbird:hooks/hooks.json:stop:0:0": { + trusted_hash: "sha256:stop", + }, + }, + mergeStrategy: "upsert", + }, + ]); + assert.equal(writeRequest.params.reloadUserConfig, true); + } finally { + cleanupTestEnvironment(testEnv); + } + }); + it("forwards task model, effort, prompt-file, and write mode to Claude", () => { const testEnv = createTestEnvironment(); diff --git a/tests/render.test.mjs b/tests/render.test.mjs index 7c881eb..ab3ee85 100644 --- a/tests/render.test.mjs +++ b/tests/render.test.mjs @@ -165,6 +165,14 @@ describe("renderSetupReport", () => { assert.ok(output.includes("- hooks: Codex hooks installed")); }); + it("includes hook trust details when present", () => { + const output = renderSetupReport({ + ...baseReport, + hookTrust: { detail: "trusted 3 native plugin hooks" }, + }); + assert.ok(output.includes("- hook trust: trusted 3 native plugin hooks")); + }); + it("shows review gate status", () => { const enabled = renderSetupReport({ ...baseReport, reviewGateEnabled: true }); assert.ok(enabled.includes("review gate: enabled")); diff --git a/tests/skills-contracts.test.mjs b/tests/skills-contracts.test.mjs index c061434..5267419 100644 --- a/tests/skills-contracts.test.mjs +++ b/tests/skills-contracts.test.mjs @@ -16,15 +16,14 @@ function read(relativePath) { return fs.readFileSync(path.join(PROJECT_ROOT, relativePath), "utf8"); } -test("internal runtime references keep the installed-root and notification invariants", () => { +test("internal runtime references keep the active-root and notification invariants", () => { const reviewRuntime = read("internal-skills/review-runtime/runtime.md"); const rescueRuntime = read("internal-skills/cli-runtime/runtime.md"); - const installedRootPattern = - /\/scripts\/claude-companion\.mjs/i; + const activeRootPattern = /\/scripts\/claude-companion\.mjs/i; - assert.match(reviewRuntime, /resolved the installed plugin root/i); - assert.match(reviewRuntime, installedRootPattern); - assert.match(reviewRuntime, /Do not derive a new runtime path from this document, any cache directory, or the current working tree/i); + assert.match(reviewRuntime, /resolved the active plugin root/i); + assert.match(reviewRuntime, activeRootPattern); + assert.match(reviewRuntime, /Do not derive a new runtime path from this document or the current working tree/i); assert.match(reviewRuntime, /Never emit an empty routing placeholder such as `--owner-session-id {2}--job-id`/i); assert.match(reviewRuntime, /blocking foreground shell-tool call, not as a background terminal\/session/i); assert.match(reviewRuntime, /Do not request a shell session id, poll a shell session later, or return before the companion command exits/i); @@ -34,9 +33,9 @@ test("internal runtime references keep the installed-root and notification invar assert.match(reviewRuntime, /do not silently drop the completion notification path when the parent provided a non-empty parent thread id/i); assert.match(reviewRuntime, /Use that same steering message as the child's own final assistant message for background mode/i); - assert.match(rescueRuntime, /resolved the installed plugin root/i); - assert.match(rescueRuntime, installedRootPattern); - assert.match(rescueRuntime, /Do not derive a new runtime path from this document, any cache directory, or the current working tree/i); + assert.match(rescueRuntime, /resolved the active plugin root/i); + assert.match(rescueRuntime, activeRootPattern); + assert.match(rescueRuntime, /Do not derive a new runtime path from this document or the current working tree/i); assert.match(rescueRuntime, /Never emit an empty routing placeholder such as `--owner-session-id {2}--job-id`/i); assert.match(rescueRuntime, /Do not add `--quiet-progress` by default/i); assert.match(rescueRuntime, /slash command as literal Claude Code task text/i); @@ -53,15 +52,15 @@ test("internal runtime references keep the installed-root and notification invar test("review skills keep background execution outside the companion command", () => { const review = read("skills/review/SKILL.md"); const adversarial = read("skills/adversarial-review/SKILL.md"); - const installedRootPattern = /\/scripts\/claude-companion\.mjs/i; + const activeRootPattern = /\/scripts\/claude-companion\.mjs/i; - assert.match(review, /Do not derive the companion path from this skill file or any cache directory/i); + assert.match(review, /Resolve `` as two directories above this `SKILL\.md` file/i); assert.match(review, /Use `\$cc:review` as the default when the user asks for code review, asks you to have Claude review something, or wants a second review pass without explicitly asking for stronger adversarial scrutiny/i); assert.match(review, /If the user asks for stronger challenge on design, tradeoffs, rollout risk, migration risk, configuration behavior, or provides custom review focus text, route to `\$cc:adversarial-review` instead/i); assert.match(review, /If the user wants Claude Code to investigate, validate by changing code, or actually fix\/implement something, route to `\$cc:rescue` instead/i); assert.match(review, /If the overall request is "you review it too, also ask Claude to review in the background, then you aggregate and fix it", keep the delegated Claude part on `\$cc:review` unless the user explicitly asks for a harsher or more adversarial review/i); assert.match(review, /`\$cc:review` does not accept custom focus text/i); - assert.match(review, installedRootPattern); + assert.match(review, activeRootPattern); assert.match(review, /Treat `--wait` and `--background` as Codex-side execution controls only/i); assert.match(review, /Strip them before calling the companion command/i); assert.match(review, /The companion review process itself always runs in the foreground/i); @@ -72,7 +71,7 @@ test("review skills keep background execution outside the companion command", () assert.match(review, /Do not spawn a review subagent/i); assert.match(review, /do not invoke a generic review-runner role/i); assert.match(review, /Do not fall back to raw `claude`, `claude-code`, `claude review`, `bash -lc \.\.\.claude\.\.\.`/i); - assert.match(review, /If the installed companion command fails, surface that failure/i); + assert.match(review, /If the .*companion command fails, surface that failure/i); assert.match(review, /For background review, use Codex's built-in `default` subagent/i); assert.match(review, /Do not satisfy background review by using a generic `claude_review_runner`-style helper role/i); assert.match(review, /Never satisfy background review by running the companion command itself with shell backgrounding/i); @@ -109,13 +108,13 @@ test("review skills keep background execution outside the companion command", () assert.doesNotMatch(review, /claude-companion\.mjs" review --background/i); assert.doesNotMatch(review, /claude-companion\.mjs" review \$ARGUMENTS/i); - assert.match(adversarial, /Do not derive the companion path from this skill file or any cache directory/i); + assert.match(adversarial, /Resolve `` as two directories above this `SKILL\.md` file/i); assert.match(adversarial, /Do not treat `\$cc:adversarial-review` as the default review path/i); assert.match(adversarial, /Good triggers include requests to challenge the design, challenge tradeoffs, pressure-test a risky change, question whether a migration\/config\/template change really removed the risk, or honor custom focus text that asks for harsher review/i); assert.match(adversarial, /If the user wants Claude Code to go beyond review and perform investigation, validation edits, or implementation work, route to `\$cc:rescue` instead/i); assert.match(adversarial, /If the user asks for a local review plus a separate Claude background review and then wants the main Codex thread to aggregate the findings and apply fixes, keep the delegated Claude portion on `\$cc:review` unless the user explicitly asks for the adversarial angle/i); assert.match(adversarial, /Unlike `\$cc:review`, this skill accepts custom focus text after the flags/i); - assert.match(adversarial, installedRootPattern); + assert.match(adversarial, activeRootPattern); assert.match(adversarial, /Treat `--wait` and `--background` as Codex-side execution controls only/i); assert.match(adversarial, /Strip them before calling the companion command/i); assert.match(adversarial, /The companion review process itself always runs in the foreground/i); @@ -126,7 +125,7 @@ test("review skills keep background execution outside the companion command", () assert.match(adversarial, /Do not spawn a review subagent/i); assert.match(adversarial, /do not invoke a generic review-runner role/i); assert.match(adversarial, /Do not fall back to raw `claude`, `claude-code`, `claude review`, `bash -lc \.\.\.claude\.\.\.`/i); - assert.match(adversarial, /If the installed companion command fails, surface that failure/i); + assert.match(adversarial, /If the .*companion command fails, surface that failure/i); assert.match(adversarial, /For background adversarial review, use Codex's built-in `default` subagent/i); assert.match(adversarial, /Do not satisfy background adversarial review by using a generic `claude_review_runner`-style helper role/i); assert.match(adversarial, /Never satisfy background adversarial review by running the companion command itself with shell backgrounding/i); @@ -166,13 +165,13 @@ test("review skills keep background execution outside the companion command", () test("rescue skill keeps --background and --wait as host-side controls only", () => { const rescue = read("skills/rescue/SKILL.md"); - const installedRootPattern = /\/scripts\/claude-companion\.mjs/i; + const activeRootPattern = /\/scripts\/claude-companion\.mjs/i; - assert.match(rescue, /Do not derive the companion path from this skill file or any cache directory/i); + assert.match(rescue, /Resolve `` as two directories above this `SKILL\.md` file/i); assert.match(rescue, /Prefer `\$cc:rescue` when the user wants Claude Code to diagnose the issue, validate a risky change by actually editing or testing, apply fixes from a prior review, or carry a task forward across multiple steps/i); assert.match(rescue, /Do not use rescue for "just review this diff" unless the user also wants follow-through work beyond review findings/i); assert.match(rescue, /Do not use rescue merely because the main Codex thread plans to fix things after combining its own review with a separate Claude review/i); - assert.match(rescue, installedRootPattern); + assert.match(rescue, activeRootPattern); assert.match(rescue, /`--background` and `--wait` are Codex-side execution controls only/i); assert.match(rescue, /Never satisfy background rescue by launching `claude-companion\.mjs task` itself as a detached shell process/i); assert.match(rescue, /Never forward either flag to `claude-companion\.mjs task`/i); @@ -319,26 +318,29 @@ test("rescue parent skill owns resume-candidate exploration", () => { assert.match(runtimeSkill, /The parent rescue skill already owns that choice/i); }); -test("setup skill auto-installs missing hooks before the final setup report", () => { +test("setup skill repairs native plugin hook feature gates before the final setup report", () => { const setup = read("skills/setup/SKILL.md"); - assert.match(setup, /Do not derive the companion path from this skill file or any cache directory/i); - assert.match(setup, /\/scripts\/claude-companion\.mjs/i); + assert.match(setup, /Resolve `` as two directories above this `SKILL\.md` file/i); + assert.match(setup, /\/scripts\/claude-companion\.mjs/i); assert.match(setup, /setup --json/i); - assert.match(setup, /If setup reports missing hooks, run:/i); - assert.match(setup, /node "\/scripts\/install-hooks\.mjs"/i); - assert.match(setup, /rerun the final setup command so the user sees the repaired state immediately/i); + assert.match(setup, /missing native plugin hook features/i); + assert.match(setup, /hook trust/i); + assert.match(setup, /\[features\]\.hooks/i); + assert.match(setup, /\[features\]\.plugin_hooks/i); + assert.match(setup, /native hook trust hashes/i); + assert.doesNotMatch(setup, /install-hooks\.mjs/i); }); -test("simple runtime skills use the installed plugin path instead of cache-relative placeholders", () => { +test("simple runtime skills resolve the active plugin root from the skill path", () => { const status = read("skills/status/SKILL.md"); const result = read("skills/result/SKILL.md"); const cancel = read("skills/cancel/SKILL.md"); - const installedRootPattern = /\/scripts\/claude-companion\.mjs/i; + const activeRootPattern = /\/scripts\/claude-companion\.mjs/i; for (const skillText of [status, result, cancel]) { - assert.match(skillText, /Do not derive the companion path from this skill file or any cache directory/i); - assert.match(skillText, installedRootPattern); - assert.doesNotMatch(skillText, //i); + assert.match(skillText, /Resolve `` as two directories above this `SKILL\.md` file/i); + assert.match(skillText, activeRootPattern); + assert.doesNotMatch(skillText, //i); } });