fix(cli): give hooks enable/disable failures a next step (#127370)

* fix(cli): give hooks enable/disable failures a next step

resolveHookForToggle threw bare messages for not-found, ambiguous, and
ineligible hooks while formatHookInfo, the sibling lookup in the same file,
already pointed operators at openclaw hooks list.

Name the colliding candidates, the missing requirements, and the install
route using data the hook status entry already carries, bounded through the
existing summarizeStringEntries helper.

* refactor(cli): split hooks report rendering out of hooks-cli

The new toggle diagnostics pushed src/cli/hooks-cli.ts to 709 lines, over
the 700-line cap. Move the hooks list/info/check renderers and their
formatting helpers to hooks-cli.format.ts so command wiring and report
rendering each stay readable; no suppression added.
This commit is contained in:
Peter Steinberger
2026-08-21 12:11:59 -07:00
committed by GitHub
parent 98c02e0112
commit 5afc6fb0fb
4 changed files with 458 additions and 359 deletions
+358
View File
@@ -0,0 +1,358 @@
// Renders the `openclaw hooks` list, info, and check reports.
// Kept apart from command wiring so each surface stays readable and under the file-size cap.
import {
decorativeEmoji,
decorativePrefix,
} from "../../packages/terminal-core/src/decorative-emoji.js";
import { getTerminalTableWidth, renderTable } from "../../packages/terminal-core/src/table.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import type { HookStatusEntry, HookStatusReport } from "../hooks/hooks-status.js";
import { summarizeStringEntries } from "../shared/string-sample.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
export type HooksListOptions = {
agent?: string;
json?: boolean;
eligible?: boolean;
verbose?: boolean;
};
export type HookInfoOptions = {
agent?: string;
json?: boolean;
};
export type HooksCheckOptions = {
agent?: string;
json?: boolean;
};
function formatHookStatus(hook: HookStatusEntry): string {
if (hook.loadable) {
return theme.success("✓ ready");
}
if (!hook.enabledByConfig) {
return theme.warn(decorativePrefix("⏸", "disabled"));
}
return theme.error(`${formatHookBlockedStatusReason(hook)}`);
}
function formatHookBlockedStatusReason(hook: HookStatusEntry): string {
return hook.blockedReason && hook.blockedReason !== "missing requirements"
? hook.blockedReason
: "missing";
}
function formatHookInfoBlockedStatusReason(hook: HookStatusEntry): string {
const reason =
hook.blockedReason && hook.blockedReason !== "missing requirements"
? hook.blockedReason
: "missing requirements";
return reason ? `${reason[0]?.toUpperCase() ?? ""}${reason.slice(1)}` : reason;
}
function formatHookName(hook: HookStatusEntry): string {
const emoji = hook.emoji ?? decorativeEmoji("🔗");
const name = theme.command(hook.name);
return emoji ? `${emoji} ${name}` : name;
}
function formatHookSource(hook: HookStatusEntry): string {
if (!hook.managedByPlugin) {
return hook.source;
}
return `plugin:${hook.pluginId ?? "unknown"}`;
}
export function formatHookMissingSummary(hook: HookStatusEntry, itemLimit?: number): string {
const formatEntries = (entries: string[]) =>
itemLimit === undefined
? entries.join(", ")
: summarizeStringEntries({ entries, limit: itemLimit });
const missing: string[] = [];
if (hook.enabledByConfig && hook.blockedReason && hook.blockedReason !== "missing requirements") {
missing.push(hook.blockedReason);
}
if (hook.missing.bins.length > 0) {
missing.push(`bins: ${formatEntries(hook.missing.bins)}`);
}
if (hook.missing.anyBins.length > 0) {
missing.push(`anyBins: ${formatEntries(hook.missing.anyBins)}`);
}
if (hook.missing.env.length > 0) {
missing.push(`env: ${formatEntries(hook.missing.env)}`);
}
if (hook.missing.config.length > 0) {
missing.push(`config: ${formatEntries(hook.missing.config)}`);
}
if (hook.missing.os.length > 0) {
missing.push(`os: ${formatEntries(hook.missing.os)}`);
}
return missing.join("; ");
}
/**
* Format the hooks list output
*/
export function formatHooksList(report: HookStatusReport, opts: HooksListOptions): string {
const hooks = opts.eligible ? report.hooks.filter((h) => h.loadable) : report.hooks;
if (opts.json) {
const jsonReport = {
workspaceDir: report.workspaceDir,
managedHooksDir: report.managedHooksDir,
hooks: hooks.map((h) => ({
name: h.name,
description: h.description,
emoji: h.emoji,
eligible: h.loadable,
disabled: !h.enabledByConfig,
enabledByConfig: h.enabledByConfig,
requirementsSatisfied: h.requirementsSatisfied,
loadable: h.loadable,
blockedReason: h.blockedReason,
source: h.source,
pluginId: h.pluginId,
events: h.events,
unknownEvents: h.unknownEvents,
homepage: h.homepage,
missing: h.missing,
managedByPlugin: h.managedByPlugin,
})),
};
return JSON.stringify(jsonReport, null, 2);
}
if (hooks.length === 0) {
const message = opts.eligible
? `No eligible hooks found. Run \`${formatCliCommand("openclaw hooks list")}\` to see all hooks.`
: "No hooks found.";
return message;
}
const eligible = hooks.filter((h) => h.loadable);
const tableWidth = getTerminalTableWidth();
const rows = hooks.map((hook) => {
const missing = formatHookMissingSummary(hook);
return {
Status: formatHookStatus(hook),
Hook: formatHookName(hook),
Description: theme.muted(hook.description),
Source: formatHookSource(hook),
Missing: missing ? theme.warn(missing) : "",
};
});
const columns = [
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Hook", header: "Hook", minWidth: 18, flex: true },
{ key: "Description", header: "Description", minWidth: 24, flex: true },
{ key: "Source", header: "Source", minWidth: 12, flex: true },
];
if (opts.verbose) {
columns.push({ key: "Missing", header: "Missing", minWidth: 18, flex: true });
}
const lines: string[] = [];
lines.push(
`${theme.heading("Hooks")} ${theme.muted(`(${eligible.length}/${hooks.length} ready)`)}`,
);
lines.push(
renderTable({
width: tableWidth,
columns,
rows,
}).trimEnd(),
);
return lines.join("\n");
}
/**
* Format detailed info for a single hook
*/
export function formatHookInfo(
report: HookStatusReport,
hookName: string,
opts: HookInfoOptions,
): string {
const hook = report.hooks.find((h) => h.name === hookName || h.hookKey === hookName);
if (!hook) {
if (opts.json) {
return JSON.stringify({ error: "not found", hook: hookName }, null, 2);
}
return `Hook "${hookName}" not found. Run \`${formatCliCommand("openclaw hooks list")}\` to see available hooks.`;
}
if (opts.json) {
return JSON.stringify(
{
...hook,
eligible: hook.loadable,
disabled: !hook.enabledByConfig,
},
null,
2,
);
}
const lines: string[] = [];
const emoji = hook.emoji ?? decorativeEmoji("🔗");
const status = hook.loadable
? theme.success("✓ Ready")
: !hook.enabledByConfig
? theme.warn(decorativePrefix("⏸", "Disabled"))
: theme.error(`${formatHookInfoBlockedStatusReason(hook)}`);
lines.push(`${emoji ? `${emoji} ` : ""}${theme.heading(hook.name)} ${status}`);
lines.push("");
lines.push(hook.description);
lines.push("");
// Details
lines.push(theme.heading("Details:"));
if (hook.managedByPlugin) {
lines.push(`${theme.muted(" Source:")} ${hook.source} (${hook.pluginId ?? "unknown"})`);
} else {
lines.push(`${theme.muted(" Source:")} ${hook.source}`);
}
lines.push(`${theme.muted(" Path:")} ${shortenHomePath(hook.filePath)}`);
lines.push(`${theme.muted(" Handler:")} ${shortenHomePath(hook.handlerPath)}`);
if (hook.homepage) {
lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`);
}
if (hook.events.length > 0) {
lines.push(`${theme.muted(" Events:")} ${hook.events.join(", ")}`);
}
if (hook.unknownEvents.length > 0) {
lines.push(
theme.warn(
` ⚠ Event${hook.unknownEvents.length === 1 ? "" : "s"} not emitted by core (likely typo): ${hook.unknownEvents.join(", ")}`,
),
);
}
if (hook.managedByPlugin) {
lines.push(theme.muted(" Managed by plugin; enable/disable via hooks CLI not available."));
}
if (hook.blockedReason) {
lines.push(`${theme.muted(" Blocked reason:")} ${hook.blockedReason}`);
}
// Requirements
const hasRequirements =
hook.requirements.bins.length > 0 ||
hook.requirements.anyBins.length > 0 ||
hook.requirements.env.length > 0 ||
hook.requirements.config.length > 0 ||
hook.requirements.os.length > 0;
if (hasRequirements) {
lines.push("");
lines.push(theme.heading("Requirements:"));
if (hook.requirements.bins.length > 0) {
const binsStatus = hook.requirements.bins.map((bin) => {
const missing = hook.missing.bins.includes(bin);
return missing ? theme.error(`${bin}`) : theme.success(`${bin}`);
});
lines.push(`${theme.muted(" Binaries:")} ${binsStatus.join(", ")}`);
}
if (hook.requirements.anyBins.length > 0) {
const anyBinsStatus =
hook.missing.anyBins.length > 0
? theme.error(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
: theme.success(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
lines.push(`${theme.muted(" Any binary:")} ${anyBinsStatus}`);
}
if (hook.requirements.env.length > 0) {
const envStatus = hook.requirements.env.map((env) => {
const missing = hook.missing.env.includes(env);
return missing ? theme.error(`${env}`) : theme.success(`${env}`);
});
lines.push(`${theme.muted(" Environment:")} ${envStatus.join(", ")}`);
}
if (hook.requirements.config.length > 0) {
const configStatus = hook.configChecks.map((check) => {
return check.satisfied ? theme.success(`${check.path}`) : theme.error(`${check.path}`);
});
lines.push(`${theme.muted(" Config:")} ${configStatus.join(", ")}`);
}
if (hook.requirements.os.length > 0) {
const osStatus =
hook.missing.os.length > 0
? theme.error(`✗ (${hook.requirements.os.join(", ")})`)
: theme.success(`✓ (${hook.requirements.os.join(", ")})`);
lines.push(`${theme.muted(" OS:")} ${osStatus}`);
}
}
return lines.join("\n");
}
/**
* Format check output
*/
export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptions): string {
if (opts.json) {
const eligible = report.hooks.filter((h) => h.loadable);
const notEligible = report.hooks.filter((h) => !h.loadable);
return JSON.stringify(
{
total: report.hooks.length,
eligible: eligible.length,
notEligible: notEligible.length,
hooks: {
eligible: eligible.map((h) => h.name),
notEligible: notEligible.map((h) => ({
name: h.name,
blockedReason: h.blockedReason,
missing: h.missing,
})),
},
},
null,
2,
);
}
const eligible = report.hooks.filter((h) => h.loadable);
const notEligible = report.hooks.filter((h) => !h.loadable);
const lines: string[] = [];
lines.push(theme.heading("Hooks Status"));
lines.push("");
lines.push(`${theme.muted("Total hooks:")} ${report.hooks.length}`);
lines.push(`${theme.success("Ready:")} ${eligible.length}`);
lines.push(`${theme.warn("Not ready:")} ${notEligible.length}`);
if (notEligible.length > 0) {
lines.push("");
lines.push(theme.heading("Hooks not ready:"));
for (const hook of notEligible) {
const reasons = [];
if (hook.blockedReason && hook.blockedReason !== "missing requirements") {
reasons.push(hook.blockedReason);
}
if (hook.missing.bins.length > 0) {
reasons.push(`bins: ${hook.missing.bins.join(", ")}`);
}
if (hook.missing.anyBins.length > 0) {
reasons.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
}
if (hook.missing.env.length > 0) {
reasons.push(`env: ${hook.missing.env.join(", ")}`);
}
if (hook.missing.config.length > 0) {
reasons.push(`config: ${hook.missing.config.join(", ")}`);
}
if (hook.missing.os.length > 0) {
reasons.push(`os: ${hook.missing.os.join(", ")}`);
}
const emoji = hook.emoji ?? decorativeEmoji("🔗");
lines.push(` ${emoji ? `${emoji} ` : ""}${hook.name} - ${reasons.join("; ")}`);
}
}
return lines.join("\n");
}
+2 -6
View File
@@ -3,12 +3,8 @@ import { expectDefined } from "@openclaw/normalization-core";
import { Command } from "commander";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HookStatusReport } from "../hooks/hooks-status.js";
import {
formatHookInfo,
formatHooksCheck,
formatHooksList,
registerHooksCli,
} from "./hooks-cli.js";
import { formatHookInfo, formatHooksCheck, formatHooksList } from "./hooks-cli.format.js";
import { registerHooksCli } from "./hooks-cli.js";
import { createEmptyInstallChecks } from "./requirements-test-fixtures.js";
const runPluginInstallCommandMock = vi.hoisted(() => vi.fn());
+67 -2
View File
@@ -285,8 +285,73 @@ describe("hooks CLI metadata config keys", () => {
}),
).rejects.toThrow("__exit__:1");
expect(capture.runtimeErrors.at(-1)).toContain(
`Hook "${testCase.identifier}" is ambiguous; use a unique hook name or hook key`,
expect(capture.runtimeErrors.at(-1)).toBe(
`Error: Hook "${testCase.identifier}" is ambiguous; matches: ${testCase.hooks
.map((candidate) => `${candidate.name} (${candidate.hookKey})`)
.join(", ")}. Use a unique hook name or hook key.`,
);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
});
it("bounds ambiguous hook candidates", async () => {
const hooks = Array.from({ length: 7 }, (_, index) => ({
...hook,
name: "shared-name",
hookKey: `key-${index + 1}`,
}));
mocks.buildWorkspaceHookStatus.mockReturnValue({ ...report, hooks });
await expect(
createHooksProgram().parseAsync(["hooks", "disable", "shared-name"], { from: "user" }),
).rejects.toThrow("__exit__:1");
expect(capture.runtimeErrors.at(-1)).toBe(
'Error: Hook "shared-name" is ambiguous; matches: shared-name (key-1), shared-name (key-2), shared-name (key-3), shared-name (key-4), shared-name (key-5) (+2). Use a unique hook name or hook key.',
);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
});
it.each(["enable", "disable"])(
"gives the recovery command for an unknown hook on %s",
async (action) => {
mocks.buildWorkspaceHookStatus.mockReturnValue({ ...report, hooks: [] });
await expect(
createHooksProgram().parseAsync(["hooks", action, "missing-hook"], { from: "user" }),
).rejects.toThrow("__exit__:1");
expect(capture.runtimeErrors.at(-1)).toBe(
'Error: Hook "missing-hook" not found. Run `openclaw hooks list` to see available hooks.',
);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
},
);
it("names missing requirements and the available install route", async () => {
const ineligibleHook: HookStatusEntry = {
...hook,
requirementsSatisfied: false,
loadable: false,
blockedReason: "missing requirements",
missing: {
bins: ["missing-bin"],
anyBins: ["missing-any-a", "missing-any-b"],
env: ["MISSING_ENV"],
config: ["hooks.demo.enabled"],
os: ["linux"],
},
install: [
{ id: "demo-npm", kind: "npm", label: "Install @openclaw/demo-hook (npm)", bins: [] },
],
};
mocks.buildWorkspaceHookStatus.mockReturnValue({ ...report, hooks: [ineligibleHook] });
await expect(
createHooksProgram().parseAsync(["hooks", "enable", "display-name"], { from: "user" }),
).rejects.toThrow("__exit__:1");
expect(capture.runtimeErrors.at(-1)).toBe(
'Error: Hook "display-name" is not eligible; missing bins: missing-bin; anyBins: missing-any-a, missing-any-b; env: MISSING_ENV; config: hooks.demo.enabled; os: linux. Install options: Install @openclaw/demo-hook (npm). Run `openclaw hooks info display-name` for details.',
);
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
});
+31 -351
View File
@@ -3,12 +3,8 @@ import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../../packages/gateway-protocol/src/client-info.js";
import {
decorativeEmoji,
decorativePrefix,
} from "../../packages/terminal-core/src/decorative-emoji.js";
import { decorativePrefix } from "../../packages/terminal-core/src/decorative-emoji.js";
import { formatDocsLink } from "../../packages/terminal-core/src/links.js";
import { getTerminalTableWidth, renderTable } from "../../packages/terminal-core/src/table.js";
import { theme } from "../../packages/terminal-core/src/theme.js";
import {
resolveAgentWorkspaceDir,
@@ -30,32 +26,24 @@ import { loadGatewayStartupPluginPlanWithMetadata } from "../plugins/channel-plu
import { buildPluginDiagnosticsReport } from "../plugins/status.js";
import { normalizeAgentId } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import { summarizeStringEntries } from "../shared/string-sample.js";
import { resolveOptionFromCommand } from "./cli-utils.js";
import { formatCliCommand } from "./command-format.js";
import { rethrowExpectedCliError } from "./failure-output.js";
import {
formatHookInfo,
formatHookMissingSummary,
formatHooksCheck,
formatHooksList,
type HookInfoOptions,
type HooksCheckOptions,
type HooksListOptions,
} from "./hooks-cli.format.js";
import { runNativeHookRelayCli, type NativeHookRelayCliOptions } from "./native-hook-relay-cli.js";
import { requestExitAfterOneShotOutput } from "./one-shot-exit.js";
import { runPluginInstallCommand } from "./plugins-install-command.js";
import { runPluginUpdateCommand } from "./plugins-update-command.js";
export type HooksListOptions = {
agent?: string;
json?: boolean;
eligible?: boolean;
verbose?: boolean;
};
export type HookInfoOptions = {
agent?: string;
json?: boolean;
};
export type HooksCheckOptions = {
agent?: string;
json?: boolean;
};
type HooksUpdateOptions = {
acknowledgeInstallPolicyWarning?: boolean;
all?: boolean;
@@ -163,11 +151,19 @@ function resolveHookForToggle(
const matches =
nameMatches.length > 0 ? nameMatches : report.hooks.filter((hook) => hook.hookKey === hookName);
if (matches.length > 1) {
throw new Error(`Hook "${hookName}" is ambiguous; use a unique hook name or hook key`);
const candidates = summarizeStringEntries({
entries: matches.map((hook) => `${hook.name} (${hook.hookKey})`),
limit: 5,
});
throw new Error(
`Hook "${hookName}" is ambiguous; matches: ${candidates}. Use a unique hook name or hook key.`,
);
}
const hook = matches[0];
if (!hook) {
throw new Error(`Hook "${hookName}" not found`);
throw new Error(
`Hook "${hookName}" not found. Run \`${formatCliCommand("openclaw hooks list")}\` to see available hooks.`,
);
}
if (hook.managedByPlugin) {
throw new Error(
@@ -175,7 +171,16 @@ function resolveHookForToggle(
);
}
if (opts?.requireEligible && !hook.requirementsSatisfied) {
throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`);
const missing = formatHookMissingSummary(hook, 3);
const installHint = hook.install.length
? ` Install options: ${summarizeStringEntries({
entries: hook.install.map((option) => option.label),
limit: 3,
})}.`
: "";
throw new Error(
`Hook "${hookName}" is not eligible; missing ${missing}.${installHint} Run \`${formatCliCommand(`openclaw hooks info ${hookName}`)}\` for details.`,
);
}
return hook;
}
@@ -204,66 +209,6 @@ function buildConfigWithHookEnabled(params: {
};
}
function formatHookStatus(hook: HookStatusEntry): string {
if (hook.loadable) {
return theme.success("✓ ready");
}
if (!hook.enabledByConfig) {
return theme.warn(decorativePrefix("⏸", "disabled"));
}
return theme.error(`${formatHookBlockedStatusReason(hook)}`);
}
function formatHookBlockedStatusReason(hook: HookStatusEntry): string {
return hook.blockedReason && hook.blockedReason !== "missing requirements"
? hook.blockedReason
: "missing";
}
function formatHookInfoBlockedStatusReason(hook: HookStatusEntry): string {
const reason =
hook.blockedReason && hook.blockedReason !== "missing requirements"
? hook.blockedReason
: "missing requirements";
return reason ? `${reason[0]?.toUpperCase() ?? ""}${reason.slice(1)}` : reason;
}
function formatHookName(hook: HookStatusEntry): string {
const emoji = hook.emoji ?? decorativeEmoji("🔗");
const name = theme.command(hook.name);
return emoji ? `${emoji} ${name}` : name;
}
function formatHookSource(hook: HookStatusEntry): string {
if (!hook.managedByPlugin) {
return hook.source;
}
return `plugin:${hook.pluginId ?? "unknown"}`;
}
function formatHookMissingSummary(hook: HookStatusEntry): string {
const missing: string[] = [];
if (hook.enabledByConfig && hook.blockedReason && hook.blockedReason !== "missing requirements") {
missing.push(hook.blockedReason);
}
if (hook.missing.bins.length > 0) {
missing.push(`bins: ${hook.missing.bins.join(", ")}`);
}
if (hook.missing.anyBins.length > 0) {
missing.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
}
if (hook.missing.env.length > 0) {
missing.push(`env: ${hook.missing.env.join(", ")}`);
}
if (hook.missing.config.length > 0) {
missing.push(`config: ${hook.missing.config.join(", ")}`);
}
if (hook.missing.os.length > 0) {
missing.push(`os: ${hook.missing.os.join(", ")}`);
}
return missing.join("; ");
}
function exitHooksCliWithError(err: unknown): never {
rethrowExpectedCliError(err);
defaultRuntime.error(`${theme.error("Error:")} ${formatErrorMessage(err)}`);
@@ -294,271 +239,6 @@ async function runOneShotHooksCliAction(action: () => Promise<number | void>): P
// runCli finishes shared teardown and drains both output streams.
requestExitAfterOneShotOutput(defaultRuntime, exitCode);
}
/**
* Format the hooks list output
*/
export function formatHooksList(report: HookStatusReport, opts: HooksListOptions): string {
const hooks = opts.eligible ? report.hooks.filter((h) => h.loadable) : report.hooks;
if (opts.json) {
const jsonReport = {
workspaceDir: report.workspaceDir,
managedHooksDir: report.managedHooksDir,
hooks: hooks.map((h) => ({
name: h.name,
description: h.description,
emoji: h.emoji,
eligible: h.loadable,
disabled: !h.enabledByConfig,
enabledByConfig: h.enabledByConfig,
requirementsSatisfied: h.requirementsSatisfied,
loadable: h.loadable,
blockedReason: h.blockedReason,
source: h.source,
pluginId: h.pluginId,
events: h.events,
unknownEvents: h.unknownEvents,
homepage: h.homepage,
missing: h.missing,
managedByPlugin: h.managedByPlugin,
})),
};
return JSON.stringify(jsonReport, null, 2);
}
if (hooks.length === 0) {
const message = opts.eligible
? `No eligible hooks found. Run \`${formatCliCommand("openclaw hooks list")}\` to see all hooks.`
: "No hooks found.";
return message;
}
const eligible = hooks.filter((h) => h.loadable);
const tableWidth = getTerminalTableWidth();
const rows = hooks.map((hook) => {
const missing = formatHookMissingSummary(hook);
return {
Status: formatHookStatus(hook),
Hook: formatHookName(hook),
Description: theme.muted(hook.description),
Source: formatHookSource(hook),
Missing: missing ? theme.warn(missing) : "",
};
});
const columns = [
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Hook", header: "Hook", minWidth: 18, flex: true },
{ key: "Description", header: "Description", minWidth: 24, flex: true },
{ key: "Source", header: "Source", minWidth: 12, flex: true },
];
if (opts.verbose) {
columns.push({ key: "Missing", header: "Missing", minWidth: 18, flex: true });
}
const lines: string[] = [];
lines.push(
`${theme.heading("Hooks")} ${theme.muted(`(${eligible.length}/${hooks.length} ready)`)}`,
);
lines.push(
renderTable({
width: tableWidth,
columns,
rows,
}).trimEnd(),
);
return lines.join("\n");
}
/**
* Format detailed info for a single hook
*/
export function formatHookInfo(
report: HookStatusReport,
hookName: string,
opts: HookInfoOptions,
): string {
const hook = report.hooks.find((h) => h.name === hookName || h.hookKey === hookName);
if (!hook) {
if (opts.json) {
return JSON.stringify({ error: "not found", hook: hookName }, null, 2);
}
return `Hook "${hookName}" not found. Run \`${formatCliCommand("openclaw hooks list")}\` to see available hooks.`;
}
if (opts.json) {
return JSON.stringify(
{
...hook,
eligible: hook.loadable,
disabled: !hook.enabledByConfig,
},
null,
2,
);
}
const lines: string[] = [];
const emoji = hook.emoji ?? decorativeEmoji("🔗");
const status = hook.loadable
? theme.success("✓ Ready")
: !hook.enabledByConfig
? theme.warn(decorativePrefix("⏸", "Disabled"))
: theme.error(`${formatHookInfoBlockedStatusReason(hook)}`);
lines.push(`${emoji ? `${emoji} ` : ""}${theme.heading(hook.name)} ${status}`);
lines.push("");
lines.push(hook.description);
lines.push("");
// Details
lines.push(theme.heading("Details:"));
if (hook.managedByPlugin) {
lines.push(`${theme.muted(" Source:")} ${hook.source} (${hook.pluginId ?? "unknown"})`);
} else {
lines.push(`${theme.muted(" Source:")} ${hook.source}`);
}
lines.push(`${theme.muted(" Path:")} ${shortenHomePath(hook.filePath)}`);
lines.push(`${theme.muted(" Handler:")} ${shortenHomePath(hook.handlerPath)}`);
if (hook.homepage) {
lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`);
}
if (hook.events.length > 0) {
lines.push(`${theme.muted(" Events:")} ${hook.events.join(", ")}`);
}
if (hook.unknownEvents.length > 0) {
lines.push(
theme.warn(
` ⚠ Event${hook.unknownEvents.length === 1 ? "" : "s"} not emitted by core (likely typo): ${hook.unknownEvents.join(", ")}`,
),
);
}
if (hook.managedByPlugin) {
lines.push(theme.muted(" Managed by plugin; enable/disable via hooks CLI not available."));
}
if (hook.blockedReason) {
lines.push(`${theme.muted(" Blocked reason:")} ${hook.blockedReason}`);
}
// Requirements
const hasRequirements =
hook.requirements.bins.length > 0 ||
hook.requirements.anyBins.length > 0 ||
hook.requirements.env.length > 0 ||
hook.requirements.config.length > 0 ||
hook.requirements.os.length > 0;
if (hasRequirements) {
lines.push("");
lines.push(theme.heading("Requirements:"));
if (hook.requirements.bins.length > 0) {
const binsStatus = hook.requirements.bins.map((bin) => {
const missing = hook.missing.bins.includes(bin);
return missing ? theme.error(`${bin}`) : theme.success(`${bin}`);
});
lines.push(`${theme.muted(" Binaries:")} ${binsStatus.join(", ")}`);
}
if (hook.requirements.anyBins.length > 0) {
const anyBinsStatus =
hook.missing.anyBins.length > 0
? theme.error(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
: theme.success(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
lines.push(`${theme.muted(" Any binary:")} ${anyBinsStatus}`);
}
if (hook.requirements.env.length > 0) {
const envStatus = hook.requirements.env.map((env) => {
const missing = hook.missing.env.includes(env);
return missing ? theme.error(`${env}`) : theme.success(`${env}`);
});
lines.push(`${theme.muted(" Environment:")} ${envStatus.join(", ")}`);
}
if (hook.requirements.config.length > 0) {
const configStatus = hook.configChecks.map((check) => {
return check.satisfied ? theme.success(`${check.path}`) : theme.error(`${check.path}`);
});
lines.push(`${theme.muted(" Config:")} ${configStatus.join(", ")}`);
}
if (hook.requirements.os.length > 0) {
const osStatus =
hook.missing.os.length > 0
? theme.error(`✗ (${hook.requirements.os.join(", ")})`)
: theme.success(`✓ (${hook.requirements.os.join(", ")})`);
lines.push(`${theme.muted(" OS:")} ${osStatus}`);
}
}
return lines.join("\n");
}
/**
* Format check output
*/
export function formatHooksCheck(report: HookStatusReport, opts: HooksCheckOptions): string {
if (opts.json) {
const eligible = report.hooks.filter((h) => h.loadable);
const notEligible = report.hooks.filter((h) => !h.loadable);
return JSON.stringify(
{
total: report.hooks.length,
eligible: eligible.length,
notEligible: notEligible.length,
hooks: {
eligible: eligible.map((h) => h.name),
notEligible: notEligible.map((h) => ({
name: h.name,
blockedReason: h.blockedReason,
missing: h.missing,
})),
},
},
null,
2,
);
}
const eligible = report.hooks.filter((h) => h.loadable);
const notEligible = report.hooks.filter((h) => !h.loadable);
const lines: string[] = [];
lines.push(theme.heading("Hooks Status"));
lines.push("");
lines.push(`${theme.muted("Total hooks:")} ${report.hooks.length}`);
lines.push(`${theme.success("Ready:")} ${eligible.length}`);
lines.push(`${theme.warn("Not ready:")} ${notEligible.length}`);
if (notEligible.length > 0) {
lines.push("");
lines.push(theme.heading("Hooks not ready:"));
for (const hook of notEligible) {
const reasons = [];
if (hook.blockedReason && hook.blockedReason !== "missing requirements") {
reasons.push(hook.blockedReason);
}
if (hook.missing.bins.length > 0) {
reasons.push(`bins: ${hook.missing.bins.join(", ")}`);
}
if (hook.missing.anyBins.length > 0) {
reasons.push(`anyBins: ${hook.missing.anyBins.join(", ")}`);
}
if (hook.missing.env.length > 0) {
reasons.push(`env: ${hook.missing.env.join(", ")}`);
}
if (hook.missing.config.length > 0) {
reasons.push(`config: ${hook.missing.config.join(", ")}`);
}
if (hook.missing.os.length > 0) {
reasons.push(`os: ${hook.missing.os.join(", ")}`);
}
const emoji = hook.emoji ?? decorativeEmoji("🔗");
lines.push(` ${emoji ? `${emoji} ` : ""}${hook.name} - ${reasons.join("; ")}`);
}
}
return lines.join("\n");
}
async function enableHook(hookName: string, agentId?: string): Promise<void> {
const snapshot = await readConfigFileSnapshot();
const config = (snapshot.sourceConfig ?? snapshot.config) as OpenClawConfig;