fix: normalize claude hooks before injection (#2001)

This commit is contained in:
nikhil achale 2026-05-22 13:46:01 +05:30
parent c633a7ac49
commit 401687c029
2 changed files with 73 additions and 5 deletions

View File

@ -1159,6 +1159,43 @@ describe("setupWorkspaceHooks — activity-updater (#1941)", () => {
expect(commands).toContain(ACTIVITY_CMD_UNIX); // our hook added
});
it("migrates a legacy bare SessionStart hook before adding activity-updater", async () => {
const existingSettings = {
hooks: {
SessionStart: [
{
type: "command",
command: "python .claude/hooks/session_start_context.py",
},
],
},
};
mockExistsSync.mockReturnValueOnce(true);
mockReadFile.mockResolvedValueOnce(JSON.stringify(existingSettings));
mockWriteFile.mockClear();
await agent.setupWorkspaceHooks!("/workspace/test", {} as WorkspaceHooksConfig);
const settings = getParsedSettings();
const sessionStart = (settings.hooks as Record<string, unknown>)["SessionStart"] as Array<{
matcher: string;
hooks: Array<{ command: string }>;
}>;
expect(sessionStart[0]).toEqual({
matcher: "",
hooks: [
{
type: "command",
command: "python .claude/hooks/session_start_context.py",
},
],
});
const commands = sessionStart.flatMap((g) => g.hooks).map((h) => h.command);
expect(commands).toContain("python .claude/hooks/session_start_context.py");
expect(commands).toContain(ACTIVITY_CMD_UNIX);
});
it("tolerates malformed hooks.<event> (object instead of array)", async () => {
// A user could hand-edit settings.json or an older plugin could have
// written a non-array shape there. We must not crash — start fresh.

View File

@ -810,6 +810,36 @@ interface HookRegistration {
identifiers: ReadonlyArray<string>;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isBareHookDefinition(value: unknown): boolean {
if (!isRecord(value)) return false;
return typeof value["command"] === "string" && !Array.isArray(value["hooks"]);
}
function normalizeHookEntry(entry: unknown): unknown {
if (!isRecord(entry)) return entry;
if (isBareHookDefinition(entry)) {
return { matcher: "", hooks: [entry] };
}
if (Array.isArray(entry["hooks"]) && typeof entry["matcher"] !== "string") {
return { ...entry, matcher: "" };
}
return entry;
}
function normalizeClaudeHookSettings(hooks: Record<string, unknown>): void {
for (const [event, entries] of Object.entries(hooks)) {
if (!Array.isArray(entries)) continue;
hooks[event] = entries.map(normalizeHookEntry);
}
}
/**
* Set the registration's hook in the `event`'s hook array, updating any
* existing entry whose command contains one of `identifiers` (idempotent).
@ -835,13 +865,13 @@ function upsertHookEntry(
let foundDefIdx = -1;
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
const hooksList = (entry as Record<string, unknown>)["hooks"];
if (!isRecord(entry)) continue;
const hooksList = entry["hooks"];
if (!Array.isArray(hooksList)) continue;
for (let j = 0; j < hooksList.length; j++) {
const def = hooksList[j];
if (typeof def !== "object" || def === null || Array.isArray(def)) continue;
const cmd = (def as Record<string, unknown>)["command"];
if (!isRecord(def)) continue;
const cmd = def["command"];
if (typeof cmd === "string" && reg.identifiers.some((id) => cmd.includes(id))) {
foundEntryIdx = i;
foundDefIdx = j;
@ -989,7 +1019,8 @@ async function setupHookInWorkspace(workspacePath: string): Promise<void> {
}
}
const hooks = (existingSettings["hooks"] as Record<string, unknown>) ?? {};
const hooks = isRecord(existingSettings["hooks"]) ? existingSettings["hooks"] : {};
normalizeClaudeHookSettings(hooks);
for (const reg of buildHookRegistrations(metadataCommand, activityCommand)) {
upsertHookEntry(hooks, reg);
}