const fs = require("node:fs");
const path = require("node:path");
const { stripBom, sha256File, listFiles } = require("./plugin-fs-util");
const { runCommand, createCommandInvocation, resolveCommandPath, isWindowsCommandScript, isPathLikeCommand, commandOutput, escapeRegExp } = require("./plugin-command");
const { codexHomeForTarget, openCodeConfigPathsForTarget, openCodeProjectRootForTarget, defaultPayloadRoot } = require("./plugin-agent-paths");
const { marketplaceId, marketplacePluginRef } = require("./plugin-builder");
function planPluginUninstall(pluginNames, targets, options = {}) {
const operations = [];
for (const pluginName of pluginNames) {
for (const target of targets) {
const record = readPluginRecord(target.dir, pluginName, target.agent);
const payloadRoot = record && record.payloadRoot
? record.payloadRoot
: defaultPayloadRoot(target, pluginName);
const payloadExists = fs.existsSync(payloadRoot);
const action = record || (options.force && payloadExists) ? "uninstall" : "missing";
operations.push({
action,
plugin: { name: pluginName },
target,
record,
payloadRoot,
managedFiles: record && Array.isArray(record.files) ? record.files : [],
payloadExists,
});
}
}
return { operations };
}
function applyPluginUninstallPlan(plan, options = {}) {
const applied = [];
for (const operation of plan.operations) {
if (operation.action !== "uninstall") {
applied.push(operation);
continue;
}
const result = applyPluginUninstallOperation(operation, options);
applied.push(result);
}
return applied;
}
function applyPluginUninstallOperation(operation, options = {}) {
const { payloadRoot, pluginRoot } = managedRootsForOperation(operation);
const validation = validateManagedFiles(pluginRoot, operation.managedFiles);
if (validation.mismatched.length && !options.force) {
return {
...operation,
action: "blocked",
validation,
filesRemoved: [],
registration: { skipped: true, reason: "checksum mismatch; rerun with --force to remove" },
};
}
const filesRemoved = removeManagedFiles(pluginRoot, payloadRoot, operation.managedFiles, { force: options.force });
if (options.force && fs.existsSync(payloadRoot)) {
fs.rmSync(payloadRoot, { recursive: true, force: true });
} else {
removeEmptyDirectories(payloadRoot);
}
removeEmptyDirectories(pluginRoot);
const registration = options.skipHostRegistration
? { skipped: true }
: unregisterHostPlugin(operation, options);
removePluginRecord(operation.target.dir, operation.plugin.name, operation.target.agent);
return {
...operation,
validation,
filesRemoved,
registration,
};
}
function unregisterHostPlugin(operation, options = {}) {
if (operation.target.agent === "claude") {
return unregisterClaudePlugin(operation, options);
}
if (operation.target.agent === "codex") {
return unregisterCodexPlugin(operation, options);
}
if (operation.target.agent === "opencode") {
return unregisterOpenCodePlugin(operation);
}
return { skipped: true, reason: `unsupported agent: ${operation.target.agent}` };
}
function unregisterClaudePlugin(operation, options = {}) {
const commandRunner = options.runCommand || runCommand;
const marketplace = marketplaceId(operation.plugin);
const uninstall = commandRunner("claude", ["plugin", "uninstall", marketplacePluginRef(operation.plugin)]);
const remove = commandRunner("claude", ["plugin", "marketplace", "remove", marketplace]);
const success = isBenignClaudeUninstall(uninstall) && isBenignClaudeMarketplaceRemove(remove);
return {
success,
command: "claude plugin uninstall/remove",
message: `${commandOutput(uninstall)}\n${commandOutput(remove)}`.trim(),
nextCommand: "restart Claude or open a new Claude session",
};
}
function unregisterCodexPlugin(operation, options = {}) {
const commandRunner = options.runCommand || runCommand;
const marketplace = marketplaceId(operation.plugin);
const codexHome = codexHomeForTarget(operation.target);
fs.mkdirSync(codexHome, { recursive: true });
const commandOptions = { env: { ...process.env, CODEX_HOME: codexHome } };
const remove = commandRunner("codex", ["plugin", "marketplace", "remove", marketplace], commandOptions);
const configUpdate = disableCodexPlugin(codexHome, operation.plugin);
return {
success: remove.status === 0,
command: "codex plugin marketplace remove",
message: commandOutput(remove),
configUpdate,
nextCommand: "restart Codex or open a new Codex task",
};
}
function unregisterOpenCodePlugin(operation) {
const updates = removeOpenCodePluginReferences(operation);
return {
success: updates.every((item) => item.success !== false),
command: "opencode config update",
message: updates.map((item) => `${item.changed ? "updated" : "unchanged"} ${item.path}`).join("\n"),
configUpdate: updates,
nextCommand: "restart OpenCode or open a new OpenCode session",
};
}
function disableCodexPlugin(codexHome, plugin) {
const configPath = path.join(codexHome, "config.toml");
if (!fs.existsSync(configPath)) {
return { changed: false, path: configPath };
}
const existing = fs.readFileSync(configPath, "utf8");
const withoutPlugin = removeTomlSection(existing, `[plugins."${plugin.name}@${marketplaceId(plugin)}"]`);
const withoutMarketplace = removeTomlSection(withoutPlugin, `[marketplaces.${marketplaceId(plugin)}]`);
if (withoutMarketplace === existing) {
return { changed: false, path: configPath };
}
fs.writeFileSync(configPath, withoutMarketplace, "utf8");
return { changed: true, path: configPath };
}
function removeOpenCodePluginReferences(operation) {
return openCodeConfigPathsForTarget(operation.target)
.filter((item, index, all) => all.indexOf(item) === index)
.filter((configPath) => fs.existsSync(configPath))
.map((configPath) => removeOpenCodePluginReference(configPath, operation.payloadRoot));
}
function removeOpenCodePluginReference(configPath, payloadRoot) {
const parsed = readJsoncFile(configPath);
if (!parsed) {
return { path: configPath, changed: false, success: false };
}
const skillsDir = path.join(payloadRoot, "skills");
const isOwn = (item) => path.resolve(item) === path.resolve(payloadRoot);
let changed = false;
const next = { ...parsed };
for (const field of ["plugin", "plugins"]) {
if (!Array.isArray(parsed[field])) continue;
const filtered = parsed[field].filter((item) => !isOwn(item));
if (filtered.length === parsed[field].length) continue;
changed = true;
if (filtered.length) next[field] = filtered;
else delete next[field];
}
let nextSkillsValue;
let skillsChanged = false;
if (Array.isArray(parsed.skills)) {
nextSkillsValue = parsed.skills.filter((item) => path.resolve(item) !== path.resolve(skillsDir));
skillsChanged = nextSkillsValue.length !== parsed.skills.length;
} else if (parsed.skills && Array.isArray(parsed.skills.paths)) {
const nextPaths = parsed.skills.paths.filter((item) => path.resolve(item) !== path.resolve(skillsDir));
nextSkillsValue = { ...parsed.skills, paths: nextPaths };
skillsChanged = nextPaths.length !== parsed.skills.paths.length;
}
if (skillsChanged) {
changed = true;
next.skills = nextSkillsValue;
}
if (!changed) {
return { path: configPath, changed: false, success: true };
}
fs.writeFileSync(configPath, `${JSON.stringify(next, null, 2)}\n`, "utf8");
return { path: configPath, changed: true, success: true };
}
function validateManagedFiles(payloadRoot, files) {
const missing = [];
const matched = [];
const mismatched = [];
for (const file of files) {
const filePath = path.resolve(file.path);
assertInside(payloadRoot, filePath);
if (!fs.existsSync(filePath)) {
missing.push(filePath);
continue;
}
const actual = sha256File(filePath);
if (actual === file.sha256) {
matched.push(filePath);
} else {
mismatched.push({ path: filePath, expected: file.sha256, actual });
}
}
return { matched, missing, mismatched };
}
function removeManagedFiles(pluginRoot, payloadRoot, files, options = {}) {
const removed = [];
for (const file of files) {
const filePath = path.resolve(file.path);
assertInside(pluginRoot, filePath);
if (!fs.existsSync(filePath)) continue;
fs.rmSync(filePath, { force: true });
removed.push(filePath);
}
if (options.force && fs.existsSync(payloadRoot)) {
const payloadFiles = listFiles(payloadRoot);
fs.rmSync(payloadRoot, { recursive: true, force: true });
for (const file of payloadFiles) {
if (!removed.includes(file)) {
removed.push(file);
}
}
}
return removed;
}
function removeEmptyDirectories(root) {
if (!fs.existsSync(root)) return;
const entries = fs.readdirSync(root, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
removeEmptyDirectories(path.join(root, entry.name));
}
}
if (fs.existsSync(root) && fs.readdirSync(root).length === 0) {
fs.rmdirSync(root);
}
}
function managedRootsForOperation(operation) {
const payloadRoot = path.resolve(operation.payloadRoot);
const expected = path.resolve(defaultPayloadRoot(operation.target, operation.plugin.name));
if (payloadRoot !== expected) {
throw new Error(`Refusing to uninstall plugin payload outside managed root: ${payloadRoot}`);
}
return { payloadRoot, pluginRoot: path.dirname(payloadRoot) };
}
function readInstallRecord(targetDir) {
const recordPath = path.join(targetDir, ".oh-skillhub", "installed.json");
if (!fs.existsSync(recordPath)) {
return { skills: {}, plugins: {} };
}
try {
const parsed = JSON.parse(stripBom(fs.readFileSync(recordPath, "utf8")));
return { ...parsed, skills: parsed.skills || {}, plugins: parsed.plugins || {} };
} catch {
return { skills: {}, plugins: {} };
}
}
function readPluginRecord(targetDir, pluginName, agent) {
const record = readInstallRecord(targetDir);
const key = pluginRecordKey(pluginName, agent);
return record.plugins && record.plugins[key] ? record.plugins[key] : null;
}
function removePluginRecord(targetDir, pluginName, agent) {
const recordDir = path.join(targetDir, ".oh-skillhub");
const recordPath = path.join(recordDir, "installed.json");
const record = readInstallRecord(targetDir);
const key = pluginRecordKey(pluginName, agent);
if (!record.plugins || !record.plugins[key]) {
return false;
}
delete record.plugins[key];
record.installedAt = new Date().toISOString();
fs.mkdirSync(recordDir, { recursive: true });
fs.writeFileSync(recordPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
return true;
}
function pluginRecordKey(pluginName, agent) {
return `${pluginName}:${agent}`;
}
function removeTomlSection(contents, section) {
const text = contents ? contents.replace(/\s*$/, "\n") : "";
const pattern = new RegExp(`(^|\\n)${escapeRegExp(section)}\\n[\\s\\S]*?(?=\\n\\[|$)`);
return text.replace(pattern, (match, prefix = "") => (prefix === "\n" ? "\n" : ""));
}
function isBenignClaudeUninstall(result) {
return result.status === 0 || /not installed|not found|No plugin/i.test(commandOutput(result));
}
function isBenignClaudeMarketplaceRemove(result) {
return result.status === 0 || /not found|does not exist|No marketplace/i.test(commandOutput(result));
}
function readJsoncFile(file) {
try {
return JSON.parse(stripJsonComments(stripBom(fs.readFileSync(file, "utf8"))));
} catch {
return null;
}
}
function stripJsonComments(text) {
return text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/(^|[^:])\/\/.*$/gm, "$1");
}
function assertInside(root, candidate) {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
if (relative.startsWith("..") || path.isAbsolute(relative)) {
throw new Error(`Refusing to uninstall outside managed plugin payload: ${candidate}`);
}
}
module.exports = {
applyPluginUninstallPlan,
planPluginUninstall,
readPluginRecord,
removeTomlSection,
unregisterHostPlugin,
};