Files
clawdbot/src/hooks/hooks-status.ts
2026-01-17 01:31:57 +00:00

226 lines
5.8 KiB
TypeScript

import path from "node:path";
import type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_DIR } from "../utils.js";
import {
hasBinary,
isConfigPathTruthy,
resolveConfigPath,
resolveHookConfig,
} from "./config.js";
import type {
HookEligibilityContext,
HookEntry,
HookInstallSpec,
} from "./types.js";
import { loadWorkspaceHookEntries } from "./workspace.js";
export type HookStatusConfigCheck = {
path: string;
value: unknown;
satisfied: boolean;
};
export type HookInstallOption = {
id: string;
kind: HookInstallSpec["kind"];
label: string;
bins: string[];
};
export type HookStatusEntry = {
name: string;
description: string;
source: string;
filePath: string;
baseDir: string;
handlerPath: string;
hookKey: string;
emoji?: string;
homepage?: string;
events: string[];
always: boolean;
disabled: boolean;
eligible: boolean;
requirements: {
bins: string[];
anyBins: string[];
env: string[];
config: string[];
os: string[];
};
missing: {
bins: string[];
anyBins: string[];
env: string[];
config: string[];
os: string[];
};
configChecks: HookStatusConfigCheck[];
install: HookInstallOption[];
};
export type HookStatusReport = {
workspaceDir: string;
managedHooksDir: string;
hooks: HookStatusEntry[];
};
function resolveHookKey(entry: HookEntry): string {
return entry.clawdbot?.hookKey ?? entry.hook.name;
}
function normalizeInstallOptions(entry: HookEntry): HookInstallOption[] {
const install = entry.clawdbot?.install ?? [];
if (install.length === 0) return [];
// For hooks, we just list all install options
return install.map((spec, index) => {
const id = (spec.id ?? `${spec.kind}-${index}`).trim();
const bins = spec.bins ?? [];
let label = (spec.label ?? "").trim();
if (!label) {
if (spec.kind === "bundled") {
label = "Bundled with Clawdbot";
} else if (spec.kind === "npm" && spec.package) {
label = `Install ${spec.package} (npm)`;
} else if (spec.kind === "git" && spec.repository) {
label = `Install from ${spec.repository}`;
} else {
label = "Run installer";
}
}
return { id, kind: spec.kind, label, bins };
});
}
function buildHookStatus(
entry: HookEntry,
config?: ClawdbotConfig,
eligibility?: HookEligibilityContext,
): HookStatusEntry {
const hookKey = resolveHookKey(entry);
const hookConfig = resolveHookConfig(config, hookKey);
const disabled = hookConfig?.enabled === false;
const always = entry.clawdbot?.always === true;
const emoji = entry.clawdbot?.emoji ?? entry.frontmatter.emoji;
const homepageRaw =
entry.clawdbot?.homepage ??
entry.frontmatter.homepage ??
entry.frontmatter.website ??
entry.frontmatter.url;
const homepage = homepageRaw?.trim() ? homepageRaw.trim() : undefined;
const events = entry.clawdbot?.events ?? [];
const requiredBins = entry.clawdbot?.requires?.bins ?? [];
const requiredAnyBins = entry.clawdbot?.requires?.anyBins ?? [];
const requiredEnv = entry.clawdbot?.requires?.env ?? [];
const requiredConfig = entry.clawdbot?.requires?.config ?? [];
const requiredOs = entry.clawdbot?.os ?? [];
const missingBins = requiredBins.filter((bin) => {
if (hasBinary(bin)) return false;
if (eligibility?.remote?.hasBin?.(bin)) return false;
return true;
});
const missingAnyBins =
requiredAnyBins.length > 0 &&
!(
requiredAnyBins.some((bin) => hasBinary(bin)) ||
eligibility?.remote?.hasAnyBin?.(requiredAnyBins)
)
? requiredAnyBins
: [];
const missingOs =
requiredOs.length > 0 &&
!requiredOs.includes(process.platform) &&
!eligibility?.remote?.platforms?.some((platform) => requiredOs.includes(platform))
? requiredOs
: [];
const missingEnv: string[] = [];
for (const envName of requiredEnv) {
if (process.env[envName]) continue;
if (hookConfig?.env?.[envName]) continue;
missingEnv.push(envName);
}
const configChecks: HookStatusConfigCheck[] = requiredConfig.map((pathStr) => {
const value = resolveConfigPath(config, pathStr);
const satisfied = isConfigPathTruthy(config, pathStr);
return { path: pathStr, value, satisfied };
});
const missingConfig = configChecks
.filter((check) => !check.satisfied)
.map((check) => check.path);
const missing = always
? { bins: [], anyBins: [], env: [], config: [], os: [] }
: {
bins: missingBins,
anyBins: missingAnyBins,
env: missingEnv,
config: missingConfig,
os: missingOs,
};
const eligible =
!disabled &&
(always ||
(missing.bins.length === 0 &&
missing.anyBins.length === 0 &&
missing.env.length === 0 &&
missing.config.length === 0 &&
missing.os.length === 0));
return {
name: entry.hook.name,
description: entry.hook.description,
source: entry.hook.source,
filePath: entry.hook.filePath,
baseDir: entry.hook.baseDir,
handlerPath: entry.hook.handlerPath,
hookKey,
emoji,
homepage,
events,
always,
disabled,
eligible,
requirements: {
bins: requiredBins,
anyBins: requiredAnyBins,
env: requiredEnv,
config: requiredConfig,
os: requiredOs,
},
missing,
configChecks,
install: normalizeInstallOptions(entry),
};
}
export function buildWorkspaceHookStatus(
workspaceDir: string,
opts?: {
config?: ClawdbotConfig;
managedHooksDir?: string;
entries?: HookEntry[];
eligibility?: HookEligibilityContext;
},
): HookStatusReport {
const managedHooksDir = opts?.managedHooksDir ?? path.join(CONFIG_DIR, "hooks");
const hookEntries = opts?.entries ?? loadWorkspaceHookEntries(workspaceDir, opts);
return {
workspaceDir,
managedHooksDir,
hooks: hookEntries.map((entry) => buildHookStatus(entry, opts?.config, opts?.eligibility)),
};
}