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 0000000..4fc252c --- /dev/null +++ b/cli/src/commands/__tests__/install-hook.test.ts @@ -0,0 +1,293 @@ +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' })), +})); + +// 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, + }; +}); + +// ── Setup: isolated temp home dir per test ──────────────────────────────────── + +let mockHomeDir: string; + +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(); +}); + +afterEach(() => { + fs.rmSync(mockHomeDir, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function hooksFile(): string { + return path.join(mockHomeDir, '.claude', 'settings.json'); +} + +function readSettings(): Record { + return JSON.parse(fs.readFileSync(hooksFile(), 'utf-8')); +} + +function writeSettings(data: unknown): void { + const dir = path.join(mockHomeDir, '.claude'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(hooksFile(), JSON.stringify(data)); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('installHookCommand', () => { + describe('default install (both hooks)', () => { + it('installs both Stop and SessionEnd hooks', async () => { + 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 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 stopCmd = hooks.Stop[0].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 () => { + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({}); + + const settings = readSettings(); + const hooks = settings.hooks as Record }>>; + const sessionEndCmd = hooks.SessionEnd[0].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 () => { + writeSettings({ 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 () => { + 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: 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 () => { + 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 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 () => { + 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 () => { + 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 () => { + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({ syncOnly: true }); + await installHookCommand({ syncOnly: true }); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + expect(hooks.Stop).toHaveLength(1); + }); + + it('does not install SessionEnd hook if code-insights SessionEnd hook already exists', async () => { + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({ analysisOnly: true }); + await installHookCommand({ analysisOnly: true }); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + expect(hooks.SessionEnd).toHaveLength(1); + }); + + it('shows consolidated already-installed message when both hooks exist on default install', async () => { + const { installHookCommand } = await import('../install-hook.js'); + await installHookCommand({}); + + const consoleSpy = vi.spyOn(console, 'log'); + await installHookCommand({}); + + // 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')); + + const settings = readSettings(); + const hooks = settings.hooks as Record; + expect(hooks.Stop).toHaveLength(1); + expect(hooks.SessionEnd).toHaveLength(1); + }); + }); +}); + +describe('uninstallHookCommand', () => { + beforeEach(() => { + vi.resetModules(); + }); + + it('removes both Stop and SessionEnd code-insights hooks', async () => { + 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(); + expect((settings.hooks as Record | undefined)?.Stop).toBeUndefined(); + expect((settings.hooks as Record | undefined)?.SessionEnd).toBeUndefined(); + }); + + it('preserves non-code-insights Stop hooks', async () => { + 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; + 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 () => { + 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(); + + 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 () => { + const { uninstallHookCommand } = await import('../install-hook.js'); + await expect(uninstallHookCommand()).resolves.toBeUndefined(); + }); + + it('cleans up empty hooks object after removal', async () => { + 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(); + expect(settings.hooks).toBeUndefined(); + }); +}); diff --git a/cli/src/commands/install-hook.ts b/cli/src/commands/install-hook.ts index 05b1722..6262a59 100644 --- a/cli/src/commands/install-hook.ts +++ b/cli/src/commands/install-hook.ts @@ -1,16 +1,22 @@ 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[]; Stop?: HookConfig[]; + SessionEnd?: HookConfig[]; [key: string]: HookConfig[] | undefined; }; [key: string]: unknown; @@ -21,24 +27,56 @@ 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; - try { - // Get CLI path - const cliPath = process.argv[1]; - const syncCommand = `node ${cliPath} sync -q`; + 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; + + console.log(chalk.cyan('\nInstall Code Insights Hooks\n')); - console.log(chalk.gray('This will add a Claude Code hook that syncs sessions automatically.')); - console.log(chalk.gray(`Hook command: ${syncCommand}\n`)); + try { + 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')); + 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 +89,71 @@ 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)) { + 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)) { + 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) { + // 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; } - 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 +163,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 +177,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 32b0671..be9535c 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