diff --git a/docs/hook-registration-reference.md b/docs/hook-registration-reference.md index c5ea0d9..16ad5ee 100644 --- a/docs/hook-registration-reference.md +++ b/docs/hook-registration-reference.md @@ -16,6 +16,11 @@ Hooks are registered by the current installer surface: - `ica` CLI install flow - dashboard install flow +## Metadata Source + +- Prefer `HOOK.json` for machine-readable metadata. +- Keep `HOOK.md` for human-readable docs and backward-compatible fallback metadata. + ## Version Hook system version: `v10.2+`. diff --git a/docs/hook-system-guide.md b/docs/hook-system-guide.md index c961b2f..ca5a2e7 100644 --- a/docs/hook-system-guide.md +++ b/docs/hook-system-guide.md @@ -15,6 +15,12 @@ Hooks are registered through current installer flows: - `ica` CLI (`install` with Claude integration enabled) - installer dashboard apply operations for Claude target +## Hook Package Metadata + +Use a hybrid format: +- `HOOK.json` is authoritative for machine-readable metadata (targets, registrations, matcher/command data). +- `HOOK.md` remains for human documentation and optional compatibility fallback metadata. + ## Why PreToolUse Only Runtime-native orchestration handles roles/subagents; ICA hooks focus on safety and output hygiene. diff --git a/src/installer-core/hookCatalog.ts b/src/installer-core/hookCatalog.ts index eaa0dfe..7dbff81 100644 --- a/src/installer-core/hookCatalog.ts +++ b/src/installer-core/hookCatalog.ts @@ -8,6 +8,22 @@ import { TargetPlatform } from "./types"; import { computeDirectoryDigest } from "./contentDigest"; const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---/; +const HOOK_TARGETS = ["claude", "gemini"] as const; +type HookTargetPlatform = (typeof HOOK_TARGETS)[number]; + +interface HookRegistration { + event: string; + matcher?: string; + command?: string; +} + +interface HookManifest { + name?: string; + description?: string; + version?: string; + compatibleTargets?: HookTargetPlatform[]; + registrations?: Partial>; +} export interface CatalogHook { hookId: string; @@ -23,6 +39,8 @@ export interface CatalogHook { contentDigest?: string; contentFileCount?: number; compatibleTargets: Array>; + metadataFormat?: "json" | "markdown" | "directory"; + registrations?: Partial>; } export interface HookCatalog { @@ -44,6 +62,96 @@ interface CatalogOptions { refresh: boolean; } +function isHookTarget(value: string): value is HookTargetPlatform { + return HOOK_TARGETS.includes(value as HookTargetPlatform); +} + +function normalizeTargets(values: string[]): HookTargetPlatform[] { + const filtered = values.map((value) => value.trim()).filter((value) => value.length > 0).filter(isHookTarget); + return Array.from(new Set(filtered)); +} + +function parseFrontmatterList(raw: string | undefined): string[] { + if (!raw) return []; + const trimmed = raw.trim(); + if (trimmed.startsWith("[") && trimmed.endsWith("]")) { + return trimmed + .slice(1, -1) + .split(",") + .map((item) => item.replace(/^["']|["']$/g, "").trim()) + .filter((item) => item.length > 0); + } + return trimmed + .split(",") + .map((item) => item.replace(/^["']|["']$/g, "").trim()) + .filter((item) => item.length > 0); +} + +function normalizeRegistrations(value: unknown): Partial> | undefined { + if (!value || typeof value !== "object") return undefined; + const input = value as Record; + const normalized: Partial> = {}; + + for (const target of HOOK_TARGETS) { + const entries = input[target]; + if (!Array.isArray(entries)) continue; + const parsed: HookRegistration[] = []; + for (const entry of entries) { + if (!entry || typeof entry !== "object") continue; + const item = entry as Record; + const event = typeof item.event === "string" ? item.event.trim() : ""; + if (!event) continue; + parsed.push({ + event, + matcher: typeof item.matcher === "string" ? item.matcher : undefined, + command: typeof item.command === "string" ? item.command : undefined, + }); + } + + if (parsed.length > 0) { + normalized[target] = parsed; + } + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function parseHookManifest(hookDir: string): HookManifest { + const hookJsonPath = path.join(hookDir, "HOOK.json"); + const hookMdPath = path.join(hookDir, "HOOK.md"); + let frontmatter: Record = {}; + + if (fs.existsSync(hookMdPath)) { + const content = fs.readFileSync(hookMdPath, "utf8"); + frontmatter = parseFrontmatter(content); + } + + if (fs.existsSync(hookJsonPath)) { + const parsed = JSON.parse(fs.readFileSync(hookJsonPath, "utf8")) as Record; + const targetsFromJson = Array.isArray(parsed.compatibleTargets) + ? normalizeTargets(parsed.compatibleTargets.filter((item): item is string => typeof item === "string")) + : []; + const targetsFromMd = normalizeTargets(parseFrontmatterList(frontmatter.targets)); + const compatibleTargets = targetsFromJson.length > 0 ? targetsFromJson : (targetsFromMd.length > 0 ? targetsFromMd : [...HOOK_TARGETS]); + + return { + name: typeof parsed.name === "string" ? parsed.name : frontmatter.name, + description: typeof parsed.description === "string" ? parsed.description : (frontmatter.description || ""), + version: typeof parsed.version === "string" ? parsed.version : frontmatter.version, + compatibleTargets, + registrations: normalizeRegistrations(parsed.registrations), + }; + } + + const compatibleTargets = normalizeTargets(parseFrontmatterList(frontmatter.targets)); + return { + name: frontmatter.name, + description: frontmatter.description || "", + version: frontmatter.version, + compatibleTargets: compatibleTargets.length > 0 ? compatibleTargets : [...HOOK_TARGETS], + }; +} + function parseFrontmatter(content: string): Record { const match = content.match(FRONTMATTER_RE); if (!match) return {}; @@ -67,19 +175,15 @@ function hookRootPath(source: HookSource, localRepoPath: string): string { } function toCatalogHook(source: HookSource, hookDir: string): CatalogHook | null { - const hookFile = path.join(hookDir, "HOOK.md"); - const statPath = fs.existsSync(hookFile) ? hookFile : hookDir; + const hookMdFile = path.join(hookDir, "HOOK.md"); + const hookJsonFile = path.join(hookDir, "HOOK.json"); + const statPath = fs.existsSync(hookJsonFile) ? hookJsonFile : (fs.existsSync(hookMdFile) ? hookMdFile : hookDir); const stat = fs.statSync(statPath); - - let frontmatter: Record = {}; - if (fs.existsSync(hookFile)) { - const content = fs.readFileSync(hookFile, "utf8"); - frontmatter = parseFrontmatter(content); - } - - const hookName = frontmatter.name || path.basename(hookDir); + const manifest = parseHookManifest(hookDir); + const hookName = manifest.name || path.basename(hookDir); const hookId = `${source.id}/${hookName}`; const digest = computeDirectoryDigest(hookDir); + const metadataFormat = fs.existsSync(hookJsonFile) ? "json" : (fs.existsSync(hookMdFile) ? "markdown" : "directory"); return { hookId, @@ -88,13 +192,15 @@ function toCatalogHook(source: HookSource, hookDir: string): CatalogHook | null sourceUrl: source.repoUrl, hookName, name: hookName, - description: frontmatter.description || "", + description: manifest.description || "", sourcePath: hookDir, - version: frontmatter.version, + version: manifest.version, updatedAt: stat.mtime.toISOString(), contentDigest: digest.digest, contentFileCount: digest.fileCount, - compatibleTargets: ["claude", "gemini"], + compatibleTargets: manifest.compatibleTargets || [...HOOK_TARGETS], + metadataFormat, + registrations: manifest.registrations, }; } diff --git a/src/installer-core/hookExecutor.ts b/src/installer-core/hookExecutor.ts index e3963fc..007d2bf 100644 --- a/src/installer-core/hookExecutor.ts +++ b/src/installer-core/hookExecutor.ts @@ -187,6 +187,16 @@ async function installOrSyncTarget(repoRoot: string, request: HookInstallRequest continue; } + if (!hook.compatibleTargets.includes(report.target)) { + report.skippedHooks.push(hook.hookId); + pushWarning( + report, + "HOOK_TARGET_INCOMPATIBLE", + `Skipped '${hook.hookId}' for target '${report.target}' (compatible targets: ${hook.compatibleTargets.join(", ")}).`, + ); + continue; + } + if (selectedNames.has(hook.hookName)) { report.skippedHooks.push(hook.hookId); pushWarning( diff --git a/tests/installer/hooks.test.ts b/tests/installer/hooks.test.ts index 791121b..bffa7d4 100644 --- a/tests/installer/hooks.test.ts +++ b/tests/installer/hooks.test.ts @@ -249,3 +249,113 @@ test("syncHookSource repairs stale master refspec and keeps syncing main-based h assert.match(syncedHook, /version:\s*2\.0\.0/i); }); }); + +test("hook catalog reads machine metadata from HOOK.json when present", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-state-")); + const repoBase = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-repo-")); + const repoDir = path.join(repoBase, "repo"); + fs.mkdirSync(path.join(repoDir, "hooks", "machine-hook"), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, "hooks", "machine-hook", "HOOK.json"), + JSON.stringify( + { + name: "machine-hook", + description: "Machine-readable hook manifest", + version: "1.0.0", + compatibleTargets: ["claude"], + registrations: { + claude: [ + { + event: "PreToolUse", + matcher: "^(BashTool|Bash)$", + command: "machine-hook.js", + }, + ], + }, + }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync(path.join(repoDir, "hooks", "machine-hook", "machine-hook.js"), "console.log('ok')\n", "utf8"); + fs.writeFileSync(path.join(repoDir, "hooks", "machine-hook", "HOOK.md"), "---\nname: legacy-name\ndescription: legacy\n---\n", "utf8"); + initRepo(repoDir); + + await withStateHome(stateHome, async () => { + const source = await addHookSource({ + id: "machine-hooks", + name: "machine-hooks", + repoUrl: `file://${repoDir}`, + transport: "https", + hooksRoot: "/hooks", + enabled: true, + removable: true, + }); + await syncHookSource(source, createCredentialProvider()); + const catalog = await loadHookCatalogFromSources(repoRoot, false); + const hook = catalog.hooks.find((item) => item.hookId === "machine-hooks/machine-hook"); + assert.ok(hook); + assert.equal(hook?.description, "Machine-readable hook manifest"); + assert.deepEqual(hook?.compatibleTargets, ["claude"]); + }); +}); + +test("hook install skips hooks incompatible with selected target", async () => { + const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-state-")); + const repoBase = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-repo-")); + const repoDir = path.join(repoBase, "repo"); + fs.mkdirSync(path.join(repoDir, "hooks", "claude-only"), { recursive: true }); + fs.writeFileSync( + path.join(repoDir, "hooks", "claude-only", "HOOK.json"), + JSON.stringify( + { + name: "claude-only", + description: "Claude only hook", + version: "1.0.0", + compatibleTargets: ["claude"], + }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync(path.join(repoDir, "hooks", "claude-only", "index.js"), "console.log('hook')\n", "utf8"); + initRepo(repoDir); + + await withStateHome(stateHome, async () => { + const source = await addHookSource({ + id: "targeted-hooks", + name: "targeted-hooks", + repoUrl: `file://${repoDir}`, + transport: "https", + hooksRoot: "/hooks", + enabled: true, + removable: true, + }); + await syncHookSource(source, createCredentialProvider()); + + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ica-hooks-project-")); + const installReport = await executeHookOperation(repoRoot, { + operation: "install", + targets: ["gemini"], + scope: "project", + projectPath: projectRoot, + mode: "copy", + hooks: [], + hookSelections: [ + { + sourceId: "targeted-hooks", + hookName: "claude-only", + hookId: "targeted-hooks/claude-only", + }, + ], + }); + + const geminiReport = installReport.targets.find((entry) => entry.target === "gemini"); + assert.ok(geminiReport); + assert.equal(geminiReport?.appliedHooks.length, 0); + assert.ok(geminiReport?.skippedHooks.includes("targeted-hooks/claude-only")); + assert.ok(geminiReport?.warnings.some((item) => item.code === "HOOK_TARGET_INCOMPATIBLE")); + }); +});