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

Filter by extension

Filter by extension

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

Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions scripts/lib/managed-global-integration.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) ?? "";
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -174,6 +213,7 @@ export function isCodexPluginActive() {

export function cleanupManagedGlobalIntegrations(pluginRoot) {
removeManagedHooks(pluginRoot);
removeManagedSkillWrappers();
}

export function resolveManagedMarketplacePluginPath(pluginRoot) {
Expand Down
115 changes: 114 additions & 1 deletion scripts/local-plugin-install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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(
Expand Down Expand Up @@ -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 `<plugin-root>` as two directories above this skill file. The companion entrypoint is:",
"Use the companion entrypoint at:"
)
.replace(
"Resolve `<plugin-root>` as two directories above this skill file, then run:",
"Use the companion entrypoint:"
)
.replace(
"Resolve `<plugin-root>` as two directories above this skill file.",
`Use the installed plugin root at \`${normalizedPluginRoot}\`.`
)
.replaceAll("<plugin-root>", 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) {
Expand Down Expand Up @@ -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) {
Expand Down
Loading