feat(omo-codex): batch 94 (13 files)
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { basename, join } from "node:path";
|
||||
import { copyFile, lstat, mkdir, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
|
||||
import { exists } from "./utils.mjs";
|
||||
|
||||
const MANIFEST_FILE = ".installed-agents.json";
|
||||
|
||||
export async function linkCachedPluginAgents({ codexHome, pluginRoot, platform = process.platform }) {
|
||||
const bundledAgents = await discoverBundledAgents(pluginRoot);
|
||||
if (bundledAgents.length === 0) {
|
||||
await writeManifest(pluginRoot, []);
|
||||
return [];
|
||||
}
|
||||
|
||||
const agentsDir = join(codexHome, "agents");
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
const linked = [];
|
||||
for (const agentPath of bundledAgents) {
|
||||
const linkPath = join(agentsDir, basename(agentPath));
|
||||
if (platform === "win32") {
|
||||
await replaceWithCopy(linkPath, agentPath);
|
||||
} else {
|
||||
await replaceWithSymlink(linkPath, agentPath);
|
||||
}
|
||||
linked.push({ name: basename(agentPath), path: linkPath, target: agentPath });
|
||||
}
|
||||
await writeManifest(pluginRoot, linked.map((entry) => entry.path));
|
||||
return linked;
|
||||
}
|
||||
|
||||
async function discoverBundledAgents(pluginRoot) {
|
||||
const componentsRoot = join(pluginRoot, "components");
|
||||
if (!(await exists(componentsRoot))) return [];
|
||||
|
||||
const componentEntries = await readdir(componentsRoot, { withFileTypes: true });
|
||||
const agents = [];
|
||||
for (const entry of componentEntries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const agentsRoot = join(componentsRoot, entry.name, "agents");
|
||||
if (!(await exists(agentsRoot))) continue;
|
||||
const agentEntries = await readdir(agentsRoot, { withFileTypes: true });
|
||||
for (const file of agentEntries) {
|
||||
if (!file.isFile() || !file.name.endsWith(".toml")) continue;
|
||||
agents.push(join(agentsRoot, file.name));
|
||||
}
|
||||
}
|
||||
agents.sort();
|
||||
return agents;
|
||||
}
|
||||
|
||||
async function replaceWithSymlink(linkPath, target) {
|
||||
await prepareReplacement(linkPath);
|
||||
await symlink(target, linkPath);
|
||||
}
|
||||
|
||||
async function replaceWithCopy(linkPath, target) {
|
||||
await prepareReplacement(linkPath);
|
||||
await copyFile(target, linkPath);
|
||||
}
|
||||
|
||||
async function prepareReplacement(linkPath) {
|
||||
if (!(await lstatExists(linkPath))) return;
|
||||
const entryStat = await lstat(linkPath);
|
||||
if (entryStat.isDirectory() && !entryStat.isSymbolicLink()) {
|
||||
throw new Error(`${linkPath} already exists and is a directory; refusing to replace`);
|
||||
}
|
||||
await rm(linkPath, { force: true });
|
||||
}
|
||||
|
||||
async function writeManifest(pluginRoot, agentPaths) {
|
||||
const manifestPath = join(pluginRoot, MANIFEST_FILE);
|
||||
const payload = { agents: [...agentPaths].sort() };
|
||||
await writeFile(manifestPath, `${JSON.stringify(payload, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
async function lstatExists(path) {
|
||||
try {
|
||||
await lstat(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
import { cp, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from "node:fs/promises";
|
||||
|
||||
import { createCachedMcpRuntimeArgRewriter } from "./mcp-runtime-cache.mjs";
|
||||
import { exists, isRecord } from "./utils.mjs";
|
||||
import { COMMAND_SHIM_MARKER } from "./command-shim.mjs";
|
||||
import { removeLegacyCodexComponentBins } from "./legacy-bins.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 rewriteCachedPackageLocalFileDependencies(targetPath, sourcePath);
|
||||
await maybeRunNpmInstall(targetPath, runCommand, ["install", "--omit=dev"]);
|
||||
await rewriteCachedMcpManifest(targetPath, sourcePath);
|
||||
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 pruneMarketplacePluginCaches({ codexHome, marketplaceName, pluginNames }) {
|
||||
const cacheRoot = join(codexHome, "plugins", "cache", marketplaceName);
|
||||
if (!(await exists(cacheRoot))) return;
|
||||
for (const pluginName of pluginNames) {
|
||||
await rm(join(cacheRoot, pluginName), { recursive: true, force: true });
|
||||
}
|
||||
if ((await readdir(cacheRoot)).length === 0) {
|
||||
await rm(cacheRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export async function linkCachedPluginBins({ binDir, pluginRoot, platform = process.platform }) {
|
||||
const binLinks = await discoverPackageBins(pluginRoot);
|
||||
await mkdir(binDir, { recursive: true });
|
||||
await removeLegacyCodexComponentBins(binDir, platform);
|
||||
const linked = [];
|
||||
for (const link of binLinks) {
|
||||
const linkPath = await linkCachedPluginBin(binDir, link, platform);
|
||||
linked.push({ name: link.name, path: linkPath, target: link.target });
|
||||
}
|
||||
return linked;
|
||||
}
|
||||
|
||||
async function linkCachedPluginBin(binDir, link, platform) {
|
||||
if (platform === "win32") {
|
||||
const linkPath = join(binDir, `${link.name}.cmd`);
|
||||
await replaceCommandShim(linkPath, link.target);
|
||||
return linkPath;
|
||||
}
|
||||
|
||||
const linkPath = join(binDir, link.name);
|
||||
await replaceSymlink(linkPath, link.target);
|
||||
return linkPath;
|
||||
}
|
||||
|
||||
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 replaceCommandShim(linkPath, targetPath) {
|
||||
if (await existingNonShim(linkPath)) {
|
||||
throw new Error(`${linkPath} already exists and is not a command shim`);
|
||||
}
|
||||
await writeFile(linkPath, `@echo off\r\n${COMMAND_SHIM_MARKER}\r\nnode "${targetPath}" %*\r\n`);
|
||||
}
|
||||
|
||||
async function existingNonShim(path) {
|
||||
try {
|
||||
const stat = await lstat(path);
|
||||
if (!stat.isFile()) return true;
|
||||
const content = await readFile(path, "utf8");
|
||||
if (content.includes(COMMAND_SHIM_MARKER)) return false;
|
||||
throw new Error(`${path} already exists and is not a generated command shim`);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (parts[parts.length - 1] === "package-lock.json") return false;
|
||||
return !parts.some((part) => part === ".git" || part === "node_modules");
|
||||
}
|
||||
|
||||
export async function rewriteCachedMcpManifest(pluginRoot, sourceRoot = 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;
|
||||
const rewriteRuntimeArg = createCachedMcpRuntimeArgRewriter();
|
||||
for (const [serverName, server] of Object.entries(parsed.mcpServers)) {
|
||||
if (!isRecord(server)) continue;
|
||||
if (server.cwd === "." || server.cwd === "./") {
|
||||
delete server.cwd;
|
||||
changed = true;
|
||||
}
|
||||
if (!Array.isArray(server.args)) continue;
|
||||
const nextArgs = await Promise.all(
|
||||
server.args.map((arg) => rewriteRuntimeArg({ arg, pluginRoot, serverName, sourceRoot })),
|
||||
);
|
||||
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`);
|
||||
}
|
||||
|
||||
async function rewriteCachedPackageLocalFileDependencies(pluginRoot, sourceRoot) {
|
||||
const packageJsonPaths = [];
|
||||
await collectPackageJsonPaths(pluginRoot, pluginRoot, packageJsonPaths);
|
||||
for (const packageJsonPath of packageJsonPaths) {
|
||||
const raw = await readFile(packageJsonPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!isRecord(parsed)) continue;
|
||||
const packageDir = dirname(packageJsonPath);
|
||||
const sourcePackageDir = join(sourceRoot, relative(pluginRoot, packageDir));
|
||||
let changed = false;
|
||||
for (const field of ["dependencies", "optionalDependencies", "peerDependencies"]) {
|
||||
const dependencies = parsed[field];
|
||||
if (!isRecord(dependencies)) continue;
|
||||
for (const [name, specifier] of Object.entries(dependencies)) {
|
||||
if (typeof specifier !== "string" || !specifier.startsWith("file:")) continue;
|
||||
const filePath = specifier.slice("file:".length);
|
||||
if (filePath.length === 0 || isAbsolute(filePath)) continue;
|
||||
const targetPath = resolve(packageDir, filePath);
|
||||
if (isPathInside(targetPath, pluginRoot)) continue;
|
||||
dependencies[name] = `file:${resolve(sourcePackageDir, filePath)}`;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) await writeFile(packageJsonPath, `${JSON.stringify(parsed, null, "\t")}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function collectPackageJsonPaths(directory, root, paths) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
if (entries.some((entry) => entry.isFile() && entry.name === "package.json")) {
|
||||
paths.push(join(directory, "package.json"));
|
||||
}
|
||||
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 collectPackageJsonPaths(childPath, root, paths);
|
||||
}
|
||||
}
|
||||
|
||||
function isPathInside(candidatePath, rootPath) {
|
||||
const pathFromRoot = relative(rootPath, candidatePath);
|
||||
return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot));
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const COMMAND_SHIM_MARKER = ":: generated by oh-my-openagent Codex installer";
|
||||
@@ -0,0 +1,229 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import { ensureCodexMultiAgentV2Config } from "./multi-agent-v2-config.mjs";
|
||||
import { appendBlock, findTomlSection, replaceOrInsertSetting } from "./toml-editor.mjs";
|
||||
import { exists } from "./utils.mjs";
|
||||
|
||||
const LEGACY_CODEX_PLUGIN_MARKETPLACE = ["code", "yeongyu", "codex", "plugins"].join("-");
|
||||
const SISYPHUS_LEGACY_MARKETPLACES = ["lazycodex", LEGACY_CODEX_PLUGIN_MARKETPLACE];
|
||||
const MANAGED_CODEX_AGENT_NAMES = [
|
||||
"codex-ultrawork-reviewer",
|
||||
"explorer",
|
||||
"librarian",
|
||||
"metis",
|
||||
"momus",
|
||||
"plan",
|
||||
];
|
||||
|
||||
export async function updateCodexConfig({
|
||||
configPath,
|
||||
repoRoot,
|
||||
marketplaceName,
|
||||
marketplaceSource = defaultMarketplaceSource(marketplaceName, repoRoot),
|
||||
pluginNames,
|
||||
trustedHookStates = [],
|
||||
agentConfigs = [],
|
||||
}) {
|
||||
await mkdir(dirname(configPath), { recursive: true });
|
||||
let config = "";
|
||||
if (await exists(configPath)) config = await readFile(configPath, "utf8");
|
||||
|
||||
for (const legacyMarketplaceName of legacyMarketplaceNames(marketplaceName)) {
|
||||
config = removeMarketplaceBlock(config, legacyMarketplaceName);
|
||||
config = removeStaleMarketplacePluginBlocks(config, legacyMarketplaceName, new Set());
|
||||
config = removeStaleMarketplaceHookStateBlocks(config, legacyMarketplaceName, new Set());
|
||||
}
|
||||
config = removeStaleMarketplacePluginBlocks(config, marketplaceName, new Set(pluginNames));
|
||||
config = removeStaleMarketplaceHookStateBlocks(config, marketplaceName, new Set(pluginNames));
|
||||
config = removeStaleManagedAgentBlocks(config, new Set(agentConfigs.map((agentConfig) => agentConfig.name)));
|
||||
config = ensureFeatureEnabled(config, "plugins");
|
||||
config = ensureFeatureEnabled(config, "plugin_hooks");
|
||||
config = ensureCodexMultiAgentV2Config(config);
|
||||
config = ensureMarketplaceBlock(config, marketplaceName, marketplaceSource);
|
||||
for (const pluginName of pluginNames) {
|
||||
config = ensurePluginEnabled(config, `${pluginName}@${marketplaceName}`);
|
||||
}
|
||||
for (const state of trustedHookStates) {
|
||||
config = ensureHookTrusted(config, state.key, state.trustedHash);
|
||||
}
|
||||
for (const agentConfig of agentConfigs) {
|
||||
config = ensureAgentConfig(config, agentConfig);
|
||||
}
|
||||
|
||||
await writeFile(configPath, config.trimEnd() + "\n");
|
||||
}
|
||||
|
||||
function legacyMarketplaceNames(marketplaceName) {
|
||||
return marketplaceName === "sisyphuslabs" ? SISYPHUS_LEGACY_MARKETPLACES : [];
|
||||
}
|
||||
|
||||
function removeMarketplaceBlock(config, marketplaceName) {
|
||||
return removeTomlSections(config, (header) => header === `marketplaces.${marketplaceName}`);
|
||||
}
|
||||
|
||||
function defaultMarketplaceSource(marketplaceName, repoRoot) {
|
||||
return {
|
||||
sourceType: "local",
|
||||
source: repoRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function removeStaleMarketplacePluginBlocks(config, marketplaceName, keepPluginNames) {
|
||||
return removeTomlSections(config, (header) => {
|
||||
const pluginKey = parsePluginHeaderKey(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 removeStaleManagedAgentBlocks(config, keepAgentNames) {
|
||||
const managedAgentNames = new Set(MANAGED_CODEX_AGENT_NAMES);
|
||||
return splitTomlSections(config)
|
||||
.filter((section) => {
|
||||
if (section.header === null) return true;
|
||||
const agentName = parseAgentHeaderName(section.header);
|
||||
if (agentName === null || !managedAgentNames.has(agentName) || keepAgentNames.has(agentName)) return true;
|
||||
return !section.text.includes(`config_file = ${JSON.stringify(`./agents/${agentName}.toml`)}`);
|
||||
})
|
||||
.map((section) => section.text)
|
||||
.join("")
|
||||
.replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
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, source) {
|
||||
const header = `marketplaces.${marketplaceName}`;
|
||||
const block = [
|
||||
`[${header}]`,
|
||||
`last_updated = "${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}"`,
|
||||
`source_type = ${JSON.stringify(source.sourceType)}`,
|
||||
`source = ${JSON.stringify(source.source)}`,
|
||||
source.ref === undefined ? null : `ref = ${JSON.stringify(source.ref)}`,
|
||||
"",
|
||||
].filter((line) => line !== null).join("\n");
|
||||
const section = findTomlSection(config, header);
|
||||
if (section) return config.slice(0, section.start) + block + config.slice(section.end);
|
||||
return appendBlock(config, block);
|
||||
}
|
||||
|
||||
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 ensureAgentConfig(config, agentConfig) {
|
||||
const header = `agents.${tomlKeySegment(agentConfig.name)}`;
|
||||
const section = findTomlSection(config, header);
|
||||
const configFile = JSON.stringify(agentConfig.configFile);
|
||||
if (!section) return appendBlock(config, `[${header}]\nconfig_file = ${configFile}\n`);
|
||||
return replaceOrInsertSetting(config, section, "config_file", configFile);
|
||||
}
|
||||
|
||||
function tomlKeySegment(value) {
|
||||
return /^[A-Za-z0-9_-]+$/.test(value) ? value : JSON.stringify(value);
|
||||
}
|
||||
|
||||
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 parseTomlHeader(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return null;
|
||||
if (trimmed.startsWith("[[")) return null;
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
|
||||
function parsePluginHeaderKey(header) {
|
||||
const prefix = "plugins.";
|
||||
if (!header.startsWith(prefix)) return null;
|
||||
return parseLeadingJsonString(header.slice(prefix.length));
|
||||
}
|
||||
|
||||
function parseAgentHeaderName(header) {
|
||||
const prefix = "agents.";
|
||||
if (!header.startsWith(prefix)) return null;
|
||||
const key = header.slice(prefix.length);
|
||||
return key.startsWith('"') ? parseLeadingJsonString(key) : key;
|
||||
}
|
||||
|
||||
function parseLeadingJsonString(value) {
|
||||
if (!value.startsWith('"')) return parseJsonString(value);
|
||||
let escaped = false;
|
||||
for (let index = 1; index < value.length; index += 1) {
|
||||
const char = value[index];
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') return parseJsonString(value.slice(0, index + 1));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseJsonString(value) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return typeof parsed === "string" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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,57 @@
|
||||
import { lstat, readFile, readlink, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { COMMAND_SHIM_MARKER } from "./command-shim.mjs";
|
||||
|
||||
const LEGACY_CODEX_COMPONENT_BINS = [
|
||||
{ name: "codex-comment-checker", component: "comment-checker" },
|
||||
{ name: "codex-rules", component: "rules" },
|
||||
{ name: "codex-start-work-continuation", component: "start-work-continuation" },
|
||||
{ name: "codex-telemetry", component: "telemetry" },
|
||||
{ name: "codex-ultrawork", component: "ultrawork" },
|
||||
];
|
||||
|
||||
export async function removeLegacyCodexComponentBins(binDir, platform) {
|
||||
for (const entry of LEGACY_CODEX_COMPONENT_BINS) {
|
||||
const linkPath = join(binDir, platform === "win32" ? `${entry.name}.cmd` : entry.name);
|
||||
await removeLegacyCodexComponentBin(linkPath, entry.component, platform);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeLegacyCodexComponentBin(linkPath, component, platform) {
|
||||
try {
|
||||
const stat = await lstat(linkPath);
|
||||
if (platform !== "win32") {
|
||||
if (!stat.isSymbolicLink()) return;
|
||||
const target = await readlink(linkPath);
|
||||
if (isManagedLegacyComponentTarget(target, component)) await rm(linkPath, { force: true });
|
||||
return;
|
||||
}
|
||||
if (!stat.isFile()) return;
|
||||
const content = await readFile(linkPath, "utf8");
|
||||
if (content.includes(COMMAND_SHIM_MARKER)) await rm(linkPath, { force: true });
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function isManagedLegacyComponentTarget(target, component) {
|
||||
const parts = target.split(/[\\/]+/);
|
||||
const suffixStart = parts.length - 4;
|
||||
const suffix = parts.slice(-4);
|
||||
return (
|
||||
suffix[0] === "components" &&
|
||||
suffix[1] === component &&
|
||||
suffix[2] === "dist" &&
|
||||
suffix[3] === "cli.js" &&
|
||||
hasPluginCachePrefix(parts, suffixStart)
|
||||
);
|
||||
}
|
||||
|
||||
function hasPluginCachePrefix(parts, endExclusive) {
|
||||
for (let index = 0; index < endExclusive - 1; index += 1) {
|
||||
if (parts[index] === "plugins" && parts[index + 1] === "cache") return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { isRecord } from "./utils.mjs";
|
||||
|
||||
const DEFAULT_MARKETPLACE_PATH = "packages/omo-codex/marketplace.json";
|
||||
|
||||
export async function readMarketplace(repoRoot, options = {}) {
|
||||
const marketplacePath = options.marketplacePath ?? join(repoRoot, DEFAULT_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(marketplaceRoot, plugin, options = {}) {
|
||||
const sourcePath = localSourcePath(options.pathOverride ?? plugin.source);
|
||||
const relativePath = sourcePath.slice(2);
|
||||
return join(marketplaceRoot, ...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");
|
||||
if (plugin.source === undefined || typeof plugin.source === "string") {
|
||||
if (typeof plugin.source === "string") validateLocalSourcePath(plugin.source);
|
||||
return {
|
||||
name: plugin.name,
|
||||
source: plugin.source,
|
||||
};
|
||||
}
|
||||
if (isRecord(plugin.source) && plugin.source.source === "local" && typeof plugin.source.path === "string") {
|
||||
validateLocalSourcePath(plugin.source.path);
|
||||
return {
|
||||
name: plugin.name,
|
||||
source: { source: "local", path: plugin.source.path },
|
||||
};
|
||||
}
|
||||
throw new Error("local plugin source must be a string path or { source: \"local\", path } object");
|
||||
}
|
||||
|
||||
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,77 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { cp } from "node:fs/promises";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
export function createCachedMcpRuntimeArgRewriter({ copyDist = cp } = {}) {
|
||||
const copiedDistRoots = new Map();
|
||||
return async function rewriteCachedMcpRuntimeArg({ arg, pluginRoot, serverName, sourceRoot }) {
|
||||
return rewriteCachedMcpRuntimeArgWithCache({ arg, pluginRoot, serverName, sourceRoot }, { copiedDistRoots, copyDist });
|
||||
};
|
||||
}
|
||||
|
||||
export async function rewriteCachedMcpRuntimeArg(args) {
|
||||
return createCachedMcpRuntimeArgRewriter()(args);
|
||||
}
|
||||
|
||||
async function rewriteCachedMcpRuntimeArgWithCache({ arg, pluginRoot, serverName, sourceRoot }, { copiedDistRoots, copyDist }) {
|
||||
if (typeof arg !== "string" || (!arg.startsWith("./") && !arg.startsWith("../"))) return arg;
|
||||
const fallback = resolveCachedRuntimePath(pluginRoot, sourceRoot, arg);
|
||||
const targetPath = resolve(pluginRoot, arg);
|
||||
const runtimePath = isPathInside(targetPath, pluginRoot) ? targetPath : resolve(sourceRoot, arg);
|
||||
const packageRoot = resolveExternalMcpPackageRoot(runtimePath, sourceRoot);
|
||||
if (packageRoot === undefined) return fallback;
|
||||
const distRoot = join(packageRoot, "dist");
|
||||
const distPath = relative(distRoot, runtimePath);
|
||||
if (distPath.startsWith("..") || isAbsolute(distPath)) return fallback;
|
||||
const cachedRoot = join(pluginRoot, "mcp", safePathSegment(serverName));
|
||||
const cacheKey = `${distRoot}\0${cachedRoot}`;
|
||||
let copyPromise = copiedDistRoots.get(cacheKey);
|
||||
if (copyPromise === undefined) {
|
||||
copyPromise = copyDist(distRoot, join(cachedRoot, "dist"), { recursive: true });
|
||||
copiedDistRoots.set(cacheKey, copyPromise);
|
||||
}
|
||||
await copyPromise;
|
||||
return join(cachedRoot, "dist", distPath);
|
||||
}
|
||||
|
||||
function resolveExternalMcpPackageRoot(runtimePath, sourceRoot) {
|
||||
const packagesRoot = findPackagesRoot(sourceRoot);
|
||||
if (packagesRoot === undefined) return undefined;
|
||||
if (!isPathInside(runtimePath, packagesRoot)) return undefined;
|
||||
let packageRoot = dirname(runtimePath);
|
||||
while (packageRoot !== packagesRoot) {
|
||||
if (existsSync(join(packageRoot, "package.json")) && isPathInside(runtimePath, join(packageRoot, "dist"))) {
|
||||
return packageRoot;
|
||||
}
|
||||
const parent = dirname(packageRoot);
|
||||
if (parent === packageRoot) return undefined;
|
||||
packageRoot = parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findPackagesRoot(path) {
|
||||
let current = resolve(path);
|
||||
for (let index = 0; index < 8; index++) {
|
||||
if (basename(current) === "packages") return current;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) return undefined;
|
||||
current = parent;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveCachedRuntimePath(pluginRoot, sourceRoot, runtimePath) {
|
||||
const targetPath = resolve(pluginRoot, runtimePath);
|
||||
if (isPathInside(targetPath, pluginRoot)) return targetPath;
|
||||
return resolve(sourceRoot, runtimePath);
|
||||
}
|
||||
|
||||
function isPathInside(candidatePath, rootPath) {
|
||||
const pathFromRoot = relative(rootPath, candidatePath);
|
||||
return pathFromRoot === "" || (!pathFromRoot.startsWith("..") && !isAbsolute(pathFromRoot));
|
||||
}
|
||||
|
||||
function safePathSegment(value) {
|
||||
return value.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { appendBlock, findTomlSection, removeSetting, replaceOrInsertSetting } from "./toml-editor.mjs";
|
||||
|
||||
const CODEX_MULTI_AGENT_V2_HEADER = "features.multi_agent_v2";
|
||||
const CODEX_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION = 10000;
|
||||
|
||||
export function ensureCodexMultiAgentV2Config(config) {
|
||||
const normalizedConfig = removeFeatureFlagSetting(config, "multi_agent_v2");
|
||||
const section = findTomlSection(normalizedConfig, CODEX_MULTI_AGENT_V2_HEADER);
|
||||
const maxThreadsValue = CODEX_MULTI_AGENT_V2_MAX_CONCURRENT_THREADS_PER_SESSION.toString();
|
||||
if (!section) {
|
||||
return appendBlock(
|
||||
normalizedConfig,
|
||||
`[${CODEX_MULTI_AGENT_V2_HEADER}]\nenabled = true\nmax_concurrent_threads_per_session = ${maxThreadsValue}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const enabledConfig = replaceOrInsertSetting(normalizedConfig, section, "enabled", "true");
|
||||
const updatedSection = findTomlSection(enabledConfig, CODEX_MULTI_AGENT_V2_HEADER);
|
||||
if (!updatedSection) {
|
||||
return appendBlock(
|
||||
enabledConfig,
|
||||
`[${CODEX_MULTI_AGENT_V2_HEADER}]\nenabled = true\nmax_concurrent_threads_per_session = ${maxThreadsValue}\n`,
|
||||
);
|
||||
}
|
||||
return replaceOrInsertSetting(enabledConfig, updatedSection, "max_concurrent_threads_per_session", maxThreadsValue);
|
||||
}
|
||||
|
||||
function removeFeatureFlagSetting(config, featureName) {
|
||||
const section = findTomlSection(config, "features");
|
||||
if (!section) return config;
|
||||
return removeSetting(config, section, featureName);
|
||||
}
|
||||
@@ -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,54 @@
|
||||
import { cp, mkdir, rename, rm, writeFile } from "node:fs/promises";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
import { rewriteCachedMcpManifest } from "./cache.mjs";
|
||||
|
||||
const INSTALLED_MARKETPLACES_DIR = ".tmp/marketplaces";
|
||||
|
||||
export async function writeInstalledMarketplaceSnapshot({ codexHome, marketplace, plugins }) {
|
||||
const marketplaceRoot = installedMarketplaceRoot(codexHome, marketplace.name);
|
||||
await mkdir(marketplaceRoot, { recursive: true });
|
||||
await writeMarketplaceManifest(marketplaceRoot, marketplace);
|
||||
|
||||
const snapshotPlugins = [];
|
||||
for (const plugin of plugins) {
|
||||
snapshotPlugins.push(await writeSnapshotPlugin(marketplaceRoot, plugin));
|
||||
}
|
||||
return snapshotPlugins;
|
||||
}
|
||||
|
||||
export function installedMarketplaceRoot(codexHome, marketplaceName) {
|
||||
return join(codexHome, INSTALLED_MARKETPLACES_DIR, marketplaceName);
|
||||
}
|
||||
|
||||
async function writeMarketplaceManifest(marketplaceRoot, marketplace) {
|
||||
const manifestDir = join(marketplaceRoot, ".agents", "plugins");
|
||||
await mkdir(manifestDir, { recursive: true });
|
||||
const tempPath = join(manifestDir, `.marketplace-${process.pid}-${Date.now()}.json.tmp`);
|
||||
await writeFile(tempPath, `${JSON.stringify(marketplace, null, "\t")}\n`);
|
||||
await rename(tempPath, join(manifestDir, "marketplace.json"));
|
||||
}
|
||||
|
||||
async function writeSnapshotPlugin(marketplaceRoot, plugin) {
|
||||
const pluginsDir = join(marketplaceRoot, "plugins");
|
||||
await mkdir(pluginsDir, { recursive: true });
|
||||
const targetPath = join(pluginsDir, plugin.name);
|
||||
const tempPath = join(pluginsDir, `.tmp-${plugin.name}-${process.pid}-${Date.now()}`);
|
||||
await rm(tempPath, { recursive: true, force: true });
|
||||
await cp(plugin.sourcePath, tempPath, {
|
||||
recursive: true,
|
||||
filter: (source) => shouldCopyMarketplaceSourcePath(source, plugin.sourcePath),
|
||||
});
|
||||
await rm(targetPath, { recursive: true, force: true });
|
||||
await rename(tempPath, targetPath);
|
||||
await rewriteCachedMcpManifest(targetPath, plugin.sourcePath);
|
||||
return { name: plugin.name, path: targetPath };
|
||||
}
|
||||
|
||||
function shouldCopyMarketplaceSourcePath(path, root) {
|
||||
const relative = path === root ? "" : path.slice(root.length + sep.length);
|
||||
if (relative === "") return true;
|
||||
const parts = relative.split(sep);
|
||||
if (parts[parts.length - 1] === "package-lock.json") return false;
|
||||
return !parts.some((part) => part === ".git" || part === "node_modules");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export 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) };
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function removeSetting(config, section, key) {
|
||||
const linePattern = new RegExp(`^${escapeRegExp(key)}\\s*=.*(?:\\n|$)`, "m");
|
||||
const replacement = section.text.replace(linePattern, "");
|
||||
return config.slice(0, section.start) + replacement + config.slice(section.end);
|
||||
}
|
||||
|
||||
export function appendBlock(config, block) {
|
||||
const prefix = config.trimEnd();
|
||||
return `${prefix}${prefix.length > 0 ? "\n\n" : ""}${block.trimEnd()}\n`;
|
||||
}
|
||||
|
||||
function insertSetting(sectionText, key, value) {
|
||||
const lines = sectionText.split("\n");
|
||||
lines.splice(1, 0, `${key} = ${value}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user