Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/hook-registration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+`.
6 changes: 6 additions & 0 deletions docs/hook-system-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
132 changes: 119 additions & 13 deletions src/installer-core/hookCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<HookTargetPlatform, HookRegistration[]>>;
}

export interface CatalogHook {
hookId: string;
Expand All @@ -23,6 +39,8 @@ export interface CatalogHook {
contentDigest?: string;
contentFileCount?: number;
compatibleTargets: Array<Extract<TargetPlatform, "claude" | "gemini">>;
metadataFormat?: "json" | "markdown" | "directory";
registrations?: Partial<Record<HookTargetPlatform, HookRegistration[]>>;
}

export interface HookCatalog {
Expand All @@ -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<Record<HookTargetPlatform, HookRegistration[]>> | undefined {
if (!value || typeof value !== "object") return undefined;
const input = value as Record<string, unknown>;
const normalized: Partial<Record<HookTargetPlatform, HookRegistration[]>> = {};

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<string, unknown>;
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<string, string> = {};

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<string, unknown>;
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<string, string> {
const match = content.match(FRONTMATTER_RE);
if (!match) return {};
Expand All @@ -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<string, string> = {};
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,
Expand All @@ -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,
};
}

Expand Down
10 changes: 10 additions & 0 deletions src/installer-core/hookExecutor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
110 changes: 110 additions & 0 deletions tests/installer/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
});
});