feat: add internal hooks system
This commit is contained in:
439
src/cli/hooks-internal-cli.ts
Normal file
439
src/cli/hooks-internal-cli.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import chalk from "chalk";
|
||||
import type { Command } from "commander";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
buildWorkspaceHookStatus,
|
||||
type HookStatusEntry,
|
||||
type HookStatusReport,
|
||||
} from "../hooks/hooks-status.js";
|
||||
import { loadConfig, writeConfigFile } from "../config/io.js";
|
||||
|
||||
export type HooksListOptions = {
|
||||
json?: boolean;
|
||||
eligible?: boolean;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
export type HookInfoOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
export type HooksCheckOptions = {
|
||||
json?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a single hook for display in the list
|
||||
*/
|
||||
function formatHookLine(hook: HookStatusEntry, verbose = false): string {
|
||||
const emoji = hook.emoji ?? "🔗";
|
||||
const status = hook.eligible
|
||||
? chalk.green("✓")
|
||||
: hook.disabled
|
||||
? chalk.yellow("disabled")
|
||||
: chalk.red("missing reqs");
|
||||
|
||||
const name = hook.eligible ? chalk.white(hook.name) : chalk.gray(hook.name);
|
||||
|
||||
const desc = chalk.gray(
|
||||
hook.description.length > 50 ? `${hook.description.slice(0, 47)}...` : hook.description,
|
||||
);
|
||||
|
||||
if (verbose) {
|
||||
const missing: string[] = [];
|
||||
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(", ")}`);
|
||||
}
|
||||
const missingStr = missing.length > 0 ? chalk.red(` [${missing.join("; ")}]`) : "";
|
||||
return `${emoji} ${name} ${status}${missingStr}\n ${desc}`;
|
||||
}
|
||||
|
||||
return `${emoji} ${name} ${status} - ${desc}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the hooks list output
|
||||
*/
|
||||
export function formatHooksList(report: HookStatusReport, opts: HooksListOptions): string {
|
||||
const hooks = opts.eligible ? report.hooks.filter((h) => h.eligible) : 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.eligible,
|
||||
disabled: h.disabled,
|
||||
source: h.source,
|
||||
events: h.events,
|
||||
homepage: h.homepage,
|
||||
missing: h.missing,
|
||||
})),
|
||||
};
|
||||
return JSON.stringify(jsonReport, null, 2);
|
||||
}
|
||||
|
||||
if (hooks.length === 0) {
|
||||
const message = opts.eligible
|
||||
? "No eligible hooks found. Run `clawdbot hooks list` to see all hooks."
|
||||
: "No hooks found.";
|
||||
return message;
|
||||
}
|
||||
|
||||
const eligible = hooks.filter((h) => h.eligible);
|
||||
const notEligible = hooks.filter((h) => !h.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(
|
||||
chalk.bold.cyan("Internal Hooks") + chalk.gray(` (${eligible.length}/${hooks.length} ready)`),
|
||||
);
|
||||
lines.push("");
|
||||
|
||||
if (eligible.length > 0) {
|
||||
lines.push(chalk.bold.green("Ready:"));
|
||||
for (const hook of eligible) {
|
||||
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (notEligible.length > 0 && !opts.eligible) {
|
||||
if (eligible.length > 0) lines.push("");
|
||||
lines.push(chalk.bold.yellow("Not ready:"));
|
||||
for (const hook of notEligible) {
|
||||
lines.push(` ${formatHookLine(hook, opts.verbose)}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 \`clawdbot hooks list\` to see available hooks.`;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
return JSON.stringify(hook, null, 2);
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const emoji = hook.emoji ?? "🔗";
|
||||
const status = hook.eligible
|
||||
? chalk.green("✓ Ready")
|
||||
: hook.disabled
|
||||
? chalk.yellow("⏸ Disabled")
|
||||
: chalk.red("✗ Missing requirements");
|
||||
|
||||
lines.push(`${emoji} ${chalk.bold.cyan(hook.name)} ${status}`);
|
||||
lines.push("");
|
||||
lines.push(chalk.white(hook.description));
|
||||
lines.push("");
|
||||
|
||||
// Details
|
||||
lines.push(chalk.bold("Details:"));
|
||||
lines.push(` Source: ${hook.source}`);
|
||||
lines.push(` Path: ${chalk.gray(hook.filePath)}`);
|
||||
lines.push(` Handler: ${chalk.gray(hook.handlerPath)}`);
|
||||
if (hook.homepage) {
|
||||
lines.push(` Homepage: ${chalk.blue(hook.homepage)}`);
|
||||
}
|
||||
if (hook.events.length > 0) {
|
||||
lines.push(` Events: ${hook.events.join(", ")}`);
|
||||
}
|
||||
|
||||
// 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(chalk.bold("Requirements:"));
|
||||
if (hook.requirements.bins.length > 0) {
|
||||
const binsStatus = hook.requirements.bins.map((bin) => {
|
||||
const missing = hook.missing.bins.includes(bin);
|
||||
return missing ? chalk.red(`✗ ${bin}`) : chalk.green(`✓ ${bin}`);
|
||||
});
|
||||
lines.push(` Binaries: ${binsStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.anyBins.length > 0) {
|
||||
const anyBinsStatus =
|
||||
hook.missing.anyBins.length > 0
|
||||
? chalk.red(`✗ (any of: ${hook.requirements.anyBins.join(", ")})`)
|
||||
: chalk.green(`✓ (any of: ${hook.requirements.anyBins.join(", ")})`);
|
||||
lines.push(` 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 ? chalk.red(`✗ ${env}`) : chalk.green(`✓ ${env}`);
|
||||
});
|
||||
lines.push(` Environment: ${envStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.config.length > 0) {
|
||||
const configStatus = hook.configChecks.map((check) => {
|
||||
return check.satisfied
|
||||
? chalk.green(`✓ ${check.path}`)
|
||||
: chalk.red(`✗ ${check.path}`);
|
||||
});
|
||||
lines.push(` Config: ${configStatus.join(", ")}`);
|
||||
}
|
||||
if (hook.requirements.os.length > 0) {
|
||||
const osStatus =
|
||||
hook.missing.os.length > 0
|
||||
? chalk.red(`✗ (${hook.requirements.os.join(", ")})`)
|
||||
: chalk.green(`✓ (${hook.requirements.os.join(", ")})`);
|
||||
lines.push(` 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.eligible);
|
||||
const notEligible = report.hooks.filter((h) => !h.eligible);
|
||||
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,
|
||||
missing: h.missing,
|
||||
})),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
const eligible = report.hooks.filter((h) => h.eligible);
|
||||
const notEligible = report.hooks.filter((h) => !h.eligible);
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(chalk.bold.cyan("Internal Hooks Status"));
|
||||
lines.push("");
|
||||
lines.push(`Total hooks: ${report.hooks.length}`);
|
||||
lines.push(chalk.green(`Ready: ${eligible.length}`));
|
||||
lines.push(chalk.yellow(`Not ready: ${notEligible.length}`));
|
||||
|
||||
if (notEligible.length > 0) {
|
||||
lines.push("");
|
||||
lines.push(chalk.bold.yellow("Hooks not ready:"));
|
||||
for (const hook of notEligible) {
|
||||
const reasons = [];
|
||||
if (hook.disabled) reasons.push("disabled");
|
||||
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(", ")}`);
|
||||
lines.push(` ${hook.emoji ?? "🔗"} ${hook.name} - ${reasons.join("; ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export async function enableHook(hookName: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const hook = report.hooks.find((h) => h.name === hookName);
|
||||
|
||||
if (!hook) {
|
||||
throw new Error(`Hook "${hookName}" not found`);
|
||||
}
|
||||
|
||||
if (!hook.eligible) {
|
||||
throw new Error(`Hook "${hookName}" is not eligible (missing requirements)`);
|
||||
}
|
||||
|
||||
// Update config
|
||||
const entries = { ...config.hooks?.internal?.entries };
|
||||
entries[hookName] = { ...entries[hookName], enabled: true };
|
||||
|
||||
const nextConfig = {
|
||||
...config,
|
||||
hooks: {
|
||||
...config.hooks,
|
||||
internal: {
|
||||
...config.hooks?.internal,
|
||||
enabled: true,
|
||||
entries,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
console.log(`${chalk.green("✓")} Enabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
|
||||
}
|
||||
|
||||
export async function disableHook(hookName: string): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
const hook = report.hooks.find((h) => h.name === hookName);
|
||||
|
||||
if (!hook) {
|
||||
throw new Error(`Hook "${hookName}" not found`);
|
||||
}
|
||||
|
||||
// Update config
|
||||
const entries = { ...config.hooks?.internal?.entries };
|
||||
entries[hookName] = { ...entries[hookName], enabled: false };
|
||||
|
||||
const nextConfig = {
|
||||
...config,
|
||||
hooks: {
|
||||
...config.hooks,
|
||||
internal: {
|
||||
...config.hooks?.internal,
|
||||
entries,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
console.log(`${chalk.yellow("⏸")} Disabled hook: ${hook.emoji ?? "🔗"} ${hookName}`);
|
||||
}
|
||||
|
||||
export function registerInternalHooksSubcommands(hooksCommand: Command): void {
|
||||
// Add "internal" subcommand to existing "hooks" command
|
||||
const internal = hooksCommand
|
||||
.command("internal")
|
||||
.description("Manage internal agent hooks")
|
||||
.alias("int");
|
||||
|
||||
// list command
|
||||
internal
|
||||
.command("list")
|
||||
.description("List all internal hooks")
|
||||
.option("--eligible", "Show only eligible hooks", false)
|
||||
.option("--json", "Output as JSON", false)
|
||||
.option("-v, --verbose", "Show more details including missing requirements", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHooksList(report, opts));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// info command
|
||||
internal
|
||||
.command("info <name>")
|
||||
.description("Show detailed information about a hook")
|
||||
.option("--json", "Output as JSON", false)
|
||||
.action(async (name, opts) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHookInfo(report, name, opts));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// check command
|
||||
internal
|
||||
.command("check")
|
||||
.description("Check hooks eligibility status")
|
||||
.option("--json", "Output as JSON", false)
|
||||
.action(async (opts) => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHooksCheck(report, opts));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// enable command
|
||||
internal
|
||||
.command("enable <name>")
|
||||
.description("Enable a hook")
|
||||
.action(async (name) => {
|
||||
try {
|
||||
await enableHook(name);
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// disable command
|
||||
internal
|
||||
.command("disable <name>")
|
||||
.description("Disable a hook")
|
||||
.action(async (name) => {
|
||||
try {
|
||||
await disableHook(name);
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
// Default action (no subcommand) - show list
|
||||
internal.action(async () => {
|
||||
try {
|
||||
const config = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(config, resolveDefaultAgentId(config));
|
||||
const report = buildWorkspaceHookStatus(workspaceDir, { config });
|
||||
console.log(formatHooksList(report, {}));
|
||||
} catch (err) {
|
||||
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user