Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1ece9a7
M1/B1: Add agent adapter registry foundation + install snapshot baseline
Vedansi18 May 13, 2026
d93852e
M1/B2: Refactor Claude Code into HookAdapter via agent registry
Vedansi18 May 13, 2026
66dd54b
M1/B2 follow-up: revert advisory-hook comment to original wording
Vedansi18 May 13, 2026
879ed5e
M2/B1: Add VS Code extension skeleton, IPC stub, onboarding, and icon
Vedansi18 May 14, 2026
14e849a
M2/B2: Add chat-history watcher, extractors, and schema fingerprint
Vedansi18 May 14, 2026
3794bc3
M2/B2 follow-up: WAL-aware dump script + Cursor 3.4.20 real-data fixes
Vedansi18 May 15, 2026
94d81dc
M2/B2 follow-up: extract dump-script helpers to src/ + add 28 unit tests
Vedansi18 May 15, 2026
883ade9
M2/B3: Add decision-session webview, HTML template, prompt injection
Vedansi18 May 15, 2026
a3d5fbc
M2/B3 follow-up: injectFn contract for B4 + keyboard shortcuts
Vedansi18 May 15, 2026
3d0957e
M2/B3 audit follow-up: catch onSelect errors + 3 missing test cases
Vedansi18 May 15, 2026
536bca8
Merge B2 (chat-history-capture) into B4 — B4 needs the watcher + extr…
Vedansi18 May 15, 2026
21f3f48
Merge M1 (agents framework + Claude Code adapter) into M2/B4 — B4's C…
Vedansi18 May 15, 2026
174402b
M2/B4: Add Cursor + Windsurf CLI adapters (M9 + M10)
Vedansi18 May 15, 2026
fa3c134
M2/B4 follow-up: WAL fix, host-detector, chat-input-injector, view-pr…
Vedansi18 May 15, 2026
55477c2
M2/B4 audit follow-up: wire registry into install/uninstall + extract…
Vedansi18 May 15, 2026
f6a916b
M2/B4 audit gap closure: defaultReadItemTable + adapter-error catches
Vedansi18 May 15, 2026
1194850
M2/B5: Wire watcher into extension.ts, add smoke-test documentation
Vedansi18 May 16, 2026
e0ae39a
M2/B5 follow-up: align ipc stdin payloads with Layer C's contract (no…
Vedansi18 May 16, 2026
4aa977b
M2/B5 audit follow-up: test the three watcher-callback wirings
Vedansi18 May 16, 2026
7f4f82c
M2 Drift A fix: watch ~/.codeium/windsurf when host=windsurf
Vedansi18 May 16, 2026
e50bfcd
M2 watcher fix: also fs.watch state.vscdb-wal/-shm siblings (WAL-mode…
Vedansi18 May 18, 2026
5bf6796
SMOKE-TEST: lock safe-prompt contract after live data-loss incident
Vedansi18 May 18, 2026
f9b57cb
M2: add Nexpath OutputChannel for live debugging visibility
Vedansi18 May 18, 2026
45e52bc
M2 watcher fix: use ALL matching extractors per row (fingerprint-tie …
Vedansi18 May 18, 2026
928c1ae
chore(M2): gitignore .vsix + add cursor-live-smoke state.vscdb fixtures
Vedansi18 May 20, 2026
19827a5
fix(dev-probe): show local-time date in storeToday header
Vedansi18 May 20, 2026
826a262
M2 critical fix: watcher initial-pass primes dedup, no longer emits h…
Vedansi18 May 20, 2026
f43c6c5
M2 fix: watcher tolerates state.vscdb disappearing between enumerate …
Vedansi18 May 20, 2026
f3079ca
M2 critical fix: cursor-v2024-q4 stable rawSessionId — survives Curso…
Vedansi18 May 20, 2026
4a8cd4b
M2 cleanup: remove diagnostic onTrace callback (bug found, regression…
Vedansi18 May 20, 2026
5688be4
M2/B6: pre-open notification panel so consent toast is visible in Cursor
Vedansi18 May 21, 2026
386e63f
M2/B6: surface chat-pipeline spawn failures in Nexpath Output channel
Vedansi18 May 22, 2026
b18462f
M2/R4.3 fix: multi-workspace prompt mis-attribution + duplicate rows
Vedansi18 May 22, 2026
5e7d5ce
M2/F2: Cursor Composer/Agent capture via globalStorage cursorDiskKV
Vedansi18 May 25, 2026
fe7a276
M2/B6 fix: cross-extractor dedup for Cursor Composer↔Ask mirror
Vedansi18 May 27, 2026
2e1ec1a
M2 fix: surface schema-unknown once per path (stop Windsurf log/toast…
Vedansi18 May 28, 2026
86f08fb
M2 docs: record Windsurf-capture-infeasible finding in windsurf.ts
Vedansi18 May 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ node_modules/
*.js.map
*.d.ts.map
.env
src/ext-vscode/out/
src/ext-vscode/node_modules/
src/ext-vscode/*.vsix
425 changes: 425 additions & 0 deletions scripts/dev-probe.cjs

Large diffs are not rendered by default.

231 changes: 231 additions & 0 deletions src/agents/adapters/claude-code.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
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<typeof vi.spyOn>;

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<Record<string, unknown>>)[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<Record<string, unknown>>)[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');
});
});
});
Loading