vendor: import codex-plugins as packages/omo-codex/{plugin,scripts,marketplace.json,MARKETPLACE.md}

This commit is contained in:
YeonGyu-Kim
2026-05-25 22:24:37 +09:00
parent 06c86f526a
commit 2415f37bc0
260 changed files with 22715 additions and 0 deletions
@@ -0,0 +1,93 @@
#!/usr/bin/env node
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { installCachedPlugin, linkCachedPluginBins, pruneMarketplaceCache } from "./install/cache.mjs";
import { updateCodexConfig } from "./install/config.mjs";
import { trustedHookStatesForPlugin } from "./install/hook-trust.mjs";
import { defaultRunCommand } from "./install/process.mjs";
import {
readMarketplace,
readPluginManifest,
resolvePluginSource,
validatePathSegment,
} from "./install/marketplace.mjs";
export async function installMarketplaceLocally(options = {}) {
const repoRoot = resolve(options.repoRoot ?? process.cwd());
const codexHome = resolve(options.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), ".codex"));
const binDir = resolve(options.binDir ?? process.env.CODEX_LOCAL_BIN_DIR ?? join(homedir(), ".local", "bin"));
const runCommand = options.runCommand ?? defaultRunCommand;
const log = options.log ?? console.log;
const marketplace = await readMarketplace(repoRoot);
const installed = [];
for (const entry of marketplace.plugins) {
const sourcePath = resolvePluginSource(repoRoot, entry);
const manifest = await readPluginManifest(sourcePath);
if (manifest.name !== entry.name) {
throw new Error(
`plugin manifest name ${JSON.stringify(manifest.name)} does not match marketplace name ${JSON.stringify(entry.name)}`,
);
}
const version = manifest.version ?? "local";
validatePathSegment(version, "plugin version");
log(`Building ${entry.name}@${version}`);
const plugin = await installCachedPlugin({
codexHome,
marketplaceName: marketplace.name,
name: entry.name,
runCommand,
sourcePath,
version,
});
const binLinks = await linkCachedPluginBins({ binDir, pluginRoot: plugin.path });
for (const link of binLinks) {
log(`Linked ${link.name} -> ${link.target}`);
}
installed.push(plugin);
}
const pluginNames = marketplace.plugins.map((plugin) => plugin.name);
const trustedHookStates = (
await Promise.all(
installed.map((plugin) =>
trustedHookStatesForPlugin({
marketplaceName: marketplace.name,
pluginName: plugin.name,
pluginRoot: plugin.path,
}),
),
)
).flat();
await pruneMarketplaceCache({ codexHome, marketplaceName: marketplace.name, keepPluginNames: pluginNames });
await updateCodexConfig({
configPath: join(codexHome, "config.toml"),
repoRoot,
marketplaceName: marketplace.name,
pluginNames,
trustedHookStates,
});
for (const plugin of installed) {
log(`Installed ${plugin.name}@${marketplace.name} -> ${plugin.path}`);
}
return { marketplaceName: marketplace.name, installed };
}
async function main() {
const repoRoot = process.argv[2] ? resolve(process.argv[2]) : process.cwd();
const result = await installMarketplaceLocally({ repoRoot });
console.log(`Installed ${result.installed.length} plugin(s) from ${result.marketplaceName}.`);
}
const invokedPath = process.argv[1] ? resolve(process.argv[1]) : "";
if (invokedPath === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}
@@ -0,0 +1,210 @@
import assert from "node:assert/strict";
import { mkdir, readFile, readlink, stat, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { join } from "node:path";
import test from "node:test";
import { tmpdir } from "node:os";
import { mkdtemp } from "node:fs/promises";
import { installMarketplaceLocally } from "./install-local.mjs";
async function makeTempDir() {
return mkdtemp(join(tmpdir(), "codex-plugins-install-"));
}
async function writeJson(path, value) {
await mkdir(dirname(path), { recursive: true });
await writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
}
async function writePlugin(root, name, version) {
const pluginRoot = join(root, "plugins", name);
await mkdir(join(pluginRoot, ".codex-plugin"), { recursive: true });
await mkdir(join(pluginRoot, "dist"), { recursive: true });
await mkdir(join(pluginRoot, "hooks"), { recursive: true });
await mkdir(join(pluginRoot, "skills", name), { recursive: true });
await writeJson(join(pluginRoot, ".codex-plugin", "plugin.json"), {
name,
version,
description: `${name} test plugin`,
mcpServers: "./.mcp.json",
hooks: "./hooks/hooks.json",
skills: "./skills/",
});
await writeJson(join(pluginRoot, ".mcp.json"), {
mcpServers: {
[name]: {
command: "node",
args: ["./dist/cli.js", "mcp"],
cwd: ".",
},
},
});
await writeJson(join(pluginRoot, "hooks", "hooks.json"), { hooks: {} });
await writeFile(join(pluginRoot, "skills", name, "SKILL.md"), "---\nname: test\n---\n");
await writeJson(join(pluginRoot, "package.json"), {
name: `@example/${name}`,
version,
bin: {
[name]: "./dist/cli.js",
},
scripts: {
build: "node -e \"require('fs').writeFileSync('dist/cli.js', 'console.log(1)')\"",
},
dependencies: {},
});
}
test("#given local marketplace #when installing #then copies versioned plugins and enables config", async () => {
const repoRoot = await makeTempDir();
const codexHome = await makeTempDir();
const binDir = await makeTempDir();
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
name: "debug-marketplace",
plugins: [
{
name: "alpha",
source: "./plugins/alpha",
},
{
name: "beta",
source: {
source: "local",
path: "./plugins/beta",
},
},
],
});
await writePlugin(repoRoot, "alpha", "1.2.3");
await writePlugin(repoRoot, "beta", "0.4.0");
await mkdir(join(repoRoot, "plugins", "alpha", "node_modules"), { recursive: true });
await writeFile(join(repoRoot, "plugins", "alpha", "node_modules", "skip.txt"), "skip");
await mkdir(join(codexHome, "plugins", "cache", "debug-marketplace", "stale", "0.1.0"), { recursive: true });
await writeFile(
join(codexHome, "config.toml"),
[
'[plugins."stale@debug-marketplace"]',
"enabled = true",
"",
'[hooks.state."stale@debug-marketplace:hooks/hooks.json:user_prompt_submit:0:0"]',
'trusted_hash = "sha256:old"',
"",
].join("\n"),
);
const commands = [];
const result = await installMarketplaceLocally({
repoRoot,
codexHome,
binDir,
runCommand: async (command, args, options) => {
commands.push([command, args, options.cwd]);
},
log: () => {},
});
assert.deepEqual(
result.installed.map((plugin) => `${plugin.name}@${plugin.version}`),
["alpha@1.2.3", "beta@0.4.0"],
);
const alphaCacheRoot = join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3");
assert.equal((await stat(join(alphaCacheRoot, ".mcp.json"))).isFile(), true);
assert.equal(await readlink(join(binDir, "alpha")), join(alphaCacheRoot, "dist", "cli.js"));
const alphaMcp = JSON.parse(await readFile(join(alphaCacheRoot, ".mcp.json"), "utf8"));
assert.deepEqual(alphaMcp.mcpServers.alpha.args, [join(alphaCacheRoot, "dist", "cli.js"), "mcp"]);
assert.equal(
Object.hasOwn(alphaMcp.mcpServers.alpha, "cwd"),
false,
"`cwd: \".\"` must be stripped so the spawned MCP server inherits the caller's workspace cwd",
);
assert.equal(alphaMcp.mcpServers.alpha.command, "node");
await assert.rejects(
stat(join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3", "node_modules")),
/code: 'ENOENT'|ENOENT/,
);
await assert.rejects(
stat(join(codexHome, "plugins", "cache", "debug-marketplace", "stale")),
/code: 'ENOENT'|ENOENT/,
);
assert.deepEqual(
commands.map(([command, args, cwd]) => [command, args.join(" "), cwd]),
[
["npm", "install", join(repoRoot, "plugins", "alpha")],
["npm", "run build", join(repoRoot, "plugins", "alpha")],
["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "alpha", "1.2.3")],
["npm", "install", join(repoRoot, "plugins", "beta")],
["npm", "run build", join(repoRoot, "plugins", "beta")],
["npm", "install --omit=dev", join(codexHome, "plugins", "cache", "debug-marketplace", "beta", "0.4.0")],
],
);
const config = await readFile(join(codexHome, "config.toml"), "utf8");
assert.match(config, /\[features\]\n(?:plugin_hooks = true\n)?plugins = true/);
assert.match(config, /\[marketplaces\.debug-marketplace\]/);
assert.match(config, /source_type = "local"/);
assert.match(config, /\[plugins\."alpha@debug-marketplace"\]\nenabled = true/);
assert.match(config, /\[plugins\."beta@debug-marketplace"\]\nenabled = true/);
assert.doesNotMatch(config, /stale@debug-marketplace/);
});
test("#given plugin hooks #when installing #then records trusted hook hashes", async () => {
const repoRoot = await makeTempDir();
const codexHome = await makeTempDir();
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
name: "debug-marketplace",
plugins: [{ name: "alpha", source: "./plugins/alpha" }],
});
await writePlugin(repoRoot, "alpha", "1.2.3");
await writeJson(join(repoRoot, "plugins", "alpha", "hooks", "hooks.json"), {
hooks: {
UserPromptSubmit: [
{
hooks: [
{
type: "command",
command: "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit",
timeout: 10,
statusMessage: "checking alpha",
},
],
},
],
},
});
await installMarketplaceLocally({
repoRoot,
codexHome,
runCommand: async () => {},
log: () => {},
});
const config = await readFile(join(codexHome, "config.toml"), "utf8");
assert.match(config, /\[hooks\.state\."alpha@debug-marketplace:hooks\/hooks\.json:user_prompt_submit:0:0"\]/);
assert.match(config, /trusted_hash = "sha256:[a-f0-9]{64}"/);
});
test("#given bad plugin source path #when installing #then rejects traversal", async () => {
const repoRoot = await makeTempDir();
const codexHome = await makeTempDir();
await mkdir(join(repoRoot, ".agents", "plugins"), { recursive: true });
await writeJson(join(repoRoot, ".agents", "plugins", "marketplace.json"), {
name: "debug-marketplace",
plugins: [
{
name: "escape",
source: "../escape",
},
],
});
await assert.rejects(
installMarketplaceLocally({ repoRoot, codexHome, log: () => {} }),
/local plugin source path must start with \.\//,
);
});
@@ -0,0 +1,152 @@
import { basename, dirname, join, sep } from "node:path";
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
import { exists, isRecord } from "./utils.mjs";
export async function installCachedPlugin({ codexHome, marketplaceName, name, runCommand, sourcePath, version }) {
await maybeRunNpmInstall(sourcePath, runCommand);
await maybeRunNpmBuild(sourcePath, runCommand);
const targetPath = join(codexHome, "plugins", "cache", marketplaceName, name, version);
await replaceDirectory(sourcePath, targetPath, shouldCopyPluginPath);
await maybeRunNpmInstall(targetPath, runCommand, ["install", "--omit=dev"]);
await rewriteCachedMcpManifest(targetPath);
return { name, version, path: targetPath };
}
export async function pruneMarketplaceCache({ codexHome, marketplaceName, keepPluginNames }) {
const cacheRoot = join(codexHome, "plugins", "cache", marketplaceName);
if (!(await exists(cacheRoot))) return;
const keep = new Set(keepPluginNames);
const entries = await readdir(cacheRoot, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || keep.has(entry.name)) continue;
await rm(join(cacheRoot, entry.name), { recursive: true, force: true });
}
}
export async function linkCachedPluginBins({ binDir, pluginRoot }) {
const binLinks = await discoverPackageBins(pluginRoot);
await mkdir(binDir, { recursive: true });
const linked = [];
for (const link of binLinks) {
const linkPath = join(binDir, link.name);
await replaceSymlink(linkPath, link.target);
linked.push({ name: link.name, path: linkPath, target: link.target });
}
return linked;
}
async function maybeRunNpmInstall(cwd, runCommand, args = ["install"]) {
if (!(await exists(join(cwd, "package.json")))) return;
await runCommand("npm", args, { cwd });
}
async function maybeRunNpmBuild(cwd, runCommand) {
if (!(await exists(join(cwd, "package.json")))) return;
const packageJson = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
if (!isRecord(packageJson.scripts) || typeof packageJson.scripts.build !== "string") return;
await runCommand("npm", ["run", "build"], { cwd });
}
async function replaceDirectory(sourcePath, targetPath, filter) {
await mkdir(dirname(targetPath), { recursive: true });
const tempPath = join(dirname(targetPath), `.tmp-${basename(targetPath)}-${process.pid}-${Date.now()}`);
await rm(tempPath, { recursive: true, force: true });
await cp(sourcePath, tempPath, {
recursive: true,
filter: (source) => filter(source, sourcePath),
});
await rm(targetPath, { recursive: true, force: true });
await rename(tempPath, targetPath);
}
async function discoverPackageBins(root) {
const links = [];
await collectPackageBins(root, root, links);
return links;
}
async function collectPackageBins(directory, root, links) {
const entries = await readdir(directory, { withFileTypes: true });
const packageJsonPath = join(directory, "package.json");
if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) {
await appendPackageBinLinks(packageJsonPath, directory, links);
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
const childPath = join(directory, entry.name);
if (!childPath.startsWith(root)) continue;
await collectPackageBins(childPath, root, links);
}
}
async function appendPackageBinLinks(packageJsonPath, packageRoot, links) {
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
if (!isRecord(packageJson)) return;
const bin = packageJson.bin;
if (typeof bin === "string" && typeof packageJson.name === "string") {
links.push({ name: basename(packageJson.name), target: join(packageRoot, bin) });
return;
}
if (!isRecord(bin)) return;
for (const [name, target] of Object.entries(bin)) {
if (typeof target !== "string") continue;
links.push({ name, target: join(packageRoot, target) });
}
}
async function replaceSymlink(linkPath, targetPath) {
if (await existingNonSymlink(linkPath)) {
throw new Error(`${linkPath} already exists and is not a symlink`);
}
await rm(linkPath, { force: true });
await symlink(targetPath, linkPath);
}
async function existingNonSymlink(path) {
try {
const stat = await lstat(path);
if (!stat.isSymbolicLink()) return true;
await readlink(path);
return false;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
throw error;
}
}
function shouldCopyPluginPath(path, root) {
const relative = path === root ? "" : path.slice(root.length + sep.length);
if (relative === "") return true;
const parts = relative.split(sep);
return !parts.some((part) => part === ".git" || part === "node_modules");
}
async function rewriteCachedMcpManifest(pluginRoot) {
const manifestPath = join(pluginRoot, ".mcp.json");
if (!(await exists(manifestPath))) return;
const raw = await readFile(manifestPath, "utf8");
const parsed = JSON.parse(raw);
if (!isRecord(parsed) || !isRecord(parsed.mcpServers)) return;
let changed = false;
for (const server of Object.values(parsed.mcpServers)) {
if (!isRecord(server)) continue;
if (server.cwd === "." || server.cwd === "./") {
delete server.cwd;
changed = true;
}
if (!Array.isArray(server.args)) continue;
const nextArgs = server.args.map((arg) => {
if (typeof arg !== "string") return arg;
if (arg.startsWith("./") || arg.startsWith("../")) return join(pluginRoot, arg);
return arg;
});
if (nextArgs.some((value, index) => value !== server.args[index])) {
server.args = nextArgs;
changed = true;
}
}
if (changed) await writeFile(manifestPath, `${JSON.stringify(parsed, null, "\t")}\n`);
}
@@ -0,0 +1,174 @@
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { exists } from "./utils.mjs";
export async function updateCodexConfig({ configPath, repoRoot, marketplaceName, pluginNames, trustedHookStates = [] }) {
await mkdir(dirname(configPath), { recursive: true });
let config = "";
if (await exists(configPath)) config = await readFile(configPath, "utf8");
config = removeStaleMarketplacePluginBlocks(config, marketplaceName, new Set(pluginNames));
config = removeStaleMarketplaceHookStateBlocks(config, marketplaceName, new Set(pluginNames));
config = ensureFeatureEnabled(config, "plugins");
config = ensureFeatureEnabled(config, "plugin_hooks");
config = ensureMarketplaceBlock(config, marketplaceName, repoRoot);
for (const pluginName of pluginNames) {
config = ensurePluginEnabled(config, `${pluginName}@${marketplaceName}`);
}
for (const state of trustedHookStates) {
config = ensureHookTrusted(config, state.key, state.trustedHash);
}
await writeFile(configPath, config.trimEnd() + "\n");
}
function removeStaleMarketplacePluginBlocks(config, marketplaceName, keepPluginNames) {
return removeTomlSections(config, (header) => {
const pluginKey = parseQuotedPluginHeader(header);
if (pluginKey === null) return false;
const suffix = `@${marketplaceName}`;
if (!pluginKey.endsWith(suffix)) return false;
return !keepPluginNames.has(pluginKey.slice(0, -suffix.length));
});
}
function removeStaleMarketplaceHookStateBlocks(config, marketplaceName, keepPluginNames) {
return removeTomlSections(config, (header) => {
const prefix = "hooks.state.";
if (!header.startsWith(prefix)) return false;
const hookKey = parseJsonString(header.slice(prefix.length));
if (hookKey === null) return false;
const separator = hookKey.indexOf(":");
if (separator === -1) return false;
const pluginKey = hookKey.slice(0, separator);
const suffix = `@${marketplaceName}`;
if (!pluginKey.endsWith(suffix)) return false;
return !keepPluginNames.has(pluginKey.slice(0, -suffix.length));
});
}
function ensureFeatureEnabled(config, featureName) {
const section = findTomlSection(config, "features");
if (!section) return appendBlock(config, `[features]\n${featureName} = true\n`);
return replaceOrInsertSetting(config, section, featureName, "true");
}
function ensureMarketplaceBlock(config, marketplaceName, repoRoot) {
const header = `marketplaces.${marketplaceName}`;
if (findTomlSection(config, header)) return config;
return appendBlock(
config,
[
`[${header}]`,
`last_updated = "${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}"`,
"source_type = \"local\"",
`source = ${JSON.stringify(repoRoot)}`,
"",
].join("\n"),
);
}
function ensurePluginEnabled(config, pluginKey) {
const header = `plugins.${JSON.stringify(pluginKey)}`;
const section = findTomlSection(config, header);
if (!section) return appendBlock(config, `[${header}]\nenabled = true\n`);
return replaceOrInsertSetting(config, section, "enabled", "true");
}
function ensureHookTrusted(config, key, trustedHash) {
const header = `hooks.state.${JSON.stringify(key)}`;
const section = findTomlSection(config, header);
if (!section) return appendBlock(config, `[${header}]\ntrusted_hash = ${JSON.stringify(trustedHash)}\n`);
return replaceOrInsertSetting(config, section, "trusted_hash", JSON.stringify(trustedHash));
}
function removeTomlSections(config, shouldRemove) {
return splitTomlSections(config)
.filter((section) => section.header === null || !shouldRemove(section.header))
.map((section) => section.text)
.join("")
.replace(/\n{3,}/g, "\n\n");
}
function splitTomlSections(config) {
const lines = config.match(/[^\n]*\n?|$/g) ?? [];
const sections = [];
let current = { header: null, text: "" };
for (const line of lines) {
if (line.length === 0) break;
const header = parseTomlHeader(line);
if (header !== null) {
if (current.text.length > 0) sections.push(current);
current = { header, text: line };
} else {
current.text += line;
}
}
if (current.text.length > 0) sections.push(current);
return sections;
}
function findTomlSection(config, header) {
const headerLine = `[${header}]`;
const lines = config.match(/[^\n]*\n?|$/g) ?? [];
let offset = 0;
let start = -1;
for (const line of lines) {
if (line.length === 0) break;
const trimmed = line.trim();
if (start === -1) {
if (trimmed === headerLine) start = offset;
} else if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
return { start, end: offset, text: config.slice(start, offset) };
}
offset += line.length;
}
if (start === -1) return null;
return { start, end: config.length, text: config.slice(start) };
}
function replaceOrInsertSetting(config, section, key, value) {
const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*$`, "m");
const replacement = linePattern.test(section.text)
? section.text.replace(linePattern, `${key} = ${value}`)
: insertSetting(section.text, key, value);
return config.slice(0, section.start) + replacement + config.slice(section.end);
}
function insertSetting(sectionText, key, value) {
const lines = sectionText.split("\n");
lines.splice(1, 0, `${key} = ${value}`);
return lines.join("\n");
}
function parseTomlHeader(line) {
const trimmed = line.trim();
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
if (trimmed.startsWith("[[")) return null;
return trimmed.slice(1, -1);
}
function parseQuotedPluginHeader(header) {
const prefix = "plugins.";
if (!header.startsWith(prefix)) return null;
return parseJsonString(header.slice(prefix.length));
}
function parseJsonString(value) {
try {
const parsed = JSON.parse(value);
return typeof parsed === "string" ? parsed : null;
} catch {
return null;
}
}
function appendBlock(config, block) {
const prefix = config.trimEnd();
return `${prefix}${prefix.length > 0 ? "\n\n" : ""}${block.trimEnd()}\n`;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -0,0 +1,84 @@
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { exists, isRecord } from "./utils.mjs";
const EVENT_LABELS = new Map([
["PreToolUse", "pre_tool_use"],
["PermissionRequest", "permission_request"],
["PostToolUse", "post_tool_use"],
["PreCompact", "pre_compact"],
["PostCompact", "post_compact"],
["SessionStart", "session_start"],
["UserPromptSubmit", "user_prompt_submit"],
["SubagentStart", "subagent_start"],
["SubagentStop", "subagent_stop"],
["Stop", "stop"],
]);
export async function trustedHookStatesForPlugin({ marketplaceName, pluginName, pluginRoot }) {
const manifestPath = join(pluginRoot, ".codex-plugin", "plugin.json");
if (!(await exists(manifestPath))) return [];
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
if (!isRecord(manifest) || typeof manifest.hooks !== "string") return [];
const hooksPath = join(pluginRoot, manifest.hooks);
if (!(await exists(hooksPath))) return [];
const parsed = JSON.parse(await readFile(hooksPath, "utf8"));
if (!isRecord(parsed) || !isRecord(parsed.hooks)) return [];
const keySource = `${pluginName}@${marketplaceName}:${stripDotSlash(manifest.hooks)}`;
const states = [];
for (const [eventName, groups] of Object.entries(parsed.hooks)) {
if (!Array.isArray(groups)) continue;
const eventLabel = EVENT_LABELS.get(eventName);
if (eventLabel === undefined) continue;
for (const [groupIndex, group] of groups.entries()) {
if (!isRecord(group) || !Array.isArray(group.hooks)) continue;
for (const [handlerIndex, handler] of group.hooks.entries()) {
if (!isRecord(handler) || handler.type !== "command") continue;
if (handler.async === true) continue;
if (typeof handler.command !== "string" || handler.command.trim() === "") continue;
const key = `${keySource}:${eventLabel}:${groupIndex}:${handlerIndex}`;
states.push({
key,
trustedHash: commandHookHash(eventLabel, group.matcher, handler),
});
}
}
}
return states;
}
function commandHookHash(eventName, matcher, handler) {
const command = handler.command;
const timeout = Math.max(Number(handler.timeout ?? 600), 1);
const normalizedHandler = {
type: "command",
command,
timeout,
async: false,
};
if (typeof handler.statusMessage === "string") normalizedHandler.statusMessage = handler.statusMessage;
const identity = {
event_name: eventName,
hooks: [normalizedHandler],
};
if (typeof matcher === "string") identity.matcher = matcher;
return `sha256:${createHash("sha256").update(JSON.stringify(canonicalJson(identity))).digest("hex")}`;
}
function canonicalJson(value) {
if (Array.isArray(value)) return value.map(canonicalJson);
if (!isRecord(value)) return value;
const result = {};
for (const key of Object.keys(value).sort()) {
result[key] = canonicalJson(value[key]);
}
return result;
}
function stripDotSlash(value) {
return value.startsWith("./") ? value.slice(2) : value;
}
@@ -0,0 +1,93 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { isRecord } from "./utils.mjs";
const MARKETPLACE_PATH = ".agents/plugins/marketplace.json";
export async function readMarketplace(repoRoot) {
const marketplacePath = join(repoRoot, MARKETPLACE_PATH);
const raw = await readFile(marketplacePath, "utf8");
const parsed = JSON.parse(raw);
if (!isRecord(parsed)) throw new Error("marketplace.json must be an object");
if (typeof parsed.name !== "string" || parsed.name.trim() === "") {
throw new Error("marketplace.json name must be a non-empty string");
}
validatePathSegment(parsed.name, "marketplace name");
if (!Array.isArray(parsed.plugins)) throw new Error("marketplace.json plugins must be an array");
return {
name: parsed.name,
plugins: parsed.plugins.map((plugin, index) => normalizeMarketplacePlugin(plugin, index)),
};
}
export function resolvePluginSource(repoRoot, plugin) {
const sourcePath = localSourcePath(plugin.source);
const relativePath = sourcePath.slice(2);
return join(repoRoot, ...relativePath.split(/[\\/]/));
}
export async function readPluginManifest(pluginRoot) {
const raw = await readFile(join(pluginRoot, ".codex-plugin", "plugin.json"), "utf8");
const parsed = JSON.parse(raw);
if (!isRecord(parsed)) throw new Error(`${pluginRoot} plugin.json must be an object`);
if (typeof parsed.name !== "string" || parsed.name.trim() === "") {
throw new Error(`${pluginRoot} plugin.json name must be a non-empty string`);
}
const manifest = { name: parsed.name };
if (parsed.version !== undefined) {
if (typeof parsed.version !== "string" || parsed.version.trim() === "") {
throw new Error(`${pluginRoot} plugin.json version must be a non-empty string`);
}
manifest.version = parsed.version.trim();
}
return manifest;
}
export function validatePathSegment(value, label) {
if (!/^[A-Za-z0-9._+-]+$/.test(value)) {
throw new Error(`${label} contains unsupported characters: ${value}`);
}
if (value === "." || value === "..") {
throw new Error(`${label} must not be a path traversal segment`);
}
}
function normalizeMarketplacePlugin(plugin, index) {
if (!isRecord(plugin)) throw new Error(`marketplace plugin ${index} must be an object`);
if (typeof plugin.name !== "string" || plugin.name.trim() === "") {
throw new Error(`marketplace plugin ${index} name must be a non-empty string`);
}
validatePathSegment(plugin.name, "plugin name");
return {
name: plugin.name,
source: plugin.source,
};
}
function localSourcePath(source) {
if (typeof source === "string") return validateLocalSourcePath(source);
if (
isRecord(source) &&
source.source === "local" &&
typeof source.path === "string"
) {
return validateLocalSourcePath(source.path);
}
throw new Error("local plugin source must be a string path or { source: \"local\", path } object");
}
function validateLocalSourcePath(path) {
if (!path.startsWith("./")) {
throw new Error("local plugin source path must start with ./");
}
const relative = path.slice(2);
if (relative.length === 0) throw new Error("local plugin source path must not be empty");
for (const part of relative.split(/[\\/]/)) {
if (part === "" || part === "." || part === "..") {
throw new Error("local plugin source path must stay within the marketplace root");
}
}
return path;
}
@@ -0,0 +1,19 @@
import { spawn } from "node:child_process";
export async function defaultRunCommand(command, args, options) {
await new Promise((resolvePromise, reject) => {
const child = spawn(command, args, {
cwd: options.cwd,
stdio: "inherit",
});
child.once("error", reject);
child.once("exit", (code, signal) => {
if (code === 0) {
resolvePromise();
return;
}
const suffix = signal ? `signal ${signal}` : `exit code ${code}`;
reject(new Error(`${command} ${args.join(" ")} failed in ${options.cwd} with ${suffix}`));
});
});
}
@@ -0,0 +1,15 @@
import { constants as fsConstants } from "node:fs";
import { access } from "node:fs/promises";
export async function exists(path) {
try {
await access(path, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
export function isRecord(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}