603 lines
21 KiB
TypeScript
603 lines
21 KiB
TypeScript
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
import chalk from "chalk";
|
|
import type { Command } from "commander";
|
|
import {
|
|
createPluginRegistry,
|
|
findConfigFile,
|
|
loadConfig,
|
|
type PluginSlot,
|
|
type InstalledPluginConfig,
|
|
type OrchestratorConfig,
|
|
} from "@composio/ao-core";
|
|
import { parseDocument } from "yaml";
|
|
import {
|
|
buildPluginDescriptor,
|
|
findMarketplacePlugin,
|
|
loadMarketplaceCatalog,
|
|
normalizeImportedPluginModule,
|
|
refreshMarketplaceCatalog,
|
|
} from "../lib/plugin-marketplace.js";
|
|
import {
|
|
getLatestPublishedPackageVersion,
|
|
importPluginModuleFromSource,
|
|
installPackageIntoStore,
|
|
readInstalledPackageVersion,
|
|
uninstallPackageFromStore,
|
|
} from "../lib/plugin-store.js";
|
|
import {
|
|
buildDefaultPackageName,
|
|
normalizePluginName,
|
|
resolveScaffoldDirectory,
|
|
scaffoldPlugin,
|
|
} from "../lib/plugin-scaffold.js";
|
|
import { runSetupAction } from "./setup.js";
|
|
|
|
function findInstalledPlugin(
|
|
plugins: InstalledPluginConfig[],
|
|
descriptor: InstalledPluginConfig,
|
|
): InstalledPluginConfig | undefined {
|
|
return plugins.find((plugin) => {
|
|
if (descriptor.package && plugin.package === descriptor.package) return true;
|
|
if (descriptor.path && plugin.path === descriptor.path) return true;
|
|
return plugin.name === descriptor.name;
|
|
});
|
|
}
|
|
|
|
function upsertInstalledPlugin(
|
|
plugins: InstalledPluginConfig[],
|
|
descriptor: InstalledPluginConfig,
|
|
): InstalledPluginConfig[] {
|
|
const existing = findInstalledPlugin(plugins, descriptor);
|
|
if (!existing) {
|
|
return [...plugins, descriptor];
|
|
}
|
|
|
|
return plugins.map((plugin) => (plugin === existing ? { ...existing, ...descriptor } : plugin));
|
|
}
|
|
|
|
function writePluginsConfig(configPath: string, plugins: InstalledPluginConfig[]): void {
|
|
const rawYaml = readFileSync(configPath, "utf-8");
|
|
const doc = parseDocument(rawYaml);
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const rawConfig = (doc.toJS() as Record<string, any>) ?? {};
|
|
if (plugins.length === 0) {
|
|
delete rawConfig.plugins;
|
|
} else {
|
|
rawConfig.plugins = plugins;
|
|
}
|
|
doc.contents = doc.createNode(rawConfig) as typeof doc.contents;
|
|
const rendered = doc.toString({ indent: 2 });
|
|
const tempPath = `${configPath}.tmp.${process.pid}.${Date.now()}`;
|
|
writeFileSync(tempPath, rendered, "utf-8");
|
|
renameSync(tempPath, configPath);
|
|
}
|
|
|
|
function matchesPluginReference(plugin: InstalledPluginConfig, reference: string): boolean {
|
|
const marketplacePlugin = findMarketplacePlugin(reference);
|
|
return (
|
|
plugin.name === reference ||
|
|
plugin.package === reference ||
|
|
plugin.path === reference ||
|
|
(marketplacePlugin !== undefined &&
|
|
(plugin.package === marketplacePlugin.package || plugin.name === marketplacePlugin.id))
|
|
);
|
|
}
|
|
|
|
async function rollbackManagedPackageInstall(
|
|
packageName: string,
|
|
previousVersion: string | null,
|
|
): Promise<void> {
|
|
if (previousVersion) {
|
|
await installPackageIntoStore(packageName, previousVersion);
|
|
return;
|
|
}
|
|
|
|
await uninstallPackageFromStore(packageName);
|
|
}
|
|
|
|
function formatInstallSummary(descriptor: InstalledPluginConfig, configPath: string): string {
|
|
const version = descriptor.version ? ` @ ${descriptor.version}` : "";
|
|
return `Installed ${descriptor.name}${version} (${descriptor.source}) into ${configPath}`;
|
|
}
|
|
|
|
async function installOrVerifyPlugin(
|
|
config: OrchestratorConfig,
|
|
descriptor: InstalledPluginConfig,
|
|
specifier: string,
|
|
): Promise<InstalledPluginConfig> {
|
|
if (!descriptor.package || descriptor.source === "local") {
|
|
return verifyPluginDescriptor(config, descriptor, specifier);
|
|
}
|
|
|
|
const previousVersion = readInstalledPackageVersion(descriptor.package);
|
|
let installCompleted = false;
|
|
|
|
try {
|
|
const installedVersion = await installPackageIntoStore(descriptor.package, descriptor.version);
|
|
installCompleted = true;
|
|
return await verifyPluginDescriptor(
|
|
config,
|
|
{ ...descriptor, version: installedVersion },
|
|
descriptor.package,
|
|
);
|
|
} catch (err) {
|
|
if (installCompleted) {
|
|
try {
|
|
await rollbackManagedPackageInstall(descriptor.package, previousVersion);
|
|
} catch (rollbackErr) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const rollbackMessage =
|
|
rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
throw new Error(
|
|
`${message}\nRollback failed for ${descriptor.package}: ${rollbackMessage}`,
|
|
{ cause: rollbackErr },
|
|
);
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function resolveTargetVersion(plugin: InstalledPluginConfig): Promise<string> {
|
|
if (!plugin.package) {
|
|
throw new Error(`Plugin ${plugin.name} does not have a package-backed source and cannot be updated.`);
|
|
}
|
|
|
|
const marketplacePlugin =
|
|
findMarketplacePlugin(plugin.package) ?? findMarketplacePlugin(plugin.name);
|
|
if (marketplacePlugin?.latestVersion) {
|
|
return marketplacePlugin.latestVersion;
|
|
}
|
|
|
|
return getLatestPublishedPackageVersion(plugin.package);
|
|
}
|
|
|
|
async function updateManagedPlugin(
|
|
configPath: string,
|
|
config: OrchestratorConfig,
|
|
plugin: InstalledPluginConfig,
|
|
): Promise<"updated" | "skipped"> {
|
|
if (!plugin.package || plugin.source === "local") {
|
|
throw new Error(`Plugin ${plugin.name} is local-only and cannot be updated through the AO store.`);
|
|
}
|
|
|
|
const currentVersion = readInstalledPackageVersion(plugin.package) ?? plugin.version ?? null;
|
|
const targetVersion = await resolveTargetVersion(plugin);
|
|
|
|
if (currentVersion === targetVersion) {
|
|
if (plugin.version !== currentVersion && currentVersion) {
|
|
writePluginsConfig(
|
|
configPath,
|
|
upsertInstalledPlugin(config.plugins ?? [], { ...plugin, version: currentVersion }),
|
|
);
|
|
}
|
|
console.log(chalk.dim(`${plugin.name} is already up to date (${targetVersion}).`));
|
|
return "skipped";
|
|
}
|
|
|
|
const previousVersion = readInstalledPackageVersion(plugin.package);
|
|
let installCompleted = false;
|
|
|
|
try {
|
|
const installedVersion = await installPackageIntoStore(plugin.package, targetVersion);
|
|
installCompleted = true;
|
|
const verifiedDescriptor = await verifyPluginDescriptor(
|
|
config,
|
|
{ ...plugin, version: installedVersion },
|
|
plugin.package,
|
|
);
|
|
writePluginsConfig(
|
|
configPath,
|
|
upsertInstalledPlugin(config.plugins ?? [], verifiedDescriptor),
|
|
);
|
|
console.log(
|
|
chalk.green(
|
|
`Updated ${verifiedDescriptor.name} from ${currentVersion ?? "not installed"} to ${installedVersion}`,
|
|
),
|
|
);
|
|
return "updated";
|
|
} catch (err) {
|
|
if (installCompleted) {
|
|
try {
|
|
await rollbackManagedPackageInstall(plugin.package, previousVersion);
|
|
} catch (rollbackErr) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
const rollbackMessage =
|
|
rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr);
|
|
throw new Error(
|
|
`${message}\nRollback failed for ${plugin.package}: ${rollbackMessage}`,
|
|
{ cause: rollbackErr },
|
|
);
|
|
}
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function verifyPluginDescriptor(
|
|
config: OrchestratorConfig,
|
|
descriptor: InstalledPluginConfig,
|
|
specifier: string,
|
|
): Promise<InstalledPluginConfig> {
|
|
const imported = normalizeImportedPluginModule(await importPluginModuleFromSource(specifier));
|
|
if (!imported) {
|
|
throw new Error(`Imported module ${specifier} does not export a valid AO plugin`);
|
|
}
|
|
|
|
const normalizedDescriptor: InstalledPluginConfig = {
|
|
...descriptor,
|
|
name: imported.manifest.name,
|
|
};
|
|
|
|
const registry = createPluginRegistry();
|
|
const tempConfig: OrchestratorConfig = {
|
|
...config,
|
|
plugins: upsertInstalledPlugin(config.plugins ?? [], normalizedDescriptor),
|
|
};
|
|
const originalWarn = console.warn;
|
|
console.warn = () => {};
|
|
try {
|
|
await registry.loadFromConfig(tempConfig, importPluginModuleFromSource);
|
|
} finally {
|
|
console.warn = originalWarn;
|
|
}
|
|
|
|
const registered = registry.get(imported.manifest.slot, imported.manifest.name);
|
|
if (!registered) {
|
|
throw new Error(
|
|
`Plugin ${imported.manifest.name} imported successfully but did not load through the registry`,
|
|
);
|
|
}
|
|
|
|
return normalizedDescriptor;
|
|
}
|
|
|
|
function printPluginListFromCatalog(
|
|
config: OrchestratorConfig | null,
|
|
installedOnly: boolean,
|
|
catalog: ReturnType<typeof loadMarketplaceCatalog>,
|
|
slotFilter?: string,
|
|
): void {
|
|
const installed = config?.plugins ?? [];
|
|
|
|
if (installedOnly) {
|
|
const filtered = slotFilter
|
|
? installed.filter((p) => {
|
|
const entry = catalog.find((e) => e.id === p.name || e.package === p.package);
|
|
return entry?.slot === slotFilter;
|
|
})
|
|
: installed;
|
|
|
|
if (filtered.length === 0) {
|
|
console.log(chalk.dim(slotFilter ? `No installed plugins with type "${slotFilter}".` : "No plugins are installed in this config."));
|
|
return;
|
|
}
|
|
|
|
for (const plugin of filtered) {
|
|
const source = plugin.source === "local" ? plugin.path : plugin.package;
|
|
const version = plugin.version ? ` @ ${plugin.version}` : "";
|
|
console.log(
|
|
`${chalk.green(plugin.name)}${chalk.dim(version)} ${chalk.dim(`(${plugin.source}) ${source ?? ""}`)}`,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
const filteredCatalog = slotFilter ? catalog.filter((p) => p.slot === slotFilter) : catalog;
|
|
|
|
if (filteredCatalog.length === 0) {
|
|
console.log(chalk.dim(slotFilter ? `No marketplace plugins with type "${slotFilter}".` : "No marketplace plugins found."));
|
|
return;
|
|
}
|
|
|
|
for (const plugin of filteredCatalog) {
|
|
const isInstalled = installed.some(
|
|
(entry) => entry.package === plugin.package || entry.name === plugin.id,
|
|
);
|
|
const marker = isInstalled ? chalk.green("installed") : chalk.dim("available");
|
|
console.log(`${chalk.cyan(plugin.id)} ${chalk.dim(`(${plugin.slot})`)} ${marker}`);
|
|
console.log(chalk.dim(` ${plugin.description}`));
|
|
}
|
|
}
|
|
|
|
export function registerPlugin(program: Command): void {
|
|
const plugin = program.command("plugin").description("Browse and manage AO plugins");
|
|
|
|
plugin
|
|
.command("list")
|
|
.description("List bundled marketplace plugins")
|
|
.option("--installed", "Show only plugins installed in the current config")
|
|
.option("--type <slot>", "Filter by plugin slot (e.g. agent, notifier, tracker)")
|
|
.option("--refresh", "Fetch the latest registry and update the local marketplace cache")
|
|
.action(async (opts: { installed?: boolean; type?: string; refresh?: boolean }) => {
|
|
let config: OrchestratorConfig | null = null;
|
|
const configPath = findConfigFile();
|
|
if (configPath) {
|
|
config = loadConfig(configPath);
|
|
}
|
|
const catalog = opts.refresh === true ? await refreshMarketplaceCatalog() : loadMarketplaceCatalog();
|
|
printPluginListFromCatalog(config, opts.installed === true, catalog, opts.type);
|
|
});
|
|
|
|
plugin
|
|
.command("search")
|
|
.description("Search the bundled marketplace catalog")
|
|
.argument("<query>", "Search term")
|
|
.action((query: string) => {
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
const matches = loadMarketplaceCatalog().filter((plugin) => {
|
|
return (
|
|
plugin.id.toLowerCase().includes(normalizedQuery) ||
|
|
plugin.package.toLowerCase().includes(normalizedQuery) ||
|
|
plugin.description.toLowerCase().includes(normalizedQuery) ||
|
|
plugin.slot.toLowerCase().includes(normalizedQuery)
|
|
);
|
|
});
|
|
|
|
if (matches.length === 0) {
|
|
console.log(chalk.dim(`No marketplace plugins matched "${query}".`));
|
|
return;
|
|
}
|
|
|
|
for (const match of matches) {
|
|
console.log(`${chalk.cyan(match.id)} ${chalk.dim(`(${match.slot})`)}`);
|
|
console.log(chalk.dim(` ${match.description}`));
|
|
}
|
|
});
|
|
|
|
plugin
|
|
.command("create")
|
|
.description("Scaffold a new AO plugin package")
|
|
.argument("[directory]", "Target directory for the new plugin")
|
|
.option("--name <name>", "Display/plugin name")
|
|
.option("--slot <slot>", "Plugin slot: runtime | agent | workspace | tracker | scm | notifier | terminal")
|
|
.option("--description <description>", "Short plugin description")
|
|
.option("--author <author>", "Package author")
|
|
.option("--package-name <packageName>", "npm package name")
|
|
.option("--non-interactive", "Skip prompts and require explicit values for required fields")
|
|
.action(
|
|
async (
|
|
directory: string | undefined,
|
|
opts: {
|
|
name?: string;
|
|
slot?: string;
|
|
description?: string;
|
|
author?: string;
|
|
packageName?: string;
|
|
nonInteractive?: boolean;
|
|
},
|
|
) => {
|
|
const slotOptions: PluginSlot[] = [
|
|
"runtime",
|
|
"agent",
|
|
"workspace",
|
|
"tracker",
|
|
"scm",
|
|
"notifier",
|
|
"terminal",
|
|
];
|
|
const isInteractive = process.stdin.isTTY && opts.nonInteractive !== true;
|
|
let name = opts.name;
|
|
let slot = slotOptions.find((candidate) => candidate === opts.slot);
|
|
let description = opts.description;
|
|
let author = opts.author ?? process.env["USER"] ?? process.env["USERNAME"];
|
|
let packageName = opts.packageName;
|
|
|
|
if (isInteractive) {
|
|
const clack = await import("@clack/prompts");
|
|
clack.intro(chalk.bgCyan(chalk.black(" ao plugin create ")));
|
|
|
|
if (!name) {
|
|
const value = await clack.text({
|
|
message: "Plugin display name:",
|
|
placeholder: "My Plugin",
|
|
validate: (input) => {
|
|
if (!input) return "Plugin name is required";
|
|
try {
|
|
normalizePluginName(input);
|
|
} catch (err) {
|
|
return err instanceof Error ? err.message : String(err);
|
|
}
|
|
},
|
|
});
|
|
if (clack.isCancel(value)) {
|
|
clack.cancel("Plugin creation cancelled.");
|
|
process.exit(0);
|
|
}
|
|
name = value as string;
|
|
}
|
|
|
|
if (!slot) {
|
|
const value = await clack.select({
|
|
message: "Plugin slot:",
|
|
options: slotOptions.map((candidate) => ({ value: candidate, label: candidate })),
|
|
});
|
|
if (clack.isCancel(value)) {
|
|
clack.cancel("Plugin creation cancelled.");
|
|
process.exit(0);
|
|
}
|
|
slot = value as PluginSlot;
|
|
}
|
|
|
|
if (!description) {
|
|
const value = await clack.text({
|
|
message: "Short description:",
|
|
placeholder: `AO ${slot} plugin`,
|
|
validate: (input) => (!input ? "Description is required" : undefined),
|
|
});
|
|
if (clack.isCancel(value)) {
|
|
clack.cancel("Plugin creation cancelled.");
|
|
process.exit(0);
|
|
}
|
|
description = value as string;
|
|
}
|
|
|
|
if (!author) {
|
|
const value = await clack.text({
|
|
message: "Author:",
|
|
placeholder: "Your Name",
|
|
});
|
|
if (clack.isCancel(value)) {
|
|
clack.cancel("Plugin creation cancelled.");
|
|
process.exit(0);
|
|
}
|
|
author = (value as string) || undefined;
|
|
}
|
|
|
|
if (!packageName) {
|
|
const value = await clack.text({
|
|
message: "Package name:",
|
|
initialValue: buildDefaultPackageName(slot, name),
|
|
validate: (input) => (!input ? "Package name is required" : undefined),
|
|
});
|
|
if (clack.isCancel(value)) {
|
|
clack.cancel("Plugin creation cancelled.");
|
|
process.exit(0);
|
|
}
|
|
packageName = value as string;
|
|
}
|
|
}
|
|
|
|
if (!name) throw new Error("--name is required");
|
|
if (!slot) throw new Error("--slot is required");
|
|
if (!description) throw new Error("--description is required");
|
|
|
|
const targetDirectory = resolveScaffoldDirectory(name, directory);
|
|
const createdDir = scaffoldPlugin({
|
|
author,
|
|
description,
|
|
directory: targetDirectory,
|
|
displayName: name,
|
|
packageName: packageName ?? buildDefaultPackageName(slot, name),
|
|
slot,
|
|
});
|
|
|
|
console.log(chalk.green(`Created plugin scaffold at ${createdDir}`));
|
|
console.log(chalk.dim(` Package: ${packageName ?? buildDefaultPackageName(slot, name)}`));
|
|
console.log(chalk.dim(` Slot: ${slot}`));
|
|
console.log(chalk.dim(" Next: npm install && npm run build"));
|
|
},
|
|
);
|
|
|
|
plugin
|
|
.command("install")
|
|
.description("Install a plugin into the current config")
|
|
.argument("<reference>", "Marketplace id, package name, or local path")
|
|
.option("--url <url>", "OpenClaw webhook URL (passed to setup when installing notifier-openclaw)")
|
|
.option("--token <token>", "OpenClaw hooks auth token (passed to setup when installing notifier-openclaw)")
|
|
.action(async (reference: string, opts: { url?: string; token?: string }) => {
|
|
const configPath = findConfigFile();
|
|
if (!configPath) {
|
|
throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first.");
|
|
}
|
|
|
|
const config = loadConfig(configPath);
|
|
const { descriptor, specifier, setupAction } = buildPluginDescriptor(reference, configPath);
|
|
const verifiedDescriptor = await installOrVerifyPlugin(config, descriptor, specifier);
|
|
const previousPlugins = config.plugins ?? [];
|
|
const nextPlugins = upsertInstalledPlugin(previousPlugins, verifiedDescriptor);
|
|
writePluginsConfig(configPath, nextPlugins);
|
|
|
|
console.log(chalk.green(formatInstallSummary(verifiedDescriptor, configPath)));
|
|
|
|
if (setupAction === "openclaw-setup") {
|
|
// Always run setup — interactive in TTY, auto-detect in non-TTY.
|
|
// Non-interactive setup will auto-detect OpenClaw on localhost if
|
|
// no --url is given and the gateway is reachable.
|
|
try {
|
|
await runSetupAction({ url: opts.url, token: opts.token });
|
|
} catch (err) {
|
|
// Rollback: restore previous plugin list so a failed setup
|
|
// doesn't leave a half-configured notifier enabled in config.
|
|
writePluginsConfig(configPath, previousPlugins);
|
|
console.log(chalk.dim("Rolled back plugin config after setup failure."));
|
|
throw err;
|
|
}
|
|
}
|
|
});
|
|
|
|
plugin
|
|
.command("update")
|
|
.description("Update installer-managed plugins in the current config")
|
|
.argument("[reference]", "Installed plugin name, marketplace id, or package name")
|
|
.option("--all", "Update all installer-managed plugins in the current config")
|
|
.action(async (reference: string | undefined, opts: { all?: boolean }) => {
|
|
const configPath = findConfigFile();
|
|
if (!configPath) {
|
|
throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first.");
|
|
}
|
|
|
|
if (!reference && opts.all !== true) {
|
|
throw new Error("Specify a plugin reference or pass --all.");
|
|
}
|
|
|
|
let config = loadConfig(configPath);
|
|
const managedPlugins = (config.plugins ?? []).filter((plugin) => plugin.source !== "local");
|
|
const targets = opts.all
|
|
? managedPlugins
|
|
: managedPlugins.filter((plugin) => reference && matchesPluginReference(plugin, reference));
|
|
|
|
if (targets.length === 0) {
|
|
throw new Error(
|
|
opts.all
|
|
? "No installer-managed plugins are configured in this project."
|
|
: `Plugin ${reference} is not installed in this config`,
|
|
);
|
|
}
|
|
|
|
const failures: string[] = [];
|
|
let updated = 0;
|
|
let skipped = 0;
|
|
|
|
for (const target of targets) {
|
|
try {
|
|
const result = await updateManagedPlugin(configPath, config, target);
|
|
if (result === "updated") {
|
|
updated++;
|
|
} else {
|
|
skipped++;
|
|
}
|
|
config = loadConfig(configPath);
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
failures.push(`${target.name}: ${message}`);
|
|
}
|
|
}
|
|
|
|
if (updated > 0 || skipped > 0) {
|
|
console.log(
|
|
chalk.dim(
|
|
`Plugin update summary: ${updated} updated, ${skipped} already current, ${failures.length} failed.`,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (failures.length > 0) {
|
|
throw new Error(`Failed to update plugin(s):\n ${failures.join("\n ")}`);
|
|
}
|
|
});
|
|
|
|
plugin
|
|
.command("uninstall")
|
|
.description("Remove a plugin from the current config")
|
|
.argument("<reference>", "Installed plugin name, package name, or local path")
|
|
.action((reference: string) => {
|
|
const configPath = findConfigFile();
|
|
if (!configPath) {
|
|
throw new Error("No agent-orchestrator.yaml found. Run 'ao start' first.");
|
|
}
|
|
|
|
const config = loadConfig(configPath);
|
|
const nextPlugins = (config.plugins ?? []).filter((plugin) => {
|
|
return !matchesPluginReference(plugin, reference);
|
|
});
|
|
|
|
if (nextPlugins.length === (config.plugins ?? []).length) {
|
|
throw new Error(`Plugin ${reference} is not installed in this config`);
|
|
}
|
|
|
|
writePluginsConfig(configPath, nextPlugins);
|
|
console.log(chalk.green(`Removed ${reference} from ${configPath}`));
|
|
});
|
|
}
|