const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const readline = require("node:readline");
const readlinePromises = require("node:readline/promises");
const { resolveAgentTargets } = require("./agents");
const { applyCleanPlan, planClean, scanInstalledSkills } = require("./cleaner");
const { loadLocalManifest, loadProfiles, loadRuntimeManifest, matchDomain, matchCommonStage, selectSkills, suggestSkillName } = require("./manifest");
const { applyInstallPlan, planInstall } = require("./planner");
const { loadNeutralPlugin, marketplacePluginRef, parsePluginManifest } = require("./plugin-builder");
const { inspectPlugin, renderPluginDoctor, renderPluginStatus } = require("./plugin-doctor");
const {
applyLegacyCleanup,
planLegacyCleanup,
renderLegacyCleanup,
renderLegacyDoctor,
scanLegacy,
} = require("./plugin-doctor-legacy");
const { applyPluginInstallPlan, loadAndPlanPluginInstall, readPluginRecord } = require("./plugin-installer");
const { ensurePluginSourceRoot, ensurePluginWorkspaceRoot, listWorkspacePlugins } = require("./plugin-source");
const { applyPluginUninstallPlan, planPluginUninstall } = require("./plugin-uninstaller");
const { installPluginV2, precheckPluginV2Install, uninstallPluginV2, updatePluginV2 } = require("./plugin-v2-installer");
const { collectGarbage, repairScope, setHostEnabled } = require("./plugin-gc");
const { migratePlugin } = require("./plugin-migrate");
const { scopeRootFor } = require("./plugin-registry");
const {
checkVersion,
generateManifests,
renderManifestGeneration,
renderManifestVersionCheck,
} = require("./plugin-manifest-strategy");
const { ensureSkillSourceRoot, ensureSkillSourceRootAsync } = require("./source");
const { applyUpdatePlan, planUpdate, planUpdateAll, renderUpdateResults } = require("./updater");
const { buildTelemetryEvent, enqueueTelemetryEvents, flushTelemetryQueue, telemetryStatus } = require("./telemetry");
const packageJson = require("../package.json");
const COMMON_STAGES = ["cicd", "design", "development", "requirements", "testing", "troubleshooting"];
const ANSI = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
cyan: "\x1b[36m",
green: "\x1b[32m",
red: "\x1b[31m",
reverse: "\x1b[7m",
};
const AGENT_CHOICES = [
{ agent: "codex", label: "Codex", hint: "~/.codex/skills" },
{ agent: "claude", label: "Claude", hint: "~/.claude/skills" },
{ agent: "opencode", label: "OpenCode", hint: "~/.config/opencode/skills" },
{ agent: "all", label: "All", hint: "Codex + Claude + OpenCode" },
];
const INDIVIDUAL_AGENT_INDEXES = [0, 1, 2];
const ALL_AGENT_INDEX = 3;
const ACTION_CHOICES = [
{ action: "install", label: "Install skills", hint: "Choose OpenHarmony skill groups to install" },
{ action: "clean", label: "Clean installed skills", hint: "Scan an agent target and remove selected skills" },
];
async function main(argv = []) {
if (argv.length === 0) {
await runHome();
return;
}
if (argv[0] === "--help" || argv[0] === "-h") {
console.log(helpText());
return;
}
const command = argv[0] || "help";
const rest = argv.slice(1);
const options = parseArgs(rest);
if (command === "help" || options.help) {
console.log(helpText());
return;
}
if (command === "plugin") {
console.log(renderPluginCommand(rest));
return;
}
if (command === "list") {
console.log(await renderList(options));
return;
}
if (command === "install") {
console.log(await renderInstall(options));
return;
}
if (command === "uninstall") {
const pluginName = options.names[0] || "<plugin>";
throw new Error(workflowPluginNamespaceError("uninstall", pluginName));
}
if (command === "doctor") {
console.log(renderDoctor(options));
return;
}
if (command === "clean") {
await runClean(options);
return;
}
if (command === "update") {
await runUpdate(options);
return;
}
if (command === "update-all") {
await runUpdateAll(options);
return;
}
if (command === "telemetry") {
console.log(renderTelemetry(rest));
return;
}
throw new Error(`Unknown command "${command}". Run oh-skillhub help.`);
}
function parseArgs(args) {
const options = { names: [] };
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") options.help = true;
else if (arg === "--domain") options.domain = args[++index];
else if (arg === "--stage") options.stage = args[++index];
else if (arg === "--common") options.common = args[++index];
else if (arg === "--preset" || arg === "--profile") options.preset = args[++index];
else if (arg === "--agent") options.agent = args[++index];
else if (arg === "--scope") options.scope = args[++index];
else if (arg === "--target") options.target = args[++index];
else if (arg === "--all-common") options.allCommon = true;
else if (arg === "--name") {
const nameVal = args[++index];
if (nameVal !== undefined) options.names.push(nameVal);
}
else if (arg === "--dry-run") options.dryRun = true;
else if (arg === "--scan-legacy") options.scanLegacy = true;
else if (arg === "--fix") options.fix = true;
else if (arg === "--offline") options.offline = true;
else if (arg === "--out") options.out = args[++index];
else if (arg === "--check") options.check = true;
else if (arg === "--no-telemetry") options.noTelemetry = true;
else if (arg === "--force") options.force = true;
else if (arg === "--atomic") options.atomic = true;
else if (arg === "--no-gc") options.autoGc = false;
else if (arg === "--strict") options.strict = true;
else if (arg === "--replace-same-version") options.replaceSameVersion = true;
else if (arg === "--allow-downgrade") options.allowDowngrade = true;
else if (arg === "--all-scopes") options.allScopes = true;
else if (arg === "--allow-materialized-copy") options.allowMaterializedCopy = true;
else if (arg === "--purge") options.purge = true;
else if (arg === "--verbose") options.verbose = true;
else if (!arg.startsWith("-")) options.names.push(arg);
else throw new Error(`Unknown option "${arg}".`);
}
return options;
}
function isPluginInstallRequest(options) {
return false;
}
function hasPluginNames(options) {
return false;
}
function isOnlyPluginNames(options) {
return false;
}
async function runHome(input = process.stdin, output = process.stdout) {
if (input.isTTY && output.isTTY) {
const action = await runRawActionSelection(input, output);
if (action === "clean") {
await runClean({}, input, output);
return;
}
await runInteractiveInstaller(input, output);
return;
}
const answers = splitPromptAnswers(await readAll(input));
const hasActionAnswer = answers.length >= 3 && isSingleSelection(answers[0], ACTION_CHOICES.length);
if (!hasActionAnswer) {
await runInteractiveInstallerFromAnswers(answers, output);
return;
}
output.write(renderActionMenu());
output.write("Select action [1]: \n");
const action = ACTION_CHOICES[parseSingleSelection(answers[0], ACTION_CHOICES.length, 1)].action;
if (action === "clean") {
await runCleanWithAnswers({}, input, output, answers.slice(1));
return;
}
await runInteractiveInstallerFromAnswers(answers.slice(1), output);
}
async function runClean(options, input = process.stdin, output = process.stdout) {
const scriptedAnswers = input.isTTY ? null : splitPromptAnswers(await readAll(input));
await runCleanWithAnswers(options, input, output, scriptedAnswers);
}
async function runCleanWithAnswers(options, input, output, scriptedAnswers = null) {
const cleanOptions = { names: [], ...options };
const hasScriptedAnswers = Array.isArray(scriptedAnswers);
let answerIndex = 0;
const takeAnswer = () => {
if (!hasScriptedAnswers) return null;
const answer = scriptedAnswers[answerIndex] || "";
answerIndex += 1;
return answer;
};
let agent = cleanOptions.agent;
if (!agent && !cleanOptions.target) {
if (!hasScriptedAnswers && input.isTTY && output.isTTY) {
agent = await runRawAgentSelection(input, output);
} else {
output.write(renderAgentMenu());
output.write("Select target [1]: \n");
agent = parseAgentSelection(takeAnswer() || "");
}
}
const targets = resolveAgentTargets({
agent: agent || "codex",
scope: cleanOptions.scope || "user",
target: cleanOptions.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
output.write(renderMigrationNotice(targets));
const discovered = scanInstalledSkills(targets);
if (!discovered.length) {
output.write(renderNoSkillsFound(targets));
return;
}
let selected;
let prefilledConfirmation = null;
if (!cleanOptions.names.length && !cleanOptions.dryRun) {
if (!hasScriptedAnswers && input.isTTY && output.isTTY) {
selected = await promptCleanSelection(input, output, discovered);
} else {
output.write(renderCleanSelectionMenu(discovered));
output.write("Select skills [1]: \n");
selected = parseSelection(takeAnswer() || "", discovered.length, 1).map((index) => discovered[index]);
prefilledConfirmation = takeAnswer() || "";
}
} else {
selected = selectCleanSkills(discovered, cleanOptions.names);
if (hasScriptedAnswers || !input.isTTY) {
prefilledConfirmation = takeAnswer();
}
}
if (!selected.length) {
throw new Error(`No installed skills matched: ${cleanOptions.names.join(", ")}`);
}
const plan = planClean(selected, { mode: cleanOptions.purge ? "purge" : "trash" });
if (cleanOptions.dryRun) {
output.write(`${renderCleanPlan("Clean dry run", plan)}\n`);
return;
}
await confirmClean(input, output, plan, prefilledConfirmation);
const applied = applyCleanPlan(plan);
output.write(`${renderCleanPlan("Clean summary", { operations: applied })}\n`);
output.write(`${renderCleanCompletion(applied)}\n`);
}
async function promptCleanSelection(input, output, skills) {
if (input.isTTY && output.isTTY) {
const selectedIndexes = await runRawCleanSelection(input, output, skills);
return selectedIndexes.map((index) => skills[index]);
}
output.write(renderCleanSelectionMenu(skills));
const rl = readlinePromises.createInterface({ input, output });
try {
const answer = await rl.question("Select skills [1]: ");
return parseSelection(answer, skills.length, 1).map((index) => skills[index]);
} finally {
rl.close();
}
}
function selectCleanSkills(skills, names = []) {
if (!names.length) {
return skills;
}
const wanted = new Set(names);
return skills.filter((skill) => wanted.has(skill.name) || wanted.has(skill.directoryName));
}
async function confirmClean(input, output, plan, prefilledAnswer = null) {
const first = plan.operations[0];
const action = first.action === "purge" ? "Permanently delete" : "Remove";
const target = plan.operations.length
? `${first.agent}:${first.scope}`
: "selected target";
output.write(`${action} ${plan.operations.length} selected skill(s) from ${target}?\n`);
if (first.action === "purge") {
output.write("This cannot be undone.\n");
} else {
output.write("Selected directories will be moved to .oh-skillhub/trash.\n");
}
if (prefilledAnswer !== null) {
output.write("Press Enter to confirm, or type anything else to cancel: \n");
if (prefilledAnswer.trim()) {
throw new Error("Cancelled. No skills were removed.");
}
return;
}
const rl = readlinePromises.createInterface({ input, output });
try {
const answer = await rl.question("Press Enter to confirm, or type anything else to cancel: ");
if (answer.trim()) {
throw new Error("Cancelled. No skills were removed.");
}
} finally {
rl.close();
}
}
function renderCleanSelectionMenu(skills) {
const lines = ["Detected skills"];
skills.forEach((skill, index) => {
const group = formatSkillGroup([skill.domain, skill.stage]);
lines.push(
`${String(index + 1).padStart(2, " ")}. [ ] ${skill.name.padEnd(36, " ")} ${skill.status.padEnd(9, " ")} ${group}`,
);
});
lines.push("");
return `${lines.join("\n")}\n`;
}
function renderCleanPlan(title, plan) {
const lines = [title];
for (const operation of plan.operations) {
const destination = operation.destination || operation.source;
lines.push(
`${operation.action.toUpperCase()} ${operation.skill.name} -> ${operation.agent}:${operation.scope} ${destination}`,
);
}
return lines.join("\n");
}
function renderCleanCompletion(applied) {
const first = applied[0];
const target = first ? `${first.agent}:${first.scope}` : "selected target";
return ["", `Done. Removed ${applied.length} skill(s) from ${target}.`].join("\n");
}
function renderNoSkillsFound(targets) {
const lines = ["No skills found."];
for (const target of targets) {
lines.push(`${target.agent}:${target.scope} ${target.dir}`);
}
return `${lines.join("\n")}\n`;
}
function renderMigrationNotice(targets) {
const lines = [];
for (const target of targets) {
if (!target.migration) {
continue;
}
const report = target.migration;
if (report.removedEmpty) {
lines.push(`Migrated ${target.agent}:${target.scope}: removed empty legacy ${report.oldDir}.`);
continue;
}
const moved = report.moved.filter((item) => item.action === "moved").length;
const merged = report.moved.filter((item) => item.action === "merged").length;
const conflicts = (report.conflicts || []).length;
let line = `Migrated ${target.agent}:${target.scope} from ${report.oldDir} to ${report.newDir} (${moved} moved`;
if (merged) {
line += `, ${merged} record merged`;
}
if (conflicts) {
line += `, ${conflicts} conflict(s) kept new`;
}
line += `).`;
lines.push(line);
}
return lines.length ? `${lines.join("\n")}\n` : "";
}
function formatSkillGroup(segments) {
const known = segments.filter((value) => value && value !== "unknown");
return known.length ? known.join("/") : "unknown";
}
function formatSkillBracket(skill) {
if (skill.path) {
const parts = skill.path.split("/").filter(Boolean);
if (parts.length > 2 && parts[0] === "skills") {
return parts.slice(1, -1).join("/");
}
}
return formatSkillGroup([skill.scope, skill.domain, skill.stage]);
}
async function renderList(options) {
const manifest = await loadRuntimeManifest({ env: process.env, offline: options.offline });
const profiles = loadProfiles();
let skills;
if (options.preset) {
skills = selectSkills(manifest, profiles, { preset: options.preset }).skills;
} else {
skills = manifest.skills
.filter((skill) => !options.domain || matchDomain(skill, options.domain))
.filter((skill) => !options.common || matchCommonStage(skill, options.common))
.filter((skill) => !options.stage || skill.stage === options.stage);
}
if (!skills.length && !options.preset && options.domain && profiles[options.domain]) {
skills = selectSkills(manifest, profiles, { preset: options.domain }).skills;
}
const lines = [`OpenHarmony skills (${skills.length})`];
for (const skill of skills) {
lines.push(`${skill.name} [${formatSkillBracket(skill)}]`);
lines.push(` ${skill.description}`);
}
return lines.join("\n");
}
async function renderInstall(options) {
const manifest = await withSpinner(process.stdout, "Refreshing skill catalog", () =>
loadRuntimeManifest({ env: process.env, offline: options.offline }),
);
const profiles = loadProfiles();
const selectionOptions = {
names: options.names,
domain: options.domain,
common: options.common,
stage: options.stage,
preset: options.preset || (options.domain && profiles[options.domain] ? options.domain : undefined),
allCommon: options.allCommon,
};
const selection = selectSkills(manifest, profiles, selectionOptions);
if (!selection.skills.length) {
if (selection.unknownNames.length > 0) {
throw new Error(buildUnknownSuggestions(selection.unknownNames, manifest));
}
throw new Error(buildNoMatchError(options, manifest, profiles));
}
const targets = resolveAgentTargets({
agent: options.agent || "codex",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
return executeInstall(manifest, selection, options, targets);
}
async function renderPluginInstall(options) {
const pluginName = options.names[0];
const targets = resolveAgentTargets({
agent: options.agent || "codex",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
const sourceRoot = ensurePluginSourceRoot(pluginName, { env: process.env });
const plan = loadAndPlanPluginInstall(sourceRoot, pluginName, targets, {
source: process.env.OH_SKILLHUB_SOURCE,
ref: process.env.OH_SKILLHUB_REF,
force: options.force,
});
if (options.dryRun) {
return renderPluginInstallPlan("Plugin dry run install plan", plan);
}
const conflicts = plan.operations.filter((operation) => operation.action === "conflict");
if (conflicts.length) {
throw new Error(`${renderPluginInstallPlan("Plugin install conflicts", { operations: conflicts })}\nUse --force to replace unmanaged plugin payload directories.`);
}
const applied = applyPluginInstallPlan(plan, {
source: process.env.OH_SKILLHUB_SOURCE,
ref: process.env.OH_SKILLHUB_REF,
packageVersion: packageJson.version,
skipHostRegistration: process.env.OH_SKILLHUB_SKIP_HOST_REGISTRATION === "1",
});
return renderPluginInstallPlan("Plugin install summary", { operations: applied });
}
function renderPluginUninstall(options) {
const pluginNames = options.names || [];
if (!pluginNames.length) {
throw new Error("Usage: uninstall <plugin> [--agent codex|claude|opencode|all] [--scope user|project]");
}
const targets = resolveAgentTargets({
agent: options.agent || "all",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
const plan = planPluginUninstall(pluginNames, targets, { force: options.force });
if (options.dryRun) {
return renderPluginUninstallPlan("Plugin dry run uninstall plan", plan);
}
const applied = applyPluginUninstallPlan(plan, {
force: options.force,
skipHostRegistration: process.env.OH_SKILLHUB_SKIP_HOST_REGISTRATION === "1",
});
return renderPluginUninstallPlan("Plugin uninstall summary", { operations: applied });
}
function renderPluginUpdate(options) {
const pluginNames = options.names || [];
if (!pluginNames.length) {
throw new Error("Usage: update <plugin> [--agent codex|claude|opencode|all] [--scope user|project]");
}
const targets = resolveAgentTargets({
agent: options.agent || "all",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
const plan = { operations: [] };
for (const pluginName of pluginNames) {
const sourceRoot = ensurePluginSourceRoot(pluginName, { env: process.env });
const installedTargets = targets.filter((target) => readPluginRecord(target.dir, pluginName, target.agent));
const missingTargets = targets.filter((target) => !readPluginRecord(target.dir, pluginName, target.agent));
const installPlan = loadAndPlanPluginInstall(sourceRoot, pluginName, installedTargets, {
source: process.env.OH_SKILLHUB_SOURCE,
ref: process.env.OH_SKILLHUB_REF,
skipUnchanged: true,
});
plan.operations.push(...installPlan.operations);
if (missingTargets.length) {
const missingPlan = loadAndPlanPluginInstall(sourceRoot, pluginName, missingTargets, {
source: process.env.OH_SKILLHUB_SOURCE,
ref: process.env.OH_SKILLHUB_REF,
skipUnchanged: true,
});
plan.operations.push(...missingPlan.operations.map((operation) => ({ ...operation, action: "missing" })));
}
}
if (options.dryRun) {
return renderPluginInstallPlan("Plugin dry run update plan", plan);
}
const applied = applyPluginInstallPlan(plan, {
source: process.env.OH_SKILLHUB_SOURCE,
ref: process.env.OH_SKILLHUB_REF,
packageVersion: packageJson.version,
skipHostRegistration: process.env.OH_SKILLHUB_SKIP_HOST_REGISTRATION === "1",
});
return renderPluginInstallPlan("Plugin update summary", { operations: mergeAppliedPluginOperations(plan.operations, applied) });
}
function renderPluginCommand(args) {
const subcommand = args[0];
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
return pluginHelpText();
}
const options = parseArgs(args.slice(1));
if (subcommand === "list") {
return renderPluginList(options);
}
if (subcommand === "status" || subcommand === "doctor") {
const pluginName = options.names[0];
if (!pluginName) {
throw new Error(`Usage: plugin ${subcommand} <plugin> [--agent codex|claude|opencode|all] [--scope user|project]`);
}
if (options.fix && !options.scanLegacy) {
throw new Error("--fix requires --scan-legacy");
}
if (options.force && !options.fix) {
throw new Error("--force requires --fix (use with --scan-legacy --fix)");
}
if (options.scanLegacy && subcommand !== "doctor") {
throw new Error("--scan-legacy requires plugin doctor");
}
const targets = resolveAgentTargets({
agent: options.agent || "all",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
migrate: options.scanLegacy ? false : undefined,
});
if (options.scanLegacy) {
return renderPluginLegacyDoctor(pluginName, targets, options);
}
const report = inspectPlugin(pluginName, targets, {
scope: options.scope || "user",
cwd: process.cwd(),
homeDir: os.homedir(),
allScopes: options.allScopes,
allowMaterializedCopy: options.allowMaterializedCopy,
});
return subcommand === "doctor"
? renderPluginDoctor(report)
: renderPluginStatus(report, { verbose: options.verbose });
}
if (subcommand === "install" || subcommand === "update") {
const pluginName = options.names[0];
if (!pluginName) {
throw new Error(`Usage: plugin ${subcommand} <plugin> [--agent codex|claude|opencode|all] [--scope user|project]`);
}
const targets = resolveAgentTargets({
agent: options.agent || "all",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
const sourceRoot = ensurePluginSourceRoot(pluginName, { env: process.env });
const plugin = loadNeutralPlugin(sourceRoot, pluginName);
if (options.dryRun) {
return renderPluginV2DryRun(subcommand, plugin, targets, {
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
}
const apply = subcommand === "update" ? updatePluginV2 : installPluginV2;
const result = apply(plugin, targets, {
source: process.env.OH_SKILLHUB_SOURCE,
ref: process.env.OH_SKILLHUB_REF,
packageVersion: packageJson.version,
cwd: process.cwd(),
homeDir: os.homedir(),
force: options.force,
atomic: options.atomic,
autoGc: options.autoGc,
strict: options.strict,
replaceSameVersion: options.replaceSameVersion,
allowDowngrade: options.allowDowngrade,
skipHostRegistration: process.env.OH_SKILLHUB_SKIP_HOST_REGISTRATION === "1",
});
return renderPluginV2InstallResult(result);
}
if (subcommand === "uninstall") {
const pluginName = options.names[0];
if (!pluginName) {
throw new Error("Usage: plugin uninstall <plugin> [--agent codex|claude|opencode|all] [--scope user|project]");
}
const targets = resolveAgentTargets({
agent: options.agent || "all",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
if (options.dryRun) {
return renderPluginV2UninstallDryRun(pluginName, targets);
}
const result = uninstallPluginV2(pluginName, targets, {
cwd: process.cwd(),
homeDir: os.homedir(),
atomic: options.atomic,
skipHostRegistration: process.env.OH_SKILLHUB_SKIP_HOST_REGISTRATION === "1",
});
return renderPluginV2UninstallResult(result);
}
if (subcommand === "manifest") {
const pluginName = options.names[0];
if (!pluginName) {
throw new Error("Usage: plugin manifest <plugin> [--agent codex|claude|opencode|all] [--out <dir>] [--check] [--scope user|project]");
}
const targets = resolveAgentTargets({
agent: options.agent || "all",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
const sourceRoot = ensurePluginSourceRoot(pluginName, { env: process.env });
const agents = targets.map((target) => target.agent);
if (options.check) {
const report = checkVersion({
sourceRoot,
pluginName,
agents,
scope: options.scope || "user",
cwd: process.cwd(),
homeDir: os.homedir(),
});
return renderManifestVersionCheck(report);
}
const outDir = options.out
? path.resolve(process.cwd(), options.out)
: path.join(process.cwd(), "dist");
const report = generateManifests({ sourceRoot, pluginName, agents, outDir });
return renderManifestGeneration(report);
}
if (subcommand === "gc") {
const scopeRoot = scopeRootFor({
scope: options.scope || "user",
cwd: process.cwd(),
homeDir: os.homedir(),
});
const report = collectGarbage(scopeRoot, { dryRun: options.dryRun });
return renderPluginGc(report, { dryRun: options.dryRun, scopeRoot });
}
if (subcommand === "repair") {
const scopeRoot = scopeRootFor({
scope: options.scope || "user",
cwd: process.cwd(),
homeDir: os.homedir(),
});
const report = repairScope(scopeRoot);
return renderPluginRepair(report, { scopeRoot });
}
if (subcommand === "enable" || subcommand === "disable") {
const pluginName = options.names[0];
if (!pluginName || !options.agent || options.agent === "all") {
throw new Error(`Usage: plugin ${subcommand} <plugin> --agent codex|claude|opencode [--scope user|project]`);
}
const scopeRoot = scopeRootFor({
scope: options.scope || "user",
cwd: process.cwd(),
homeDir: os.homedir(),
});
const result = setHostEnabled(scopeRoot, pluginName, options.agent, subcommand === "enable");
return renderPluginHostToggle(result);
}
if (subcommand === "migrate") {
const pluginName = options.names[0];
if (!pluginName) {
throw new Error("Usage: plugin migrate <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--atomic]");
}
const targets = resolveAgentTargets({
agent: options.agent || "all",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
const sourceRoot = ensurePluginSourceRoot(pluginName, { env: process.env });
const plugin = loadNeutralPlugin(sourceRoot, pluginName);
if (options.dryRun) {
return renderPluginV2DryRun("migrate", plugin, targets, {
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
}
const result = migratePlugin(plugin, targets, {
source: process.env.OH_SKILLHUB_SOURCE,
ref: process.env.OH_SKILLHUB_REF,
packageVersion: packageJson.version,
cwd: process.cwd(),
homeDir: os.homedir(),
atomic: options.atomic,
autoGc: options.autoGc,
skipHostRegistration: process.env.OH_SKILLHUB_SKIP_HOST_REGISTRATION === "1",
});
return renderPluginMigrateResult(result);
}
throw new Error(`Unknown plugin command "${subcommand}". Run oh-skillhub plugin --help.`);
}
function renderPluginList(options = {}) {
const sourceRoot = ensurePluginWorkspaceRoot({
env: process.env,
cwd: process.cwd(),
});
const plugins = listWorkspacePlugins(sourceRoot);
const lines = [
"Available plugins",
`source: ${sourceRoot}`,
];
if (!plugins.length) {
lines.push(" (none)");
return lines.join("\n");
}
for (const plugin of plugins) {
const summary = readWorkspacePluginSummary(sourceRoot, plugin);
const detail = [
summary.version ? `v${summary.version}` : "",
summary.description || plugin.description || "",
].filter(Boolean).join(" - ");
lines.push(detail ? ` ${plugin.id} ${detail}` : ` ${plugin.id}`);
if (options.verbose) {
lines.push(` manifest: ${plugin.path}`);
}
}
return lines.join("\n");
}
function readWorkspacePluginSummary(sourceRoot, plugin) {
const root = path.resolve(sourceRoot);
const manifestPath = path.resolve(root, plugin.path);
const relative = path.relative(root, manifestPath);
if (relative.startsWith("..") || path.isAbsolute(relative) || !fs.existsSync(manifestPath)) {
return {};
}
try {
const manifest = parsePluginManifest(fs.readFileSync(manifestPath, "utf8"));
return {
version: manifest.version || "",
description: manifest.description || "",
};
} catch {
return {};
}
}
function renderPluginV2DryRun(action, plugin, targets, options = {}) {
const label = action.toUpperCase();
const lines = [`Plugin v2 dry run ${action} plan`, `${label} ${plugin.name} -> scope ${targets[0] ? targets[0].scope : "user"}`];
const precheck = precheckPluginV2Install(plugin, targets, options);
if (precheck.conflicts.length) {
lines.push("", "Conflicts (install will be blocked without --force):");
for (const conflict of precheck.conflicts) {
lines.push(` ${conflict.message}`);
}
}
if (precheck.warnings.length) {
lines.push("", "Warnings:");
for (const warning of precheck.warnings) {
lines.push(` ${warning.message}`);
}
}
for (const target of targets) {
lines.push(` projection: ${target.agent}:${target.scope}`);
}
lines.push(" store: canonical digest will be computed from neutral source files");
lines.push(" registry: scope registry will be updated");
lines.push(" channel: .ohos-channel marker will be written (openharmony-skills)");
return lines.join("\n");
}
function renderPluginV2InstallResult(result) {
const label = result.action.toUpperCase();
const lines = [
`Plugin v2 ${result.action} summary`,
`${label} ${result.plugin.name} -> scope ${result.scope}`,
` store: ${result.store.storeRoot}`,
` digest: ${result.store.digest}`,
` registry: ${result.registryPath}`,
];
if (result.warnings && result.warnings.length) {
lines.push(" warnings:");
for (const warning of result.warnings) {
lines.push(` ${warning.message}`);
}
}
for (const item of result.targets) {
lines.push(` projection: ${item.target.agent}:${item.target.scope} ${item.payload.payloadRoot}`);
lines.push(` mode: ${item.projection.mode}`);
if (item.channelMarker) {
lines.push(` channel marker: ${item.channelMarker}`);
}
if (item.registration && item.registration.skipped) {
lines.push(" registration: skipped");
} else if (item.registration) {
lines.push(` registration: ${item.registration.success ? "ok" : "failed"} (${item.registration.command})`);
if (item.registration.success && item.registration.nextCommand) {
lines.push(` next: ${item.registration.nextCommand}`);
}
if (!item.registration.success && item.registration.message) {
lines.push(` registration detail: ${item.registration.message.split(/\r?\n/)[0]}`);
}
}
}
return lines.join("\n");
}
function renderPluginV2UninstallDryRun(pluginName, targets) {
const lines = ["Plugin v2 dry run uninstall plan", `UNINSTALL ${pluginName} -> scope ${targets[0] ? targets[0].scope : "user"}`];
for (const target of targets) {
lines.push(` projection: ${target.agent}:${target.scope}`);
}
lines.push(" registry: selected host references will be removed");
lines.push(" store: retained until plugin gc");
return lines.join("\n");
}
function renderPluginV2UninstallResult(result) {
const lines = [
"Plugin v2 uninstall summary",
`UNINSTALL ${result.pluginName} -> scope ${result.scope}`,
` registry: ${result.registryPath}`,
` store: ${result.pendingGc ? "pending-gc" : "retained"}`,
];
for (const item of result.targets) {
lines.push(` projection: ${item.target.agent}:${item.target.scope} ${item.payloadRoot}`);
if (item.registration && item.registration.skipped) {
lines.push(" registration: skipped");
} else if (item.registration) {
lines.push(` registration: ${item.registration.success ? "ok" : "failed"} (${item.registration.command})`);
}
}
return lines.join("\n");
}
function renderPluginGc(report, options = {}) {
const action = options.dryRun ? "GC dry run" : "GC summary";
const lines = [
`Plugin ${action}`,
`scope root: ${options.scopeRoot || ""}`,
` store dirs: ${report.storeDirs.length}`,
` projections: ${report.projections.length}`,
` trash dirs: ${report.trashDirs.length}`,
` staging roots: ${report.stagingRoots.length}`,
];
if (report.registryCleaned && report.registryCleaned.length) {
lines.push(` registry cleaned: ${report.registryCleaned.join(", ")}`);
}
const all = [
...report.storeDirs.map((item) => `store: ${item}`),
...report.projections.map((item) => `projection: ${item}`),
...report.trashDirs.map((item) => `trash: ${item}`),
...report.stagingRoots.map((item) => `staging: ${item}`),
];
if (all.length) {
lines.push(options.dryRun ? "would remove:" : "removed:");
lines.push(...all);
}
return lines.join("\n");
}
function renderPluginRepair(report, options = {}) {
const lines = [
"Plugin repair",
`scope root: ${options.scopeRoot || ""}`,
];
const recovery = report.recovery || [];
if (recovery.length) {
lines.push("journal recovery:");
for (const item of recovery) {
lines.push(` ${item.txid}: ${item.result}`);
}
} else {
lines.push("journal recovery: none");
}
const gc = report.gc || {};
lines.push(
`gc: store ${gc.storeDirs ? gc.storeDirs.length : 0}, projections ${gc.projections ? gc.projections.length : 0}, trash ${gc.trashDirs ? gc.trashDirs.length : 0}, staging ${gc.stagingRoots ? gc.stagingRoots.length : 0}`,
);
return lines.join("\n");
}
function renderPluginHostToggle(result) {
return `Plugin host ${result.enabled ? "enabled" : "disabled"}: ${result.pluginName} (${result.agent})`;
}
function renderPluginMigrateResult(result) {
const lines = [
"Plugin migrate summary",
`MIGRATE ${result.plugin.name} -> scope ${result.scope}`,
` store: ${result.store.storeRoot}`,
` digest: ${result.store.digest}`,
` registry: ${result.registryPath}`,
];
if (result.legacyCleaned && result.legacyCleaned.length) {
lines.push(" legacy payloads cleaned:");
for (const item of result.legacyCleaned) {
lines.push(` ${item.agent}:${item.scope} (${item.filesRemoved} file(s) removed)`);
}
} else {
lines.push(" legacy payloads: none");
}
for (const item of result.legacyUntouched || []) {
lines.push(` ${item.agent}:${item.scope}: no legacy payload to clean`);
}
return lines.join("\n");
}
function renderPluginLegacyDoctor(pluginName, targets, options) {
const legacyOptions = {
scope: options.scope || "user",
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
};
const report = scanLegacy(pluginName, targets, legacyOptions);
if (!options.fix) {
return renderLegacyDoctor(report);
}
const plan = planLegacyCleanup(report, { force: options.force });
const applied = applyLegacyCleanup(plan, {
homeDir: os.homedir(),
force: options.force,
skipHostCli: process.env.OH_SKILLHUB_SKIP_HOST_REGISTRATION === "1",
skipBackup: false,
});
const lines = [
renderLegacyDoctor(report),
"",
renderLegacyCleanup("Legacy cleanup summary", applied, plan.mode),
];
const v2Report = inspectPlugin(pluginName, targets, {
scope: options.scope || "user",
cwd: process.cwd(),
homeDir: os.homedir(),
});
lines.push("", renderPluginDoctor(v2Report));
return lines.join("\n");
}
function workflowPluginNamespaceError(action, pluginName) {
return [
`Workflow plugin ${action} has moved to the plugin namespace.`,
`Use: npx oh-skillhub plugin ${action} ${pluginName} --agent all --scope user`,
].join("\n");
}
function mergeAppliedPluginOperations(operations, applied) {
const byKey = new Map(applied.map((operation) => [pluginOperationKey(operation), operation]));
return operations.map((operation) => byKey.get(pluginOperationKey(operation)) || operation);
}
function pluginOperationKey(operation) {
return `${operation.plugin.name}:${operation.target.agent}:${operation.target.scope}:${operation.target.dir}`;
}
function buildUnknownSuggestions(unknownNames, manifest) {
const lines = [];
for (const name of unknownNames) {
const suggestions = suggestSkillName(name, manifest);
const suggested = suggestions
.filter((item) => item.score > 0.4)
.map((item) => item.name)
.join(", ");
if (suggested) {
lines.push(`Unknown skill: ${name}`);
lines.push(` ${name} -> did you mean: ${suggested}?`);
} else {
lines.push(`Unknown skill: ${name}`);
}
}
return lines.join("\n");
}
function buildNoMatchError(options, manifest, profiles) {
if (options.domain) {
return buildUnknownDomainError(options.domain, manifest, profiles);
}
if (options.common) {
return buildUnknownCommonStageError(options.common, manifest);
}
if (options.stage) {
const stages = uniqueSkillValues(manifest.skills, (skill) => skill.stage);
return `Unknown stage: ${options.stage}\n Available stages: ${stages.join(", ")}`;
}
return "No skills matched the install request.";
}
function buildUnknownDomainError(domain, manifest, profiles) {
const domains = collectAvailableDomains(manifest);
const lines = [`Unknown domain: ${domain}`];
const stageHint = COMMON_STAGES.find(
(stage) => stage.startsWith(domain) || domain.startsWith(stage),
);
if (stageHint) {
lines.push(` "${domain}" looks like a stage. Try: --stage ${stageHint}`);
}
const profileHint = Object.keys(profiles).find(
(name) => name.startsWith(domain) || domain.startsWith(name),
);
if (profileHint) {
lines.push(` "${domain}" looks like a preset. Try: --preset ${profileHint}`);
}
lines.push(` Available domains: ${domains.join(", ")}`);
return lines.join("\n");
}
function collectDomainFolders(manifest) {
const set = new Set();
for (const skill of manifest.skills) {
if (!skill.path) continue;
const parts = skill.path.split("/");
if (parts[0] === "skills" && parts[1] === "domain" && parts[2]) {
set.add(parts[2]);
}
}
return Array.from(set).sort();
}
function collectCommonStages(manifest) {
const set = new Set();
for (const skill of manifest.skills) {
if (skill.scope === "common" && skill.stage) set.add(skill.stage);
if (skill.path) {
const parts = skill.path.split("/");
if (parts[0] === "skills" && parts[1] === "common" && parts[2]) {
set.add(parts[2]);
}
}
}
return Array.from(set).sort();
}
function buildUnknownCommonStageError(stage, manifest) {
const stages = collectCommonStages(manifest);
return `Unknown common stage: ${stage}\n Available common stages: ${stages.join(", ")}`;
}
function collectAvailableDomains(manifest) {
const set = new Set(collectDomainFolders(manifest));
for (const skill of manifest.skills) {
if (skill.domain) set.add(skill.domain);
}
return Array.from(set).sort();
}
function uniqueSkillValues(skills, selector) {
return Array.from(new Set(skills.map(selector).filter(Boolean))).sort();
}
async function executeInstall(sourceManifest, selection, options, targets) {
const plan = planInstall(selection.skills, targets);
if (options.dryRun) {
return renderInstallPlan("Dry run install plan", plan);
}
const sourceRoot = ensureSkillSourceRoot(sourceManifest, { env: process.env, skills: selection.skills });
const applied = applyInstallPlan(plan, {
sourceRoot,
source: sourceManifest.source,
ref: sourceManifest.ref,
sourceRevision: sourceManifest.sourceRevision,
});
await recordTelemetryEvents(applied, {
enabled: !options.noTelemetry && !options.offline,
selection: {
profile: options.preset || options.domain || null,
},
});
return renderInstallPlan("Install summary", plan);
}
async function runInteractiveInstaller(input = process.stdin, output = process.stdout) {
if (!(input.isTTY && output.isTTY)) {
await runInteractiveInstallerFromAnswers(splitPromptAnswers(await readAll(input)), output);
return;
}
const manifest = await loadInteractiveManifest(input, output);
const choices = buildRepositoryChoices(manifest);
const agent = await runRawAgentSelection(input, output);
const selectedIndexes = await runRawTuiSelection(input, output, choices, agent);
await installInteractiveSelection(manifest, choices, agent, selectedIndexes, output);
}
async function runInteractiveInstallerFromAnswers(answers, output = process.stdout) {
const manifest = await loadRuntimeManifest({ env: process.env });
const choices = buildRepositoryChoices(manifest);
const [agentAnswer, groupAnswer] = answers.length > 1 ? [answers[0], answers.slice(1).join(" ")] : ["", answers[0] || ""];
const defaultGroup = defaultGroupNumber(choices);
output.write(renderAgentMenu());
output.write("Select target [1]: \n");
const agent = parseAgentSelection(agentAnswer);
output.write(renderTuiMenu(choices, agent));
output.write(`Select groups [${defaultGroup}]: \n`);
const selectedIndexes = parseSelection(groupAnswer, choices.length, defaultGroup);
await installInteractiveSelection(manifest, choices, agent, selectedIndexes, output);
}
async function loadInteractiveManifest(_input, output) {
if (output.isTTY) {
return withSpinner(output, "Refreshing skill catalog", () => loadRuntimeManifest({ env: process.env }));
}
return loadRuntimeManifest({ env: process.env });
}
async function installInteractiveSelection(manifest, choices, agent, selectedIndexes, output) {
const selectedChoices = selectedIndexes.map((index) => choices[index]);
const skills = selectSkillsForChoices(manifest, selectedChoices);
if (!skills.length) {
throw new Error("No skills matched the selected groups.");
}
output.write(`\nInstalling ${selectedChoices.map((choice) => choice.path).join(", ")} for ${formatAgentSelection(agent)}:user...\n`);
const sourceRoot = await withSpinner(output, `Preparing skill source from ${manifest.source}#${manifest.ref}`, () =>
ensureSkillSourceRootAsync(manifest, { env: process.env, skills }),
);
output.write(`${await renderInstallForSkills(skills, { agent, scope: "user" }, sourceRoot, manifest)}\n`);
output.write(`${renderInteractiveCompletion(skills, { agent, scope: "user" }, output)}\n`);
}
async function withSpinner(output, message, task, options = {}) {
if (!output.isTTY) {
output.write(`${message}...\n`);
return task();
}
const frames = options.frames || ["|", "/", "-", "\\"];
const intervalMs = options.intervalMs || 120;
let index = 0;
const render = () => {
output.write(`\r${frames[index % frames.length]} ${message}...`);
index += 1;
};
render();
const timer = setInterval(render, intervalMs);
try {
const result = await task();
clearInterval(timer);
output.write(`\r${" ".repeat(message.length + 8)}\r✓ ${message}\n`);
return result;
} catch (error) {
clearInterval(timer);
output.write(`\r${" ".repeat(message.length + 8)}\r! ${message}\n`);
throw error;
}
}
function readAll(input) {
return new Promise((resolve, reject) => {
let text = "";
input.setEncoding("utf8");
input.on("data", (chunk) => {
text += chunk;
});
input.on("end", () => resolve(text));
input.on("error", reject);
});
}
function splitPromptAnswers(text) {
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n").filter((line, index, lines) => {
return line.length > 0 || index < lines.length - 1;
});
}
function buildRepositoryChoices(manifest) {
const choices = [];
const commonSkills = manifest.skills.filter((skill) => skill.scope === "common");
choices.push({
scope: "common",
stage: "all",
path: "common / all",
count: commonSkills.length,
skills: commonSkills,
allCommon: true,
});
for (const stage of COMMON_STAGES) {
const skills = manifest.skills.filter((skill) => skill.scope === "common" && skill.stage === stage);
choices.push({ scope: "common", stage, path: `common / ${stage}`, count: skills.length, skills });
}
for (const domain of collectDomainFolders(manifest)) {
const skills = manifest.skills.filter((skill) => skill.scope === "domain" && matchDomain(skill, domain));
choices.push({ scope: "domain", domain, path: `domain / ${domain}`, count: skills.length, skills });
}
return choices;
}
function renderTuiMenu(choices, agent = "codex") {
const width = 72;
const line = `+${"-".repeat(width - 2)}+`;
const lines = [
line,
`| ${padRight("OH SkillHub", width - 4)} |`,
`| ${padRight("OpenHarmony Skills Installer", width - 4)} |`,
line,
"",
"Target",
` Agent: ${formatAgentSelection(agent)}`,
" Scope: user",
"",
"Choose skill groups",
" Interactive TTY: Up/Down or j/k to move, Space to toggle, Enter to install",
" Piped input: enter numbers separated by spaces, e.g. 1 for all common",
"",
];
choices.forEach((choice, index) => {
const marker = choice.count > 0 ? "[ ]" : "[ ]";
lines.push(` ${String(index + 1).padStart(2, " ")}. ${marker} ${choice.path.padEnd(28, " ")} ${choice.count} skill(s)`);
for (const leaf of renderSkillLeaves(choice)) {
lines.push(` - ${leaf}`);
}
});
lines.push("");
return `${lines.join("\n")}\n`;
}
function renderAgentMenu() {
const width = 72;
const line = `+${"-".repeat(width - 2)}+`;
const lines = [
line,
`| ${padRight("OH SkillHub", width - 4)} |`,
`| ${padRight("OpenHarmony Skills Installer", width - 4)} |`,
line,
"",
"Choose install target",
" Pick where the selected skills should be installed.",
"",
];
AGENT_CHOICES.forEach((choice, index) => {
lines.push(` ${index + 1}. [ ] ${choice.label.padEnd(10, " ")} ${choice.hint}`);
});
lines.push("");
return `${lines.join("\n")}\n`;
}
function renderActionMenu() {
const width = 72;
const line = `+${"-".repeat(width - 2)}+`;
const lines = [
line,
`| ${padRight("OH SkillHub", width - 4)} |`,
`| ${padRight("OpenHarmony Skills Manager", width - 4)} |`,
line,
"",
"Choose action",
" Install new skills or clean existing skills from an agent target.",
"",
];
ACTION_CHOICES.forEach((choice, index) => {
lines.push(` ${index + 1}. [ ] ${choice.label.padEnd(22, " ")} ${choice.hint}`);
});
lines.push("");
return `${lines.join("\n")}\n`;
}
async function runPromptAgentSelection(input, output, existingInterface) {
output.write(renderAgentMenu());
const rl = existingInterface || readlinePromises.createInterface({ input, output });
try {
const answer = await rl.question("Select target [1]: ");
return AGENT_CHOICES[parseSingleSelection(answer, AGENT_CHOICES.length, 1)].agent;
} finally {
if (!existingInterface) rl.close();
}
}
async function runPromptSelection(input, output, choices, agent = "codex", existingInterface) {
output.write(renderTuiMenu(choices, agent));
const rl = existingInterface || readlinePromises.createInterface({ input, output });
try {
const answer = await rl.question("Select groups [9]: ");
return parseSelection(answer, choices.length, 9);
} finally {
if (!existingInterface) rl.close();
}
}
function runRawTuiSelection(input, output, choices, agent = "codex") {
return new Promise((resolve, reject) => {
let cursor = 8;
const selected = new Set();
let notice = "";
const wasRaw = input.isRaw;
prepareRawInput(input);
function render() {
output.write("\x1b[2J\x1b[H");
output.write(renderRawTuiMenu(choices, cursor, selected, agent, notice));
notice = "";
}
function cleanup() {
input.removeListener("keypress", onKeypress);
releaseRawInput(input, wasRaw);
output.write("\x1b[?25h");
}
function onKeypress(_str, key) {
if (key && key.ctrl && key.name === "c") {
cleanup();
reject(new Error("Cancelled."));
return;
}
if (key && (key.name === "down" || key.name === "j")) {
cursor = (cursor + 1) % choices.length;
render();
return;
}
if (key && (key.name === "up" || key.name === "k")) {
cursor = (cursor - 1 + choices.length) % choices.length;
render();
return;
}
if (key && key.name === "space") {
if (selected.has(cursor)) selected.delete(cursor);
else selected.add(cursor);
render();
return;
}
if (key && key.name === "return") {
if (!selected.size) {
notice = "Select at least one group with Space.";
render();
return;
}
cleanup();
resolve(Array.from(selected).sort((a, b) => a - b));
}
}
output.write("\x1b[?25l");
render();
input.on("keypress", onKeypress);
});
}
function runRawAgentSelection(input, output) {
return new Promise((resolve, reject) => {
let cursor = 0;
const selected = new Set();
let notice = "";
const wasRaw = input.isRaw;
prepareRawInput(input);
function render() {
output.write("\x1b[2J\x1b[H");
output.write(renderRawAgentMenu(cursor, selected, notice));
notice = "";
}
function cleanup() {
input.removeListener("keypress", onKeypress);
releaseRawInput(input, wasRaw);
output.write("\x1b[?25h");
}
function onKeypress(_str, key) {
if (key && key.ctrl && key.name === "c") {
cleanup();
reject(new Error("Cancelled."));
return;
}
if (key && (key.name === "down" || key.name === "j")) {
cursor = (cursor + 1) % AGENT_CHOICES.length;
render();
return;
}
if (key && (key.name === "up" || key.name === "k")) {
cursor = (cursor - 1 + AGENT_CHOICES.length) % AGENT_CHOICES.length;
render();
return;
}
if (key && key.name === "space") {
toggleAgentSelection(selected, cursor);
render();
return;
}
if (key && key.name === "return") {
if (!normalizeAgentIndexes(selected).size) {
notice = "Select at least one target with Space.";
render();
return;
}
cleanup();
resolve(agentSelectionFromIndexes(selected, cursor));
}
}
output.write("\x1b[?25l");
render();
input.on("keypress", onKeypress);
});
}
function runRawActionSelection(input, output) {
return new Promise((resolve, reject) => {
let cursor = 0;
let selected = null;
let notice = "";
const wasRaw = input.isRaw;
prepareRawInput(input);
function render() {
output.write("\x1b[2J\x1b[H");
output.write(renderRawActionMenu(cursor, selected, notice));
notice = "";
}
function cleanup() {
input.removeListener("keypress", onKeypress);
releaseRawInput(input, wasRaw);
output.write("\x1b[?25h");
}
function onKeypress(_str, key) {
if (key && key.ctrl && key.name === "c") {
cleanup();
reject(new Error("Cancelled."));
return;
}
if (key && (key.name === "down" || key.name === "j")) {
cursor = (cursor + 1) % ACTION_CHOICES.length;
render();
return;
}
if (key && (key.name === "up" || key.name === "k")) {
cursor = (cursor - 1 + ACTION_CHOICES.length) % ACTION_CHOICES.length;
render();
return;
}
if (key && key.name === "space") {
selected = cursor;
render();
return;
}
if (key && key.name === "return") {
if (selected === null) {
notice = "Select an action with Space.";
render();
return;
}
cleanup();
resolve(ACTION_CHOICES[selected].action);
}
}
output.write("\x1b[?25l");
render();
input.on("keypress", onKeypress);
});
}
function renderRawActionMenu(cursor, selected = null, notice = "") {
const width = 76;
const line = `+${"-".repeat(width - 2)}+`;
const lines = [
line,
rawHeaderLine("OH SkillHub", width, ANSI.cyan, ANSI.bold),
rawHeaderLine("OpenHarmony Skills Manager", width, ANSI.dim),
line,
"",
colorize("Choose action", ANSI.bold),
colorize(" Up/Down or j/k: move Space: select Enter: confirm Ctrl+C: cancel", ANSI.dim),
"",
];
ACTION_CHOICES.forEach((choice, index) => {
const pointer = index === cursor ? ">" : " ";
const highlighted = index === cursor;
const row = `${pointer} ${rawCheckbox(index === selected, highlighted)} ${choice.label.padEnd(22, " ")} ${choice.hint}`;
lines.push(index === cursor ? colorize(row, ANSI.reverse, ANSI.bold) : row);
});
if (notice) {
lines.push("", colorize(notice, ANSI.cyan, ANSI.bold));
}
lines.push("");
return `${lines.join("\n")}\n`;
}
function prepareRawInput(input) {
if (typeof input.on === "function" && typeof input.listenerCount === "function") {
readline.emitKeypressEvents(input);
}
input.setRawMode(true);
if (typeof input.resume === "function") {
input.resume();
}
}
function releaseRawInput(input, wasRaw) {
input.setRawMode(Boolean(wasRaw));
if (!wasRaw && typeof input.pause === "function") {
input.pause();
}
}
function renderRawAgentMenu(cursor, selected = new Set(), notice = "") {
const width = 76;
const line = `+${"-".repeat(width - 2)}+`;
const selectedIndexes = normalizeAgentIndexes(selected);
const lines = [
line,
rawHeaderLine("OH SkillHub", width, ANSI.cyan, ANSI.bold),
rawHeaderLine("OpenHarmony Skills Installer", width, ANSI.dim),
line,
"",
colorize("Choose install target", ANSI.bold),
colorize(" Up/Down or j/k: move Space: select Enter: confirm Ctrl+C: cancel", ANSI.dim),
"",
];
AGENT_CHOICES.forEach((choice, index) => {
const pointer = index === cursor ? ">" : " ";
const highlighted = index === cursor;
const row = `${pointer} ${rawCheckbox(isAgentIndexSelected(selectedIndexes, index), highlighted)} ${choice.label.padEnd(10, " ")} ${choice.hint}`;
lines.push(index === cursor ? colorize(row, ANSI.reverse, ANSI.bold) : row);
});
if (notice) {
lines.push("", colorize(notice, ANSI.cyan, ANSI.bold));
}
lines.push("");
return `${lines.join("\n")}\n`;
}
function renderRawTuiMenu(choices, cursor, selected, agent = "codex", notice = "") {
const width = 76;
const line = `+${"-".repeat(width - 2)}+`;
const lines = [
line,
rawHeaderLine("OH SkillHub", width, ANSI.cyan, ANSI.bold),
rawHeaderLine("OpenHarmony Skills Installer", width, ANSI.dim),
line,
"",
colorize("Target", ANSI.bold),
` Agent: ${agent}`,
" Scope: user",
"",
colorize("Choose skill groups", ANSI.bold),
colorize(" Up/Down or j/k: move Space: select Enter: install Ctrl+C: cancel", ANSI.dim),
"",
];
choices.forEach((choice, index) => {
const pointer = index === cursor ? ">" : " ";
const highlighted = index === cursor;
const checkbox = rawCheckbox(selected.has(index), highlighted);
const row = `${pointer} ${checkbox} ${choice.path.padEnd(28, " ")} ${choice.count} skill(s)`;
lines.push(highlighted ? colorize(row, ANSI.reverse, ANSI.bold) : row);
for (const leaf of renderSkillLeaves(choice)) {
lines.push(colorize(` - ${leaf}`, ANSI.dim));
}
});
if (notice) {
lines.push("", colorize(notice, ANSI.cyan, ANSI.bold));
}
lines.push("");
return `${lines.join("\n")}\n`;
}
function runRawCleanSelection(input, output, skills) {
return new Promise((resolve, reject) => {
let cursor = 0;
const selected = new Set();
let notice = "";
const wasRaw = input.isRaw;
prepareRawInput(input);
function render() {
output.write("\x1b[2J\x1b[H");
output.write(renderRawCleanSelectionMenu(skills, cursor, selected, notice));
notice = "";
}
function cleanup() {
input.removeListener("keypress", onKeypress);
releaseRawInput(input, wasRaw);
output.write("\x1b[?25h");
}
function onKeypress(_str, key) {
if (key && key.ctrl && key.name === "c") {
cleanup();
reject(new Error("Cancelled."));
return;
}
if (key && (key.name === "down" || key.name === "j")) {
cursor = (cursor + 1) % skills.length;
render();
return;
}
if (key && (key.name === "up" || key.name === "k")) {
cursor = (cursor - 1 + skills.length) % skills.length;
render();
return;
}
if (key && key.name === "space") {
if (selected.has(cursor)) selected.delete(cursor);
else selected.add(cursor);
render();
return;
}
if (key && key.name === "return") {
if (!selected.size) {
notice = "Select at least one skill with Space.";
render();
return;
}
cleanup();
resolve(Array.from(selected).sort((a, b) => a - b));
}
}
output.write("\x1b[?25l");
render();
input.on("keypress", onKeypress);
});
}
function renderRawCleanSelectionMenu(skills, cursor, selected, notice = "") {
const width = 92;
const line = `+${"-".repeat(width - 2)}+`;
const lines = [
line,
rawHeaderLine("OH SkillHub", width, ANSI.cyan, ANSI.bold),
rawHeaderLine("OpenHarmony Skills Cleaner", width, ANSI.dim),
line,
"",
colorize("Detected skills", ANSI.bold),
colorize(" Up/Down or j/k: move Space: select Enter: clean Ctrl+C: cancel", ANSI.dim),
"",
];
skills.forEach((skill, index) => {
const pointer = index === cursor ? ">" : " ";
const highlighted = index === cursor;
const checkbox = rawCheckbox(selected.has(index), highlighted);
const group = formatSkillGroup([skill.domain, skill.stage]);
const row = `${pointer} ${checkbox} ${skill.name.padEnd(36, " ")} ${skill.status.padEnd(9, " ")} ${group}`;
lines.push(highlighted ? colorize(row, ANSI.reverse, ANSI.bold) : row);
});
if (notice) {
lines.push("", colorize(notice, ANSI.cyan, ANSI.bold));
}
lines.push("");
return `${lines.join("\n")}\n`;
}
function colorize(value, ...codes) {
return `${codes.join("")}${value}${ANSI.reset}`;
}
function rawHeaderLine(value, width, ...codes) {
const styled = colorize(value, ...codes);
return `| ${styled}${" ".repeat(width - 4 - value.length)} |`;
}
function rawCheckbox(isSelected, highlighted) {
if (!isSelected) {
return "[ ]";
}
const restore = highlighted ? `${ANSI.reverse}${ANSI.bold}` : "";
return `${colorize("[✓]", ANSI.green, ANSI.bold)}${restore}`;
}
function renderSkillLeaves(choice) {
return (choice.skills || []).map((skill) => {
if (choice.scope === "common") {
return skill.name;
}
const prefix = `skills/domain/${choice.domain}/`;
if (skill.path.startsWith(prefix)) {
return skill.path.slice(prefix.length).split("/").join(" / ");
}
return skill.name;
});
}
function padRight(value, width) {
return value.length >= width ? value.slice(0, width) : `${value}${" ".repeat(width - value.length)}`;
}
function parseSelection(answer, max, defaultNumber) {
const text = answer.trim();
const tokens = text ? text.split(/\s+/) : [String(defaultNumber)];
const indexes = [];
for (const token of tokens) {
const number = Number(token);
if (!Number.isInteger(number) || number < 1 || number > max) {
throw new Error(`Invalid selection "${token}". Choose numbers from 1 to ${max}.`);
}
const index = number - 1;
if (!indexes.includes(index)) {
indexes.push(index);
}
}
return indexes;
}
function parseSingleSelection(answer, max, defaultNumber) {
const indexes = parseSelection(answer, max, defaultNumber);
if (indexes.length !== 1) {
throw new Error("Choose one target.");
}
return indexes[0];
}
function parseAgentSelection(answer) {
const indexes = parseSelection(answer, AGENT_CHOICES.length, 1);
if (indexes.includes(ALL_AGENT_INDEX)) {
return "all";
}
const agents = indexes.map((index) => AGENT_CHOICES[index].agent);
return agents.length === 1 ? agents[0] : agents;
}
function isSingleSelection(answer, max) {
try {
parseSingleSelection(answer, max, 1);
return true;
} catch {
return false;
}
}
function defaultGroupNumber(choices) {
const arkuiIndex = choices.findIndex((choice) => choice.scope === "domain" && choice.domain === "arkui");
return arkuiIndex >= 0 ? arkuiIndex + 1 : 1;
}
function normalizeAgentIndexes(selected) {
const indexes = selected instanceof Set ? Array.from(selected) : [selected];
if (indexes.includes(ALL_AGENT_INDEX)) {
return new Set(INDIVIDUAL_AGENT_INDEXES);
}
return new Set(indexes.filter((index) => INDIVIDUAL_AGENT_INDEXES.includes(index)));
}
function isAgentIndexSelected(selected, index) {
if (index === ALL_AGENT_INDEX) {
return INDIVIDUAL_AGENT_INDEXES.every((item) => selected.has(item));
}
return selected.has(index);
}
function toggleAgentSelection(selected, cursor) {
if (cursor === ALL_AGENT_INDEX) {
if (INDIVIDUAL_AGENT_INDEXES.every((index) => selected.has(index))) {
selected.clear();
selected.add(0);
return;
}
selected.clear();
for (const index of INDIVIDUAL_AGENT_INDEXES) {
selected.add(index);
}
return;
}
if (selected.has(cursor) && selected.size > 1) {
selected.delete(cursor);
return;
}
selected.add(cursor);
}
function agentSelectionFromIndexes(selected, cursor = 0) {
const normalized = normalizeAgentIndexes(selected);
if (!normalized.size) {
normalized.add(INDIVIDUAL_AGENT_INDEXES.includes(cursor) ? cursor : 0);
}
if (INDIVIDUAL_AGENT_INDEXES.every((index) => normalized.has(index))) {
return "all";
}
const agents = Array.from(normalized)
.sort((left, right) => left - right)
.map((index) => AGENT_CHOICES[index].agent);
return agents.length === 1 ? agents[0] : agents;
}
function formatAgentSelection(agent) {
if (Array.isArray(agent)) {
return agent.join(", ");
}
return agent;
}
function selectSkillsForChoices(manifest, choices) {
const selected = new Map();
for (const choice of choices) {
for (const skill of manifest.skills) {
if (choice.allCommon && skill.scope === "common") {
selected.set(skill.name, skill);
}
if (choice.scope === "common" && skill.scope === "common" && skill.stage === choice.stage) {
selected.set(skill.name, skill);
}
if (choice.scope === "domain" && skill.scope === "domain" && matchDomain(skill, choice.domain)) {
selected.set(skill.name, skill);
}
}
}
return Array.from(selected.values()).sort((left, right) => left.name.localeCompare(right.name));
}
async function renderInstallForSkills(skills, targetOptions, sourceRootOverride = null, manifestOverride = null) {
const targets = resolveAgentTargets({
agent: targetOptions.agent,
scope: targetOptions.scope,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
const plan = planInstall(skills, targets);
const manifest = manifestOverride || loadLocalManifest();
const sourceRoot = sourceRootOverride || ensureSkillSourceRoot(manifest, { env: process.env, skills });
const applied = applyInstallPlan(plan, {
sourceRoot,
source: manifest.source,
ref: manifest.ref,
sourceRevision: manifest.sourceRevision,
});
await recordTelemetryEvents(applied, { enabled: true, selection: { profile: null } });
const notice = renderMigrationNotice(targets);
return `${notice}${renderInstallPlan("Install summary", plan)}`;
}
async function recordTelemetryEvents(operations, options = {}) {
const telemetryEnabled = options.enabled !== false && process.env.OH_SKILLHUB_NO_TELEMETRY !== "1";
if (!telemetryEnabled || !operations.length) {
return { queued: 0, sent: 0, remaining: 0, skipped: true };
}
const installSessionId = randomSessionId();
const events = operations.map((operation) =>
buildTelemetryEvent({
event: "skill_installed",
operation: operation.action,
installSessionId,
skill: operation.skill,
target: operation.target,
selection: {
profile: options.selection && options.selection.profile ? options.selection.profile : null,
domain: options.selection && options.selection.domain ? options.selection.domain : operation.skill.domain,
stage: options.selection && options.selection.stage ? options.selection.stage : operation.skill.stage,
},
packageVersion: packageJson.version,
}),
);
const queued = enqueueTelemetryEvents(events, { enabled: telemetryEnabled });
const flush = await flushTelemetryQueue();
return { queued, ...flush };
}
function randomSessionId() {
return `${Date.now().toString(36)}-${Math.random().toString(16).slice(2)}`;
}
function renderInteractiveCompletion(skills, targetOptions, output = process.stdout) {
return [
"",
renderStatusLine(
"success",
"INSTALL SUCCESS",
`Installed ${skills.length} skill(s) for ${formatAgentSelection(targetOptions.agent)}:${targetOptions.scope}.`,
output,
),
].join("\n");
}
function renderStatusLine(kind, title, detail, output = process.stdout) {
const color = kind === "failure" ? ANSI.red : ANSI.green;
const heading = output.isTTY ? colorize(title, color, ANSI.bold) : title;
return detail ? `${heading}\n${detail}` : heading;
}
function renderInstallPlan(title, plan) {
const lines = [title];
for (const operation of plan.operations) {
lines.push(
`${operation.action.toUpperCase()} ${operation.skill.name} -> ${operation.target.agent}:${operation.target.scope} ${operation.destination}`,
);
}
return lines.join("\n");
}
function renderPluginInstallPlan(title, plan) {
const lines = [title];
for (const operation of plan.operations) {
const skillCount = operation.payload.operations.filter((item) => item.type === "skill").length;
const fileCount = operation.files ? operation.files.length : operation.payload.operations.length;
lines.push(
`${operation.action.toUpperCase()} ${operation.plugin.name} -> ${operation.target.agent}:${operation.target.scope} ${operation.payload.payloadRoot}`,
);
lines.push(` skills: ${skillCount}`);
lines.push(` payload operations: ${operation.payload.operations.length}`);
lines.push(` managed files: ${fileCount}`);
if (operation.conflicts && operation.conflicts.length) {
for (const conflict of operation.conflicts) {
lines.push(` conflict: ${conflict}`);
}
}
if (operation.action === "skip") {
lines.push(" status: already up to date");
}
if (operation.action === "missing") {
lines.push(" status: not installed");
}
if (operation.registration && !operation.registration.skipped) {
lines.push(` registration: ${operation.registration.success ? "ok" : "failed"} (${operation.registration.command})`);
if (!operation.registration.success && operation.registration.message) {
lines.push(` registration detail: ${operation.registration.message.split(/\r?\n/)[0]}`);
}
if (operation.registration.success && operation.registration.nextCommand) {
lines.push(` next: ${operation.registration.nextCommand}`);
}
}
if (operation.registration && operation.registration.skipped && operation.registration.reason) {
lines.push(` registration: skipped (${operation.registration.reason})`);
}
}
lines.push(...renderClaudeUpdateHints(title, plan.operations));
return lines.join("\n");
}
function renderClaudeUpdateHints(title, operations) {
if (!/\bupdate\b/i.test(title)) {
return [];
}
const refs = Array.from(
new Map(
operations
.filter((operation) => operation.target.agent === "claude" && operation.action !== "missing")
.map((operation) => [operation.plugin.name, marketplacePluginRef(operation.plugin)]),
).values(),
);
if (!refs.length) {
return [];
}
return [
"",
"If the plugin is already installed in Claude Code, refresh Claude's installed plugin with:",
...refs.map((ref) => ` claude plugin update ${ref}`),
];
}
function renderPluginUninstallPlan(title, plan) {
const lines = [title];
for (const operation of plan.operations) {
const fileCount = operation.filesRemoved
? operation.filesRemoved.length
: operation.managedFiles.length;
lines.push(
`${operation.action.toUpperCase()} ${operation.plugin.name} -> ${operation.target.agent}:${operation.target.scope} ${operation.payloadRoot}`,
);
lines.push(` managed files: ${fileCount}`);
if (operation.action === "missing") {
lines.push(" status: not installed");
}
if (operation.action === "blocked" && operation.validation && operation.validation.mismatched.length) {
lines.push(` blocked: ${operation.validation.mismatched.length} checksum mismatch(es); use --force to remove`);
}
if (operation.registration && !operation.registration.skipped) {
lines.push(` registration: ${operation.registration.success ? "ok" : "failed"} (${operation.registration.command})`);
if (!operation.registration.success && operation.registration.message) {
lines.push(` registration detail: ${operation.registration.message.split(/\r?\n/)[0]}`);
}
if (operation.registration.success && operation.registration.nextCommand) {
lines.push(` next: ${operation.registration.nextCommand}`);
}
}
if (operation.registration && operation.registration.skipped && operation.registration.reason) {
lines.push(` registration: skipped (${operation.registration.reason})`);
}
}
return lines.join("\n");
}
function renderDoctor(options) {
const targets = resolveAgentTargets({
agent: options.agent || "codex",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
const lines = ["Agent target diagnostics"];
for (const target of targets) {
const exists = fs.existsSync(target.dir);
lines.push(`${target.agent} (${target.scope})`);
lines.push(` path: ${target.dir}`);
lines.push(` exists: ${exists ? "yes" : "no"}`);
lines.push(` writable: ${exists ? isWritable(target.dir) : "not checked"}`);
}
return lines.join("\n");
}
function renderTelemetry(args) {
const action = args[0] || "status";
if (action !== "status") {
throw new Error("Only telemetry status is implemented in this version.");
}
const status = telemetryStatus();
return [
"Telemetry",
` enabled: ${status.enabled ? "yes" : "no"}`,
` collector: ${status.collectorUrl || "(not configured)"}`,
` cache: ${status.cacheDir}`,
` queue: ${status.queueSize} event(s)`,
].join("\n");
}
function isWritable(dir) {
try {
fs.accessSync(dir, fs.constants.W_OK);
return "yes";
} catch {
return "no";
}
}
async function runUpdate(options) {
const targets = resolveAgentTargets({
agent: options.agent || "all",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
const skillNames = options.names || [];
if (!skillNames.length) {
throw new Error("Usage: update <skill-id> [--agent <agent>] [--scope <scope>]");
}
const offline = options.offline;
for (const skillName of skillNames) {
console.log("Updating " + skillName + "...");
try {
const updateResult = await planUpdate(skillName, targets, { env: process.env, offline });
const results = updateResult.results;
const plan = updateResult.plan;
const skipped = results.filter((r) => r.skipped);
for (const result of skipped) {
console.log(" " + (result.message || result.skillName) + ": already up to date");
}
if (plan && plan.operations.length) {
const toUpdate = results.filter((r) => r.needsUpdate);
console.log(" Pulling latest for " + toUpdate.length + " skill(s)...");
const applied = await applyUpdatePlan(plan, { env: process.env, offline });
for (const op of applied) {
console.log(" Updated: " + op.skill.name + " -> " + op.target.agent + ":" + op.target.scope);
}
}
} catch (error) {
console.error(" Failed to update " + skillName + ": " + error.message);
}
}
console.log("");
console.log("Update complete. Restart opencode to load updated skills.");
}
async function runUpdateAll(options) {
const targets = resolveAgentTargets({
agent: options.agent || "all",
scope: options.scope || "user",
target: options.target,
cwd: process.cwd(),
homeDir: os.homedir(),
env: process.env,
});
const offline = options.offline;
console.log("Checking for skill updates...");
const updateResult = await planUpdateAll(targets, { env: process.env, offline });
const results = updateResult.results;
const plan = updateResult.plan;
const message = updateResult.message;
if (message) {
console.log(message);
return;
}
const skipped = results.filter((r) => r.skipped);
for (const result of skipped) {
console.log(" " + (result.message || result.skillName) + ": already up to date");
}
if (plan && plan.operations.length) {
const toUpdate = results.filter((r) => r.needsUpdate);
console.log("Updating " + toUpdate.length + " skill(s)...");
const applied = await applyUpdatePlan(plan, { env: process.env, offline });
for (const op of applied) {
console.log(" Updated: " + op.skill.name + " -> " + op.target.agent + ":" + op.target.scope);
}
} else {
console.log("All skills are up to date.");
}
console.log("");
console.log("Update complete. Restart opencode to load updated skills.");
}
function helpText() {
return [
"oh-skillhub",
"",
"Run without arguments to choose install or clean interactively.",
"",
"Commands:",
" list [--domain <name>] [--common <stage>] [--stage <name>] [--preset <name>] [--offline]",
" install [skill...|plugin] [--name <skill>] [--domain <name>] [--common <stage>] [--preset <name>] [--all-common] [--agent codex|claude|opencode|all]",
" uninstall <plugin...> [--agent codex|claude|opencode|all] [--scope user|project] [--dry-run] [--force]",
" update <skill...|plugin...> [--agent codex|claude|opencode|all] [--scope user|project] [--offline]",
" plugin list [--verbose]",
" plugin install <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--dry-run] [--force]",
" plugin update <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--dry-run] [--force]",
" plugin uninstall <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--dry-run]",
" plugin status <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--verbose]",
" plugin doctor <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--scan-legacy] [--fix] [--force]",
" plugin manifest <plugin> [--agent codex|claude|opencode|all] [--out <dir>] [--check] [--scope user|project]",
" plugin gc [--scope user|project] [--dry-run]",
" plugin repair [--scope user|project]",
" plugin enable <plugin> --agent codex|claude|opencode [--scope user|project]",
" plugin disable <plugin> --agent codex|claude|opencode [--scope user|project]",
" plugin migrate <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--atomic]",
" update-all [--agent codex|claude|opencode|all] [--scope user|project] [--offline]",
" clean [skill...] [--agent codex|claude|opencode|all] [--scope user|project] [--dry-run] [--purge]",
" doctor [--agent codex|claude|opencode|all]",
" telemetry status",
"",
"Examples:",
" npx oh-skillhub install --all-common --agent codex --scope user",
" npx oh-skillhub install --common design --agent codex --scope user",
" npx oh-skillhub install --domain arkui --agent all --scope user",
" npx oh-skillhub install ohos-dev-cpp-coding-style --agent codex",
" npx oh-skillhub install --name ohos-dev-napi-module --agent all --scope user",
" npx oh-skillhub plugin install ohos-sdd --agent all --scope user",
" npx oh-skillhub plugin update ohos-sdd --agent all --scope user",
" npx oh-skillhub plugin uninstall ohos-sdd --agent codex --scope user",
" npx oh-skillhub plugin doctor ohos-sdd --agent all --scope user",
" npx oh-skillhub plugin doctor ohos-sdd --agent all --scan-legacy",
" npx oh-skillhub plugin doctor ohos-sdd --agent all --scan-legacy --fix",
" npx oh-skillhub update ohos-dev-cpp-coding-style --agent opencode",
" npx oh-skillhub update-all --agent opencode --scope user",
].join("\n");
}
function pluginHelpText() {
return [
"oh-skillhub plugin",
"",
"Read-only workflow plugin diagnostics for the lifecycle v2 migration.",
"",
"Commands:",
" plugin list [--verbose]",
" plugin install <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--dry-run] [--force]",
" plugin update <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--dry-run] [--force]",
" plugin uninstall <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--dry-run]",
" plugin status <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--verbose]",
" plugin doctor <plugin> [--agent codex|claude|opencode|all] [--scope user|project] [--scan-legacy] [--fix] [--force]",
" plugin manifest <plugin> [--agent codex|claude|opencode|all] [--out <dir>] [--check] [--scope user|project]",
"",
"Channel conflict options:",
" --force Overwrite marketplace-channel installations (use when you need to switch channels)",
"",
"Scan-legacy options:",
" --scan-legacy Detect cross-source residuals (dev repo, marketplace, oh-skillhub legacy) across all agents",
" --fix Remove detected residuals (requires --scan-legacy); moves to trash by default",
" --force Permanently delete instead of trashing (requires --fix)",
"",
"Examples:",
" npx oh-skillhub plugin list",
" npx oh-skillhub plugin install ohos-delivery-kit --agent all --scope user",
" npx oh-skillhub plugin update ohos-delivery-kit --agent all --scope user",
" npx oh-skillhub plugin uninstall ohos-delivery-kit --agent claude --scope user",
" npx oh-skillhub plugin status ohos-delivery-kit --agent all --scope user --verbose",
" npx oh-skillhub plugin doctor ohos-sdd --agent claude --scope user",
" npx oh-skillhub plugin manifest ohos-delivery-kit --agent all --out ./dist",
" npx oh-skillhub plugin manifest ohos-delivery-kit --agent codex --check",
" npx oh-skillhub plugin doctor ohos-sdd --agent all --scan-legacy",
" npx oh-skillhub plugin doctor ohos-sdd --agent all --scan-legacy --fix",
].join("\n");
}
module.exports = {
buildRepositoryChoices,
buildUnknownSuggestions,
executeInstall,
helpText,
isPluginInstallRequest,
main,
parseArgs,
prepareRawInput,
releaseRawInput,
renderActionMenu,
renderAgentMenu,
renderCleanPlan,
renderDoctor,
renderInstall,
renderPluginInstallPlan,
renderPluginCommand,
renderPluginUpdate,
renderPluginUninstall,
renderPluginUninstallPlan,
renderList,
renderRawActionMenu,
renderRawAgentMenu,
renderRawCleanSelectionMenu,
renderRawTuiMenu,
renderStatusLine,
renderTelemetry,
renderTuiMenu,
selectSkillsForChoices,
withSpinner,
parseSelection,
};