Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
134 changes: 134 additions & 0 deletions scripts/local-plugin-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,24 @@ 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"]';
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(
Expand Down Expand Up @@ -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("<plugin-root>", 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 === "") {
Expand Down Expand Up @@ -423,6 +555,7 @@ function runInstallHooks(pluginRoot) {
function install(pluginRoot, skipHookInstall) {
upsertMarketplaceEntry(pluginRoot);
configureLocalPlugin();
installCodexSkillWrappers(pluginRoot);
if (!skipHookInstall) {
runInstallHooks(pluginRoot);
}
Expand All @@ -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}`);
Expand Down
18 changes: 18 additions & 0 deletions tests/installer-cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function copyFixture(sourceRoot) {
"internal-skills",
"package.json",
"scripts",
"skills",
];

for (const relativePath of includePaths) {
Expand Down Expand Up @@ -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, /<plugin-root>/);
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/);
Expand Down Expand Up @@ -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(".."));
});
Expand Down Expand Up @@ -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"\]/);
Expand Down