diff --git a/src/utils/__tests__/hooks.test.ts b/src/utils/__tests__/hooks.test.ts index 59439cc2..1b45978c 100644 --- a/src/utils/__tests__/hooks.test.ts +++ b/src/utils/__tests__/hooks.test.ts @@ -10,8 +10,14 @@ import { it } from 'vitest'; -import { DEFAULT_SETTINGS } from '../../types/Settings'; -import { syncWidgetHooks } from '../hooks'; +import { + DEFAULT_SETTINGS, + SettingsSchema +} from '../../types/Settings'; +import { + removeManagedHooks, + syncWidgetHooks +} from '../hooks'; const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR; let testClaudeConfigDir = ''; @@ -76,4 +82,166 @@ describe('syncWidgetHooks', () => { ] }); }); + + it('heals legacy untagged ccstatusline hooks instead of leaving duplicates', async () => { + const settingsPath = getClaudeSettingsPath(); + const command = '/Users/test/.bun/bin/ccstatusline'; + fs.writeFileSync(settingsPath, JSON.stringify({ + statusLine: { type: 'command', command }, + hooks: { + PreToolUse: [ + { + matcher: 'Skill', + hooks: [{ type: 'command', command: 'bunx -y ccstatusline@latest --hook' }] + }, + { + matcher: 'Other', + hooks: [{ type: 'command', command: 'keep-command' }] + } + ], + UserPromptSubmit: [ + { hooks: [{ type: 'command', command: 'bunx -y ccstatusline@latest --hook' }] } + ] + } + }, null, 2), 'utf-8'); + + const settings = SettingsSchema.parse({ lines: [[{ id: 'skills-1', type: 'skills' }]] }); + + await syncWidgetHooks(settings); + + const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record }; + expect(saved.hooks).toEqual({ + PreToolUse: [ + { + matcher: 'Other', + hooks: [{ type: 'command', command: 'keep-command' }] + }, + { + _tag: 'ccstatusline-managed', + matcher: 'Skill', + hooks: [{ type: 'command', command: `${command} --hook` }] + } + ], + UserPromptSubmit: [ + { + _tag: 'ccstatusline-managed', + hooks: [{ type: 'command', command: `${command} --hook` }] + } + ] + }); + }); + + it('preserves other commands in mixed legacy hook entries while syncing', async () => { + const settingsPath = getClaudeSettingsPath(); + const command = '/Users/test/.bun/bin/ccstatusline'; + fs.writeFileSync(settingsPath, JSON.stringify({ + statusLine: { type: 'command', command }, + hooks: { + PreToolUse: [ + { + matcher: 'Skill', + hooks: [ + { type: 'command', command: 'npx ccstatusline --hook' }, + { type: 'command', command: 'keep-command' } + ] + } + ] + } + }, null, 2), 'utf-8'); + + const settings = SettingsSchema.parse({ lines: [[{ id: 'skills-1', type: 'skills' }]] }); + + await syncWidgetHooks(settings); + + const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record }; + expect(saved.hooks).toEqual({ + PreToolUse: [ + { + matcher: 'Skill', + hooks: [{ type: 'command', command: 'keep-command' }] + }, + { + _tag: 'ccstatusline-managed', + matcher: 'Skill', + hooks: [{ type: 'command', command: `${command} --hook` }] + } + ], + UserPromptSubmit: [ + { + _tag: 'ccstatusline-managed', + hooks: [{ type: 'command', command: `${command} --hook` }] + } + ] + }); + }); + + it('preserves other commands in mixed legacy hook entries while removing managed hooks', async () => { + const settingsPath = getClaudeSettingsPath(); + fs.writeFileSync(settingsPath, JSON.stringify({ + hooks: { + PreToolUse: [ + { + matcher: 'Skill', + hooks: [ + { type: 'command', command: 'bunx -y ccstatusline@latest --hook' }, + { type: 'command', command: 'keep-command' } + ] + }, + { + matcher: 'LegacyOnly', + hooks: [{ type: 'command', command: 'npx ccstatusline --hook' }] + }, + { + _tag: 'ccstatusline-managed', + matcher: 'Managed', + hooks: [{ type: 'command', command: '/Users/test/.bun/bin/ccstatusline --hook' }] + } + ] + } + }, null, 2), 'utf-8'); + + await removeManagedHooks(); + + const saved = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { hooks?: Record }; + expect(saved.hooks).toEqual({ + PreToolUse: [ + { + matcher: 'Skill', + hooks: [{ type: 'command', command: 'keep-command' }] + } + ] + }); + }); + + it('is idempotent — repeated syncs heal legacy hooks without accumulating', async () => { + const settingsPath = getClaudeSettingsPath(); + const command = '/Users/test/.bun/bin/ccstatusline'; + // Seed a legacy untagged hook so the first sync exercises the heal (regex) path, + // not just the tagged-entry path. + fs.writeFileSync(settingsPath, JSON.stringify({ + statusLine: { type: 'command', command }, + hooks: { + PreToolUse: [ + { + matcher: 'Skill', + hooks: [{ type: 'command', command: 'npx ccstatusline --hook' }] + } + ] + } + }, null, 2), 'utf-8'); + + const settings = SettingsSchema.parse({ lines: [[{ id: 'skills-1', type: 'skills' }]] }); + + await syncWidgetHooks(settings); + const afterFirst = fs.readFileSync(settingsPath, 'utf-8'); + + await syncWidgetHooks(settings); + const afterSecond = fs.readFileSync(settingsPath, 'utf-8'); + + expect(afterSecond).toEqual(afterFirst); + + const saved = JSON.parse(afterSecond) as { hooks?: Record }; + expect(saved.hooks?.PreToolUse).toHaveLength(1); + expect(saved.hooks?.UserPromptSubmit).toHaveLength(1); + }); }); diff --git a/src/utils/hooks.ts b/src/utils/hooks.ts index f43d74d3..74d1a419 100644 --- a/src/utils/hooks.ts +++ b/src/utils/hooks.ts @@ -15,6 +15,13 @@ export interface WidgetHookDef { const HOOK_TAG = 'ccstatusline-managed'; +// Matches ccstatusline hook commands written by any install method +// (global binary, `bunx ccstatusline@latest --hook`, `npx ccstatusline --hook`, …). +// Used to heal legacy/untagged hooks that predate HOOK_TAG so they do not +// accumulate alongside the managed set on every sync. The space before `--hook` +// and trailing boundary avoid matching unrelated `--hook*` substrings. +const CCSTATUSLINE_HOOK_PATTERN = /ccstatusline.* --hook(?:\s|$)/; + interface HookEntry { _tag?: string; matcher?: string; @@ -27,12 +34,45 @@ function hasWidgetHooks(widget: Widget | null): widget is WidgetWithHooks { return Boolean(widget && 'getHooks' in widget && typeof widget.getHooks === 'function'); } +function isCcstatuslineManagedEntry(entry: HookEntry): boolean { + return entry._tag === HOOK_TAG; +} + +function isLegacyCcstatuslineHookCommand(hook: { type: string; command: string }): boolean { + return CCSTATUSLINE_HOOK_PATTERN.test(hook.command); +} + +function stripManagedHookEntry(entry: HookEntry): HookEntry | null { + if (isCcstatuslineManagedEntry(entry)) { + return null; + } + + if (!entry.hooks) { + return entry; + } + + const remainingHooks = entry.hooks.filter(hook => !isLegacyCcstatuslineHookCommand(hook)); + if (remainingHooks.length === entry.hooks.length) { + return entry; + } + + if (remainingHooks.length === 0) { + return null; + } + + return { + ...entry, + hooks: remainingHooks + }; +} + function stripManagedHooks(hooks: Record): void { for (const event of Object.keys(hooks)) { - hooks[event] = (hooks[event] ?? []).filter(entry => entry._tag !== HOOK_TAG); + hooks[event] = (hooks[event] ?? []) + .map(stripManagedHookEntry) + .filter((entry): entry is HookEntry => entry !== null); if (hooks[event].length === 0) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete hooks[event]; + Reflect.deleteProperty(hooks, event); } } } @@ -63,7 +103,7 @@ export async function syncWidgetHooks(settings: Settings): Promise { const claudeSettings = await loadClaudeSettings({ logErrors: false }); const hooks = (claudeSettings.hooks ?? {}) as Record; - // Remove all ccstatusline-managed hooks + // Remove tagged entries and legacy untagged ccstatusline hook commands stripManagedHooks(hooks); const statusCommand = await getExistingStatusLine();