feat(cli): unify hook pack installs under plugins

This commit is contained in:
Peter Steinberger
2026-03-22 11:19:07 -07:00
parent b44152fcc8
commit aa80b1eb7c
8 changed files with 740 additions and 400 deletions

View File

@@ -5,7 +5,14 @@ import type { Command } from "commander";
import type { OpenClawConfig } from "../config/config.js";
import { loadConfig, writeConfigFile } from "../config/config.js";
import { resolveStateDir } from "../config/paths.js";
import type { HookInstallRecord } from "../config/types.hooks.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import {
installHooksFromNpmSpec,
installHooksFromPath,
resolveHookInstallDir,
} from "../hooks/install.js";
import { recordHookInstall } from "../hooks/installs.js";
import { resolveArchiveKind } from "../infra/archive.js";
import { parseClawHubPluginSpec } from "../infra/clawhub.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
@@ -73,6 +80,22 @@ export type PluginUninstallOptions = {
dryRun?: boolean;
};
type HookInternalEntryLike = Record<string, unknown> & { enabled?: boolean };
type HookPackUpdateOutcome = {
hookId: string;
status: "updated" | "unchanged" | "skipped" | "error";
message: string;
currentVersion?: string;
nextVersion?: string;
};
type HookPackUpdateSummary = {
config: OpenClawConfig;
changed: boolean;
outcomes: HookPackUpdateOutcome[];
};
function resolveFileNpmSpecToLocalPath(
raw: string,
): { ok: true; path: string } | { ok: false; error: string } | null {
@@ -230,6 +253,39 @@ function createPluginInstallLogger(): { info: (msg: string) => void; warn: (msg:
};
}
function createHookPackInstallLogger(): {
info: (msg: string) => void;
warn: (msg: string) => void;
} {
return {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
};
}
function enableInternalHookEntries(config: OpenClawConfig, hookNames: string[]): OpenClawConfig {
const entries = { ...config.hooks?.internal?.entries } as Record<string, HookInternalEntryLike>;
for (const hookName of hookNames) {
entries[hookName] = {
...entries[hookName],
enabled: true,
};
}
return {
...config,
hooks: {
...config.hooks,
internal: {
...config.hooks?.internal,
enabled: true,
entries,
},
},
};
}
function extractInstalledNpmPackageName(install: PluginInstallRecord): string | undefined {
if (install.source !== "npm") {
return undefined;
@@ -244,6 +300,17 @@ function extractInstalledNpmPackageName(install: PluginInstallRecord): string |
);
}
function extractInstalledNpmHookPackageName(install: HookInstallRecord): string | undefined {
const resolvedName = install.resolvedName?.trim();
if (resolvedName) {
return resolvedName;
}
return (
(install.spec ? parseRegistryNpmSpec(install.spec)?.name : undefined) ??
(install.resolvedSpec ? parseRegistryNpmSpec(install.resolvedSpec)?.name : undefined)
);
}
function resolvePluginUpdateSelection(params: {
installs: Record<string, PluginInstallRecord>;
rawId?: string;
@@ -280,6 +347,63 @@ function resolvePluginUpdateSelection(params: {
};
}
function resolveHookPackUpdateSelection(params: {
installs: Record<string, HookInstallRecord>;
rawId?: string;
all?: boolean;
}): { hookIds: string[]; specOverrides?: Record<string, string> } {
if (params.all) {
return { hookIds: Object.keys(params.installs) };
}
if (!params.rawId) {
return { hookIds: [] };
}
if (params.rawId in params.installs) {
return { hookIds: [params.rawId] };
}
const parsedSpec = parseRegistryNpmSpec(params.rawId);
if (!parsedSpec || parsedSpec.selectorKind === "none") {
return { hookIds: [] };
}
const matches = Object.entries(params.installs).filter(([, install]) => {
return extractInstalledNpmHookPackageName(install) === parsedSpec.name;
});
if (matches.length !== 1) {
return { hookIds: [] };
}
const [hookId] = matches[0];
if (!hookId) {
return { hookIds: [] };
}
return {
hookIds: [hookId],
specOverrides: {
[hookId]: parsedSpec.raw,
},
};
}
function formatPluginInstallWithHookFallbackError(pluginError: string, hookError: string): string {
return `${pluginError}\nAlso not a valid hook pack: ${hookError}`;
}
function logHookPackRestartHint() {
defaultRuntime.log("Restart the gateway to load hooks.");
}
async function readInstalledPackageVersion(dir: string): Promise<string | undefined> {
try {
const raw = fs.readFileSync(path.join(dir, "package.json"), "utf-8");
const parsed = JSON.parse(raw) as { version?: unknown };
return typeof parsed.version === "string" ? parsed.version : undefined;
} catch {
return undefined;
}
}
function logSlotWarnings(warnings: string[]) {
if (warnings.length === 0) {
return;
@@ -308,7 +432,266 @@ function shouldFallbackFromClawHubToNpm(error: string): boolean {
/Version not found/i.test(normalized)
);
}
async function tryInstallHookPackFromLocalPath(params: {
config: OpenClawConfig;
resolvedPath: string;
link?: boolean;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (params.link) {
const stat = fs.statSync(params.resolvedPath);
if (!stat.isDirectory()) {
return {
ok: false,
error: "Linked hook pack paths must be directories.",
};
}
const probe = await installHooksFromPath({
path: params.resolvedPath,
dryRun: true,
});
if (!probe.ok) {
return probe;
}
const existing = params.config.hooks?.internal?.load?.extraDirs ?? [];
const merged = Array.from(new Set([...existing, params.resolvedPath]));
let next: OpenClawConfig = {
...params.config,
hooks: {
...params.config.hooks,
internal: {
...params.config.hooks?.internal,
enabled: true,
load: {
...params.config.hooks?.internal?.load,
extraDirs: merged,
},
},
},
};
next = enableInternalHookEntries(next, probe.hooks);
next = recordHookInstall(next, {
hookId: probe.hookPackId,
source: "path",
sourcePath: params.resolvedPath,
installPath: params.resolvedPath,
version: probe.version,
hooks: probe.hooks,
});
await writeConfigFile(next);
defaultRuntime.log(`Linked hook pack path: ${shortenHomePath(params.resolvedPath)}`);
logHookPackRestartHint();
return { ok: true };
}
const result = await installHooksFromPath({
path: params.resolvedPath,
logger: createHookPackInstallLogger(),
});
if (!result.ok) {
return result;
}
let next = enableInternalHookEntries(params.config, result.hooks);
const source: "archive" | "path" = resolveArchiveKind(params.resolvedPath) ? "archive" : "path";
next = recordHookInstall(next, {
hookId: result.hookPackId,
source,
sourcePath: params.resolvedPath,
installPath: result.targetDir,
version: result.version,
hooks: result.hooks,
});
await writeConfigFile(next);
defaultRuntime.log(`Installed hook pack: ${result.hookPackId}`);
logHookPackRestartHint();
return { ok: true };
}
async function tryInstallHookPackFromNpmSpec(params: {
config: OpenClawConfig;
spec: string;
pin?: boolean;
}): Promise<{ ok: true } | { ok: false; error: string }> {
const result = await installHooksFromNpmSpec({
spec: params.spec,
logger: createHookPackInstallLogger(),
});
if (!result.ok) {
return result;
}
let next = enableInternalHookEntries(params.config, result.hooks);
const installRecord = resolvePinnedNpmInstallRecordForCli(
params.spec,
Boolean(params.pin),
result.targetDir,
result.version,
result.npmResolution,
defaultRuntime.log,
theme.warn,
);
next = recordHookInstall(next, {
hookId: result.hookPackId,
...installRecord,
hooks: result.hooks,
});
await writeConfigFile(next);
defaultRuntime.log(`Installed hook pack: ${result.hookPackId}`);
logHookPackRestartHint();
return { ok: true };
}
async function updateTrackedHookPacks(params: {
config: OpenClawConfig;
hookIds?: string[];
dryRun?: boolean;
specOverrides?: Record<string, string>;
}): Promise<HookPackUpdateSummary> {
const installs = params.config.hooks?.internal?.installs ?? {};
const targets = params.hookIds?.length ? params.hookIds : Object.keys(installs);
const outcomes: HookPackUpdateOutcome[] = [];
let next = params.config;
let changed = false;
for (const hookId of targets) {
const record = installs[hookId];
if (!record) {
outcomes.push({
hookId,
status: "skipped",
message: `No install record for hook pack "${hookId}".`,
});
continue;
}
if (record.source !== "npm") {
outcomes.push({
hookId,
status: "skipped",
message: `Skipping hook pack "${hookId}" (source: ${record.source}).`,
});
continue;
}
const effectiveSpec = params.specOverrides?.[hookId] ?? record.spec;
if (!effectiveSpec) {
outcomes.push({
hookId,
status: "skipped",
message: `Skipping hook pack "${hookId}" (missing npm spec).`,
});
continue;
}
let installPath: string;
try {
installPath = record.installPath ?? resolveHookInstallDir(hookId);
} catch (err) {
outcomes.push({
hookId,
status: "error",
message: `Invalid install path for hook pack "${hookId}": ${String(err)}`,
});
continue;
}
const currentVersion = await readInstalledPackageVersion(installPath);
const onIntegrityDrift = async (drift: {
spec: string;
expectedIntegrity: string;
actualIntegrity: string;
resolution: { resolvedSpec?: string };
}) => {
const specLabel = drift.resolution.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for hook pack "${hookId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
);
if (params.dryRun) {
return true;
}
return await promptYesNo(`Continue updating hook pack "${hookId}" with this artifact?`);
};
const result = params.dryRun
? await installHooksFromNpmSpec({
spec: effectiveSpec,
mode: "update",
dryRun: true,
expectedHookPackId: hookId,
expectedIntegrity: record.integrity,
onIntegrityDrift,
logger: createHookPackInstallLogger(),
})
: await installHooksFromNpmSpec({
spec: effectiveSpec,
mode: "update",
expectedHookPackId: hookId,
expectedIntegrity: record.integrity,
onIntegrityDrift,
logger: createHookPackInstallLogger(),
});
if (!result.ok) {
outcomes.push({
hookId,
status: "error",
message: `Failed to ${params.dryRun ? "check" : "update"} hook pack "${hookId}": ${result.error}`,
});
continue;
}
const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir));
const currentLabel = currentVersion ?? "unknown";
const nextLabel = nextVersion ?? "unknown";
if (params.dryRun) {
outcomes.push({
hookId,
status:
currentVersion && nextVersion && currentVersion === nextVersion ? "unchanged" : "updated",
currentVersion: currentVersion ?? undefined,
nextVersion: nextVersion ?? undefined,
message:
currentVersion && nextVersion && currentVersion === nextVersion
? `Hook pack "${hookId}" is up to date (${currentLabel}).`
: `Would update hook pack "${hookId}": ${currentLabel} -> ${nextLabel}.`,
});
continue;
}
next = recordHookInstall(next, {
hookId,
source: "npm",
spec: effectiveSpec,
installPath: result.targetDir,
version: nextVersion,
resolvedName: result.npmResolution?.name,
resolvedSpec: result.npmResolution?.resolvedSpec,
integrity: result.npmResolution?.integrity,
hooks: result.hooks,
});
changed = true;
outcomes.push({
hookId,
status:
currentVersion && nextVersion && currentVersion === nextVersion ? "unchanged" : "updated",
currentVersion: currentVersion ?? undefined,
nextVersion: nextVersion ?? undefined,
message:
currentVersion && nextVersion && currentVersion === nextVersion
? `Hook pack "${hookId}" already at ${currentLabel}.`
: `Updated hook pack "${hookId}": ${currentLabel} -> ${nextLabel}.`,
});
}
return { config: next, changed, outcomes };
}
async function installBundledPluginSource(params: {
config: OpenClawConfig;
rawSpec: string;
@@ -352,7 +735,7 @@ async function installBundledPluginSource(params: {
defaultRuntime.log(`Restart the gateway to load plugins.`);
}
async function runPluginInstallCommand(params: {
export async function runPluginInstallCommand(params: {
raw: string;
opts: { link?: boolean; pin?: boolean; marketplace?: string };
}) {
@@ -428,7 +811,17 @@ async function runPluginInstallCommand(params: {
const merged = Array.from(new Set([...existing, resolved]));
const probe = await installPluginFromPath({ path: resolved, dryRun: true });
if (!probe.ok) {
defaultRuntime.error(probe.error);
const hookFallback = await tryInstallHookPackFromLocalPath({
config: cfg,
resolvedPath: resolved,
link: true,
});
if (hookFallback.ok) {
return;
}
defaultRuntime.error(
formatPluginInstallWithHookFallbackError(probe.error, hookFallback.error),
);
return defaultRuntime.exit(1);
}
@@ -466,7 +859,16 @@ async function runPluginInstallCommand(params: {
logger: createPluginInstallLogger(),
});
if (!result.ok) {
defaultRuntime.error(result.error);
const hookFallback = await tryInstallHookPackFromLocalPath({
config: cfg,
resolvedPath: resolved,
});
if (hookFallback.ok) {
return;
}
defaultRuntime.error(
formatPluginInstallWithHookFallbackError(result.error, hookFallback.error),
);
return defaultRuntime.exit(1);
}
// Plugin CLI registrars may have warmed the manifest registry cache before install;
@@ -616,7 +1018,17 @@ async function runPluginInstallCommand(params: {
findBundledSource: (lookup) => findBundledPluginSource({ lookup }),
});
if (!bundledFallbackPlan) {
defaultRuntime.error(result.error);
const hookFallback = await tryInstallHookPackFromNpmSpec({
config: cfg,
spec: raw,
pin: opts.pin,
});
if (hookFallback.ok) {
return;
}
defaultRuntime.error(
formatPluginInstallWithHookFallbackError(result.error, hookFallback.error),
);
return defaultRuntime.exit(1);
}
@@ -652,6 +1064,90 @@ async function runPluginInstallCommand(params: {
defaultRuntime.log(`Installed plugin: ${result.pluginId}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
}
export async function runPluginUpdateCommand(params: { id?: string; opts: PluginUpdateOptions }) {
const cfg = loadConfig();
const pluginSelection = resolvePluginUpdateSelection({
installs: cfg.plugins?.installs ?? {},
rawId: params.id,
all: params.opts.all,
});
const hookSelection = resolveHookPackUpdateSelection({
installs: cfg.hooks?.internal?.installs ?? {},
rawId: params.id,
all: params.opts.all,
});
if (pluginSelection.pluginIds.length === 0 && hookSelection.hookIds.length === 0) {
if (params.opts.all) {
defaultRuntime.log("No tracked plugins or hook packs to update.");
return;
}
defaultRuntime.error("Provide a plugin or hook-pack id, or use --all.");
return defaultRuntime.exit(1);
}
const pluginResult = await updateNpmInstalledPlugins({
config: cfg,
pluginIds: pluginSelection.pluginIds,
specOverrides: pluginSelection.specOverrides,
dryRun: params.opts.dryRun,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
},
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
);
if (drift.dryRun) {
return true;
}
return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`);
},
});
const hookResult = await updateTrackedHookPacks({
config: pluginResult.config,
hookIds: hookSelection.hookIds,
specOverrides: hookSelection.specOverrides,
dryRun: params.opts.dryRun,
});
for (const outcome of pluginResult.outcomes) {
if (outcome.status === "error") {
defaultRuntime.log(theme.error(outcome.message));
continue;
}
if (outcome.status === "skipped") {
defaultRuntime.log(theme.warn(outcome.message));
continue;
}
defaultRuntime.log(outcome.message);
}
for (const outcome of hookResult.outcomes) {
if (outcome.status === "error") {
defaultRuntime.log(theme.error(outcome.message));
continue;
}
if (outcome.status === "skipped") {
defaultRuntime.log(theme.warn(outcome.message));
continue;
}
defaultRuntime.log(outcome.message);
}
if (!params.opts.dryRun && (pluginResult.changed || hookResult.changed)) {
await writeConfigFile(hookResult.config);
defaultRuntime.log("Restart the gateway to load plugins and hooks.");
}
}
export function registerPluginsCli(program: Command) {
const plugins = program
.command("plugins")
@@ -1161,7 +1657,7 @@ export function registerPluginsCli(program: Command) {
plugins
.command("install")
.description(
"Install a plugin (path, archive, npm spec, clawhub:package, or marketplace entry)",
"Install a plugin or hook pack (path, archive, npm spec, clawhub:package, or marketplace entry)",
)
.argument(
"<path-or-spec-or-plugin>",
@@ -1179,70 +1675,12 @@ export function registerPluginsCli(program: Command) {
plugins
.command("update")
.description("Update installed plugins (npm, clawhub, and marketplace installs)")
.argument("[id]", "Plugin id (omit with --all)")
.option("--all", "Update all tracked plugins", false)
.description("Update installed plugins and tracked hook packs")
.argument("[id]", "Plugin or hook-pack id (omit with --all)")
.option("--all", "Update all tracked plugins and hook packs", false)
.option("--dry-run", "Show what would change without writing", false)
.action(async (id: string | undefined, opts: PluginUpdateOptions) => {
const cfg = loadConfig();
const installs = cfg.plugins?.installs ?? {};
const selection = resolvePluginUpdateSelection({
installs,
rawId: id,
all: opts.all,
});
const targets = selection.pluginIds;
if (targets.length === 0) {
if (opts.all) {
defaultRuntime.log("No tracked plugins to update.");
return;
}
defaultRuntime.error("Provide a plugin id or use --all.");
return defaultRuntime.exit(1);
}
const result = await updateNpmInstalledPlugins({
config: cfg,
pluginIds: targets,
specOverrides: selection.specOverrides,
dryRun: opts.dryRun,
logger: {
info: (msg) => defaultRuntime.log(msg),
warn: (msg) => defaultRuntime.log(theme.warn(msg)),
},
onIntegrityDrift: async (drift) => {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}`,
),
);
if (drift.dryRun) {
return true;
}
return await promptYesNo(`Continue updating "${drift.pluginId}" with this artifact?`);
},
});
for (const outcome of result.outcomes) {
if (outcome.status === "error") {
defaultRuntime.log(theme.error(outcome.message));
continue;
}
if (outcome.status === "skipped") {
defaultRuntime.log(theme.warn(outcome.message));
continue;
}
defaultRuntime.log(outcome.message);
}
if (!opts.dryRun && result.changed) {
await writeConfigFile(result.config);
defaultRuntime.log("Restart the gateway to load plugins.");
}
await runPluginUpdateCommand({ id, opts });
});
plugins