diff --git a/src/agents/adapters/claude-code.test.ts b/src/agents/adapters/claude-code.test.ts deleted file mode 100644 index 9cf4a965..00000000 --- a/src/agents/adapters/claude-code.test.ts +++ /dev/null @@ -1,231 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import type { InstallContext } from '../types.js'; -import { - claudeCodeAdapter, - getClaudeSettingsPath, - buildHookCommand, - buildStopHookCommand, - buildHookEntry, - writeHookEntry, - removeHookEntry, -} from './claude-code.js'; - -/** - * Unit tests for the Claude Code HookAdapter and its supporting hook helpers. - * These cover the adapter's contract (detect/install/uninstall/settingsPath/buildHooks) - * plus the building-block functions that were moved here from install.ts in M1 - * Branch 2 (v0.1.3/m1/claude-code-refactor). - * - * The install-time byte-identical guarantee is enforced separately by - * src/cli/commands/install.snapshot.test.ts. - */ -describe('claude-code adapter + helpers', () => { - let tmpHome: string; - let originalArgv1: string; - let logSpy: ReturnType; - - const makeCtx = (): InstallContext => ({ - home: tmpHome, - cwd: join(tmpHome, 'cwd'), - yes: true, - dbPath: ':memory:', - }); - - beforeEach(() => { - tmpHome = mkdtempSync(join(tmpdir(), 'nexpath-claude-adapter-')); - originalArgv1 = process.argv[1]; - process.argv[1] = '/fixture/nexpath/dist/cli/index.js'; - logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - }); - - afterEach(() => { - process.argv[1] = originalArgv1; - logSpy.mockRestore(); - rmSync(tmpHome, { recursive: true, force: true }); - }); - - // ── helpers ───────────────────────────────────────────────────────────────── - - describe('getClaudeSettingsPath', () => { - it('joins home with .claude/settings.json', () => { - expect(getClaudeSettingsPath(tmpHome)).toBe(join(tmpHome, '.claude', 'settings.json')); - }); - }); - - describe('buildHookCommand', () => { - it('produces a node command with the resolved CLI path and the prompt-store DB path', () => { - const cmd = buildHookCommand(tmpHome); - expect(cmd).toBe(`node "/fixture/nexpath/dist/cli/index.js" auto --db "${tmpHome}/.nexpath/prompt-store.db"`); - }); - }); - - describe('buildStopHookCommand', () => { - it('produces a node command ending in `stop`', () => { - const cmd = buildStopHookCommand(tmpHome); - expect(cmd).toBe(`node "/fixture/nexpath/dist/cli/index.js" stop --db "${tmpHome}/.nexpath/prompt-store.db"`); - }); - }); - - describe('buildHookEntry', () => { - it('returns UserPromptSubmit and Stop groups each with _nexpath_hook marker and one command entry', () => { - const entry = buildHookEntry(tmpHome); - expect(Object.keys(entry).sort()).toEqual(['Stop', 'UserPromptSubmit']); - const ups = (entry.UserPromptSubmit as Array>)[0]; - expect(ups._nexpath_hook).toBe(true); - expect(ups.matcher).toBe(''); - const upsHooks = ups.hooks as Array<{ type: string; command: string }>; - expect(upsHooks).toHaveLength(1); - expect(upsHooks[0].type).toBe('command'); - expect(upsHooks[0].command).toContain('auto'); - const stop = (entry.Stop as Array>)[0]; - expect(stop._nexpath_hook).toBe(true); - const stopHooks = stop.hooks as Array<{ type: string; command: string }>; - expect(stopHooks[0].command).toContain('stop'); - }); - }); - - describe('writeHookEntry + removeHookEntry round-trip', () => { - it('writes the hook entry and produces a parseable JSON file', () => { - const settingsPath = getClaudeSettingsPath(tmpHome); - writeHookEntry(settingsPath, tmpHome); - const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')); - expect(parsed.hooks.UserPromptSubmit[0]._nexpath_hook).toBe(true); - expect(parsed.hooks.Stop[0]._nexpath_hook).toBe(true); - }); - - it('is idempotent — re-writing replaces the previous nexpath hook, does not duplicate', () => { - const settingsPath = getClaudeSettingsPath(tmpHome); - writeHookEntry(settingsPath, tmpHome); - writeHookEntry(settingsPath, tmpHome); - const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')); - expect(parsed.hooks.UserPromptSubmit).toHaveLength(1); - expect(parsed.hooks.Stop).toHaveLength(1); - }); - - it('preserves non-nexpath hooks already present in settings.json', () => { - const settingsPath = getClaudeSettingsPath(tmpHome); - // pre-existing hook entry from some other tool - const { mkdirSync, writeFileSync } = require('node:fs') as typeof import('node:fs'); - mkdirSync(join(tmpHome, '.claude'), { recursive: true }); - writeFileSync( - settingsPath, - JSON.stringify({ - hooks: { - UserPromptSubmit: [{ matcher: '', hooks: [{ type: 'command', command: 'other-tool' }] }], - }, - }), - 'utf8', - ); - writeHookEntry(settingsPath, tmpHome); - const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')); - expect(parsed.hooks.UserPromptSubmit).toHaveLength(2); // one foreign + one nexpath - const nexpathGroup = parsed.hooks.UserPromptSubmit.find((g: { _nexpath_hook?: boolean }) => g._nexpath_hook); - const foreignGroup = parsed.hooks.UserPromptSubmit.find((g: { _nexpath_hook?: boolean }) => !g._nexpath_hook); - expect(nexpathGroup).toBeDefined(); - expect(foreignGroup.hooks[0].command).toBe('other-tool'); - }); - - it('removeHookEntry returns true and strips only the nexpath group', () => { - const settingsPath = getClaudeSettingsPath(tmpHome); - writeHookEntry(settingsPath, tmpHome); - const result = removeHookEntry(settingsPath); - expect(result).toBe(true); - const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')); - // After removal, both UserPromptSubmit and Stop are gone (had only the nexpath entry) - expect(parsed.hooks.UserPromptSubmit).toBeUndefined(); - expect(parsed.hooks.Stop).toBeUndefined(); - }); - - it('removeHookEntry returns false when no nexpath hook is present', () => { - const settingsPath = getClaudeSettingsPath(tmpHome); - expect(removeHookEntry(settingsPath)).toBe(false); - }); - }); - - // ── adapter ───────────────────────────────────────────────────────────────── - - describe('claudeCodeAdapter static fields', () => { - it('has the expected id, label, and category', () => { - expect(claudeCodeAdapter.id).toBe('claude-code'); - expect(claudeCodeAdapter.label).toBe('Claude Code'); - expect(claudeCodeAdapter.category).toBe('hook'); - }); - }); - - describe('claudeCodeAdapter.detect', () => { - it('always returns true', () => { - expect(claudeCodeAdapter.detect(makeCtx())).toBe(true); - }); - }); - - describe('claudeCodeAdapter.settingsPath', () => { - it('returns ~/.claude/settings.json relative to ctx.home when no override', () => { - expect(claudeCodeAdapter.settingsPath(makeCtx())).toBe(join(tmpHome, '.claude', 'settings.json')); - }); - - it('returns ctx.settingsPath when provided (override)', () => { - const override = join(tmpHome, 'custom', 'settings.json'); - expect(claudeCodeAdapter.settingsPath({ ...makeCtx(), settingsPath: override })).toBe(override); - }); - }); - - describe('claudeCodeAdapter.buildHooks', () => { - it('returns UserPromptSubmit and Stop with command-type entries', () => { - const hooks = claudeCodeAdapter.buildHooks(makeCtx()); - expect(hooks.UserPromptSubmit).toHaveLength(1); - expect(hooks.UserPromptSubmit[0].type).toBe('command'); - expect(hooks.UserPromptSubmit[0].command).toContain('auto'); - expect(hooks.Stop[0].command).toContain('stop'); - }); - }); - - describe('claudeCodeAdapter.install', () => { - it('writes the hook entry and returns status installed', async () => { - const result = await claudeCodeAdapter.install(makeCtx()); - expect(result.status).toBe('installed'); - const settingsPath = join(tmpHome, '.claude', 'settings.json'); - expect(existsSync(settingsPath)).toBe(true); - const parsed = JSON.parse(readFileSync(settingsPath, 'utf8')); - expect(parsed.hooks.UserPromptSubmit[0]._nexpath_hook).toBe(true); - }); - - it('logs the success line containing the settings path', async () => { - await claudeCodeAdapter.install(makeCtx()); - const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); - expect(allLogs).toContain('advisory hook written to'); - expect(allLogs).toContain(join(tmpHome, '.claude', 'settings.json')); - }); - - it('writes to ctx.settingsPath when override is provided, not to home/.claude/', async () => { - const customPath = join(tmpHome, 'custom-dir', 'override.json'); - const result = await claudeCodeAdapter.install({ ...makeCtx(), settingsPath: customPath }); - expect(result.status).toBe('installed'); - expect(existsSync(customPath)).toBe(true); - expect(existsSync(join(tmpHome, '.claude', 'settings.json'))).toBe(false); - const parsed = JSON.parse(readFileSync(customPath, 'utf8')); - expect(parsed.hooks.UserPromptSubmit[0]._nexpath_hook).toBe(true); - }); - }); - - describe('claudeCodeAdapter.uninstall', () => { - it('removes the hook entry after a prior install', async () => { - await claudeCodeAdapter.install(makeCtx()); - logSpy.mockClear(); - await claudeCodeAdapter.uninstall(makeCtx()); - const parsed = JSON.parse(readFileSync(join(tmpHome, '.claude', 'settings.json'), 'utf8')); - expect(parsed.hooks.UserPromptSubmit).toBeUndefined(); - expect(parsed.hooks.Stop).toBeUndefined(); - const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); - expect(allLogs).toContain('advisory hook removed'); - }); - - it('logs the skip message when no nexpath hook is present', async () => { - await claudeCodeAdapter.uninstall(makeCtx()); - const allLogs = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); - expect(allLogs).toContain('hook not registered, skipped'); - }); - }); -}); diff --git a/src/agents/adapters/claude-code.ts b/src/agents/adapters/claude-code.ts deleted file mode 100644 index f3372835..00000000 --- a/src/agents/adapters/claude-code.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { registerAdapter } from '../registry.js'; -import type { HookAdapter, InstallContext, InstallResult } from '../types.js'; - -/** - * Claude Code hook helpers — relocated verbatim from src/cli/commands/install.ts - * in Milestone M1 Branch 2 (v0.1.3/m1/claude-code-refactor). Identifier names, - * function bodies, and behaviour are preserved exactly so the install snapshot - * from M1 Branch 1 remains byte-identical (zero-diff invariant; see dev plan - * §1.5 in reviewduel-submodule). - * - * src/cli/commands/install.ts re-exports these names from this module for - * backward compatibility with existing imports (install.test.ts and others). - */ - -// ── Private file helpers (duplicated from install.ts so this adapter is -// ── self-sufficient and avoids a circular import) ───────────────────────────── - -function readJsonSafe(filePath: string): Record { - try { - return JSON.parse(readFileSync(filePath, 'utf8')) as Record; - } catch { - return {}; - } -} - -function writeJson(filePath: string, data: unknown): void { - mkdirSync(dirname(filePath), { recursive: true }); - writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8'); -} - -// ── Hook command + entry builders (moved from install.ts) ───────────────────── - -/** Path to the user-level Claude Code settings file (where hooks are configured). */ -export function getClaudeSettingsPath(home: string): string { - return join(home, '.claude', 'settings.json'); -} - -/** - * Build the shell command string for the UserPromptSubmit hook. - * - * Uses `node` explicitly with the absolute path to the running CLI so the hook - * works regardless of whether nexpath is globally installed or run from a local - * build (PATH is not required). - * - * Forward slashes are used throughout so the value is safe in PowerShell and cmd - * on Windows as well as bash/zsh on Unix. - */ -export function buildHookCommand(home: string, platform = process.platform): string { - const cliPath = resolve(process.argv[1]).replace(/\\/g, '/'); - const dbPath = join(home, '.nexpath', 'prompt-store.db').replace(/\\/g, '/'); - return `node "${cliPath}" auto --db "${dbPath}"`; -} - -/** Build the shell command string for the Stop hook. */ -export function buildStopHookCommand(home: string, platform = process.platform): string { - const cliPath = resolve(process.argv[1]).replace(/\\/g, '/'); - const dbPath = join(home, '.nexpath', 'prompt-store.db').replace(/\\/g, '/'); - return `node "${cliPath}" stop --db "${dbPath}"`; -} - -/** - * Build the UserPromptSubmit + Stop hook entry objects. - * - * The `_nexpath_hook: true` field is the reliable deduplication and removal - * marker — it survives path changes across reinstalls, unlike scanning the - * command string. - * - * No `timeout` field is set so Claude Code uses its default (600 s), which is - * required for hooks that block for UI interaction (the decision session). - */ -export function buildHookEntry(home: string, platform = process.platform): Record { - return { - UserPromptSubmit: [ - { - _nexpath_hook: true, - matcher: '', - hooks: [ - { - type: 'command', - command: buildHookCommand(home, platform), - }, - ], - }, - ], - Stop: [ - { - _nexpath_hook: true, - matcher: '', - hooks: [ - { - type: 'command', - command: buildStopHookCommand(home, platform), - }, - ], - }, - ], - }; -} - -/** - * Write the nexpath UserPromptSubmit and Stop hooks into ~/.claude/settings.json. - * - * Uses a read-filter-append pattern so existing hooks written by other tools are - * preserved. Any prior nexpath hook group (identified by `_nexpath_hook: true`) - * is removed before appending the fresh entry, making this operation idempotent. - */ -export function writeHookEntry(filePath: string, home: string, platform = process.platform): void { - const data = readJsonSafe(filePath); - const hooks = (data.hooks as Record | undefined) ?? {}; - const entry = buildHookEntry(home, platform); - - // UserPromptSubmit - const existingUPS = (hooks.UserPromptSubmit as Array> | undefined) ?? []; - hooks.UserPromptSubmit = [ - ...existingUPS.filter((g) => !g._nexpath_hook), - ...(entry.UserPromptSubmit as unknown[]), - ]; - - // Stop - const existingStop = (hooks.Stop as Array> | undefined) ?? []; - hooks.Stop = [ - ...existingStop.filter((g) => !g._nexpath_hook), - ...(entry.Stop as unknown[]), - ]; - - data.hooks = hooks; - writeJson(filePath, data); -} - -/** - * Remove the nexpath UserPromptSubmit and Stop hooks from ~/.claude/settings.json. - * - * Identifies nexpath-written hook groups by the `_nexpath_hook: true` field. - * Returns false if the file does not exist or no nexpath hooks were found. - */ -export function removeHookEntry(filePath: string): boolean { - if (!existsSync(filePath)) return false; - const data = readJsonSafe(filePath); - const hooks = data.hooks as Record | undefined; - if (!hooks) return false; - - let removed = false; - - // UserPromptSubmit - const upsGroups = hooks.UserPromptSubmit as Array> | undefined; - if (upsGroups) { - const filtered = upsGroups.filter((g) => !g._nexpath_hook); - if (filtered.length < upsGroups.length) { - removed = true; - if (filtered.length === 0) delete hooks.UserPromptSubmit; - else hooks.UserPromptSubmit = filtered; - } - } - - // Stop - const stopGroups = hooks.Stop as Array> | undefined; - if (stopGroups) { - const filtered = stopGroups.filter((g) => !g._nexpath_hook); - if (filtered.length < stopGroups.length) { - removed = true; - if (filtered.length === 0) delete hooks.Stop; - else hooks.Stop = filtered; - } - } - - if (!removed) return false; - data.hooks = hooks; - writeJson(filePath, data); - return true; -} - -// ── HookAdapter implementation ──────────────────────────────────────────────── - -/** - * Claude Code adapter. Always detected (Claude Code is treated as universally - * available, matching the existing install.ts behaviour where it is always - * added to the detected-agents list). Install writes the hook entry; uninstall - * removes it. - * - * The console.log strings, the settings file path, and the hook command bytes - * are byte-identical to the pre-refactor inline code so the install snapshot - * from M1 Branch 1 remains unchanged. - */ -export const claudeCodeAdapter: HookAdapter = { - id: 'claude-code', - label: 'Claude Code', - category: 'hook', - - detect(): boolean { - return true; - }, - - settingsPath(ctx: InstallContext): string { - return ctx.settingsPath ?? getClaudeSettingsPath(ctx.home); - }, - - buildHooks(ctx: InstallContext): Record> { - return { - UserPromptSubmit: [{ type: 'command', command: buildHookCommand(ctx.home) }], - Stop: [{ type: 'command', command: buildStopHookCommand(ctx.home) }], - }; - }, - - async install(ctx: InstallContext): Promise { - // Use ctx.settingsPath when provided (preserves pre-refactor decoupling - // between target file path and homedir-based command content). Falls back - // to deriving from ctx.home when not provided (production path). - const settingsPath = ctx.settingsPath ?? getClaudeSettingsPath(ctx.home); - try { - writeHookEntry(settingsPath, ctx.home); - console.log(`✓ ${'Claude Code'.padEnd(12)} — advisory hook written to ${settingsPath}`); - return { status: 'installed' }; - } catch (err) { - console.log(`⚠ ${'Claude Code'.padEnd(12)} — hook write failed: ${(err as Error).message}`); - return { status: 'failed', notes: (err as Error).message }; - } - }, - - async uninstall(ctx: InstallContext): Promise { - const settingsPath = ctx.settingsPath ?? getClaudeSettingsPath(ctx.home); - const removed = removeHookEntry(settingsPath); - console.log(removed - ? `✓ ${'Claude Code'.padEnd(12)} — advisory hook removed` - : `- ${'Claude Code'.padEnd(12)} — hook not registered, skipped`); - }, -}; - -// Side-effect registration on module load. The registry is in-process state; -// agents/index.ts side-effect imports this module so the adapter is registered -// before any caller invokes detectAll/getAdapter. -registerAdapter(claudeCodeAdapter); diff --git a/src/agents/index.ts b/src/agents/index.ts deleted file mode 100644 index 3f0764b2..00000000 --- a/src/agents/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Adapter registrations live here as side-effect imports. -// Each adapter module calls registerAdapter() at module load. -// -// Subsequent milestones add Cursor, Windsurf, CLI-wrap, and browser adapters. -import './adapters/claude-code.js'; diff --git a/src/agents/registry.test.ts b/src/agents/registry.test.ts deleted file mode 100644 index ef5f4d85..00000000 --- a/src/agents/registry.test.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import type { AgentAdapter, InstallContext } from './types.js'; - -/** - * Unit tests for the adapter registry. - * - * The registry stores adapters in a module-local mutable array, so tests would - * leak state into each other if we re-used the same module instance. Each test - * calls `vi.resetModules()` and re-imports `./registry.js` so it gets a fresh, - * empty registry. - */ -describe('agents/registry', () => { - let registerAdapter: typeof import('./registry.js')['registerAdapter']; - let detectAll: typeof import('./registry.js')['detectAll']; - let getAdapter: typeof import('./registry.js')['getAdapter']; - - const dummyCtx: InstallContext = { - home: '/fake/home', - cwd: '/fake/cwd', - yes: true, - dbPath: ':memory:', - }; - - const makeAdapter = (id: string, detectResult: boolean): AgentAdapter => ({ - id, - label: `Label-${id}`, - category: 'hook', - detect: () => detectResult, - install: async () => ({ status: 'installed' }), - uninstall: async () => {}, - }); - - beforeEach(async () => { - vi.resetModules(); - const reg = await import('./registry.js'); - registerAdapter = reg.registerAdapter; - detectAll = reg.detectAll; - getAdapter = reg.getAdapter; - }); - - describe('registerAdapter + getAdapter', () => { - it('registers an adapter and retrieves it by id', () => { - const a = makeAdapter('cursor', true); - registerAdapter(a); - expect(getAdapter('cursor')).toBe(a); - }); - - it('returns undefined for an unknown id', () => { - registerAdapter(makeAdapter('cursor', true)); - expect(getAdapter('nonexistent')).toBeUndefined(); - }); - - it('returns undefined when no adapters are registered', () => { - expect(getAdapter('any')).toBeUndefined(); - }); - - it('supports multiple registrations and finds each by its id', () => { - const a = makeAdapter('a', true); - const b = makeAdapter('b', true); - registerAdapter(a); - registerAdapter(b); - expect(getAdapter('a')).toBe(a); - expect(getAdapter('b')).toBe(b); - }); - }); - - describe('detectAll', () => { - it('returns an empty list when no adapters are registered', async () => { - const result = await detectAll(dummyCtx); - expect(result).toEqual([]); - }); - - it('returns only adapters whose detect() returns true', async () => { - const yes = makeAdapter('yes', true); - const no = makeAdapter('no', false); - registerAdapter(yes); - registerAdapter(no); - const result = await detectAll(dummyCtx); - expect(result).toEqual([yes]); - }); - - it('supports async detect() returning a Promise', async () => { - const yesAsync: AgentAdapter = { - ...makeAdapter('async-yes', true), - detect: async () => true, - }; - const noAsync: AgentAdapter = { - ...makeAdapter('async-no', false), - detect: async () => false, - }; - registerAdapter(yesAsync); - registerAdapter(noAsync); - const result = await detectAll(dummyCtx); - expect(result.map((x) => x.id)).toEqual(['async-yes']); - }); - - it('preserves insertion order across multiple detected adapters', async () => { - registerAdapter(makeAdapter('a', true)); - registerAdapter(makeAdapter('b', true)); - registerAdapter(makeAdapter('c', true)); - const result = await detectAll(dummyCtx); - expect(result.map((x) => x.id)).toEqual(['a', 'b', 'c']); - }); - - it('passes the InstallContext through to each adapter.detect()', async () => { - const detectSpy = vi.fn(() => true); - const adapter: AgentAdapter = { - ...makeAdapter('spy', true), - detect: detectSpy, - }; - registerAdapter(adapter); - await detectAll(dummyCtx); - expect(detectSpy).toHaveBeenCalledWith(dummyCtx); - expect(detectSpy).toHaveBeenCalledTimes(1); - }); - - it('skips false-detecting adapters while keeping true-detecting ones, regardless of registration order', async () => { - registerAdapter(makeAdapter('first-no', false)); - registerAdapter(makeAdapter('second-yes', true)); - registerAdapter(makeAdapter('third-no', false)); - registerAdapter(makeAdapter('fourth-yes', true)); - const result = await detectAll(dummyCtx); - expect(result.map((x) => x.id)).toEqual(['second-yes', 'fourth-yes']); - }); - }); -}); diff --git a/src/agents/registry.ts b/src/agents/registry.ts deleted file mode 100644 index 480d2909..00000000 --- a/src/agents/registry.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { AgentAdapter, InstallContext } from './types.js'; - -/** - * Module-local mutable list. Adapters register themselves at module import time - * via side-effect imports in ./index.ts. This file does not import any adapter - * directly — that keeps the registry decoupled from individual adapter modules. - */ -const adapters: AgentAdapter[] = []; - -export function registerAdapter(a: AgentAdapter): void { - adapters.push(a); -} - -export async function detectAll(ctx: InstallContext): Promise { - const present: AgentAdapter[] = []; - for (const a of adapters) { - if (await a.detect(ctx)) present.push(a); - } - return present; -} - -export function getAdapter(id: string): AgentAdapter | undefined { - return adapters.find((a) => a.id === id); -} diff --git a/src/agents/types.ts b/src/agents/types.ts deleted file mode 100644 index bbb1f79d..00000000 --- a/src/agents/types.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Adapter interface contract for the coding-agent hooking module (Layer B of the - * three-layer architecture). Every coding agent surface (Claude Code, Cursor, - * Windsurf, Codex CLI, Aider, Replit, Bolt.new, Lovable, ChatGPT) is wired into - * nexpath through one of these four adapter categories. - * - * See: lib/share/submodule/reviewduel-submodule/docs/architecture/coding-agent-hooks-architecture.md - */ - -export type AdapterCategory = - | 'hook' - | 'vscode-extension' - | 'cli-wrap' - | 'browser-extension'; - -export interface InstallContext { - home: string; - cwd: string; - /** Skip confirmation prompts. */ - yes: boolean; - /** Path to ~/.nexpath/prompt-store.db (or ':memory:' for tests). */ - dbPath: string; - /** - * Optional override for the target settings/config file the adapter writes to. - * If omitted, the adapter derives the path from `home`. Tests (and non-default - * install locations) pass this to decouple the file path from `home` — - * preserving the pre-refactor behaviour where `paths.claudeSettings` was - * passed independently of `homedir()`. - * - * Adapters that don't write a single settings file (e.g. CLIWrapAdapter, - * BrowserExtensionAdapter) ignore this field. - */ - settingsPath?: string; -} - -export interface InstallResult { - status: 'installed' | 'already-installed' | 'skipped' | 'failed'; - notes?: string; -} - -export interface AgentAdapter { - /** Stable identifier, e.g. 'claude-code', 'cursor', 'replit'. */ - id: string; - label: string; - category: AdapterCategory; - - /** Returns true if this agent appears to be present on the current system. */ - detect(ctx: InstallContext): Promise | boolean; - - /** Wire up the binding — write config, install extension, register shell alias, etc. */ - install(ctx: InstallContext): Promise; - - /** Reverse of install. */ - uninstall(ctx: InstallContext): Promise; -} - -/** Adapters that wire into agents with native lifecycle hooks (e.g. Claude Code). */ -export interface HookAdapter extends AgentAdapter { - category: 'hook'; - /** Path to the agent's settings file that hosts hook entries. */ - settingsPath(ctx: InstallContext): string; - /** Build the hook entries to merge into the agent's settings file. */ - buildHooks(ctx: InstallContext): Record>; -} - -/** Adapters that ship a companion VS Code-format extension (e.g. Cursor, Windsurf). */ -export interface VSCodeExtensionAdapter extends AgentAdapter { - category: 'vscode-extension'; - /** Marketplace identifiers. */ - marketplace: { openVsx: string; vsCode?: string }; - /** Per-OS chat-history paths the extension's file-watcher subscribes to. */ - chatHistoryPaths(ctx: InstallContext): string[]; - /** Schema-extractor — reads new rows out of the watched file. */ - extractPrompt(rowKey: string, rowValue: unknown): { prompt: string; sessionId: string } | null; -} - -/** Adapters that wrap a CLI binary (e.g. Codex CLI, Aider). */ -export interface CLIWrapAdapter extends AgentAdapter { - category: 'cli-wrap'; - /** The original binary name, e.g. 'codex', 'aider'. */ - realBinary: string; - /** Strategy: 'shell-alias' or 'node-proxy'. */ - strategy: 'shell-alias' | 'node-proxy'; - /** For node-proxy: heuristic that detects an agent response boundary in stdout. */ - detectResponseEnd?(stdoutChunk: string): boolean; -} - -/** Adapters that ship a browser extension content script (Replit, Bolt.new, Lovable, ChatGPT). */ -export interface BrowserExtensionAdapter extends AgentAdapter { - category: 'browser-extension'; - /** Origins this content script runs against (Manifest V3 match patterns). */ - origins: string[]; - /** Capture strategy in priority order. */ - capture: Array<'fetch' | 'websocket' | 'dom-events' | 'mutation-observer'>; - /** Inline content-script source to be bundled into the extension build. */ - contentScriptModule: string; -} diff --git a/src/cli/commands/__snapshots__/install.snapshot.test.ts.snap b/src/cli/commands/__snapshots__/install.snapshot.test.ts.snap deleted file mode 100644 index 266704e6..00000000 --- a/src/cli/commands/__snapshots__/install.snapshot.test.ts.snap +++ /dev/null @@ -1,41 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`installAction snapshot (M1 zero-diff safety net) > writes the same settings.json bytes and stdout as the pre-refactor baseline 1`] = ` -{ - "settingsContent": "{ - "hooks": { - "UserPromptSubmit": [ - { - "_nexpath_hook": true, - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \\"/fixture/nexpath/dist/cli/index.js\\" auto --db \\"$HOME/.nexpath/prompt-store.db\\"" - } - ] - } - ], - "Stop": [ - { - "_nexpath_hook": true, - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "node \\"/fixture/nexpath/dist/cli/index.js\\" stop --db \\"$HOME/.nexpath/prompt-store.db\\"" - } - ] - } - ] - } -} -", - "stdout": "Detected: Claude Code -✓ Claude Code — advisory hook written to $HOME/.claude/settings.json - -Restart your agents to activate nexpath-prompt-store. -Note: advisory pipeline (nexpath auto) auto-wired for Claude Code only. -Run: nexpath status to verify connections.", -} -`; diff --git a/src/cli/commands/install.snapshot.test.ts b/src/cli/commands/install.snapshot.test.ts deleted file mode 100644 index eabbc6c1..00000000 --- a/src/cli/commands/install.snapshot.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs'; -import { tmpdir, homedir } from 'node:os'; -import { join } from 'node:path'; -import { installAction, resolveAgentPaths, type AgentPaths } from './install.js'; - -/** - * Real $HOME captured at module load — DEFAULT_DB_PATH (resolved when store/db.ts - * was imported) bakes this in, so we normalise it out of the snapshot to keep - * the snapshot portable across machines. - */ -const REAL_HOME = homedir(); - -/** - * Snapshot test — captures the current behaviour of installAction before any - * refactor begins. This is the safety net for Milestone M1 Branch 2's zero-diff - * guarantee: after the refactor merges, this snapshot MUST remain byte-identical. - * - * See dev plan §1.5 and §1.6 in the reviewduel-submodule docs. - */ -describe('installAction snapshot (M1 zero-diff safety net)', () => { - let tmpHome: string; - let originalArgv1: string; - let originalPlatform: NodeJS.Platform; - let logSpy: ReturnType; - - beforeEach(() => { - tmpHome = mkdtempSync(join(tmpdir(), 'nexpath-snapshot-')); - vi.stubEnv('HOME', tmpHome); - - // Pin process.argv[1] so the hook command's resolved CLI path is deterministic. - originalArgv1 = process.argv[1]; - process.argv[1] = '/fixture/nexpath/dist/cli/index.js'; - - // Pin process.platform to 'linux' so the disclosure modifier-key ("Ctrl+X" - // vs "Cmd+X" on macOS) is stable regardless of where the test runs. - originalPlatform = process.platform; - Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); - - logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - }); - - afterEach(() => { - process.argv[1] = originalArgv1; - Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true }); - vi.unstubAllEnvs(); - logSpy.mockRestore(); - rmSync(tmpHome, { recursive: true, force: true }); - }); - - it('writes the same settings.json bytes and stdout as the pre-refactor baseline', async () => { - // Controlled paths — no real user dirs touched, snapshot is fully portable. - const paths: AgentPaths = resolveAgentPaths( - tmpHome, - join(tmpHome, 'AppData', 'Roaming'), - join(tmpHome, 'cwd'), - 'linux', - ); - - await installAction( - { yes: true }, - { - isWin: false, - paths, - dbPath: ':memory:', - skipClipboardCheck: true, - }, - ); - - const settingsPath = join(tmpHome, '.claude', 'settings.json'); - const settingsContent = existsSync(settingsPath) - ? readFileSync(settingsPath, 'utf8') - : null; - - const stdout = logSpy.mock.calls.map((c) => c.join(' ')).join('\n'); - - // Normalise both the test-tmp home (resolved at install-time) and the real - // home (which DEFAULT_DB_PATH captured at module-import-time). - const normalise = (s: string | null): string | null => - s === null - ? null - : s.replaceAll(tmpHome, '$HOME').replaceAll(REAL_HOME, '$HOME'); - - expect({ - settingsContent: normalise(settingsContent), - stdout: normalise(stdout), - }).toMatchSnapshot(); - }); -}); diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index bd3fa97e..3ec46345 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -21,30 +21,6 @@ import { type KeySource, } from '../../config/ApiKeyResolver.js'; -// Side-effect import: registers all coding-agent adapters with the in-process -// registry. Must precede any getAdapter() call in installAction below. -import '../../agents/index.js'; -import { getAdapter } from '../../agents/registry.js'; -import type { HookAdapter } from '../../agents/types.js'; -// Internal use + backward-compat re-export of the Claude Code hook helpers -// (moved to src/agents/adapters/claude-code.ts in M1 Branch 2 — v0.1.3/m1/claude-code-refactor). -import { - getClaudeSettingsPath, - buildHookCommand, - buildStopHookCommand, - buildHookEntry, - writeHookEntry, - removeHookEntry, -} from '../../agents/adapters/claude-code.js'; -export { - getClaudeSettingsPath, - buildHookCommand, - buildStopHookCommand, - buildHookEntry, - writeHookEntry, - removeHookEntry, -}; - export const MCP_SERVER_NAME = 'nexpath-prompt-store'; // ── Config entry builders (pure — no I/O) ───────────────────────────────────── @@ -289,10 +265,145 @@ export function removeOpenCodeEntry(filePath: string): boolean { } // ── Claude Code hook helpers ────────────────────────────────────────────────── -// Function definitions moved to src/agents/adapters/claude-code.ts in M1 Branch 2 -// (v0.1.3/m1/claude-code-refactor). Bodies are byte-identical to what lived -// here before; the import + re-export at the top of this file preserves the -// existing public API so callers importing from './install.js' are unaffected. + +/** Path to the user-level Claude Code settings file (where hooks are configured). */ +export function getClaudeSettingsPath(home: string): string { + return join(home, '.claude', 'settings.json'); +} + +/** + * Build the shell command string for the UserPromptSubmit hook. + * + * Uses `node` explicitly with the absolute path to the running CLI so the hook + * works regardless of whether nexpath is globally installed or run from a local + * build (PATH is not required). + * + * Forward slashes are used throughout so the value is safe in PowerShell and cmd + * on Windows as well as bash/zsh on Unix. + */ +export function buildHookCommand(home: string, platform = process.platform): string { + const cliPath = resolve(process.argv[1]).replace(/\\/g, '/'); + const dbPath = join(home, '.nexpath', 'prompt-store.db').replace(/\\/g, '/'); + return `node "${cliPath}" auto --db "${dbPath}"`; +} + +/** Build the shell command string for the Stop hook. */ +export function buildStopHookCommand(home: string, platform = process.platform): string { + const cliPath = resolve(process.argv[1]).replace(/\\/g, '/'); + const dbPath = join(home, '.nexpath', 'prompt-store.db').replace(/\\/g, '/'); + return `node "${cliPath}" stop --db "${dbPath}"`; +} + +/** + * Build the UserPromptSubmit + Stop hook entry objects. + * + * The `_nexpath_hook: true` field is the reliable deduplication and removal + * marker — it survives path changes across reinstalls, unlike scanning the + * command string. + * + * No `timeout` field is set so Claude Code uses its default (600 s), which is + * required for hooks that block for UI interaction (the decision session). + */ +export function buildHookEntry(home: string, platform = process.platform): Record { + return { + UserPromptSubmit: [ + { + _nexpath_hook: true, + matcher: '', + hooks: [ + { + type: 'command', + command: buildHookCommand(home, platform), + }, + ], + }, + ], + Stop: [ + { + _nexpath_hook: true, + matcher: '', + hooks: [ + { + type: 'command', + command: buildStopHookCommand(home, platform), + }, + ], + }, + ], + }; +} + +/** + * Write the nexpath UserPromptSubmit and Stop hooks into ~/.claude/settings.json. + * + * Uses a read-filter-append pattern so existing hooks written by other tools are + * preserved. Any prior nexpath hook group (identified by `_nexpath_hook: true`) + * is removed before appending the fresh entry, making this operation idempotent. + */ +export function writeHookEntry(filePath: string, home: string, platform = process.platform): void { + const data = readJsonSafe(filePath); + const hooks = (data.hooks as Record | undefined) ?? {}; + const entry = buildHookEntry(home, platform); + + // UserPromptSubmit + const existingUPS = (hooks.UserPromptSubmit as Array> | undefined) ?? []; + hooks.UserPromptSubmit = [ + ...existingUPS.filter((g) => !g._nexpath_hook), + ...(entry.UserPromptSubmit as unknown[]), + ]; + + // Stop + const existingStop = (hooks.Stop as Array> | undefined) ?? []; + hooks.Stop = [ + ...existingStop.filter((g) => !g._nexpath_hook), + ...(entry.Stop as unknown[]), + ]; + + data.hooks = hooks; + writeJson(filePath, data); +} + +/** + * Remove the nexpath UserPromptSubmit and Stop hooks from ~/.claude/settings.json. + * + * Identifies nexpath-written hook groups by the `_nexpath_hook: true` field. + * Returns false if the file does not exist or no nexpath hooks were found. + */ +export function removeHookEntry(filePath: string): boolean { + if (!existsSync(filePath)) return false; + const data = readJsonSafe(filePath); + const hooks = data.hooks as Record | undefined; + if (!hooks) return false; + + let removed = false; + + // UserPromptSubmit + const upsGroups = hooks.UserPromptSubmit as Array> | undefined; + if (upsGroups) { + const filtered = upsGroups.filter((g) => !g._nexpath_hook); + if (filtered.length < upsGroups.length) { + removed = true; + if (filtered.length === 0) delete hooks.UserPromptSubmit; + else hooks.UserPromptSubmit = filtered; + } + } + + // Stop + const stopGroups = hooks.Stop as Array> | undefined; + if (stopGroups) { + const filtered = stopGroups.filter((g) => !g._nexpath_hook); + if (filtered.length < stopGroups.length) { + removed = true; + if (filtered.length === 0) delete hooks.Stop; + else hooks.Stop = filtered; + } + } + + if (!removed) return false; + data.hooks = hooks; + writeJson(filePath, data); + return true; +} // ── Claude Code CLI helpers ─────────────────────────────────────────────────── @@ -637,17 +748,13 @@ export async function installAction( } } // Register the advisory pipeline hook (separate from MCP — different file) - const claudeAdapter = getAdapter('claude-code') as HookAdapter | undefined; - if (claudeAdapter) { - await claudeAdapter.install({ - home: homedir(), - cwd: process.cwd(), - yes: !!opts.yes, - dbPath, - // Pass paths.claudeSettings so tests that inject a custom paths - // object still control the target file independently of homedir(). - settingsPath: paths.claudeSettings, - }); + try { + writeHookEntry(paths.claudeSettings, homedir(), isWin ? 'win32' : process.platform); + console.log(`\u2713 ${'Claude Code'.padEnd(12)} \u2014 advisory hook written to ${paths.claudeSettings}`); + registered.push('Claude Code'); + } catch (err) { + console.log(`\u26a0 ${'Claude Code'.padEnd(12)} \u2014 hook write failed: ${(err as Error).message}`); + failed.push('Claude Code'); } } else if (REGISTER_MCP_SERVER) { if (agent.type === 'cline') {