diff --git a/src/main/services/runtime/cliSettingsArgs.ts b/src/main/services/runtime/cliSettingsArgs.ts index fb7dc940..70f0d285 100644 --- a/src/main/services/runtime/cliSettingsArgs.ts +++ b/src/main/services/runtime/cliSettingsArgs.ts @@ -1,5 +1,7 @@ type JsonObject = Record; +type JsonArray = unknown[]; + function isJsonObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -13,10 +15,77 @@ function parseJsonSettingsObject(raw: string): JsonObject | null { } } +function isHookEntry(value: unknown): value is JsonObject { + return isJsonObject(value) && Array.isArray(value.hooks); +} + +function getHookEntryDedupeKey(value: unknown): string | null { + if (!isHookEntry(value)) { + return null; + } + + const commands = value.hooks + .map((hook) => (isJsonObject(hook) && typeof hook.command === 'string' ? hook.command : null)) + .filter((command): command is string => Boolean(command)); + if (commands.length === 0) { + return null; + } + + return JSON.stringify({ + matcher: typeof value.matcher === 'string' ? value.matcher : '', + commands, + }); +} + +function mergeHookEntryArrays(target: JsonArray, source: JsonArray): JsonArray { + const merged = [...target]; + const seen = new Set(); + for (const entry of merged) { + const key = getHookEntryDedupeKey(entry); + if (key) { + seen.add(key); + } + } + + for (const entry of source) { + const key = getHookEntryDedupeKey(entry); + if (key && seen.has(key)) { + continue; + } + merged.push(entry); + if (key) { + seen.add(key); + } + } + + return merged; +} + +function mergeHooksObject(target: JsonObject, source: JsonObject): JsonObject { + const merged: JsonObject = { ...target }; + for (const [hookName, sourceValue] of Object.entries(source)) { + const currentValue = merged[hookName]; + if (Array.isArray(currentValue) && Array.isArray(sourceValue)) { + merged[hookName] = mergeHookEntryArrays(currentValue, sourceValue); + continue; + } + if (isJsonObject(currentValue) && isJsonObject(sourceValue)) { + merged[hookName] = deepMergeJsonObjects(currentValue, sourceValue); + continue; + } + merged[hookName] = sourceValue; + } + return merged; +} + function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObject { const merged: JsonObject = { ...target }; for (const [key, value] of Object.entries(source)) { const current = merged[key]; + if (key === 'hooks' && isJsonObject(current) && isJsonObject(value)) { + merged[key] = mergeHooksObject(current, value); + continue; + } if (isJsonObject(current) && isJsonObject(value)) { merged[key] = deepMergeJsonObjects(current, value); continue; diff --git a/test/main/services/runtime/cliSettingsArgs.test.ts b/test/main/services/runtime/cliSettingsArgs.test.ts index 742d14a2..c76dfd2a 100644 --- a/test/main/services/runtime/cliSettingsArgs.test.ts +++ b/test/main/services/runtime/cliSettingsArgs.test.ts @@ -64,4 +64,71 @@ describe('mergeJsonSettingsArgs', () => { '{"fastMode":false,"codex":{"forced_login_method":"chatgpt"}}', ]); }); + + it('preserves multiple hook entries for the same hook event', () => { + const merged = mergeJsonSettingsArgs([ + '--settings', + JSON.stringify({ + hooks: { + Stop: [ + { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh user-stop.sh' }], + }, + ], + }, + }), + '--settings', + JSON.stringify({ + hooks: { + Stop: [ + { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh app-stop.sh' }], + }, + ], + }, + }), + ]); + + expect(JSON.parse(getSettingsValues(merged)[0] ?? '{}')).toEqual({ + hooks: { + Stop: [ + { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh user-stop.sh' }], + }, + { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh app-stop.sh' }], + }, + ], + }, + }); + }); + + it('dedupes identical hook entries while preserving unrelated array replacement semantics', () => { + const appHook = { + matcher: '', + hooks: [{ type: 'command', command: '/bin/sh app-stop.sh' }], + }; + + const merged = mergeJsonSettingsArgs([ + '--settings', + JSON.stringify({ + permissions: { allow: ['Read'] }, + hooks: { Stop: [appHook] }, + }), + '--settings', + JSON.stringify({ + permissions: { allow: ['Bash'] }, + hooks: { Stop: [appHook] }, + }), + ]); + + expect(JSON.parse(getSettingsValues(merged)[0] ?? '{}')).toEqual({ + permissions: { allow: ['Bash'] }, + hooks: { Stop: [appHook] }, + }); + }); });