From 670f33ea5d5f190b4ab8b54861959645b7d42f78 Mon Sep 17 00:00:00 2001 From: jinku Date: Mon, 6 Apr 2026 02:25:25 -0700 Subject: [PATCH] Add fallback Codex skill wrappers for unsupported installs --- README.md | 6 +- scripts/lib/managed-global-integration.mjs | 40 ++++++ scripts/local-plugin-install.mjs | 115 ++++++++++++++- tests/e2e/codex-skills-e2e.test.mjs | 159 +++++++++++++++++++++ tests/installer-cli.test.mjs | 47 +++++- 5 files changed, 364 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 3a8da3f..368d860 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ Both install flows: - ask Codex app-server to run `plugin/install` when that API is available - fall back to config-based activation on older or unsupported Codex builds -Outside the plugin directory, the only managed state is the hook entries in `~/.codex/hooks.json`. This plugin no longer installs a global rescue agent under `~/.codex/agents`. +When Codex's official `plugin/install` API is unavailable, the installer also writes fallback `cc-*` wrappers into `~/.codex/skills` and `~/.codex/prompts` so `$cc:*` commands remain discoverable. + +Outside the plugin directory, the managed state is the hook entries in `~/.codex/hooks.json`, plus fallback `cc-*` wrappers in `~/.codex/skills` and `~/.codex/prompts` when the installer has to use the older compatibility path. This plugin no longer installs a global rescue agent under `~/.codex/agents`. ### npx @@ -122,6 +124,8 @@ If Claude Code is already installed and authenticated, the other `$cc:*` skills - diagnose missing auth - enable or disable the review gate +If the plugin was installed through another marketplace path or copied into Codex without running this installer, run `$cc:setup` once so it can install the managed hooks. + After install, you should see: - the `$cc:*` skills listed in Codex diff --git a/scripts/lib/managed-global-integration.mjs b/scripts/lib/managed-global-integration.mjs index 2f507ca..c29ea16 100644 --- a/scripts/lib/managed-global-integration.mjs +++ b/scripts/lib/managed-global-integration.mjs @@ -22,6 +22,17 @@ const CODEX_PLUGIN_CACHE_DIR = path.join( PLUGIN_NAME, "local" ); +const CODEX_SKILLS_DIR = path.join(CODEX_HOME, "skills"); +const CODEX_PROMPTS_DIR = path.join(CODEX_HOME, "prompts"); +const MANAGED_WRAPPER_SKILLS = [ + "review", + "adversarial-review", + "rescue", + "status", + "result", + "cancel", + "setup", +]; const PLUGIN_SECTION_HEADER_PATTERN = /^\[\s*plugins\s*\.\s*["']?cc@local-plugins["']?\s*\]\s*(?:#.*)?$/i; const PLUGIN_ENABLED_PATTERN = /^enabled\s*=\s*true\s*(?:#.*)?$/i; @@ -44,6 +55,15 @@ function normalizePathSlashes(value) { return value.replace(/\\/g, "/"); } +function removeIfEmpty(dirPath) { + if (!fs.existsSync(dirPath)) { + return; + } + if (fs.readdirSync(dirPath).length === 0) { + fs.rmdirSync(dirPath); + } +} + function readConfigFile() { return readText(CODEX_CONFIG_FILE) ?? ""; } @@ -89,6 +109,25 @@ export function removeManagedHooks(pluginRoot) { writeText(CODEX_HOOKS_FILE, `${JSON.stringify({ hooks: nextHooks }, null, 2)}\n`); } +function formatWrapperName(skillName) { + return `${PLUGIN_NAME}-${skillName}`; +} + +export function removeManagedSkillWrappers() { + for (const skillName of MANAGED_WRAPPER_SKILLS) { + fs.rmSync(path.join(CODEX_SKILLS_DIR, formatWrapperName(skillName)), { + recursive: true, + force: true, + }); + fs.rmSync(path.join(CODEX_PROMPTS_DIR, `${formatWrapperName(skillName)}.md`), { + force: true, + }); + } + + removeIfEmpty(CODEX_SKILLS_DIR); + removeIfEmpty(CODEX_PROMPTS_DIR); +} + export function getManagedPluginSignals() { const configContent = readText(CODEX_CONFIG_FILE); const cachePresent = fs.existsSync(CODEX_PLUGIN_CACHE_DIR); @@ -174,6 +213,7 @@ export function isCodexPluginActive() { export function cleanupManagedGlobalIntegrations(pluginRoot) { removeManagedHooks(pluginRoot); + removeManagedSkillWrappers(); } export function resolveManagedMarketplacePluginPath(pluginRoot) { diff --git a/scripts/local-plugin-install.mjs b/scripts/local-plugin-install.mjs index e510557..9748c13 100644 --- a/scripts/local-plugin-install.mjs +++ b/scripts/local-plugin-install.mjs @@ -16,6 +16,7 @@ import { resolveCodexHome } from "./lib/codex-paths.mjs"; import { cleanupManagedGlobalIntegrations, resolveManagedMarketplacePluginPath, + removeManagedSkillWrappers, } from "./lib/managed-global-integration.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); @@ -27,7 +28,18 @@ 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 PLUGIN_CONFIG_HEADER = `[plugins."${PLUGIN_NAME}@${MARKETPLACE_NAME}"]`; +const EXPORTED_SKILLS = [ + "review", + "adversarial-review", + "rescue", + "status", + "result", + "cancel", + "setup", +]; function usage() { console.error( @@ -85,6 +97,105 @@ function normalizeTrailingNewline(text) { return `${text.replace(/\s*$/, "")}\n`; } +function normalizePathSlashes(value) { + return value.replace(/\\/g, "/"); +} + +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 + .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() { const existing = readText(MARKETPLACE_FILE); if (!existing) { @@ -391,15 +502,17 @@ export async function install(pluginRoot, skipHookInstall) { let usedFallback = false; try { await installPluginThroughCodex(); + removeManagedSkillWrappers(); } catch (error) { if (!isCodexInstallFallbackEligible(error)) { throw error; } enablePluginThroughConfigFallback(); + 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. ${detail}` + `Warning: Codex plugin/install unavailable; enabled the plugin through config fallback and installed Codex-native cc-* wrappers. ${detail}` ); } if (!skipHookInstall) { diff --git a/tests/e2e/codex-skills-e2e.test.mjs b/tests/e2e/codex-skills-e2e.test.mjs index 9d5d5ab..c50dbfa 100644 --- a/tests/e2e/codex-skills-e2e.test.mjs +++ b/tests/e2e/codex-skills-e2e.test.mjs @@ -190,6 +190,67 @@ function installPlugin(testEnv) { assert.ok(fs.existsSync(configFile), "installer should create a Codex config.toml"); } +function installPluginWithEnv(testEnv, extraEnv = {}) { + const result = spawnSync(process.execPath, [INSTALLER_SCRIPT, "install"], { + cwd: PROJECT_ROOT, + env: { + ...testEnv.env, + ...extraEnv, + }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr || result.stdout); + return result; +} + +function createMethodNotFoundCodex(testEnv) { + const scriptPath = path.join(testEnv.rootDir, "fake-codex-app-server-method-not-found.mjs"); + const logPath = path.join(testEnv.codexHome, "fake-codex-requests.log"); + + fs.writeFileSync( + scriptPath, + String.raw`import fs from "node:fs"; +import path from "node:path"; +import readline from "node:readline"; + +const [, , logPath] = process.argv; +const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + +rl.on("line", (line) => { + if (!line.trim()) { + return; + } + + const message = JSON.parse(line); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, JSON.stringify(message) + "\n", "utf8"); + + if (message.method === "initialize") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { ok: true } }) + "\n"); + return; + } + + process.stdout.write( + JSON.stringify({ + jsonrpc: "2.0", + id: message.id, + error: { code: -32601, message: "Method not found" }, + }) + "\n" + ); +});`, + "utf8" + ); + + return { + env: { + CC_PLUGIN_CODEX_EXECUTABLE: process.execPath, + CC_PLUGIN_CODEX_APP_SERVER_ARGS_JSON: JSON.stringify([scriptPath, logPath]), + }, + logPath, + }; +} + function writeConfigToml(testEnv, port) { const configFile = path.join(testEnv.codexHome, "config.toml"); const existing = fs.existsSync(configFile) @@ -1365,6 +1426,60 @@ 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"); @@ -1597,4 +1712,48 @@ describe("Codex direct-skill E2E", () => { cleanupEnvironment(testEnv); } }); + + it("auto-installs hooks during $cc:setup --enable-review-gate when the json probe reports they are missing", async (t) => { + if (!codexAvailable()) { + t.skip("codex CLI is not available in this environment"); + return; + } + + const testEnv = createEnvironment(); + const userRequest = "$cc:setup --enable-review-gate"; + const provider = startDirectSkillProvider({ + userRequest, + 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`, + ], + }); + testEnv.providerPort = await provider.listen(); + writeConfigToml(testEnv, testEnv.providerPort); + + try { + const execResult = await runCodexExec( + testEnv, + buildSkillPrompt("cc:setup", SETUP_SKILL_PATH, userRequest) + ); + + 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, /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" + ); + } finally { + await provider.close(); + cleanupEnvironment(testEnv); + } + }); }); diff --git a/tests/installer-cli.test.mjs b/tests/installer-cli.test.mjs index 21e84d0..c7dc501 100644 --- a/tests/installer-cli.test.mjs +++ b/tests/installer-cli.test.mjs @@ -572,9 +572,13 @@ describe("installer-cli", () => { 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"); 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)); const marketplace = JSON.parse(fs.readFileSync(marketplaceFile, "utf8")); assert.equal(marketplace.plugins[0].name, "cc"); @@ -628,6 +632,8 @@ describe("installer-cli", () => { 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"); @@ -637,7 +643,11 @@ describe("installer-cli", () => { 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 not depend on the Codex cache path"); + 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" @@ -654,6 +664,7 @@ describe("installer-cli", () => { 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"); @@ -663,6 +674,7 @@ describe("installer-cli", () => { 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" @@ -671,6 +683,30 @@ describe("installer-cli", () => { 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); + copyFixture(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"); + + fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true }); + fs.writeFileSync(staleSkillPath, "stale wrapper\n", "utf8"); + fs.mkdirSync(path.dirname(stalePromptPath), { recursive: true }); + fs.writeFileSync(stalePromptPath, "stale prompt\n", "utf8"); + fs.mkdirSync(path.dirname(unrelatedSkillPath), { recursive: true }); + fs.writeFileSync(unrelatedSkillPath, "leave me alone\n", "utf8"); + + runInstaller("install", homeDir, sourceRoot, fakeCodex.env); + + assert.ok(!fs.existsSync(staleSkillPath)); + assert.ok(!fs.existsSync(stalePromptPath)); + assert.ok(fs.existsSync(unrelatedSkillPath), "official install should not remove unrelated user skills"); + }); + it("uninstalls cleanly while preserving unrelated user config", () => { const homeDir = makeTempHome(); const sourceRoot = makeTempSource(); @@ -783,6 +819,13 @@ describe("installer-cli", () => { const cacheDir = path.join(codexDir, "plugins", "cache", "local-plugins", "cc", "local"); const configFile = path.join(codexDir, "config.toml"); 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.writeFileSync( configFile, @@ -816,6 +859,8 @@ describe("installer-cli", () => { 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"); const marketplace = JSON.parse( fs.readFileSync(path.join(homeDir, ".agents", "plugins", "marketplace.json"), "utf8")