diff --git a/packages/plugins/agent-claude-code/src/index.test.ts b/packages/plugins/agent-claude-code/src/index.test.ts index 8b65084b3..623bc60ad 100644 --- a/packages/plugins/agent-claude-code/src/index.test.ts +++ b/packages/plugins/agent-claude-code/src/index.test.ts @@ -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)["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. (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. diff --git a/packages/plugins/agent-claude-code/src/index.ts b/packages/plugins/agent-claude-code/src/index.ts index 934fe3be2..9954403b6 100644 --- a/packages/plugins/agent-claude-code/src/index.ts +++ b/packages/plugins/agent-claude-code/src/index.ts @@ -810,6 +810,36 @@ interface HookRegistration { identifiers: ReadonlyArray; } +function isRecord(value: unknown): value is Record { + 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): 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)["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)["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 { } } - const hooks = (existingSettings["hooks"] as Record) ?? {}; + const hooks = isRecord(existingSettings["hooks"]) ? existingSettings["hooks"] : {}; + normalizeClaudeHookSettings(hooks); for (const reg of buildHookRegistrations(metadataCommand, activityCommand)) { upsertHookEntry(hooks, reg); }