const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { stripBom, sha256File, readJsonFile } = require("./plugin-fs-util");
const { codexHomeForTarget, openCodeConfigPathsForTarget, openCodeProjectRootForTarget, registryPathForScope, defaultPayloadRoot, collectOpenCodePluginEntries } = require("./plugin-agent-paths");
const { marketplaceId, marketplacePluginRef } = require("./plugin-builder");
const { readPluginRecord } = require("./plugin-installer");
function inspectPlugin(pluginName, targets, options = {}) {
const scope = targets[0] ? targets[0].scope : options.scope || "user";
const cwd = options.cwd || process.cwd();
const homeDir = options.homeDir || os.homedir();
const registry = inspectRegistry(pluginName, { scope, cwd, homeDir });
const targetReports = targets.map((target) => inspectTarget(pluginName, target));
const findings = buildFindings(pluginName, registry, targetReports, options);
if (options.allScopes) {
findings.push(...crossScopeFindings(pluginName, scope, { cwd, homeDir }));
}
return {
pluginName,
scope,
registry,
targets: targetReports,
findings,
};
}
function inspectRegistry(pluginName, options) {
const registryPath = registryPathForScope(options);
if (!fs.existsSync(registryPath)) {
return { path: registryPath, exists: false, hasPlugin: false };
}
const parsed = readJsonFile(registryPath, null);
if (!parsed) {
return { path: registryPath, exists: true, readable: false, hasPlugin: false };
}
return {
path: registryPath,
exists: true,
readable: true,
schemaVersion: parsed.schemaVersion || null,
hasPlugin: !!(parsed.plugins && parsed.plugins[pluginName]),
plugin: parsed.plugins && parsed.plugins[pluginName] ? parsed.plugins[pluginName] : null,
};
}
function inspectTarget(pluginName, target) {
const record = readPluginRecord(target.dir, pluginName, target.agent);
const payloadRoot = record && record.payloadRoot ? record.payloadRoot : defaultPayloadRoot(target, pluginName);
const pluginRoot = path.dirname(payloadRoot);
const payloadExists = fs.existsSync(payloadRoot);
const skillNames = pluginSkillNames(payloadRoot, record);
const fileValidation = record && Array.isArray(record.files)
? validateRecordFiles(record.files)
: { matched: 0, missing: 0, mismatched: 0 };
return {
agent: target.agent,
scope: target.scope,
targetDir: target.dir,
payloadRoot,
pluginRoot,
legacyCopy: !!record || payloadExists,
payloadExists,
record: record ? summarizeRecord(record) : null,
fileValidation,
host: inspectHost(pluginName, target, payloadRoot),
independentSkills: inspectIndependentSkills(target.dir, skillNames),
};
}
function summarizeRecord(record) {
return {
version: record.version || null,
contentHash: record.contentHash || null,
generatedBy: record.generatedBy || null,
files: Array.isArray(record.files) ? record.files.length : 0,
};
}
function inspectHost(pluginName, target, payloadRoot) {
if (target.agent === "codex") {
return inspectCodexHost(pluginName, target, payloadRoot);
}
if (target.agent === "claude") {
return inspectClaudeHost(pluginName, payloadRoot);
}
if (target.agent === "opencode") {
return inspectOpenCodeHost(target, payloadRoot);
}
return { agent: target.agent, supported: false };
}
function inspectCodexHost(pluginName, target, payloadRoot) {
const plugin = { name: pluginName };
const codexHome = codexHomeForTarget(target);
const configPath = path.join(codexHome, "config.toml");
const configText = fs.existsSync(configPath) ? fs.readFileSync(configPath, "utf8") : "";
const pluginSection = `[plugins."${marketplacePluginRef(plugin)}"]`;
const marketplaceSection = `[marketplaces.${marketplaceId(plugin)}]`;
return {
configPath,
configExists: fs.existsSync(configPath),
pluginEnabled: configText.includes(pluginSection) && /enabled\s*=\s*true/.test(sectionText(configText, pluginSection)),
marketplaceConfigured: configText.includes(marketplaceSection),
marketplacePath: path.join(path.dirname(payloadRoot), ".agents", "plugins", "marketplace.json"),
marketplaceExists: fs.existsSync(path.join(path.dirname(payloadRoot), ".agents", "plugins", "marketplace.json")),
};
}
function inspectClaudeHost(pluginName, payloadRoot) {
const plugin = { name: pluginName };
const marketplacePath = path.join(path.dirname(payloadRoot), ".claude-plugin", "marketplace.json");
const manifestPath = path.join(payloadRoot, ".claude-plugin", "plugin.json");
return {
pluginRef: marketplacePluginRef(plugin),
marketplacePath,
marketplaceExists: fs.existsSync(marketplacePath),
manifestPath,
manifestExists: fs.existsSync(manifestPath),
};
}
function inspectOpenCodeHost(target, payloadRoot) {
const configPaths = openCodeConfigPathsForTarget(target);
const refs = configPaths.map((configPath) => inspectOpenCodeConfig(configPath, payloadRoot));
return {
configPaths,
registered: refs.some((item) => item.referencesPayload),
configs: refs,
};
}
function inspectOpenCodeConfig(configPath, payloadRoot) {
if (!fs.existsSync(configPath)) {
return { path: configPath, exists: false, referencesPayload: false };
}
const parsed = readJsoncFile(configPath);
if (!parsed) {
return { path: configPath, exists: true, readable: false, referencesPayload: false };
}
const plugins = collectOpenCodePluginEntries(parsed);
return {
path: configPath,
exists: true,
readable: true,
referencesPayload: plugins.some((item) => path.resolve(item) === path.resolve(payloadRoot)),
};
}
function inspectIndependentSkills(targetDir, skillNames) {
const conflicts = [];
for (const skillName of skillNames) {
const skillDir = path.join(targetDir, skillName);
if (fs.existsSync(path.join(skillDir, "SKILL.md"))) {
conflicts.push({ name: skillName, path: skillDir });
}
}
return conflicts;
}
function pluginSkillNames(payloadRoot, record) {
const names = new Set();
if (record && Array.isArray(record.files)) {
for (const file of record.files) {
const rel = path.relative(payloadRoot, file.path);
const parts = rel.split(path.sep);
if (parts[0] === "skills" && parts[2] === "SKILL.md") {
names.add(parts[1]);
}
}
}
const skillRoot = path.join(payloadRoot, "skills");
if (fs.existsSync(skillRoot)) {
for (const entry of fs.readdirSync(skillRoot, { withFileTypes: true })) {
if (entry.isDirectory() && fs.existsSync(path.join(skillRoot, entry.name, "SKILL.md"))) {
names.add(entry.name);
}
}
}
return Array.from(names).sort();
}
function validateRecordFiles(files) {
let matched = 0;
let missing = 0;
let mismatched = 0;
for (const file of files) {
if (!file || !file.path || !file.sha256) continue;
if (!fs.existsSync(file.path)) {
missing += 1;
continue;
}
if (sha256File(file.path) === file.sha256) {
matched += 1;
} else {
mismatched += 1;
}
}
return { matched, missing, mismatched };
}
function buildFindings(pluginName, registry, targets, options = {}) {
const findings = [];
if (!registry.exists) {
findings.push({ level: "warn", message: `scope registry missing: ${registry.path}` });
} else if (!registry.readable) {
findings.push({ level: "fail", message: `scope registry unreadable: ${registry.path}` });
} else if (!registry.hasPlugin) {
findings.push({ level: "warn", message: `plugin not recorded in scope registry: ${pluginName}` });
} else {
findings.push(...registryPluginFindings(pluginName, registry, options));
}
for (const target of targets) {
const label = `${target.agent}:${target.scope}`;
if (target.legacyCopy) {
findings.push({ level: "warn", message: `${label} uses legacy-copy payload: ${target.payloadRoot}` });
} else {
findings.push({ level: "info", message: `${label} has no legacy payload` });
}
if (target.record && target.fileValidation.missing) {
findings.push({ level: "fail", message: `${label} has ${target.fileValidation.missing} missing managed file(s)` });
}
if (target.record && target.fileValidation.mismatched) {
findings.push({ level: "fail", message: `${label} has ${target.fileValidation.mismatched} checksum mismatch(es)` });
}
for (const conflict of target.independentSkills) {
findings.push({ level: "warn", message: `${label} has independent skill shadowing plugin skill ${conflict.name}: ${conflict.path}` });
}
if (target.legacyCopy) {
findings.push(...hostFindings(label, target.agent, target.host));
}
}
return findings;
}
function registryPluginFindings(pluginName, registry, options = {}) {
const findings = [];
const plugin = registry.plugin;
const scopeRoot = path.dirname(registry.path);
const storePath = plugin && plugin.storePath ? path.join(scopeRoot, plugin.storePath) : null;
if (storePath && !fs.existsSync(storePath)) {
findings.push({ level: "fail", message: `registry store path missing for ${pluginName}: ${storePath}` });
}
const hosts = plugin && plugin.hosts ? plugin.hosts : {};
for (const [agent, host] of Object.entries(hosts)) {
if (host.projection) {
const projectionPath = path.join(scopeRoot, host.projection);
if (!fs.existsSync(projectionPath)) {
findings.push({ level: "fail", message: `registry projection missing for ${agent}: ${projectionPath}` });
}
}
if (host.mode === "materialized-copy" || host.mode === "mixed-materialized-copy") {
const level = options.allowMaterializedCopy ? "warn" : "fail";
findings.push({
level,
message:
`${agent} projection uses ${host.mode} (content copied instead of linked)` +
(options.allowMaterializedCopy ? "" : "; rerun with --allow-materialized-copy to acknowledge"),
});
}
}
return findings;
}
function crossScopeFindings(pluginName, scope, options = {}) {
const findings = [];
const otherScope = scope === "project" ? "user" : "project";
const otherRoot = scope === "project"
? path.join(options.homeDir || os.homedir(), ".oh-skillhub")
: path.join(options.cwd || process.cwd(), ".oh-skillhub");
const otherRegistryPath = path.join(otherRoot, "registry.json");
if (!fs.existsSync(otherRegistryPath)) {
return findings;
}
let other = null;
try {
other = JSON.parse(fs.readFileSync(otherRegistryPath, "utf8"));
} catch {
return findings;
}
if (other && other.plugins && other.plugins[pluginName]) {
const precedence = scope === "project"
? "project scope shadows user scope when running inside the project"
: "user scope install present alongside project scope; project scope takes precedence inside the project";
findings.push({
level: "warn",
message: `plugin ${pluginName} is also installed in ${otherScope} scope: ${otherRegistryPath}; ${precedence}`,
});
}
return findings;
}
function hostFindings(label, agent, host) {
const findings = [];
if (agent === "codex") {
if (!host.marketplaceExists) {
findings.push({ level: "warn", message: `${label} Codex marketplace file missing: ${host.marketplacePath}` });
}
if (!host.pluginEnabled) {
findings.push({ level: "warn", message: `${label} Codex plugin enable section missing or disabled: ${host.configPath}` });
}
}
if (agent === "claude") {
if (!host.marketplaceExists) {
findings.push({ level: "warn", message: `${label} Claude marketplace file missing: ${host.marketplacePath}` });
}
if (!host.manifestExists) {
findings.push({ level: "warn", message: `${label} Claude plugin manifest missing: ${host.manifestPath}` });
}
}
if (agent === "opencode" && !host.registered) {
findings.push({ level: "warn", message: `${label} OpenCode config does not reference plugin payload` });
}
return findings;
}
function renderPluginStatus(report, options = {}) {
const lines = [`Plugin status: ${report.pluginName}`, `scope: ${report.scope}`];
lines.push(`registry: ${report.registry.exists ? "present" : "missing"} ${report.registry.path}`);
if (report.registry.exists && report.registry.readable) {
lines.push(`registry schema: ${report.registry.schemaVersion || "unknown"}`);
lines.push(`registry plugin: ${report.registry.hasPlugin ? "present" : "missing"}`);
if (report.registry.plugin) {
lines.push(`registry version: ${report.registry.plugin.version || "unknown"}`);
lines.push(`registry digest: ${report.registry.plugin.contentDigest || "unknown"}`);
lines.push(`registry store: ${report.registry.plugin.storePath || "unknown"}`);
const hosts = report.registry.plugin.hosts ? Object.keys(report.registry.plugin.hosts) : [];
lines.push(`registry hosts: ${hosts.length ? hosts.join(", ") : "none"}`);
}
}
for (const target of report.targets) {
lines.push("");
lines.push(`${target.agent}:${target.scope}`);
lines.push(` target: ${target.targetDir}`);
lines.push(` legacy-copy: ${target.legacyCopy ? "present" : "missing"}`);
lines.push(` payload: ${target.payloadRoot}`);
if (target.record) {
lines.push(` record: version ${target.record.version || "unknown"}, files ${target.record.files}, hash ${target.record.contentHash || "unknown"}`);
lines.push(` file check: matched ${target.fileValidation.matched}, missing ${target.fileValidation.missing}, mismatched ${target.fileValidation.mismatched}`);
} else {
lines.push(" record: missing");
}
lines.push(...renderHostStatus(target.agent, target.host));
if (target.independentSkills.length) {
lines.push(` independent skill conflicts: ${target.independentSkills.map((item) => item.name).join(", ")}`);
} else if (options.verbose) {
lines.push(" independent skill conflicts: none");
}
}
return lines.join("\n");
}
function renderPluginDoctor(report) {
const lines = [`Plugin doctor: ${report.pluginName}`, `scope: ${report.scope}`, ""];
const failCount = report.findings.filter((item) => item.level === "fail").length;
const warnCount = report.findings.filter((item) => item.level === "warn").length;
const infoCount = report.findings.filter((item) => item.level === "info").length;
lines.push(`summary: ${failCount} fail, ${warnCount} warn, ${infoCount} info`);
for (const finding of report.findings) {
lines.push(`${finding.level.toUpperCase()}: ${finding.message}`);
}
return lines.join("\n");
}
function renderHostStatus(agent, host) {
if (agent === "codex") {
return [
` codex marketplace: ${host.marketplaceExists ? "present" : "missing"} ${host.marketplacePath}`,
` codex config: ${host.pluginEnabled ? "enabled" : "missing/disabled"} ${host.configPath}`,
];
}
if (agent === "claude") {
return [
` claude ref: ${host.pluginRef}`,
` claude marketplace: ${host.marketplaceExists ? "present" : "missing"} ${host.marketplacePath}`,
` claude manifest: ${host.manifestExists ? "present" : "missing"} ${host.manifestPath}`,
];
}
if (agent === "opencode") {
const configSummary = host.configs
.filter((item) => item.exists)
.map((item) => `${item.referencesPayload ? "registered" : "not-registered"} ${item.path}`);
return [` opencode config: ${host.registered ? "registered" : "missing"}`, ...configSummary.map((item) => ` ${item}`)];
}
return [` host: unsupported ${agent}`];
}
function sectionText(text, section) {
const index = text.indexOf(section);
if (index < 0) return "";
const rest = text.slice(index + section.length);
const nextSection = rest.search(/\n\[/);
return nextSection >= 0 ? rest.slice(0, nextSection) : rest;
}
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(/(^|\s)\/\/.*$/gm, "$1");
}
module.exports = {
inspectPlugin,
renderPluginDoctor,
renderPluginStatus,
};