chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,996 @@
|
||||
/**
|
||||
* Plugin CLI command handlers.
|
||||
*
|
||||
* Handles `omp plugin <command>` subcommands for plugin lifecycle management.
|
||||
*/
|
||||
|
||||
import { APP_NAME, getProjectDir } from "@oh-my-pi/pi-utils";
|
||||
import chalk from "chalk";
|
||||
import { resolveOrDefaultProjectRegistryPath } from "../discovery/helpers";
|
||||
import { PluginManager, parseSettingValue, validateSetting } from "../extensibility/plugins";
|
||||
import {
|
||||
getInstalledPluginsRegistryPath,
|
||||
getMarketplacesCacheDir,
|
||||
getMarketplacesRegistryPath,
|
||||
getPluginsCacheDir,
|
||||
MarketplaceManager,
|
||||
} from "../extensibility/plugins/marketplace/index.js";
|
||||
import { theme } from "../modes/theme/theme";
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
||||
export type PluginAction =
|
||||
| "install"
|
||||
| "uninstall"
|
||||
| "list"
|
||||
| "link"
|
||||
| "doctor"
|
||||
| "features"
|
||||
| "config"
|
||||
| "enable"
|
||||
| "disable"
|
||||
| "marketplace"
|
||||
| "discover"
|
||||
| "upgrade";
|
||||
|
||||
export interface PluginCommandArgs {
|
||||
action: PluginAction;
|
||||
args: string[];
|
||||
flags: {
|
||||
json?: boolean;
|
||||
fix?: boolean;
|
||||
force?: boolean;
|
||||
dryRun?: boolean;
|
||||
local?: boolean;
|
||||
enable?: string;
|
||||
disable?: string;
|
||||
set?: string;
|
||||
scope?: "user" | "project";
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Argument Parser
|
||||
// =============================================================================
|
||||
|
||||
const VALID_ACTIONS: PluginAction[] = [
|
||||
"install",
|
||||
"uninstall",
|
||||
"list",
|
||||
"link",
|
||||
"doctor",
|
||||
"features",
|
||||
"config",
|
||||
"enable",
|
||||
"disable",
|
||||
"marketplace",
|
||||
"discover",
|
||||
"upgrade",
|
||||
];
|
||||
|
||||
/**
|
||||
* Parse plugin subcommand arguments.
|
||||
* Returns undefined if not a plugin command.
|
||||
*/
|
||||
export function parsePluginArgs(args: string[]): PluginCommandArgs | undefined {
|
||||
if (args.length === 0 || args[0] !== "plugin") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (args.length < 2) {
|
||||
return { action: "list", args: [], flags: {} };
|
||||
}
|
||||
|
||||
const action = args[1];
|
||||
if (!VALID_ACTIONS.includes(action as PluginAction)) {
|
||||
console.error(chalk.red(`Unknown plugin command: ${action}`));
|
||||
console.error(`Valid commands: ${VALID_ACTIONS.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result: PluginCommandArgs = {
|
||||
action: action as PluginAction,
|
||||
args: [],
|
||||
flags: {},
|
||||
};
|
||||
|
||||
// Parse remaining arguments
|
||||
for (let i = 2; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--json") {
|
||||
result.flags.json = true;
|
||||
} else if (arg === "--fix") {
|
||||
result.flags.fix = true;
|
||||
} else if (arg === "--force") {
|
||||
result.flags.force = true;
|
||||
} else if (arg === "--dry-run") {
|
||||
result.flags.dryRun = true;
|
||||
} else if (arg === "-l" || arg === "--local") {
|
||||
result.flags.local = true;
|
||||
} else if (arg === "--enable" && i + 1 < args.length) {
|
||||
result.flags.enable = args[++i];
|
||||
} else if (arg === "--disable" && i + 1 < args.length) {
|
||||
result.flags.disable = args[++i];
|
||||
} else if (arg === "--set" && i + 1 < args.length) {
|
||||
result.flags.set = args[++i];
|
||||
} else if (arg === "--scope" && i + 1 < args.length && !args[i + 1].startsWith("-")) {
|
||||
const s = args[++i];
|
||||
if (s === "user" || s === "project") {
|
||||
result.flags.scope = s;
|
||||
} else {
|
||||
console.error(chalk.red(`Invalid --scope value: "${s}". Must be "user" or "project".`));
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (arg === "--scope") {
|
||||
// --scope with no value following
|
||||
console.error(chalk.red(`--scope requires a value: "user" or "project".`));
|
||||
process.exit(1);
|
||||
} else if (!arg.startsWith("-")) {
|
||||
result.args.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
import { classifyInstallTarget } from "./classify-install-target";
|
||||
|
||||
export { classifyInstallTarget } from "./classify-install-target";
|
||||
|
||||
// =============================================================================
|
||||
// Command Handlers
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Run a plugin command.
|
||||
*/
|
||||
export async function runPluginCommand(cmd: PluginCommandArgs): Promise<void> {
|
||||
const manager = new PluginManager();
|
||||
|
||||
switch (cmd.action) {
|
||||
case "install":
|
||||
await handleInstall(manager, cmd.args, cmd.flags);
|
||||
break;
|
||||
case "uninstall":
|
||||
await handleUninstall(manager, cmd.args, cmd.flags);
|
||||
break;
|
||||
case "list":
|
||||
await handleList(manager, cmd.flags);
|
||||
break;
|
||||
case "link":
|
||||
await handleLink(manager, cmd.args, cmd.flags);
|
||||
break;
|
||||
case "doctor":
|
||||
await handleDoctor(manager, cmd.flags);
|
||||
break;
|
||||
case "features":
|
||||
await handleFeatures(manager, cmd.args, cmd.flags);
|
||||
break;
|
||||
case "config":
|
||||
await handleConfig(manager, cmd.args, cmd.flags);
|
||||
break;
|
||||
case "enable":
|
||||
await handleEnable(manager, cmd.args, cmd.flags);
|
||||
break;
|
||||
case "disable":
|
||||
await handleDisable(manager, cmd.args, cmd.flags);
|
||||
break;
|
||||
case "marketplace":
|
||||
await handleMarketplace(cmd.args, cmd.flags);
|
||||
break;
|
||||
case "discover":
|
||||
await handleDiscover(cmd.args, cmd.flags);
|
||||
break;
|
||||
case "upgrade":
|
||||
await handleUpgrade(cmd.args, cmd.flags);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Marketplace Handlers
|
||||
// =============================================================================
|
||||
|
||||
async function makeMarketplaceManager(): Promise<MarketplaceManager> {
|
||||
return new MarketplaceManager({
|
||||
marketplacesRegistryPath: getMarketplacesRegistryPath(),
|
||||
installedRegistryPath: getInstalledPluginsRegistryPath(),
|
||||
projectInstalledRegistryPath: await resolveOrDefaultProjectRegistryPath(getProjectDir()),
|
||||
marketplacesCacheDir: getMarketplacesCacheDir(),
|
||||
pluginsCacheDir: getPluginsCacheDir(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleMarketplace(args: string[], _flags: PluginCommandArgs["flags"]): Promise<void> {
|
||||
const subcommand = args[0] ?? "list";
|
||||
const manager = await makeMarketplaceManager();
|
||||
|
||||
switch (subcommand) {
|
||||
case "add": {
|
||||
const source = args[1];
|
||||
if (!source) {
|
||||
console.error(chalk.red(`Usage: ${APP_NAME} plugin marketplace add <source>`));
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
await manager.addMarketplace(source);
|
||||
console.log(chalk.green(`${theme.status.success} Added marketplace: ${source}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to add marketplace: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "remove":
|
||||
case "rm": {
|
||||
const name = args[1];
|
||||
if (!name) {
|
||||
console.error(chalk.red(`Usage: ${APP_NAME} plugin marketplace remove <name>`));
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
await manager.removeMarketplace(name);
|
||||
console.log(chalk.green(`${theme.status.success} Removed marketplace: ${name}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to remove marketplace: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "update": {
|
||||
try {
|
||||
const name = args[1];
|
||||
if (name) {
|
||||
await manager.updateMarketplace(name);
|
||||
console.log(chalk.green(`${theme.status.success} Updated marketplace: ${name}`));
|
||||
} else {
|
||||
const results = await manager.updateAllMarketplaces();
|
||||
console.log(chalk.green(`${theme.status.success} Updated ${results.length} marketplace(s)`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to update marketplace: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (subcommand !== "list") {
|
||||
console.error(chalk.red(`Unknown marketplace subcommand: ${subcommand}`));
|
||||
console.error(chalk.dim("Valid subcommands: add, remove, update, list"));
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const marketplaces = await manager.listMarketplaces();
|
||||
if (marketplaces.length === 0) {
|
||||
console.log(chalk.dim("No marketplaces configured"));
|
||||
console.log(chalk.dim(`\nAdd one with: ${APP_NAME} plugin marketplace add <source>`));
|
||||
return;
|
||||
}
|
||||
console.log(chalk.bold("Configured Marketplaces:\n"));
|
||||
for (const mp of marketplaces) {
|
||||
console.log(` ${chalk.cyan(mp.name)} ${chalk.dim(mp.sourceUri)}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to list marketplaces: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDiscover(args: string[], _flags: PluginCommandArgs["flags"]): Promise<void> {
|
||||
const marketplace = args[0];
|
||||
const manager = await makeMarketplaceManager();
|
||||
try {
|
||||
const plugins = await manager.listAvailablePlugins(marketplace);
|
||||
|
||||
if (plugins.length === 0) {
|
||||
console.log(chalk.dim(marketplace ? `No plugins found in ${marketplace}` : "No plugins available"));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`Available Plugins${marketplace ? ` (${marketplace})` : ""}:\n`));
|
||||
for (const plugin of plugins) {
|
||||
console.log(` ${chalk.cyan(plugin.name)}${plugin.version ? `@${plugin.version}` : ""}`);
|
||||
if (plugin.description) {
|
||||
console.log(chalk.dim(` ${plugin.description}`));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to discover plugins: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpgrade(args: string[], flags: PluginCommandArgs["flags"]): Promise<void> {
|
||||
const manager = await makeMarketplaceManager();
|
||||
const pluginId = args[0];
|
||||
try {
|
||||
if (pluginId) {
|
||||
if (flags.scope) {
|
||||
const result = await manager.upgradePlugin(pluginId, flags.scope);
|
||||
console.log(chalk.green(`Upgraded ${pluginId} (${flags.scope}) to ${result.version}`));
|
||||
} else {
|
||||
const entries = await manager.upgradePluginAcrossScopes(pluginId);
|
||||
for (const entry of entries) {
|
||||
console.log(chalk.green(`Upgraded ${pluginId} (${entry.scope}) to ${entry.version}`));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (flags.scope) {
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`Warning: --scope is ignored when upgrading all plugins. Use 'omp plugin upgrade <id> --scope ${flags.scope}' to target a specific plugin and scope.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
const results = await manager.upgradeAllPlugins();
|
||||
if (results.length === 0) {
|
||||
console.log("All marketplace plugins are up to date.");
|
||||
} else {
|
||||
for (const r of results) {
|
||||
console.log(chalk.green(` ${r.pluginId} (${r.scope}): ${r.from} -> ${r.to}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`Failed to upgrade: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstall(
|
||||
manager: PluginManager,
|
||||
packages: string[],
|
||||
flags: { json?: boolean; force?: boolean; dryRun?: boolean; scope?: "user" | "project" },
|
||||
): Promise<void> {
|
||||
if (packages.length === 0) {
|
||||
console.error(chalk.red(`Usage: ${APP_NAME} plugin install <source>[features] ...`));
|
||||
console.error(chalk.dim("Examples:"));
|
||||
console.error(chalk.dim(` ${APP_NAME} plugin install @oh-my-pi/exa`));
|
||||
console.error(chalk.dim(` ${APP_NAME} plugin install name@marketplace`));
|
||||
console.error(chalk.dim(` ${APP_NAME} plugin install github:user/repo`));
|
||||
console.error(chalk.dim(` ${APP_NAME} plugin install https://github.com/user/repo#v1.0`));
|
||||
console.error(chalk.dim(` ${APP_NAME} plugin install ./path/to/local/plugin`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Build known marketplace set for classification
|
||||
const mktMgr = await makeMarketplaceManager();
|
||||
const knownMarketplaces = new Set((await mktMgr.listMarketplaces()).map(m => m.name));
|
||||
|
||||
for (const spec of packages) {
|
||||
const target = classifyInstallTarget(spec, knownMarketplaces);
|
||||
|
||||
if (target.type === "marketplace") {
|
||||
try {
|
||||
const entry = await mktMgr.installPlugin(target.name, target.marketplace, {
|
||||
force: flags.force,
|
||||
scope: flags.scope,
|
||||
});
|
||||
console.log(
|
||||
chalk.green(
|
||||
`${theme.status.success} Installed ${target.name} from ${target.marketplace} (${entry.version})`,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to install ${spec}: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target.type === "local") {
|
||||
// Local paths route to link(): symlink the directory into the plugins
|
||||
// node_modules tree so source edits show up without a reinstall. Matches
|
||||
// `omp plugin link <path>` so users can use either verb interchangeably.
|
||||
if (flags.scope) {
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`Warning: --scope is only supported for marketplace installs (name@marketplace). Ignoring for ${spec}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (flags.force) {
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`Warning: --force has no effect for local path installs (link is already idempotent). Ignoring for ${spec}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (flags.dryRun) {
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ dryRun: true, action: "link", path: target.path }, null, 2));
|
||||
} else {
|
||||
console.log(chalk.dim(`[dry-run] Would link ${spec}`));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await manager.link(target.path);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green(`${theme.status.success} Linked ${result.name} from ${spec}`));
|
||||
if (result.manifest.description) {
|
||||
console.log(chalk.dim(` ${result.manifest.description}`));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to install ${spec}: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// --scope only applies to marketplace installs; warn when it would be silently no-op'd for npm.
|
||||
if (flags.scope) {
|
||||
console.error(
|
||||
chalk.yellow(
|
||||
`Warning: --scope is only supported for marketplace installs (name@marketplace). Ignoring for ${spec}.`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// npm path
|
||||
try {
|
||||
const result = await manager.install(spec, { force: flags.force, dryRun: flags.dryRun });
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
if (flags.dryRun) {
|
||||
console.log(chalk.dim(`[dry-run] Would install ${spec}`));
|
||||
} else {
|
||||
console.log(chalk.green(`${theme.status.success} Installed ${result.name}@${result.version}`));
|
||||
if (result.enabledFeatures && result.enabledFeatures.length > 0) {
|
||||
console.log(chalk.dim(` Features: ${result.enabledFeatures.join(", ")}`));
|
||||
}
|
||||
if (result.manifest.description) {
|
||||
console.log(chalk.dim(` ${result.manifest.description}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to install ${spec}: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUninstall(
|
||||
manager: PluginManager,
|
||||
packages: string[],
|
||||
flags: { json?: boolean; scope?: "user" | "project" },
|
||||
): Promise<void> {
|
||||
if (packages.length === 0) {
|
||||
console.error(chalk.red(`Usage: ${APP_NAME} plugin uninstall <package> ...`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// For uninstall, check the installed plugins registry directly.
|
||||
// This works even if the marketplace entry was later removed from marketplaces.json.
|
||||
const mktMgr = await makeMarketplaceManager();
|
||||
const installedPlugins = new Set((await mktMgr.listInstalledPlugins()).map(p => p.id));
|
||||
|
||||
for (const name of packages) {
|
||||
if (installedPlugins.has(name)) {
|
||||
// Exact match against installed marketplace plugin IDs (name@marketplace)
|
||||
try {
|
||||
await mktMgr.uninstallPlugin(name, flags.scope);
|
||||
console.log(chalk.green(`${theme.status.success} Uninstalled ${name}`));
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to uninstall ${name}: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// npm path
|
||||
try {
|
||||
await manager.uninstall(name);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ uninstalled: name }));
|
||||
} else {
|
||||
console.log(chalk.green(`${theme.status.success} Uninstalled ${name}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to uninstall ${name}: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleList(manager: PluginManager, flags: { json?: boolean }): Promise<void> {
|
||||
const npmPlugins = await manager.list();
|
||||
const mktMgr = await makeMarketplaceManager();
|
||||
const mktPlugins = await mktMgr.listInstalledPlugins();
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ npm: npmPlugins, marketplace: mktPlugins }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (npmPlugins.length === 0 && mktPlugins.length === 0) {
|
||||
console.log(chalk.dim("No plugins installed"));
|
||||
console.log(chalk.dim(`\nInstall plugins with: ${APP_NAME} plugin install <package>`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (npmPlugins.length > 0) {
|
||||
console.log(chalk.bold("npm Plugins:\n"));
|
||||
for (const plugin of npmPlugins) {
|
||||
const status = plugin.enabled ? chalk.green(theme.status.enabled) : chalk.dim(theme.status.disabled);
|
||||
const nameVersion = `${plugin.name}@${plugin.version}`;
|
||||
console.log(`${status} ${nameVersion}`);
|
||||
if (plugin.manifest.description) {
|
||||
console.log(chalk.dim(` ${plugin.manifest.description}`));
|
||||
}
|
||||
if (plugin.enabledFeatures && plugin.enabledFeatures.length > 0) {
|
||||
console.log(chalk.dim(` Features: ${plugin.enabledFeatures.join(", ")}`));
|
||||
}
|
||||
if (plugin.manifest.features) {
|
||||
const availableFeatures = Object.keys(plugin.manifest.features);
|
||||
if (availableFeatures.length > 0) {
|
||||
const enabledSet = new Set(plugin.enabledFeatures ?? []);
|
||||
const featureDisplay = availableFeatures
|
||||
.map(f => (enabledSet.has(f) ? chalk.green(f) : chalk.dim(f)))
|
||||
.join(", ");
|
||||
console.log(chalk.dim(` Available: [${featureDisplay}]`));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mktPlugins.length > 0) {
|
||||
if (npmPlugins.length > 0) console.log();
|
||||
console.log(chalk.bold("Marketplace Plugins:\n"));
|
||||
for (const plugin of mktPlugins) {
|
||||
const entry = plugin.entries[0];
|
||||
const version = entry?.version ?? "unknown";
|
||||
const shadowLabel = plugin.shadowedBy ? chalk.dim(" [shadowed]") : "";
|
||||
const scopeLabel = chalk.dim(` (${plugin.scope})`);
|
||||
console.log(` ${plugin.id} (${version})${scopeLabel}${shadowLabel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLink(manager: PluginManager, paths: string[], flags: { json?: boolean }): Promise<void> {
|
||||
if (paths.length === 0) {
|
||||
console.error(chalk.red(`Usage: ${APP_NAME} plugin link <path>`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await manager.link(paths[0]);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
console.log(chalk.green(`${theme.status.success} Linked ${result.name} from ${paths[0]}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to link: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDoctor(manager: PluginManager, flags: { json?: boolean; fix?: boolean }): Promise<void> {
|
||||
const checks = await manager.doctor({ fix: flags.fix });
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(checks, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold("Plugin Health Check\n"));
|
||||
|
||||
for (const check of checks) {
|
||||
const icon =
|
||||
check.status === "ok"
|
||||
? chalk.green(theme.status.success)
|
||||
: check.status === "warning"
|
||||
? chalk.yellow(theme.status.warning)
|
||||
: chalk.red(theme.status.error);
|
||||
console.log(`${icon} ${check.name}: ${check.message}`);
|
||||
if (check.fixed) {
|
||||
console.log(chalk.dim(` ${theme.nav.cursor} Fixed`));
|
||||
}
|
||||
}
|
||||
|
||||
const errors = checks.filter(c => c.status === "error" && !c.fixed).length;
|
||||
const warnings = checks.filter(c => c.status === "warning" && !c.fixed).length;
|
||||
const ok = checks.filter(c => c.status === "ok").length;
|
||||
const fixed = checks.filter(c => c.fixed).length;
|
||||
|
||||
console.log("");
|
||||
console.log(`Summary: ${ok} ok, ${warnings} warnings, ${errors} errors${fixed > 0 ? `, ${fixed} fixed` : ""}`);
|
||||
|
||||
if (errors > 0) {
|
||||
if (!flags.fix) {
|
||||
console.log(chalk.dim("\nRun with --fix to attempt automatic repair"));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFeatures(
|
||||
manager: PluginManager,
|
||||
args: string[],
|
||||
flags: { json?: boolean; enable?: string; disable?: string; set?: string },
|
||||
): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
console.error(
|
||||
chalk.red(`Usage: ${APP_NAME} plugin features <plugin> [--enable f1,f2] [--disable f1] [--set f1,f2]`),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pluginName = args[0];
|
||||
const plugins = await manager.list();
|
||||
const plugin = plugins.find(p => p.name === pluginName);
|
||||
|
||||
if (!plugin) {
|
||||
console.error(chalk.red(`Plugin "${pluginName}" not found`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Handle modifications
|
||||
if (flags.enable || flags.disable || flags.set) {
|
||||
let currentFeatures = new Set((await manager.getEnabledFeatures(pluginName)) ?? []);
|
||||
|
||||
if (flags.set) {
|
||||
// --set replaces all features
|
||||
currentFeatures = new Set(
|
||||
flags.set
|
||||
.split(",")
|
||||
.map(f => f.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
} else {
|
||||
if (flags.enable) {
|
||||
for (const f of flags.enable
|
||||
.split(",")
|
||||
.map(f => f.trim())
|
||||
.filter(Boolean)) {
|
||||
currentFeatures.add(f);
|
||||
}
|
||||
}
|
||||
if (flags.disable) {
|
||||
for (const f of flags.disable
|
||||
.split(",")
|
||||
.map(f => f.trim())
|
||||
.filter(Boolean)) {
|
||||
currentFeatures.delete(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await manager.setEnabledFeatures(pluginName, [...currentFeatures]);
|
||||
console.log(chalk.green(`${theme.status.success} Updated features for ${pluginName}`));
|
||||
}
|
||||
|
||||
// Display current state
|
||||
const updatedFeatures = await manager.getEnabledFeatures(pluginName);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
plugin: pluginName,
|
||||
enabledFeatures: updatedFeatures,
|
||||
availableFeatures: plugin.manifest.features ? Object.keys(plugin.manifest.features) : [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`Features for ${pluginName}:\n`));
|
||||
|
||||
if (!plugin.manifest.features || Object.keys(plugin.manifest.features).length === 0) {
|
||||
console.log(chalk.dim(" No optional features available"));
|
||||
return;
|
||||
}
|
||||
|
||||
const enabledSet = new Set(updatedFeatures ?? []);
|
||||
for (const [name, feat] of Object.entries(plugin.manifest.features)) {
|
||||
const enabled = enabledSet.has(name);
|
||||
const icon = enabled ? chalk.green(theme.status.enabled) : chalk.dim(theme.status.disabled);
|
||||
const defaultLabel = feat.default ? chalk.dim(" (default)") : "";
|
||||
console.log(`${icon} ${name}${defaultLabel}`);
|
||||
if (feat.description) {
|
||||
console.log(chalk.dim(` ${feat.description}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfig(
|
||||
manager: PluginManager,
|
||||
args: string[],
|
||||
flags: { json?: boolean; local?: boolean },
|
||||
): Promise<void> {
|
||||
if (args.length === 0) {
|
||||
console.error(
|
||||
chalk.red(`Usage: ${APP_NAME} plugin config <list|get|set|delete|validate> <plugin> [key] [value]`),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const [subcommand, pluginName, key, ...valueArgs] = args;
|
||||
|
||||
// Special case: validate doesn't need a plugin name
|
||||
if (subcommand === "validate") {
|
||||
await handleConfigValidate(manager, flags);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pluginName) {
|
||||
console.error(chalk.red("Plugin name required"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const plugins = await manager.list();
|
||||
const plugin = plugins.find(p => p.name === pluginName);
|
||||
|
||||
if (!plugin) {
|
||||
console.error(chalk.red(`Plugin "${pluginName}" not found`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
switch (subcommand) {
|
||||
case "list": {
|
||||
const settings = await manager.getPluginSettings(pluginName);
|
||||
const schema = plugin.manifest.settings || {};
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ settings, schema }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(chalk.bold(`Settings for ${pluginName}:\n`));
|
||||
|
||||
if (Object.keys(schema).length === 0) {
|
||||
console.log(chalk.dim(" No settings defined"));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [k, s] of Object.entries(schema)) {
|
||||
const value = settings[k] ?? s.default;
|
||||
const displayValue = s.secret && value ? "********" : String(value ?? chalk.dim("(not set)"));
|
||||
console.log(` ${k}: ${displayValue}`);
|
||||
if (s.description) {
|
||||
console.log(chalk.dim(` ${s.description}`));
|
||||
}
|
||||
if (s.env) {
|
||||
console.log(chalk.dim(` env: ${s.env}`));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "get": {
|
||||
if (!key) {
|
||||
console.error(chalk.red("Key required"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const settings = await manager.getPluginSettings(pluginName);
|
||||
const schema = plugin.manifest.settings?.[key];
|
||||
const value = settings[key] ?? schema?.default;
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ [key]: value }));
|
||||
} else {
|
||||
const displayValue = schema?.secret && value ? "********" : String(value ?? "(not set)");
|
||||
console.log(displayValue);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "set": {
|
||||
if (!key) {
|
||||
console.error(chalk.red("Key required"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const valueStr = valueArgs.join(" ");
|
||||
const schema = plugin.manifest.settings?.[key];
|
||||
|
||||
// Parse value according to type
|
||||
let value: unknown = valueStr;
|
||||
if (schema) {
|
||||
value = parseSettingValue(valueStr, schema);
|
||||
|
||||
// Validate
|
||||
const validation = validateSetting(value, schema);
|
||||
if (!validation.valid) {
|
||||
console.error(chalk.red(validation.error!));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await manager.setPluginSetting(pluginName, key, value);
|
||||
console.log(chalk.green(`${theme.status.success} Set ${key}`));
|
||||
break;
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
if (!key) {
|
||||
console.error(chalk.red("Key required"));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await manager.deletePluginSetting(pluginName, key);
|
||||
console.log(chalk.green(`${theme.status.success} Deleted ${key}`));
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(chalk.red(`Unknown config subcommand: ${subcommand}`));
|
||||
console.error(chalk.dim("Valid subcommands: list, get, set, delete, validate"));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfigValidate(manager: PluginManager, flags: { json?: boolean }): Promise<void> {
|
||||
const plugins = await manager.list();
|
||||
const results: Array<{ plugin: string; key: string; error: string }> = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
const settings = await manager.getPluginSettings(plugin.name);
|
||||
const schema = plugin.manifest.settings || {};
|
||||
|
||||
for (const [key, s] of Object.entries(schema)) {
|
||||
const value = settings[key];
|
||||
if (value !== undefined) {
|
||||
const validation = validateSetting(value, s);
|
||||
if (!validation.valid) {
|
||||
results.push({ plugin: plugin.name, key, error: validation.error! });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ valid: results.length === 0, errors: results }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log(chalk.green(`${theme.status.success} All settings valid`));
|
||||
} else {
|
||||
for (const { plugin, key, error } of results) {
|
||||
console.log(chalk.red(`${theme.status.error} ${plugin}.${key}: ${error}`));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEnable(
|
||||
manager: PluginManager,
|
||||
plugins: string[],
|
||||
flags: { json?: boolean; scope?: "user" | "project" },
|
||||
): Promise<void> {
|
||||
return handleSetEnabled(manager, plugins, flags, true);
|
||||
}
|
||||
|
||||
async function handleDisable(
|
||||
manager: PluginManager,
|
||||
plugins: string[],
|
||||
flags: { json?: boolean; scope?: "user" | "project" },
|
||||
): Promise<void> {
|
||||
return handleSetEnabled(manager, plugins, flags, false);
|
||||
}
|
||||
|
||||
async function handleSetEnabled(
|
||||
manager: PluginManager,
|
||||
plugins: string[],
|
||||
flags: { json?: boolean; scope?: "user" | "project" },
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
const action = enabled ? "enable" : "disable";
|
||||
const pastTense = enabled ? "Enabled" : "Disabled";
|
||||
const jsonKey = enabled ? "enabled" : "disabled";
|
||||
|
||||
if (plugins.length === 0) {
|
||||
console.error(chalk.red(`Usage: ${APP_NAME} plugin ${action} <plugin> ...`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mktMgr = await makeMarketplaceManager();
|
||||
const installedPlugins = new Set((await mktMgr.listInstalledPlugins()).map(p => p.id));
|
||||
|
||||
for (const name of plugins) {
|
||||
if (installedPlugins.has(name)) {
|
||||
try {
|
||||
await mktMgr.setPluginEnabled(name, enabled, flags.scope);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ [jsonKey]: name }));
|
||||
} else {
|
||||
console.log(chalk.green(`${theme.status.success} ${pastTense} ${name}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to ${action} ${name}: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await manager.setEnabled(name, enabled);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ [jsonKey]: name }));
|
||||
} else {
|
||||
console.log(chalk.green(`${theme.status.success} ${pastTense} ${name}`));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`${theme.status.error} Failed to ${action} ${name}: ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Help
|
||||
// =============================================================================
|
||||
|
||||
export function printPluginHelp(): void {
|
||||
console.log(`${chalk.bold(`${APP_NAME} plugin`)} - Plugin lifecycle management
|
||||
|
||||
${chalk.bold("Commands:")}
|
||||
install <source>[features] Install plugins from npm, GitHub, or git URL
|
||||
uninstall <pkg> Remove plugins
|
||||
list Show installed plugins
|
||||
link <path> Link local plugin for development
|
||||
doctor Check plugin health
|
||||
features <pkg> View/modify enabled features
|
||||
config <cmd> <pkg> [key] [val] Manage plugin settings
|
||||
enable <pkg> Enable a disabled plugin
|
||||
disable <pkg> Disable plugin without uninstalling
|
||||
marketplace <cmd> Manage marketplace sources (add, remove, update, list)
|
||||
discover [marketplace] Browse available marketplace plugins
|
||||
|
||||
${chalk.bold("Feature Syntax:")}
|
||||
pkg Install with default features
|
||||
pkg[feat1,feat2] Install with specific features
|
||||
pkg[*] Install with all features
|
||||
pkg[] Install with no optional features
|
||||
|
||||
${chalk.bold("Sources:")}
|
||||
pkg, pkg@1.2.3 npm package (optionally pinned)
|
||||
github:user/repo[#ref] GitHub shorthand (also gitlab:, bitbucket:, codeberg:, sourcehut:)
|
||||
https://github.com/user/repo Full git URL (https, ssh, or git protocol)
|
||||
name@marketplace Marketplace plugin (see marketplace command)
|
||||
./path, ../path, /abs, ~/path Local plugin directory (symlinked, same as plugin link)
|
||||
|
||||
${chalk.bold("Config Subcommands:")}
|
||||
config list <pkg> List all settings
|
||||
config get <pkg> <key> Get a setting value
|
||||
config set <pkg> <key> <val> Set a setting value
|
||||
config delete <pkg> <key> Delete a setting
|
||||
config validate Validate all plugin settings
|
||||
|
||||
${chalk.bold("Options:")}
|
||||
--json Output as JSON
|
||||
--fix Attempt automatic fixes (doctor)
|
||||
--force Overwrite without prompting (install)
|
||||
--scope <scope> Install scope: user (default) or project (install name@marketplace)
|
||||
--dry-run Preview changes without applying (install)
|
||||
-l, --local Use project-local overrides
|
||||
|
||||
${chalk.bold("Examples:")}
|
||||
${APP_NAME} plugin install @oh-my-pi/exa[search]
|
||||
${APP_NAME} plugin list --json
|
||||
${APP_NAME} plugin features my-plugin --enable search,web
|
||||
${APP_NAME} plugin config set my-plugin apiKey sk-xxx
|
||||
${APP_NAME} plugin doctor --fix
|
||||
${APP_NAME} plugin install --scope project name@marketplace
|
||||
${APP_NAME} plugin install github:user/repo#v1.0
|
||||
`);
|
||||
}
|
||||
Reference in New Issue
Block a user