Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion node_modules/.package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

183 changes: 183 additions & 0 deletions src/init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Tests for runInit / runUninstall — covers the settings.json shape codachi
* writes, migration of legacy PostToolExecution entries, idempotency, and
* preservation of user-configured hooks from other tools.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

let tmpHome: string;

beforeEach(() => {
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'codachi-init-'));
vi.spyOn(os, 'homedir').mockReturnValue(tmpHome);
// Pretend we're running from an npx cache so detectMode() returns bin mode —
// that keeps the assertions stable across machines.
process.argv = ['node', path.join(os.tmpdir(), '_npx', 'x', 'bin', 'codachi'), 'init'];
});

afterEach(() => {
fs.rmSync(tmpHome, { recursive: true, force: true });
vi.restoreAllMocks();
});

async function importFresh() {
vi.resetModules();
return (await import('./init.js')) as typeof import('./init.js');
}

function readSettings(): Record<string, any> {
return JSON.parse(fs.readFileSync(path.join(tmpHome, '.claude', 'settings.json'), 'utf8'));
}

describe('runInit', () => {
it('writes the hook under PostToolUse (not PostToolExecution)', async () => {
const { runInit } = await importFresh();
runInit();
const s = readSettings();
expect(s.hooks).toBeDefined();
expect(s.hooks.PostToolUse).toBeDefined();
expect(s.hooks.PostToolExecution).toBeUndefined();
});

it('writes hook entries in the canonical {matcher, hooks: [{type, command}]} shape', async () => {
const { runInit } = await importFresh();
runInit();
const s = readSettings();
const entry = s.hooks.PostToolUse[0];
expect(entry).toMatchObject({
matcher: '',
hooks: [{ type: 'command', command: 'codachi-hook' }],
});
});

it('sets the statusLine command', async () => {
const { runInit } = await importFresh();
runInit();
const s = readSettings();
expect(s.statusLine).toEqual({ type: 'command', command: 'codachi' });
});

it('migrates legacy PostToolExecution entries onto PostToolUse', async () => {
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(tmpHome, '.claude', 'settings.json'),
JSON.stringify({
hooks: {
PostToolExecution: [{ matcher: '', command: 'codachi-hook' }],
},
}),
);
const { runInit } = await importFresh();
runInit();
const s = readSettings();
expect(s.hooks.PostToolExecution).toBeUndefined();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks[0].command).toBe('codachi-hook');
});

it('is idempotent — running twice leaves exactly one codachi entry', async () => {
const { runInit } = await importFresh();
runInit();
runInit();
const s = readSettings();
expect(s.hooks.PostToolUse).toHaveLength(1);
});

it('replaces a legacy flat-shape codachi entry with the wrapped form', async () => {
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(tmpHome, '.claude', 'settings.json'),
JSON.stringify({
hooks: {
PostToolUse: [{ matcher: '', command: 'codachi-hook' }],
},
}),
);
const { runInit } = await importFresh();
runInit();
const s = readSettings();
expect(s.hooks.PostToolUse).toHaveLength(1);
expect(s.hooks.PostToolUse[0].hooks).toEqual([
{ type: 'command', command: 'codachi-hook' },
]);
});

it('preserves unrelated PostToolUse entries from other tools', async () => {
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
const otherHook = {
matcher: 'Bash',
hooks: [{ type: 'command', command: '/some/other-hook.sh' }],
};
fs.writeFileSync(
path.join(tmpHome, '.claude', 'settings.json'),
JSON.stringify({ hooks: { PostToolUse: [otherHook] } }),
);
const { runInit } = await importFresh();
runInit();
const s = readSettings();
expect(s.hooks.PostToolUse).toHaveLength(2);
expect(s.hooks.PostToolUse).toContainEqual(otherHook);
});

it('preserves unrelated top-level settings keys', async () => {
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(tmpHome, '.claude', 'settings.json'),
JSON.stringify({ mcpServers: { foo: { type: 'sse', url: 'http://x' } } }),
);
const { runInit } = await importFresh();
runInit();
const s = readSettings();
expect(s.mcpServers).toEqual({ foo: { type: 'sse', url: 'http://x' } });
});
});

describe('runUninstall', () => {
it('removes the codachi hook from PostToolUse', async () => {
const { runInit, runUninstall } = await importFresh();
runInit();
runUninstall();
const s = readSettings();
expect(s.hooks).toBeUndefined();
expect(s.statusLine).toBeUndefined();
});

it('also cleans up any lingering PostToolExecution entries', async () => {
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(tmpHome, '.claude', 'settings.json'),
JSON.stringify({
statusLine: { type: 'command', command: 'codachi' },
hooks: {
PostToolExecution: [{ matcher: '', command: 'codachi-hook' }],
},
}),
);
const { runUninstall } = await importFresh();
runUninstall();
const s = readSettings();
expect(s.hooks).toBeUndefined();
expect(s.statusLine).toBeUndefined();
});

it('preserves other hooks during uninstall', async () => {
const otherHook = {
matcher: 'Write',
hooks: [{ type: 'command', command: '/other/pre.sh' }],
};
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
fs.writeFileSync(
path.join(tmpHome, '.claude', 'settings.json'),
JSON.stringify({ hooks: { PreToolUse: [otherHook] } }),
);
const { runInit, runUninstall } = await importFresh();
runInit();
runUninstall();
const s = readSettings();
expect(s.hooks.PreToolUse).toEqual([otherHook]);
expect(s.hooks.PostToolUse).toBeUndefined();
});
});
67 changes: 49 additions & 18 deletions src/init.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* codachi init — auto-configure Claude Code's ~/.claude/settings.json.
*
* Adds a statusLine entry and a PostToolExecution hook. The commands it writes
* Adds a statusLine entry and a PostToolUse hook. The commands it writes
* depend on how codachi is running:
*
* - When installed globally or via `npx codachi init` (argv[1] resolved to
Expand All @@ -13,7 +13,8 @@
* if the clone is not on PATH.
*
* Idempotent: if a codachi statusLine / hook already exists, it updates in
* place rather than duplicating.
* place rather than duplicating. Also migrates any legacy PostToolExecution
* entries from pre-fix installs onto the canonical PostToolUse key.
*/
import fs from 'node:fs';
import path from 'node:path';
Expand Down Expand Up @@ -47,6 +48,20 @@ function detectMode(): { statusCmd: string; hookCmd: string; mode: 'bin' | 'loca
};
}

const isCodachiCommand = (cmd: unknown): boolean =>
typeof cmd === 'string' && /codachi(-hook)?|codachi[\\/]dist[\\/]hook/.test(cmd);

const isCodachiEntry = (h: unknown): boolean => {
const hook = h as Record<string, unknown>;
if (isCodachiCommand(hook.command)) return true;
if (Array.isArray(hook.hooks)) {
return (hook.hooks as Record<string, unknown>[]).some(
inner => isCodachiCommand(inner.command),
);
}
return false;
};

export function runInit(): void {
const { statusCmd, hookCmd, mode } = detectMode();

Expand All @@ -61,15 +76,30 @@ export function runInit(): void {
settings.statusLine = { type: 'command', command: statusCmd };

const hooks = (settings.hooks ?? {}) as Record<string, unknown[]>;
const postHooks = Array.isArray(hooks.PostToolExecution) ? hooks.PostToolExecution : [];

// Replace any existing codachi hook rather than duplicating.
const cleaned = postHooks.filter((h: unknown) => {
const hook = h as Record<string, unknown>;
return !(typeof hook.command === 'string' && /codachi(-hook)?|codachi[\\/]dist[\\/]hook/.test(hook.command));
// Migrate any legacy `PostToolExecution` entries (from versions before
// this fix) onto the canonical `PostToolUse` key so upgrading users don't
// end up with a stale hook that never fires.
if (Array.isArray(hooks.PostToolExecution)) {
const legacy = hooks.PostToolExecution;
delete hooks.PostToolExecution;
hooks.PostToolUse = [
...(Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : []),
...legacy,
];
}

const postHooks = Array.isArray(hooks.PostToolUse) ? hooks.PostToolUse : [];

// Replace any existing codachi hook rather than duplicating. Match both
// the canonical wrapped form ({matcher, hooks: [{type, command}]}) and
// any legacy flat form ({matcher, command}).
const cleaned = postHooks.filter(h => !isCodachiEntry(h));
cleaned.push({
matcher: '',
hooks: [{ type: 'command', command: hookCmd }],
});
cleaned.push({ matcher: '', command: hookCmd });
hooks.PostToolExecution = cleaned;
hooks.PostToolUse = cleaned;
settings.hooks = hooks;

fs.mkdirSync(path.dirname(SETTINGS_FILE), { recursive: true });
Expand Down Expand Up @@ -105,16 +135,17 @@ export function runUninstall(): void {
changed = true;
}

// Remove codachi hook from PostToolExecution.
// Remove codachi hook from PostToolUse (and any leftover PostToolExecution).
const hooks = settings.hooks as Record<string, unknown[]> | undefined;
if (hooks?.PostToolExecution && Array.isArray(hooks.PostToolExecution)) {
const before = hooks.PostToolExecution.length;
hooks.PostToolExecution = hooks.PostToolExecution.filter((h: unknown) => {
const hook = h as Record<string, unknown>;
return !(typeof hook.command === 'string' && /codachi(-hook)?|codachi[\\/]dist[\\/]hook/.test(hook.command));
});
if (hooks.PostToolExecution.length < before) changed = true;
if (hooks.PostToolExecution.length === 0) delete hooks.PostToolExecution;
if (hooks) {
for (const key of ['PostToolUse', 'PostToolExecution'] as const) {
const entries = hooks[key];
if (!Array.isArray(entries)) continue;
const before = entries.length;
hooks[key] = entries.filter(h => !isCodachiEntry(h));
if (hooks[key].length < before) changed = true;
if (hooks[key].length === 0) delete hooks[key];
}
if (Object.keys(hooks).length === 0) delete settings.hooks;
}

Expand Down