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
172 changes: 170 additions & 2 deletions src/utils/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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<string, unknown[]> };
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<string, unknown[]> };
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<string, unknown[]> };
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<string, unknown[]> };
expect(saved.hooks?.PreToolUse).toHaveLength(1);
expect(saved.hooks?.UserPromptSubmit).toHaveLength(1);
});
});
48 changes: 44 additions & 4 deletions src/utils/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, HookEntry[]>): 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);
}
}
}
Expand Down Expand Up @@ -63,7 +103,7 @@ export async function syncWidgetHooks(settings: Settings): Promise<void> {
const claudeSettings = await loadClaudeSettings({ logErrors: false });
const hooks = (claudeSettings.hooks ?? {}) as Record<string, HookEntry[]>;

// Remove all ccstatusline-managed hooks
// Remove tagged entries and legacy untagged ccstatusline hook commands
stripManagedHooks(hooks);

const statusCommand = await getExistingStatusLine();
Expand Down