diff --git a/README.md b/README.md index 6e9876d..e9f2285 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ In addition to mirroring the upstream command surface, this repository adds a fe ## Install -Choose either install path below. Both install the plugin into `~/.codex/plugins/cc`, create or update `~/.agents/plugins/marketplace.json`, enable `cc@local-plugins` in `~/.codex/config.toml`, enable `codex_hooks = true`, and install Codex hooks plus the global `cc-rescue` agent. +Choose either install path below. Both install the plugin into `~/.codex/plugins/cc`, create or update `~/.agents/plugins/marketplace.json`, install Codex-native `cc-*` wrappers into `~/.codex/skills` and `~/.codex/prompts`, enable `cc@local-plugins` in `~/.codex/config.toml`, enable `codex_hooks = true`, and install Codex hooks plus the global `cc-rescue` agent. ### npx diff --git a/scripts/local-plugin-install.mjs b/scripts/local-plugin-install.mjs index 1ce0410..3fed5b2 100644 --- a/scripts/local-plugin-install.mjs +++ b/scripts/local-plugin-install.mjs @@ -24,6 +24,8 @@ const MARKETPLACE_FILE = path.join(HOME_DIR, ".agents", "plugins", "marketplace. const CODEX_CONFIG_FILE = path.join(CODEX_HOME, "config.toml"); const CODEX_HOOKS_FILE = path.join(CODEX_HOME, "hooks.json"); const CODEX_AGENT_FILE = path.join(CODEX_HOME, "agents", "cc-rescue.toml"); +const CODEX_SKILLS_DIR = path.join(CODEX_HOME, "skills"); +const CODEX_PROMPTS_DIR = path.join(CODEX_HOME, "prompts"); const MANAGED_AGENT_MARKER = "# Managed by cc-plugin-codex."; const PLUGIN_CONFIG_HEADER = `[plugins."${PLUGIN_NAME}@${MARKETPLACE_NAME}"]`; const AGENT_CONFIG_HEADER = '[agents."cc-rescue"]'; @@ -31,6 +33,15 @@ const MANAGED_AGENT_REGISTRATION_LINES = [ 'description = "Forward substantial rescue tasks to Claude Code through the companion runtime."', 'config_file = "agents/cc-rescue.toml"', ]; +const EXPORTED_SKILLS = [ + "review", + "adversarial-review", + "rescue", + "status", + "result", + "cancel", + "setup", +]; function usage() { console.error( @@ -92,6 +103,127 @@ function normalizeTrailingNewline(text) { return `${text.replace(/\s*$/, "")}\n`; } +function formatCodexPromptName(skillName) { + return `${PLUGIN_NAME}-${skillName}`; +} + +function formatCodexSkillInvocationName(skillName) { + return `${PLUGIN_NAME}:${skillName}`; +} + +function extractFrontmatterFields(markdown) { + const match = markdown.match(/^---\n([\s\S]*?)\n---\n?/); + const fields = new Map(); + if (!match) { + return fields; + } + + for (const line of match[1].split("\n")) { + const fieldMatch = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (!fieldMatch) { + continue; + } + fields.set(fieldMatch[1], fieldMatch[2]); + } + + return fields; +} + +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: ${formatCodexSkillInvocationName(skillName)}`; + } + return line; + }); + return `---\n${nextLines.join("\n")}\n---`; + } + ); +} + +function rewriteSkillBody(markdown, pluginRoot) { + const normalizedPluginRoot = normalizePathSlashes(pluginRoot); + return markdown + .replaceAll("", normalizedPluginRoot) + .replace( + new RegExp( + `Resolve \`${escapeRegExp(normalizedPluginRoot)}\` as two directories above this skill file\\. The companion entrypoint is:`, + "g" + ), + "Use the companion entrypoint at:" + ) + .replace( + new RegExp( + `Resolve \`${escapeRegExp(normalizedPluginRoot)}\` as two directories above this skill file, then run:`, + "g" + ), + "Use the companion entrypoint:" + ) + .replace( + new RegExp( + `The global cc-rescue agent is installed by \`node \"${escapeRegExp(normalizedPluginRoot)}\\/scripts\\/install-hooks\\.mjs\"\` and registered in ~\\/\\.codex\\/config\\.toml\\.\\n\\nUse the companion entrypoint at:`, + "g" + ), + `The global cc-rescue agent is installed by \`node "${normalizedPluginRoot}/scripts/install-hooks.mjs"\` and registered in \`~/.codex/config.toml\`.\n\nUse the companion entrypoint at:` + ); +} + +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +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, + formatCodexPromptName(skillName), + "SKILL.md" + ); + writeText(targetSkillPath, normalizeTrailingNewline(wrappedSkill)); + + const frontmatterFields = extractFrontmatterFields(sourceSkill); + const promptLines = ["---"]; + if (frontmatterFields.has("description")) { + promptLines.push(`description: ${frontmatterFields.get("description")}`); + } + promptLines.push("---", ""); + promptLines.push( + `Use the $${formatCodexSkillInvocationName(skillName)} skill for this command and follow its instructions exactly.`, + "", + "Treat any text after the prompt name as the raw arguments to pass through." + ); + writeText( + path.join(CODEX_PROMPTS_DIR, `${formatCodexPromptName(skillName)}.md`), + normalizeTrailingNewline(promptLines.join("\n")) + ); + } +} + +function removeCodexSkillWrappers() { + for (const skillName of EXPORTED_SKILLS) { + fs.rmSync(path.join(CODEX_SKILLS_DIR, formatCodexPromptName(skillName)), { + recursive: true, + force: true, + }); + fs.rmSync(path.join(CODEX_PROMPTS_DIR, `${formatCodexPromptName(skillName)}.md`), { + force: true, + }); + } +} + function resolveMarketplacePluginPath(pluginRoot) { const relative = path.relative(HOME_DIR, pluginRoot); if (!relative || relative === "") { @@ -423,6 +555,7 @@ function runInstallHooks(pluginRoot) { function install(pluginRoot, skipHookInstall) { upsertMarketplaceEntry(pluginRoot); configureLocalPlugin(); + installCodexSkillWrappers(pluginRoot); if (!skipHookInstall) { runInstallHooks(pluginRoot); } @@ -432,6 +565,7 @@ function install(pluginRoot, skipHookInstall) { function uninstall(pluginRoot) { removeMarketplaceEntry(pluginRoot); removeLocalPluginConfig(); + removeCodexSkillWrappers(); removeManagedHooks(pluginRoot); removeManagedAgentFile(); console.log(`Uninstalled ${PLUGIN_NAME} from ${pluginRoot}`); diff --git a/tests/installer-cli.test.mjs b/tests/installer-cli.test.mjs index ebfe3e6..0989925 100644 --- a/tests/installer-cli.test.mjs +++ b/tests/installer-cli.test.mjs @@ -40,6 +40,7 @@ function copyFixture(sourceRoot) { "internal-skills", "package.json", "scripts", + "skills", ]; for (const relativePath of includePaths) { @@ -95,13 +96,26 @@ describe("installer-cli", () => { const configFile = path.join(homeDir, ".codex", "config.toml"); const hooksFile = path.join(homeDir, ".codex", "hooks.json"); const agentFile = path.join(homeDir, ".codex", "agents", "cc-rescue.toml"); + const codexSkillPath = path.join(homeDir, ".codex", "skills", "cc-review", "SKILL.md"); + const codexPromptPath = path.join(homeDir, ".codex", "prompts", "cc-review.md"); assert.ok(fs.existsSync(path.join(installDir, "scripts", "installer-cli.mjs"))); + assert.ok(fs.existsSync(codexSkillPath)); + assert.ok(fs.existsSync(codexPromptPath)); 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 codexSkill = fs.readFileSync(codexSkillPath, "utf8"); + const codexPrompt = fs.readFileSync(codexPromptPath, "utf8"); + assert.match(codexSkill, /^---\nname: cc:review\n/m); + assert.match(codexSkill, /Use the companion entrypoint at:/); + assert.match(codexSkill, /node ".*\/\.codex\/plugins\/cc\/scripts\/claude-companion\.mjs" review/); + assert.doesNotMatch(codexSkill, /two directories above this skill file/); + assert.doesNotMatch(codexSkill, //); + assert.match(codexPrompt, /Use the \$cc:review skill/); + const config = fs.readFileSync(configFile, "utf8"); assert.match(config, /\[plugins\."cc@local-plugins"\]/); assert.match(config, /enabled = true/); @@ -134,6 +148,8 @@ describe("installer-cli", () => { const expectedPath = `./${path.relative(homeDir, installDir).replace(/\\/g, "/")}`; assert.ok(fs.existsSync(path.join(installDir, "scripts", "installer-cli.mjs"))); + assert.ok(fs.existsSync(path.join(codexHome, "skills", "cc-review", "SKILL.md"))); + assert.ok(fs.existsSync(path.join(codexHome, "prompts", "cc-review.md"))); assert.equal(marketplace.plugins[0].source.path, expectedPath); assert.ok(expectedPath.includes("..")); }); @@ -212,6 +228,8 @@ describe("installer-cli", () => { const hooks = JSON.parse(fs.readFileSync(path.join(homeDir, ".codex", "hooks.json"), "utf8")); assert.ok(!fs.existsSync(installDir)); + assert.ok(!fs.existsSync(path.join(homeDir, ".codex", "skills", "cc-review"))); + assert.ok(!fs.existsSync(path.join(homeDir, ".codex", "prompts", "cc-review.md"))); assert.equal(marketplace.plugins.length, 1); assert.equal(marketplace.plugins[0].name, "other"); assert.match(config, /\[plugins\."github@openai-curated"\]/);