From cabcdaa5430354be60b1254faecd7a3c102e0ba9 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Sun, 29 Mar 2026 20:51:56 +0530 Subject: [PATCH 1/4] feat(cli): update install-hook to install both Stop and SessionEnd hooks Adds SessionEnd hook for zero-config native session analysis (Issue #241). Default install now installs both Stop (sync) and SessionEnd (analysis) hooks. New --sync-only / --analysis-only flags for granular control. Uninstall-hook now cleans both hook types. Telemetry tracks installed types. Co-Authored-By: Claude Sonnet 4.6 --- .../commands/__tests__/install-hook.test.ts | 322 ++++++++++++++++++ cli/src/commands/install-hook.ts | 153 ++++++--- cli/src/index.ts | 8 +- 3 files changed, 440 insertions(+), 43 deletions(-) create mode 100644 cli/src/commands/__tests__/install-hook.test.ts diff --git a/cli/src/commands/__tests__/install-hook.test.ts b/cli/src/commands/__tests__/install-hook.test.ts new file mode 100644 index 00000000..0f296f35 --- /dev/null +++ b/cli/src/commands/__tests__/install-hook.test.ts @@ -0,0 +1,322 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +vi.mock('../../utils/telemetry.js', () => ({ + trackEvent: vi.fn(), + captureError: vi.fn(), + classifyError: vi.fn(() => ({ error_type: 'unknown', error_message: 'unknown' })), +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const HOOKS_FILE = path.join(os.homedir(), '.claude', 'settings.json'); +const CLAUDE_SETTINGS_DIR = path.join(os.homedir(), '.claude'); + +/** Read and parse the settings.json file written during test */ +function readSettings(): Record { + return JSON.parse(fs.readFileSync(HOOKS_FILE, 'utf-8')); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('installHookCommand', () => { + let tmpDir: string; + let originalHooksFile: string | null = null; + + beforeEach(() => { + // Use a temp dir so we don't corrupt real ~/.claude/settings.json + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-hook-test-')); + + // Back up real settings.json if it exists + if (fs.existsSync(HOOKS_FILE)) { + originalHooksFile = fs.readFileSync(HOOKS_FILE, 'utf-8'); + } else { + originalHooksFile = null; + } + + vi.resetModules(); + }); + + afterEach(() => { + // Restore real settings.json + if (originalHooksFile !== null) { + fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); + fs.writeFileSync(HOOKS_FILE, originalHooksFile); + } else if (fs.existsSync(HOOKS_FILE)) { + fs.unlinkSync(HOOKS_FILE); + } + + // Clean up temp dir + fs.rmSync(tmpDir, { recursive: true, force: true }); + + vi.clearAllMocks(); + }); + + describe('default install (both hooks)', () => { + it('installs both Stop and SessionEnd hooks', async () => { + // Ensure clean state + if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); + + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({}); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + + expect(hooks).toBeDefined(); + expect(Array.isArray(hooks.Stop)).toBe(true); + expect(Array.isArray(hooks.SessionEnd)).toBe(true); + expect(hooks.Stop).toHaveLength(1); + expect(hooks.SessionEnd).toHaveLength(1); + }); + + it('Stop hook contains sync command', async () => { + if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); + + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({}); + + const settings = readSettings(); + const hooks = settings.hooks as Record }>>; + const stopHookEntry = hooks.Stop[0]; + + expect(stopHookEntry.hooks).toHaveLength(1); + const stopCmd = stopHookEntry.hooks[0]; + expect(stopCmd.type).toBe('command'); + expect(stopCmd.command).toContain('sync -q'); + }); + + it('SessionEnd hook contains insights command with 120s timeout', async () => { + if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); + + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({}); + + const settings = readSettings(); + const hooks = settings.hooks as Record }>>; + const sessionEndEntry = hooks.SessionEnd[0]; + + expect(sessionEndEntry.hooks).toHaveLength(1); + const sessionEndCmd = sessionEndEntry.hooks[0]; + expect(sessionEndCmd.type).toBe('command'); + expect(sessionEndCmd.command).toContain('insights --hook --native -q'); + expect(sessionEndCmd.timeout).toBe(120000); + }); + + it('preserves existing settings.json content', async () => { + // Write some pre-existing settings + fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); + fs.writeFileSync(HOOKS_FILE, JSON.stringify({ theme: 'dark', someOtherKey: 42 })); + + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({}); + + const settings = readSettings(); + expect(settings.theme).toBe('dark'); + expect(settings.someOtherKey).toBe(42); + }); + + it('preserves existing non-code-insights hooks', async () => { + fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); + fs.writeFileSync(HOOKS_FILE, JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: 'other-tool sync' }] }], + }, + })); + + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({}); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + // Should have 2 Stop hooks now: the existing one + our new one + expect(hooks.Stop).toHaveLength(2); + }); + }); + + describe('--sync-only flag', () => { + it('installs only Stop hook when --sync-only is set', async () => { + if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); + + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({ syncOnly: true }); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + expect(hooks.Stop).toHaveLength(1); + expect(hooks.SessionEnd).toBeUndefined(); + }); + }); + + describe('--analysis-only flag', () => { + it('installs only SessionEnd hook when --analysis-only is set', async () => { + if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); + + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({ analysisOnly: true }); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + expect(hooks.SessionEnd).toHaveLength(1); + expect(hooks.Stop).toBeUndefined(); + }); + }); + + describe('duplicate detection', () => { + it('does not install Stop hook if code-insights Stop hook already exists', async () => { + if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); + + const { installHookCommand } = await import('../install-hook.js'); + // First install + await installHookCommand({ syncOnly: true }); + // Second install + await installHookCommand({ syncOnly: true }); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + // Should still be just 1, not 2 + expect(hooks.Stop).toHaveLength(1); + }); + + it('does not install SessionEnd hook if code-insights SessionEnd hook already exists', async () => { + if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); + + const { installHookCommand } = await import('../install-hook.js'); + // First install + await installHookCommand({ analysisOnly: true }); + // Second install + await installHookCommand({ analysisOnly: true }); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + expect(hooks.SessionEnd).toHaveLength(1); + }); + + it('reports already-installed when both hooks exist on default install', async () => { + if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); + + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({}); + await installHookCommand({}); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + expect(hooks.Stop).toHaveLength(1); + expect(hooks.SessionEnd).toHaveLength(1); + }); + }); +}); + +describe('uninstallHookCommand', () => { + let originalHooksFile: string | null = null; + + beforeEach(() => { + if (fs.existsSync(HOOKS_FILE)) { + originalHooksFile = fs.readFileSync(HOOKS_FILE, 'utf-8'); + } else { + originalHooksFile = null; + } + vi.resetModules(); + }); + + afterEach(() => { + if (originalHooksFile !== null) { + fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); + fs.writeFileSync(HOOKS_FILE, originalHooksFile); + } else if (fs.existsSync(HOOKS_FILE)) { + fs.unlinkSync(HOOKS_FILE); + } + vi.clearAllMocks(); + }); + + it('removes both Stop and SessionEnd code-insights hooks', async () => { + // Set up settings with both hooks installed + fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); + fs.writeFileSync(HOOKS_FILE, JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: 'node /usr/local/lib/node_modules/@code-insights/cli/dist/index.js sync -q' }] }], + SessionEnd: [{ hooks: [{ type: 'command', command: 'node /usr/local/lib/node_modules/@code-insights/cli/dist/index.js insights --hook --native -q', timeout: 120000 }] }], + }, + })); + + const { uninstallHookCommand } = await import('../install-hook.js'); + await uninstallHookCommand(); + + const settings = readSettings(); + // Both hook arrays should be gone (cleaned up) + expect((settings.hooks as Record | undefined)?.Stop).toBeUndefined(); + expect((settings.hooks as Record | undefined)?.SessionEnd).toBeUndefined(); + }); + + it('preserves non-code-insights Stop hooks', async () => { + fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); + fs.writeFileSync(HOOKS_FILE, JSON.stringify({ + hooks: { + Stop: [ + { hooks: [{ type: 'command', command: 'other-tool cleanup' }] }, + { hooks: [{ type: 'command', command: 'node /path/code-insights sync -q' }] }, + ], + }, + })); + + const { uninstallHookCommand } = await import('../install-hook.js'); + await uninstallHookCommand(); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + // Other tool's hook should be preserved + expect(hooks.Stop).toHaveLength(1); + const remaining = hooks.Stop[0] as { hooks: Array<{ command: string }> }; + expect(remaining.hooks[0].command).toBe('other-tool cleanup'); + }); + + it('preserves non-code-insights SessionEnd hooks', async () => { + fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); + fs.writeFileSync(HOOKS_FILE, JSON.stringify({ + hooks: { + SessionEnd: [ + { hooks: [{ type: 'command', command: 'other-tool end-session' }] }, + { hooks: [{ type: 'command', command: 'node /path/code-insights insights --hook --native -q', timeout: 120000 }] }, + ], + }, + })); + + const { uninstallHookCommand } = await import('../install-hook.js'); + await uninstallHookCommand(); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + expect(hooks.SessionEnd).toHaveLength(1); + const remaining = hooks.SessionEnd[0] as { hooks: Array<{ command: string }> }; + expect(remaining.hooks[0].command).toBe('other-tool end-session'); + }); + + it('handles missing settings.json gracefully', async () => { + if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); + + const { uninstallHookCommand } = await import('../install-hook.js'); + // Should not throw + await expect(uninstallHookCommand()).resolves.toBeUndefined(); + }); + + it('cleans up empty hooks object after removal', async () => { + fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); + fs.writeFileSync(HOOKS_FILE, JSON.stringify({ + hooks: { + Stop: [{ hooks: [{ type: 'command', command: 'node /path/code-insights sync -q' }] }], + SessionEnd: [{ hooks: [{ type: 'command', command: 'node /path/code-insights insights --hook --native -q', timeout: 120000 }] }], + }, + })); + + const { uninstallHookCommand } = await import('../install-hook.js'); + await uninstallHookCommand(); + + const settings = readSettings(); + // hooks object should be removed entirely since all entries were code-insights + expect(settings.hooks).toBeUndefined(); + }); +}); diff --git a/cli/src/commands/install-hook.ts b/cli/src/commands/install-hook.ts index 05b1722b..f0a69786 100644 --- a/cli/src/commands/install-hook.ts +++ b/cli/src/commands/install-hook.ts @@ -11,6 +11,7 @@ interface ClaudeSettings { hooks?: { PostToolUse?: HookConfig[]; Stop?: HookConfig[]; + SessionEnd?: HookConfig[]; [key: string]: HookConfig[] | undefined; }; [key: string]: unknown; @@ -21,24 +22,52 @@ interface HookConfig { hooks: Array; } +export interface InstallHookOptions { + syncOnly?: boolean; + analysisOnly?: boolean; +} + /** Extract command string from both old (string) and new ({type, command}) hook formats */ function getHookCommand(hook: string | { type: string; command: string }): string { return typeof hook === 'string' ? hook : hook.command; } +/** Check if a hook array already contains a code-insights hook */ +function hookAlreadyInstalled(hookList: HookConfig[]): boolean { + return hookList.some( + (h) => h.hooks.some((hook) => getHookCommand(hook).includes('code-insights')) + ); +} + /** - * Install Claude Code hook for auto-sync + * Install Claude Code hooks for auto-sync and native session analysis. + * + * By default installs both the Stop (sync) and SessionEnd (analysis) hooks. + * Use --sync-only or --analysis-only for granular control. */ -export async function installHookCommand(): Promise { - console.log(chalk.cyan('\n🔗 Install Code Insights Hook\n')); +export async function installHookCommand(options: InstallHookOptions = {}): Promise { + const { syncOnly = false, analysisOnly = false } = options; + + const installSync = !analysisOnly; + const installAnalysis = !syncOnly; + + console.log(chalk.cyan('\nInstall Code Insights Hooks\n')); try { - // Get CLI path const cliPath = process.argv[1]; const syncCommand = `node ${cliPath} sync -q`; - - console.log(chalk.gray('This will add a Claude Code hook that syncs sessions automatically.')); - console.log(chalk.gray(`Hook command: ${syncCommand}\n`)); + const analysisCommand = `node ${cliPath} insights --hook --native -q`; + + if (!syncOnly && !analysisOnly) { + console.log(chalk.gray('This will add two Claude Code hooks:\n')); + console.log(chalk.white(' Stop hook — Syncs sessions after each response')); + console.log(chalk.white(' SessionEnd hook — Analyzes sessions using your Claude subscription')); + console.log(chalk.gray(' No API key needed. (~15-30s per session)\n')); + } else if (syncOnly) { + console.log(chalk.gray(`This will add a Stop hook: ${syncCommand}\n`)); + } else { + console.log(chalk.gray(`This will add a SessionEnd hook: ${analysisCommand}\n`)); + } // Load existing settings let settings: ClaudeSettings = {}; @@ -51,41 +80,73 @@ export async function installHookCommand(): Promise { } } - // Initialize hooks structure if (!settings.hooks) { settings.hooks = {}; } - // Add Stop hook (runs when Claude finishes responding) - const stopHook: HookConfig = { - hooks: [{ type: 'command', command: syncCommand }], - }; + let syncInstalled = false; + let analysisInstalled = false; + + // Install Stop hook (sync) + if (installSync) { + const existingStopHooks = settings.hooks.Stop || []; + if (hookAlreadyInstalled(existingStopHooks)) { + console.log(chalk.yellow('Stop hook already installed.')); + } else { + const stopHook: HookConfig = { + hooks: [{ type: 'command', command: syncCommand }], + }; + settings.hooks.Stop = [...existingStopHooks, stopHook]; + syncInstalled = true; + } + } - // Check if hook already exists - const existingStopHooks = settings.hooks.Stop || []; - const hookExists = existingStopHooks.some( - (h) => h.hooks.some((hook) => getHookCommand(hook).includes('code-insights')) - ); + // Install SessionEnd hook (analysis) + if (installAnalysis) { + const existingSessionEndHooks = settings.hooks.SessionEnd || []; + if (hookAlreadyInstalled(existingSessionEndHooks)) { + console.log(chalk.yellow('SessionEnd hook already installed.')); + } else { + const sessionEndHook: HookConfig = { + hooks: [{ type: 'command', command: analysisCommand, timeout: 120000 }], + }; + settings.hooks.SessionEnd = [...existingSessionEndHooks, sessionEndHook]; + analysisInstalled = true; + } + } - if (hookExists) { - console.log(chalk.yellow('Code Insights hook already installed.')); + if (!syncInstalled && !analysisInstalled) { + console.log(chalk.yellow('Code Insights hooks already installed.')); console.log(chalk.gray('To reinstall, first run `code-insights uninstall-hook`')); return; } - settings.hooks.Stop = [...existingStopHooks, stopHook]; - // Write settings fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); fs.writeFileSync(HOOKS_FILE, JSON.stringify(settings, null, 2)); + const installedTypes: string[] = []; + if (syncInstalled) installedTypes.push('sync'); + if (analysisInstalled) installedTypes.push('analysis'); + console.log(chalk.green('Hook installed successfully!')); console.log(chalk.gray(`\nConfiguration saved to: ${HOOKS_FILE}`)); - console.log(chalk.cyan('\nHow it works:')); - console.log(chalk.white(' When a Claude Code session ends, the hook runs automatically')); - console.log(chalk.white(' Sessions are synced to your local database (~/.code-insights/data.db)')); - console.log(chalk.white(' Run `code-insights stats` anytime to see your analytics')); - trackEvent('cli_install_hook', { success: true }); + + if (!analysisOnly) { + console.log(chalk.cyan('\nHow it works:')); + console.log(chalk.white(' Stop hook: sessions are synced after each Claude response')); + } + if (!syncOnly) { + console.log(chalk.white(' SessionEnd hook: sessions are analyzed when a session ends')); + console.log(chalk.white(' No API key needed — uses your Claude Code subscription')); + } + + trackEvent('cli_install_hook', { + success: true, + hook_types: installedTypes.join(','), + sync_installed: syncInstalled, + analysis_installed: analysisInstalled, + }); } catch (error) { console.log(chalk.red(`Failed to install hook: ${error instanceof Error ? error.message : 'Unknown error'}`)); const { error_type, error_message } = classifyError(error); @@ -95,10 +156,10 @@ export async function installHookCommand(): Promise { } /** - * Uninstall Claude Code hook + * Uninstall Claude Code hooks — removes both Stop (sync) and SessionEnd (analysis) hooks. */ export async function uninstallHookCommand(): Promise { - console.log(chalk.cyan('\n🔗 Uninstall Code Insights Hook\n')); + console.log(chalk.cyan('\nUninstall Code Insights Hooks\n')); if (!fs.existsSync(HOOKS_FILE)) { console.log(chalk.yellow('No hooks file found. Nothing to uninstall.')); @@ -109,29 +170,41 @@ export async function uninstallHookCommand(): Promise { const content = fs.readFileSync(HOOKS_FILE, 'utf-8'); const settings: ClaudeSettings = JSON.parse(content); - if (!settings.hooks?.Stop) { - console.log(chalk.yellow('No Stop hooks found. Nothing to uninstall.')); + if (!settings.hooks?.Stop && !settings.hooks?.SessionEnd) { + console.log(chalk.yellow('No Code Insights hooks found. Nothing to uninstall.')); return; } - // Filter out Code Insights hooks - settings.hooks.Stop = settings.hooks.Stop.filter( - (h) => !h.hooks.some((hook) => getHookCommand(hook).includes('code-insights')) - ); + // Filter out Code Insights Stop hooks + if (settings.hooks.Stop) { + settings.hooks.Stop = settings.hooks.Stop.filter( + (h) => !h.hooks.some((hook) => getHookCommand(hook).includes('code-insights')) + ); + if (settings.hooks.Stop.length === 0) { + delete settings.hooks.Stop; + } + } - // Clean up empty arrays - if (settings.hooks.Stop.length === 0) { - delete settings.hooks.Stop; + // Filter out Code Insights SessionEnd hooks + if (settings.hooks.SessionEnd) { + settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter( + (h) => !h.hooks.some((hook) => getHookCommand(hook).includes('code-insights')) + ); + if (settings.hooks.SessionEnd.length === 0) { + delete settings.hooks.SessionEnd; + } } - if (Object.keys(settings.hooks).length === 0) { + + // Clean up empty hooks object + if (settings.hooks && Object.keys(settings.hooks).length === 0) { delete settings.hooks; } fs.writeFileSync(HOOKS_FILE, JSON.stringify(settings, null, 2)); - console.log(chalk.green('✅ Hook uninstalled successfully!')); + console.log(chalk.green('Hooks uninstalled successfully!')); } catch (error) { - console.log(chalk.red('Failed to uninstall hook:')); + console.log(chalk.red('Failed to uninstall hooks:')); console.error(error instanceof Error ? error.message : 'Unknown error'); } } diff --git a/cli/src/index.ts b/cli/src/index.ts index 32b06710..be9535c8 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -89,12 +89,14 @@ program program .command('install-hook') - .description('Install Claude Code hook for automatic sync') - .action(installHookCommand); + .description('Install Claude Code hooks for automatic sync and session analysis') + .option('--sync-only', 'Install only the Stop (sync) hook') + .option('--analysis-only', 'Install only the SessionEnd (analysis) hook') + .action((opts) => installHookCommand({ syncOnly: opts.syncOnly, analysisOnly: opts.analysisOnly })); program .command('uninstall-hook') - .description('Remove Claude Code hook') + .description('Remove Claude Code hooks (sync and analysis)') .action(uninstallHookCommand); program From 5b673c6ba580e54131b12ba47a9f6aa41e4c9485 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Sun, 29 Mar 2026 20:56:57 +0530 Subject: [PATCH 2/4] fix(cli): add mutual exclusion guard for --sync-only + --analysis-only Passing both flags together previously resolved to neither hook being installed with misleading output. Now exits early with a clear error. Co-Authored-By: Claude Sonnet 4.6 --- cli/src/commands/__tests__/install-hook.test.ts | 12 ++++++++++++ cli/src/commands/install-hook.ts | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/cli/src/commands/__tests__/install-hook.test.ts b/cli/src/commands/__tests__/install-hook.test.ts index 0f296f35..600a94e4 100644 --- a/cli/src/commands/__tests__/install-hook.test.ts +++ b/cli/src/commands/__tests__/install-hook.test.ts @@ -138,6 +138,18 @@ describe('installHookCommand', () => { }); }); + describe('--sync-only + --analysis-only mutual exclusion', () => { + it('returns early with error message when both flags are set', async () => { + if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); + + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({ syncOnly: true, analysisOnly: true }); + + // No hooks file should have been written + expect(fs.existsSync(HOOKS_FILE)).toBe(false); + }); + }); + describe('--sync-only flag', () => { it('installs only Stop hook when --sync-only is set', async () => { if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); diff --git a/cli/src/commands/install-hook.ts b/cli/src/commands/install-hook.ts index f0a69786..28179cb7 100644 --- a/cli/src/commands/install-hook.ts +++ b/cli/src/commands/install-hook.ts @@ -48,6 +48,11 @@ function hookAlreadyInstalled(hookList: HookConfig[]): boolean { export async function installHookCommand(options: InstallHookOptions = {}): Promise { const { syncOnly = false, analysisOnly = false } = options; + if (syncOnly && analysisOnly) { + console.log(chalk.red('Cannot use --sync-only and --analysis-only together. Use neither flag to install both hooks.')); + return; + } + const installSync = !analysisOnly; const installAnalysis = !syncOnly; From 043718a2411004e263c7a06656b6ac90942ac31b Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Mon, 30 Mar 2026 06:44:31 +0530 Subject: [PATCH 3/4] fix(cli): consolidate already-installed message and add console spy to test - Remove per-hook "already installed" prints; single consolidated message shows which hooks were already present (e.g. "sync + analysis") - Add console.log spy to mutual exclusion test to guard against silent removal of the error message Co-Authored-By: Claude Sonnet 4.6 --- cli/src/commands/__tests__/install-hook.test.ts | 15 ++++++++++++++- cli/src/commands/install-hook.ts | 12 +++++------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/__tests__/install-hook.test.ts b/cli/src/commands/__tests__/install-hook.test.ts index 600a94e4..1beee965 100644 --- a/cli/src/commands/__tests__/install-hook.test.ts +++ b/cli/src/commands/__tests__/install-hook.test.ts @@ -143,10 +143,13 @@ describe('installHookCommand', () => { if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); const { installHookCommand } = await import('../install-hook.js'); + const consoleSpy = vi.spyOn(console, 'log'); await installHookCommand({ syncOnly: true, analysisOnly: true }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Cannot use')); // No hooks file should have been written expect(fs.existsSync(HOOKS_FILE)).toBe(false); + consoleSpy.mockRestore(); }); }); @@ -208,13 +211,23 @@ describe('installHookCommand', () => { expect(hooks.SessionEnd).toHaveLength(1); }); - it('reports already-installed when both hooks exist on default install', async () => { + it('shows consolidated already-installed message when both hooks exist on default install', async () => { if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); const { installHookCommand } = await import('../install-hook.js'); await installHookCommand({}); + + const consoleSpy = vi.spyOn(console, 'log'); await installHookCommand({}); + // Consolidated single message, not two separate messages + const alreadyInstalledCalls = consoleSpy.mock.calls.filter( + (args) => typeof args[0] === 'string' && String(args[0]).includes('already installed') + ); + expect(alreadyInstalledCalls).toHaveLength(1); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('sync + analysis')); + consoleSpy.mockRestore(); + const settings = readSettings(); const hooks = settings.hooks as Record; expect(hooks.Stop).toHaveLength(1); diff --git a/cli/src/commands/install-hook.ts b/cli/src/commands/install-hook.ts index 28179cb7..0fa96946 100644 --- a/cli/src/commands/install-hook.ts +++ b/cli/src/commands/install-hook.ts @@ -95,9 +95,7 @@ export async function installHookCommand(options: InstallHookOptions = {}): Prom // Install Stop hook (sync) if (installSync) { const existingStopHooks = settings.hooks.Stop || []; - if (hookAlreadyInstalled(existingStopHooks)) { - console.log(chalk.yellow('Stop hook already installed.')); - } else { + if (!hookAlreadyInstalled(existingStopHooks)) { const stopHook: HookConfig = { hooks: [{ type: 'command', command: syncCommand }], }; @@ -109,9 +107,7 @@ export async function installHookCommand(options: InstallHookOptions = {}): Prom // Install SessionEnd hook (analysis) if (installAnalysis) { const existingSessionEndHooks = settings.hooks.SessionEnd || []; - if (hookAlreadyInstalled(existingSessionEndHooks)) { - console.log(chalk.yellow('SessionEnd hook already installed.')); - } else { + if (!hookAlreadyInstalled(existingSessionEndHooks)) { const sessionEndHook: HookConfig = { hooks: [{ type: 'command', command: analysisCommand, timeout: 120000 }], }; @@ -121,7 +117,9 @@ export async function installHookCommand(options: InstallHookOptions = {}): Prom } if (!syncInstalled && !analysisInstalled) { - console.log(chalk.yellow('Code Insights hooks already installed.')); + // Both requested hooks were already present — show a single consolidated message + const label = installSync && installAnalysis ? 'sync + analysis' : installSync ? 'sync' : 'analysis'; + console.log(chalk.yellow(`Code Insights hooks already installed (${label}).`)); console.log(chalk.gray('To reinstall, first run `code-insights uninstall-hook`')); return; } From bd054aeb53038372711a07aa5805fa0a46d78b07 Mon Sep 17 00:00:00 2001 From: Srikanth Rao M Date: Mon, 30 Mar 2026 06:47:37 +0530 Subject: [PATCH 4/4] fix(cli): stable CLI path via import.meta.url, isolate tests with temp homedir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace process.argv[1] with import.meta.url-based path resolution so hook commands written to settings.json are stable across npm link, global install, and npx (where argv[1] points to a changing cache path) - Rewrite test setup to mock os.homedir() via vi.mock with a mutable sentinel object (avoids ESM hoisting issue with let declarations); each test gets an isolated temp dir — real ~/.claude/settings.json is never touched Co-Authored-By: Claude Sonnet 4.6 --- .../commands/__tests__/install-hook.test.ts | 174 ++++++------------ cli/src/commands/install-hook.ts | 10 +- 2 files changed, 67 insertions(+), 117 deletions(-) diff --git a/cli/src/commands/__tests__/install-hook.test.ts b/cli/src/commands/__tests__/install-hook.test.ts index 1beee965..4fc252c1 100644 --- a/cli/src/commands/__tests__/install-hook.test.ts +++ b/cli/src/commands/__tests__/install-hook.test.ts @@ -11,56 +11,57 @@ vi.mock('../../utils/telemetry.js', () => ({ classifyError: vi.fn(() => ({ error_type: 'unknown', error_message: 'unknown' })), })); -// ── Helpers ─────────────────────────────────────────────────────────────────── - -const HOOKS_FILE = path.join(os.homedir(), '.claude', 'settings.json'); -const CLAUDE_SETTINGS_DIR = path.join(os.homedir(), '.claude'); +// Mock os module so homedir() returns our temp dir. +// Uses a mutable object (not a `let`) because vi.mock factories are hoisted before +// variable declarations — a plain object property is safe to read at any point. +const _mockOs = { homeDir: '' }; + +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => _mockOs.homeDir, + }; +}); -/** Read and parse the settings.json file written during test */ -function readSettings(): Record { - return JSON.parse(fs.readFileSync(HOOKS_FILE, 'utf-8')); -} +// ── Setup: isolated temp home dir per test ──────────────────────────────────── -// ── Tests ───────────────────────────────────────────────────────────────────── +let mockHomeDir: string; -describe('installHookCommand', () => { - let tmpDir: string; - let originalHooksFile: string | null = null; +beforeEach(() => { + // Each test gets its own temp dir as home — never touches real ~/.claude/settings.json + mockHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-hook-test-')); + _mockOs.homeDir = mockHomeDir; + // Reset module cache so CLAUDE_SETTINGS_DIR / HOOKS_FILE pick up the new mockHomeDir + vi.resetModules(); +}); - beforeEach(() => { - // Use a temp dir so we don't corrupt real ~/.claude/settings.json - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-hook-test-')); +afterEach(() => { + fs.rmSync(mockHomeDir, { recursive: true, force: true }); + vi.clearAllMocks(); +}); - // Back up real settings.json if it exists - if (fs.existsSync(HOOKS_FILE)) { - originalHooksFile = fs.readFileSync(HOOKS_FILE, 'utf-8'); - } else { - originalHooksFile = null; - } +// ── Helpers ─────────────────────────────────────────────────────────────────── - vi.resetModules(); - }); +function hooksFile(): string { + return path.join(mockHomeDir, '.claude', 'settings.json'); +} - afterEach(() => { - // Restore real settings.json - if (originalHooksFile !== null) { - fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); - fs.writeFileSync(HOOKS_FILE, originalHooksFile); - } else if (fs.existsSync(HOOKS_FILE)) { - fs.unlinkSync(HOOKS_FILE); - } +function readSettings(): Record { + return JSON.parse(fs.readFileSync(hooksFile(), 'utf-8')); +} - // Clean up temp dir - fs.rmSync(tmpDir, { recursive: true, force: true }); +function writeSettings(data: unknown): void { + const dir = path.join(mockHomeDir, '.claude'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(hooksFile(), JSON.stringify(data)); +} - vi.clearAllMocks(); - }); +// ── Tests ───────────────────────────────────────────────────────────────────── +describe('installHookCommand', () => { describe('default install (both hooks)', () => { it('installs both Stop and SessionEnd hooks', async () => { - // Ensure clean state - if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); - const { installHookCommand } = await import('../install-hook.js'); await installHookCommand({}); @@ -74,43 +75,36 @@ describe('installHookCommand', () => { expect(hooks.SessionEnd).toHaveLength(1); }); - it('Stop hook contains sync command', async () => { - if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); - + it('Stop hook contains sync command pointing to stable CLI entry', async () => { const { installHookCommand } = await import('../install-hook.js'); await installHookCommand({}); const settings = readSettings(); const hooks = settings.hooks as Record }>>; - const stopHookEntry = hooks.Stop[0]; + const stopCmd = hooks.Stop[0].hooks[0]; - expect(stopHookEntry.hooks).toHaveLength(1); - const stopCmd = stopHookEntry.hooks[0]; expect(stopCmd.type).toBe('command'); expect(stopCmd.command).toContain('sync -q'); + // Must use node + absolute path (not process.argv[1] which is unstable under npx) + expect(stopCmd.command).toMatch(/^node .+index\.js sync -q$/); }); it('SessionEnd hook contains insights command with 120s timeout', async () => { - if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); - const { installHookCommand } = await import('../install-hook.js'); await installHookCommand({}); const settings = readSettings(); const hooks = settings.hooks as Record }>>; - const sessionEndEntry = hooks.SessionEnd[0]; + const sessionEndCmd = hooks.SessionEnd[0].hooks[0]; - expect(sessionEndEntry.hooks).toHaveLength(1); - const sessionEndCmd = sessionEndEntry.hooks[0]; expect(sessionEndCmd.type).toBe('command'); expect(sessionEndCmd.command).toContain('insights --hook --native -q'); + expect(sessionEndCmd.command).toMatch(/^node .+index\.js insights --hook --native -q$/); expect(sessionEndCmd.timeout).toBe(120000); }); it('preserves existing settings.json content', async () => { - // Write some pre-existing settings - fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); - fs.writeFileSync(HOOKS_FILE, JSON.stringify({ theme: 'dark', someOtherKey: 42 })); + writeSettings({ theme: 'dark', someOtherKey: 42 }); const { installHookCommand } = await import('../install-hook.js'); await installHookCommand({}); @@ -121,42 +115,36 @@ describe('installHookCommand', () => { }); it('preserves existing non-code-insights hooks', async () => { - fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); - fs.writeFileSync(HOOKS_FILE, JSON.stringify({ + writeSettings({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'other-tool sync' }] }], }, - })); + }); const { installHookCommand } = await import('../install-hook.js'); await installHookCommand({}); const settings = readSettings(); const hooks = settings.hooks as Record; - // Should have 2 Stop hooks now: the existing one + our new one + // Should have 2 Stop hooks: the existing one + our new one expect(hooks.Stop).toHaveLength(2); }); }); describe('--sync-only + --analysis-only mutual exclusion', () => { it('returns early with error message when both flags are set', async () => { - if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); - const { installHookCommand } = await import('../install-hook.js'); const consoleSpy = vi.spyOn(console, 'log'); await installHookCommand({ syncOnly: true, analysisOnly: true }); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Cannot use')); - // No hooks file should have been written - expect(fs.existsSync(HOOKS_FILE)).toBe(false); - consoleSpy.mockRestore(); + // No settings file should have been written + expect(fs.existsSync(hooksFile())).toBe(false); }); }); describe('--sync-only flag', () => { it('installs only Stop hook when --sync-only is set', async () => { - if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); - const { installHookCommand } = await import('../install-hook.js'); await installHookCommand({ syncOnly: true }); @@ -169,8 +157,6 @@ describe('installHookCommand', () => { describe('--analysis-only flag', () => { it('installs only SessionEnd hook when --analysis-only is set', async () => { - if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); - const { installHookCommand } = await import('../install-hook.js'); await installHookCommand({ analysisOnly: true }); @@ -183,27 +169,18 @@ describe('installHookCommand', () => { describe('duplicate detection', () => { it('does not install Stop hook if code-insights Stop hook already exists', async () => { - if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); - const { installHookCommand } = await import('../install-hook.js'); - // First install await installHookCommand({ syncOnly: true }); - // Second install await installHookCommand({ syncOnly: true }); const settings = readSettings(); const hooks = settings.hooks as Record; - // Should still be just 1, not 2 expect(hooks.Stop).toHaveLength(1); }); it('does not install SessionEnd hook if code-insights SessionEnd hook already exists', async () => { - if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); - const { installHookCommand } = await import('../install-hook.js'); - // First install await installHookCommand({ analysisOnly: true }); - // Second install await installHookCommand({ analysisOnly: true }); const settings = readSettings(); @@ -212,21 +189,18 @@ describe('installHookCommand', () => { }); it('shows consolidated already-installed message when both hooks exist on default install', async () => { - if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); - const { installHookCommand } = await import('../install-hook.js'); await installHookCommand({}); const consoleSpy = vi.spyOn(console, 'log'); await installHookCommand({}); - // Consolidated single message, not two separate messages + // Single consolidated message, not two separate ones const alreadyInstalledCalls = consoleSpy.mock.calls.filter( (args) => typeof args[0] === 'string' && String(args[0]).includes('already installed') ); expect(alreadyInstalledCalls).toHaveLength(1); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('sync + analysis')); - consoleSpy.mockRestore(); const settings = readSettings(); const hooks = settings.hooks as Record; @@ -237,78 +211,55 @@ describe('installHookCommand', () => { }); describe('uninstallHookCommand', () => { - let originalHooksFile: string | null = null; - beforeEach(() => { - if (fs.existsSync(HOOKS_FILE)) { - originalHooksFile = fs.readFileSync(HOOKS_FILE, 'utf-8'); - } else { - originalHooksFile = null; - } vi.resetModules(); }); - afterEach(() => { - if (originalHooksFile !== null) { - fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); - fs.writeFileSync(HOOKS_FILE, originalHooksFile); - } else if (fs.existsSync(HOOKS_FILE)) { - fs.unlinkSync(HOOKS_FILE); - } - vi.clearAllMocks(); - }); - it('removes both Stop and SessionEnd code-insights hooks', async () => { - // Set up settings with both hooks installed - fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); - fs.writeFileSync(HOOKS_FILE, JSON.stringify({ + writeSettings({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'node /usr/local/lib/node_modules/@code-insights/cli/dist/index.js sync -q' }] }], SessionEnd: [{ hooks: [{ type: 'command', command: 'node /usr/local/lib/node_modules/@code-insights/cli/dist/index.js insights --hook --native -q', timeout: 120000 }] }], }, - })); + }); const { uninstallHookCommand } = await import('../install-hook.js'); await uninstallHookCommand(); const settings = readSettings(); - // Both hook arrays should be gone (cleaned up) expect((settings.hooks as Record | undefined)?.Stop).toBeUndefined(); expect((settings.hooks as Record | undefined)?.SessionEnd).toBeUndefined(); }); it('preserves non-code-insights Stop hooks', async () => { - fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); - fs.writeFileSync(HOOKS_FILE, JSON.stringify({ + writeSettings({ hooks: { Stop: [ { hooks: [{ type: 'command', command: 'other-tool cleanup' }] }, { hooks: [{ type: 'command', command: 'node /path/code-insights sync -q' }] }, ], }, - })); + }); const { uninstallHookCommand } = await import('../install-hook.js'); await uninstallHookCommand(); const settings = readSettings(); const hooks = settings.hooks as Record; - // Other tool's hook should be preserved expect(hooks.Stop).toHaveLength(1); const remaining = hooks.Stop[0] as { hooks: Array<{ command: string }> }; expect(remaining.hooks[0].command).toBe('other-tool cleanup'); }); it('preserves non-code-insights SessionEnd hooks', async () => { - fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); - fs.writeFileSync(HOOKS_FILE, JSON.stringify({ + writeSettings({ hooks: { SessionEnd: [ { hooks: [{ type: 'command', command: 'other-tool end-session' }] }, { hooks: [{ type: 'command', command: 'node /path/code-insights insights --hook --native -q', timeout: 120000 }] }, ], }, - })); + }); const { uninstallHookCommand } = await import('../install-hook.js'); await uninstallHookCommand(); @@ -321,27 +272,22 @@ describe('uninstallHookCommand', () => { }); it('handles missing settings.json gracefully', async () => { - if (fs.existsSync(HOOKS_FILE)) fs.unlinkSync(HOOKS_FILE); - const { uninstallHookCommand } = await import('../install-hook.js'); - // Should not throw await expect(uninstallHookCommand()).resolves.toBeUndefined(); }); it('cleans up empty hooks object after removal', async () => { - fs.mkdirSync(CLAUDE_SETTINGS_DIR, { recursive: true }); - fs.writeFileSync(HOOKS_FILE, JSON.stringify({ + writeSettings({ hooks: { Stop: [{ hooks: [{ type: 'command', command: 'node /path/code-insights sync -q' }] }], SessionEnd: [{ hooks: [{ type: 'command', command: 'node /path/code-insights insights --hook --native -q', timeout: 120000 }] }], }, - })); + }); const { uninstallHookCommand } = await import('../install-hook.js'); await uninstallHookCommand(); const settings = readSettings(); - // hooks object should be removed entirely since all entries were code-insights expect(settings.hooks).toBeUndefined(); }); }); diff --git a/cli/src/commands/install-hook.ts b/cli/src/commands/install-hook.ts index 0fa96946..6262a59f 100644 --- a/cli/src/commands/install-hook.ts +++ b/cli/src/commands/install-hook.ts @@ -1,12 +1,17 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import { fileURLToPath } from 'url'; import chalk from 'chalk'; import { trackEvent, captureError, classifyError } from '../utils/telemetry.js'; const CLAUDE_SETTINGS_DIR = path.join(os.homedir(), '.claude'); const HOOKS_FILE = path.join(CLAUDE_SETTINGS_DIR, 'settings.json'); +// Stable path to the CLI entry point — works across npm link, global install, and npx. +// process.argv[1] is unstable (npx uses a cache path that changes per invocation). +const CLI_ENTRY = path.resolve(fileURLToPath(import.meta.url), '../../index.js'); + interface ClaudeSettings { hooks?: { PostToolUse?: HookConfig[]; @@ -59,9 +64,8 @@ export async function installHookCommand(options: InstallHookOptions = {}): Prom console.log(chalk.cyan('\nInstall Code Insights Hooks\n')); try { - const cliPath = process.argv[1]; - const syncCommand = `node ${cliPath} sync -q`; - const analysisCommand = `node ${cliPath} insights --hook --native -q`; + const syncCommand = `node ${CLI_ENTRY} sync -q`; + const analysisCommand = `node ${CLI_ENTRY} insights --hook --native -q`; if (!syncOnly && !analysisOnly) { console.log(chalk.gray('This will add two Claude Code hooks:\n'));