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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [12.3.0] - 2026-02-15

### Added
- Hybrid hook manifest metadata support with authoritative `HOOK.json` parsing and `HOOK.md` frontmatter fallback in installer catalog loading.

### Changed
- Hook metadata parsing now normalizes `compatibleTargets` and registration target aliases to improve cross-agent compatibility handling.

### Fixed
- Target installs now skip incompatible hooks deterministically instead of treating all hook metadata as universally installable.
- Restored missing CLI export surface required for installer test/build compatibility in current `dev` baseline.

## [12.2.0] - 2026-02-15

### Added
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
12.2.0
12.3.0
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.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "intelligent-code-agents",
"version": "12.1.0",
"version": "12.3.0",
"private": true,
"description": "ICA cross-platform interactive installer, CLI, and dashboard",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion src/catalog/skills.catalog.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"generatedAt": "1970-01-01T00:00:00.000Z",
"source": "multi-source",
"version": "12.2.0",
"version": "12.3.0",
"sources": [
{
"id": "official-skills",
Expand Down
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"));
});
});