diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6365af7df..6ea85f5a99f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ Docs: https://docs.openclaw.ai - Agents: add per-agent thinking/reasoning/fast defaults and auto-revert disallowed model overrides to the agent's default selection. Thanks @xuanmingguo and @vincentkoc. - Control UI/usage: drop the empty session-detail placeholder card so the usage view stays single-column until a real session detail panel is selected. (#52013) Thanks @BunsDev. - Hooks/workspace: keep repo-local `/hooks` disabled until explicitly enabled, block workspace hook name collisions from shadowing bundled/managed/plugin hooks, and treat `hooks.internal.load.extraDirs` as trusted managed hook sources. +- CLI/hooks: route hook-pack install and update through `openclaw plugins`, keep `openclaw hooks` focused on hook visibility and per-hook controls, and show plugin-managed hook details in CLI output. ### Fixes diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index 91b5f1eff58..5d45775c1b8 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -8,7 +8,7 @@ title: "Hooks" # Hooks -Hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in OpenClaw. +Hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be inspected with `openclaw hooks`, while hook-pack installation and updates now go through `openclaw plugins`. ## Getting Oriented @@ -17,7 +17,7 @@ Hooks are small scripts that run when something happens. There are two kinds: - **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events. - **Webhooks**: external HTTP webhooks that let other systems trigger work in OpenClaw. See [Webhook Hooks](/automation/webhook) or use `openclaw webhooks` for Gmail helper commands. -Hooks can also be bundled inside plugins; see [Plugin hooks](/plugins/architecture#provider-runtime-hooks). +Hooks can also be bundled inside plugins; see [Plugin hooks](/plugins/architecture#provider-runtime-hooks). `openclaw hooks list` shows both standalone hooks and plugin-managed hooks. Common uses: @@ -107,7 +107,7 @@ Hook packs are standard npm packages that export one or more hooks via `openclaw `package.json`. Install them with: ```bash -openclaw hooks install +openclaw plugins install ``` Npm specs are registry-only (package name + optional exact version or dist-tag). @@ -134,7 +134,7 @@ Hook packs can ship dependencies; they will be installed under `~/.openclaw/hook Each `openclaw.hooks` entry must stay inside the package directory after symlink resolution; entries that escape are rejected. -Security note: `openclaw hooks install` installs dependencies with `npm install --ignore-scripts` +Security note: `openclaw plugins install` installs hook-pack dependencies with `npm install --ignore-scripts` (no lifecycle scripts). Keep hook pack dependency trees "pure JS/TS" and avoid packages that rely on `postinstall` builds. diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 1ff0ab5b91b..f54aabae018 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -2,7 +2,7 @@ summary: "CLI reference for `openclaw hooks` (agent hooks)" read_when: - You want to manage agent hooks - - You want to install or update hooks + - You want to inspect hook availability or enable workspace hooks title: "hooks" --- @@ -186,14 +186,17 @@ openclaw hooks disable command-logger - Restart the gateway so hooks reload -## Install Hooks +## Install Hook Packs ```bash -openclaw hooks install -openclaw hooks install --pin +openclaw plugins install +openclaw plugins install --pin ``` -Install a hook pack from a local folder/archive or npm. +Install hook packs through the unified plugins installer. + +`openclaw hooks install` still works as a compatibility alias, but it prints a +deprecation warning and forwards to `openclaw plugins install`. Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency @@ -220,29 +223,32 @@ prerelease tag such as `@beta`/`@rc` or an exact prerelease version. ```bash # Local directory -openclaw hooks install ./my-hook-pack +openclaw plugins install ./my-hook-pack # Local archive -openclaw hooks install ./my-hook-pack.zip +openclaw plugins install ./my-hook-pack.zip # NPM package -openclaw hooks install @openclaw/my-hook-pack +openclaw plugins install @openclaw/my-hook-pack # Link a local directory without copying -openclaw hooks install -l ./my-hook-pack +openclaw plugins install -l ./my-hook-pack ``` Linked hook packs are treated as managed hooks from an operator-configured directory, not as workspace hooks. -## Update Hooks +## Update Hook Packs ```bash -openclaw hooks update -openclaw hooks update --all +openclaw plugins update +openclaw plugins update --all ``` -Update installed hook packs (npm installs only). +Update tracked npm-based hook packs through the unified plugins updater. + +`openclaw hooks update` still works as a compatibility alias, but it prints a +deprecation warning and forwards to `openclaw plugins update`. **Options:** diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 8241b8c6aa0..b3d227024f2 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -8,7 +8,7 @@ title: "plugins" # `openclaw plugins` -Manage Gateway plugins/extensions and compatible bundles. +Manage Gateway plugins/extensions, hook packs, and compatible bundles. Related: @@ -55,6 +55,10 @@ openclaw plugins install --marketplace Security note: treat plugin installs like running code. Prefer pinned versions. +`plugins install` is also the install surface for hook packs that expose +`openclaw.hooks` in `package.json`. Use `openclaw hooks` for filtered hook +visibility and per-hook enablement, not package installation. + Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency installs run with `--ignore-scripts` for safety. @@ -164,8 +168,8 @@ openclaw plugins update --dry-run openclaw plugins update @openclaw/voice-call@beta ``` -Updates apply to tracked installs in `plugins.installs`, including npm, -ClawHub, and marketplace installs. +Updates apply to tracked installs in `plugins.installs` and tracked hook-pack +installs in `hooks.internal.installs`. When you pass a plugin id, OpenClaw reuses the recorded install spec for that plugin. That means previously stored dist-tags such as `@beta` and exact pinned diff --git a/src/cli/hooks-cli.test.ts b/src/cli/hooks-cli.test.ts index 194c24a3072..92b30a0a2dc 100644 --- a/src/cli/hooks-cli.test.ts +++ b/src/cli/hooks-cli.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { HookStatusReport } from "../hooks/hooks-status.js"; -import { formatHooksCheck, formatHooksList } from "./hooks-cli.js"; +import { formatHookInfo, formatHooksCheck, formatHooksList } from "./hooks-cli.js"; import { createEmptyInstallChecks } from "./requirements-test-fixtures.js"; const report: HookStatusReport = { @@ -73,4 +73,37 @@ describe("hooks cli formatting", () => { const output = formatHooksList(pluginReport, {}); expect(output).toContain("plugin:voice-call"); }); + + it("shows plugin-managed details in hook info", () => { + const pluginReport: HookStatusReport = { + workspaceDir: "/tmp/workspace", + managedHooksDir: "/tmp/hooks", + hooks: [ + { + name: "plugin-hook", + description: "Hook from plugin", + source: "openclaw-plugin", + pluginId: "voice-call", + filePath: "/tmp/hooks/plugin-hook/HOOK.md", + baseDir: "/tmp/hooks/plugin-hook", + handlerPath: "/tmp/hooks/plugin-hook/handler.js", + hookKey: "plugin-hook", + emoji: "🔗", + homepage: undefined, + events: ["command:new"], + always: false, + enabledByConfig: true, + requirementsSatisfied: true, + loadable: true, + blockedReason: undefined, + managedByPlugin: true, + ...createEmptyInstallChecks(), + }, + ], + }; + + const output = formatHookInfo(pluginReport, "plugin-hook", {}); + expect(output).toContain("voice-call"); + expect(output).toContain("Managed by plugin"); + }); }); diff --git a/src/cli/hooks-cli.ts b/src/cli/hooks-cli.ts index ba6055a0d29..dc8f5220af5 100644 --- a/src/cli/hooks-cli.ts +++ b/src/cli/hooks-cli.ts @@ -1,6 +1,3 @@ -import fs from "node:fs"; -import fsp from "node:fs/promises"; -import path from "node:path"; import type { Command } from "commander"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -10,29 +7,17 @@ import { type HookStatusEntry, type HookStatusReport, } from "../hooks/hooks-status.js"; -import { - installHooksFromNpmSpec, - installHooksFromPath, - resolveHookInstallDir, -} from "../hooks/install.js"; -import { recordHookInstall } from "../hooks/installs.js"; import { resolveHookEntries } from "../hooks/policy.js"; import type { HookEntry } from "../hooks/types.js"; import { loadWorkspaceHookEntries } from "../hooks/workspace.js"; -import { resolveArchiveKind } from "../infra/archive.js"; import { buildPluginStatusReport } from "../plugins/status.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; -import { resolveUserPath, shortenHomePath } from "../utils.js"; +import { shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; -import { looksLikeLocalInstallSpec } from "./install-spec.js"; -import { - buildNpmInstallRecordFields, - resolvePinnedNpmInstallRecordForCli, -} from "./npm-resolution.js"; -import { promptYesNo } from "./prompt.js"; +import { runPluginInstallCommand, runPluginUpdateCommand } from "./plugins-cli.js"; export type HooksListOptions = { json?: boolean; @@ -167,71 +152,6 @@ async function runHooksCliAction(action: () => Promise | void): Promise defaultRuntime.log(msg), - warn: (msg: string) => defaultRuntime.log(theme.warn(msg)), - }; -} - -function logGatewayRestartHint() { - defaultRuntime.log("Restart the gateway to load hooks."); -} - -function logIntegrityDriftWarning( - hookId: string, - drift: { - resolution: { resolvedSpec?: string }; - spec: string; - expectedIntegrity: string; - actualIntegrity: string; - }, -) { - const specLabel = drift.resolution.resolvedSpec ?? drift.spec; - defaultRuntime.log( - theme.warn( - `Integrity drift detected for "${hookId}" (${specLabel})` + - `\nExpected: ${drift.expectedIntegrity}` + - `\nActual: ${drift.actualIntegrity}`, - ), - ); -} - -async function readInstalledPackageVersion(dir: string): Promise { - try { - const raw = await fsp.readFile(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; - } -} - -type HookInternalEntryLike = Record & { enabled?: boolean }; - -function enableInternalHookEntries(config: OpenClawConfig, hookNames: string[]): OpenClawConfig { - const entries = { ...config.hooks?.internal?.entries } as Record; - - for (const hookName of hookNames) { - entries[hookName] = { - ...entries[hookName], - enabled: true, - }; - } - - return { - ...config, - hooks: { - ...config.hooks, - internal: { - ...config.hooks?.internal, - enabled: true, - entries, - }, - }, - }; -} - /** * Format the hooks list output */ @@ -582,243 +502,28 @@ export function registerHooksCli(program: Command): void { hooks .command("install") - .description("Install a hook pack (path, archive, or npm spec)") + .description("Deprecated: install a hook pack via `openclaw plugins install`") .argument("", "Path to a hook pack or npm package spec") .option("-l, --link", "Link a local path instead of copying", false) .option("--pin", "Record npm installs as exact resolved @", false) .action(async (raw: string, opts: { link?: boolean; pin?: boolean }) => { - const resolved = resolveUserPath(raw); - const cfg = loadConfig(); - - if (fs.existsSync(resolved)) { - if (opts.link) { - const stat = fs.statSync(resolved); - if (!stat.isDirectory()) { - defaultRuntime.error("Linked hook paths must be directories."); - process.exit(1); - } - - const existing = cfg.hooks?.internal?.load?.extraDirs ?? []; - const merged = Array.from(new Set([...existing, resolved])); - const probe = await installHooksFromPath({ path: resolved, dryRun: true }); - if (!probe.ok) { - defaultRuntime.error(probe.error); - process.exit(1); - } - - let next: OpenClawConfig = { - ...cfg, - hooks: { - ...cfg.hooks, - internal: { - ...cfg.hooks?.internal, - enabled: true, - load: { - ...cfg.hooks?.internal?.load, - extraDirs: merged, - }, - }, - }, - }; - - next = enableInternalHookEntries(next, probe.hooks); - - next = recordHookInstall(next, { - hookId: probe.hookPackId, - source: "path", - sourcePath: resolved, - installPath: resolved, - version: probe.version, - hooks: probe.hooks, - }); - - await writeConfigFile(next); - defaultRuntime.log(`Linked hook path: ${shortenHomePath(resolved)}`); - logGatewayRestartHint(); - return; - } - - const result = await installHooksFromPath({ - path: resolved, - logger: createInstallLogger(), - }); - if (!result.ok) { - defaultRuntime.error(result.error); - process.exit(1); - } - - let next = enableInternalHookEntries(cfg, result.hooks); - - const source: "archive" | "path" = resolveArchiveKind(resolved) ? "archive" : "path"; - - next = recordHookInstall(next, { - hookId: result.hookPackId, - source, - sourcePath: resolved, - installPath: result.targetDir, - version: result.version, - hooks: result.hooks, - }); - - await writeConfigFile(next); - defaultRuntime.log(`Installed hooks: ${result.hooks.join(", ")}`); - logGatewayRestartHint(); - return; - } - - if (opts.link) { - defaultRuntime.error("`--link` requires a local path."); - process.exit(1); - } - - if (looksLikeLocalInstallSpec(raw, [".zip", ".tgz", ".tar.gz", ".tar"])) { - defaultRuntime.error(`Path not found: ${resolved}`); - process.exit(1); - } - - const result = await installHooksFromNpmSpec({ - spec: raw, - logger: createInstallLogger(), - }); - if (!result.ok) { - defaultRuntime.error(result.error); - process.exit(1); - } - - let next = enableInternalHookEntries(cfg, result.hooks); - const installRecord = resolvePinnedNpmInstallRecordForCli( - raw, - Boolean(opts.pin), - result.targetDir, - result.version, - result.npmResolution, - defaultRuntime.log, - theme.warn, + defaultRuntime.log( + theme.warn("`openclaw hooks install` is deprecated; use `openclaw plugins install`."), ); - - next = recordHookInstall(next, { - hookId: result.hookPackId, - ...installRecord, - hooks: result.hooks, - }); - await writeConfigFile(next); - defaultRuntime.log(`Installed hooks: ${result.hooks.join(", ")}`); - logGatewayRestartHint(); + await runPluginInstallCommand({ raw, opts }); }); hooks .command("update") - .description("Update installed hooks (npm installs only)") + .description("Deprecated: update hook packs via `openclaw plugins update`") .argument("[id]", "Hook pack id (omit with --all)") .option("--all", "Update all tracked hooks", false) .option("--dry-run", "Show what would change without writing", false) .action(async (id: string | undefined, opts: HooksUpdateOptions) => { - const cfg = loadConfig(); - const installs = cfg.hooks?.internal?.installs ?? {}; - const targets = opts.all ? Object.keys(installs) : id ? [id] : []; - - if (targets.length === 0) { - defaultRuntime.error("Provide a hook id or use --all."); - process.exit(1); - } - - let nextCfg = cfg; - let updatedCount = 0; - - for (const hookId of targets) { - const record = installs[hookId]; - if (!record) { - defaultRuntime.log(theme.warn(`No install record for "${hookId}".`)); - continue; - } - if (record.source !== "npm") { - defaultRuntime.log(theme.warn(`Skipping "${hookId}" (source: ${record.source}).`)); - continue; - } - if (!record.spec) { - defaultRuntime.log(theme.warn(`Skipping "${hookId}" (missing npm spec).`)); - continue; - } - - let installPath: string; - try { - installPath = record.installPath ?? resolveHookInstallDir(hookId); - } catch (err) { - defaultRuntime.log(theme.error(`Invalid install path for "${hookId}": ${String(err)}`)); - continue; - } - const currentVersion = await readInstalledPackageVersion(installPath); - - if (opts.dryRun) { - const probe = await installHooksFromNpmSpec({ - spec: record.spec, - mode: "update", - dryRun: true, - expectedHookPackId: hookId, - expectedIntegrity: record.integrity, - onIntegrityDrift: async (drift) => { - logIntegrityDriftWarning(hookId, drift); - return true; - }, - logger: createInstallLogger(), - }); - if (!probe.ok) { - defaultRuntime.log(theme.error(`Failed to check ${hookId}: ${probe.error}`)); - continue; - } - - const nextVersion = probe.version ?? "unknown"; - const currentLabel = currentVersion ?? "unknown"; - if (currentVersion && probe.version && currentVersion === probe.version) { - defaultRuntime.log(`${hookId} is up to date (${currentLabel}).`); - } else { - defaultRuntime.log(`Would update ${hookId}: ${currentLabel} → ${nextVersion}.`); - } - continue; - } - - const result = await installHooksFromNpmSpec({ - spec: record.spec, - mode: "update", - expectedHookPackId: hookId, - expectedIntegrity: record.integrity, - onIntegrityDrift: async (drift) => { - logIntegrityDriftWarning(hookId, drift); - return await promptYesNo(`Continue updating "${hookId}" with this artifact?`); - }, - logger: createInstallLogger(), - }); - if (!result.ok) { - defaultRuntime.log(theme.error(`Failed to update ${hookId}: ${result.error}`)); - continue; - } - - const nextVersion = result.version ?? (await readInstalledPackageVersion(result.targetDir)); - nextCfg = recordHookInstall(nextCfg, { - hookId, - ...buildNpmInstallRecordFields({ - spec: record.spec, - installPath: result.targetDir, - version: nextVersion, - resolution: result.npmResolution, - }), - hooks: result.hooks, - }); - updatedCount += 1; - - const currentLabel = currentVersion ?? "unknown"; - const nextLabel = nextVersion ?? "unknown"; - if (currentVersion && nextVersion && currentVersion === nextVersion) { - defaultRuntime.log(`${hookId} already at ${currentLabel}.`); - } else { - defaultRuntime.log(`Updated ${hookId}: ${currentLabel} → ${nextLabel}.`); - } - } - - if (updatedCount > 0) { - await writeConfigFile(nextCfg); - logGatewayRestartHint(); - } + defaultRuntime.log( + theme.warn("`openclaw hooks update` is deprecated; use `openclaw plugins update`."), + ); + await runPluginUpdateCommand({ id, opts }); }); hooks.action(async () => diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts index 77b9412590e..2c8300f0b0e 100644 --- a/src/cli/plugins-cli.test.ts +++ b/src/cli/plugins-cli.test.ts @@ -21,6 +21,9 @@ const installPluginFromNpmSpec = vi.fn(); const installPluginFromPath = vi.fn(); const installPluginFromClawHub = vi.fn(); const parseClawHubPluginSpec = vi.fn(); +const installHooksFromNpmSpec = vi.fn(); +const installHooksFromPath = vi.fn(); +const recordHookInstall = vi.fn(); const { defaultRuntime, runtimeLogs, runtimeErrors, resetRuntimeCapture } = createCliRuntimeCapture(); @@ -83,10 +86,23 @@ vi.mock("./prompt.js", () => ({ })); vi.mock("../plugins/install.js", () => ({ + PLUGIN_INSTALL_ERROR_CODE: { + NPM_PACKAGE_NOT_FOUND: "npm_package_not_found", + }, installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args), installPluginFromPath: (...args: unknown[]) => installPluginFromPath(...args), })); +vi.mock("../hooks/install.js", () => ({ + installHooksFromNpmSpec: (...args: unknown[]) => installHooksFromNpmSpec(...args), + installHooksFromPath: (...args: unknown[]) => installHooksFromPath(...args), + resolveHookInstallDir: (hookId: string) => `/tmp/hooks/${hookId}`, +})); + +vi.mock("../hooks/installs.js", () => ({ + recordHookInstall: (...args: unknown[]) => recordHookInstall(...args), +})); + vi.mock("../plugins/clawhub.js", () => ({ installPluginFromClawHub: (...args: unknown[]) => installPluginFromClawHub(...args), formatClawHubSpecifier: ({ name, version }: { name: string; version?: string }) => @@ -129,6 +145,9 @@ describe("plugins cli", () => { installPluginFromPath.mockReset(); installPluginFromClawHub.mockReset(); parseClawHubPluginSpec.mockReset(); + installHooksFromNpmSpec.mockReset(); + installHooksFromPath.mockReset(); + recordHookInstall.mockReset(); loadConfig.mockReturnValue({} as OpenClawConfig); writeConfigFile.mockResolvedValue(undefined); @@ -177,6 +196,15 @@ describe("plugins cli", () => { error: "clawhub install disabled in test", }); parseClawHubPluginSpec.mockReturnValue(null); + installHooksFromPath.mockResolvedValue({ + ok: false, + error: "hook path install disabled in test", + }); + installHooksFromNpmSpec.mockResolvedValue({ + ok: false, + error: "hook npm install disabled in test", + }); + recordHookInstall.mockImplementation((cfg: OpenClawConfig) => cfg); }); it("exits when --marketplace is combined with --link", async () => { @@ -470,7 +498,132 @@ describe("plugins cli", () => { expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); expect(runtimeErrors.at(-1)).toContain('Use "openclaw skills install demo" instead.'); }); + it("falls back to installing hook packs from npm specs", async () => { + const cfg = {} as OpenClawConfig; + const installedCfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.2.3", + }, + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(cfg); + installPluginFromClawHub.mockResolvedValue({ + ok: false, + error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found", + }); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "package.json missing openclaw.plugin.json", + }); + installHooksFromNpmSpec.mockResolvedValue({ + ok: true, + hookPackId: "demo-hooks", + hooks: ["command-audit"], + targetDir: "/tmp/hooks/demo-hooks", + version: "1.2.3", + npmResolution: { + name: "@acme/demo-hooks", + spec: "@acme/demo-hooks@1.2.3", + integrity: "sha256-demo", + }, + }); + recordHookInstall.mockReturnValue(installedCfg); + + await runCommand(["plugins", "install", "@acme/demo-hooks"]); + + expect(installHooksFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@acme/demo-hooks", + }), + ); + expect(recordHookInstall).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + hookId: "demo-hooks", + hooks: ["command-audit"], + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(installedCfg); + expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true); + }); + + it("updates tracked hook packs through plugins update", async () => { + const cfg = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.0.0", + installPath: "/tmp/hooks/demo-hooks", + resolvedName: "@acme/demo-hooks", + }, + }, + }, + }, + } as OpenClawConfig; + const nextConfig = { + hooks: { + internal: { + installs: { + "demo-hooks": { + source: "npm", + spec: "@acme/demo-hooks@1.1.0", + installPath: "/tmp/hooks/demo-hooks", + }, + }, + }, + }, + } as OpenClawConfig; + + loadConfig.mockReturnValue(cfg); + updateNpmInstalledPlugins.mockResolvedValue({ + config: cfg, + changed: false, + outcomes: [], + }); + installHooksFromNpmSpec.mockResolvedValue({ + ok: true, + hookPackId: "demo-hooks", + hooks: ["command-audit"], + targetDir: "/tmp/hooks/demo-hooks", + version: "1.1.0", + npmResolution: { + name: "@acme/demo-hooks", + spec: "@acme/demo-hooks@1.1.0", + integrity: "sha256-demo-2", + }, + }); + recordHookInstall.mockReturnValue(nextConfig); + + await runCommand(["plugins", "update", "demo-hooks"]); + + expect(installHooksFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@acme/demo-hooks@1.0.0", + mode: "update", + expectedHookPackId: "demo-hooks", + }), + ); + expect(recordHookInstall).toHaveBeenCalledWith( + cfg, + expect.objectContaining({ + hookId: "demo-hooks", + hooks: ["command-audit"], + }), + ); + expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); + expect( + runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), + ).toBe(true); + }); it("shows uninstall dry-run preview without mutating config", async () => { loadConfig.mockReturnValue({ plugins: { @@ -582,11 +735,11 @@ describe("plugins cli", () => { await expect(runCommand(["plugins", "update"])).rejects.toThrow("__exit__:1"); - expect(runtimeErrors.at(-1)).toContain("Provide a plugin id or use --all."); + expect(runtimeErrors.at(-1)).toContain("Provide a plugin or hook-pack id, or use --all."); expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); }); - it("reports no tracked plugins when update --all has empty install records", async () => { + it("reports no tracked plugins or hook packs when update --all has empty install records", async () => { loadConfig.mockReturnValue({ plugins: { installs: {}, @@ -596,7 +749,7 @@ describe("plugins cli", () => { await runCommand(["plugins", "update", "--all"]); expect(updateNpmInstalledPlugins).not.toHaveBeenCalled(); - expect(runtimeLogs.at(-1)).toBe("No tracked plugins to update."); + expect(runtimeLogs.at(-1)).toBe("No tracked plugins or hook packs to update."); }); it("maps an explicit unscoped npm dist-tag update to the tracked plugin id", async () => { @@ -771,8 +924,8 @@ describe("plugins cli", () => { }), ); expect(writeConfigFile).toHaveBeenCalledWith(nextConfig); - expect(runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins."))).toBe( - true, - ); + expect( + runtimeLogs.some((line) => line.includes("Restart the gateway to load plugins and hooks.")), + ).toBe(true); }); }); diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 33e05460cf3..61045065f41 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -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 & { 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; + + 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; rawId?: string; @@ -280,6 +347,63 @@ function resolvePluginUpdateSelection(params: { }; } +function resolveHookPackUpdateSelection(params: { + installs: Record; + rawId?: string; + all?: boolean; +}): { hookIds: string[]; specOverrides?: Record } { + 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 { + 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; +}): Promise { + 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( "", @@ -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