Skip to content
Merged
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
19 changes: 18 additions & 1 deletion src/__tests__/hooks-reconcile-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const teamConfig = {
toolPaths: {
claude: { settings: '.claude/settings.json' },
cursor: { settings: '.cursor/hooks.json' },
codex: {}, // no settings → skipped
codex: { settings: '.codex/hooks.json' },
},
} as unknown as TeamaiConfig;

Expand All @@ -41,6 +41,9 @@ function claudeSettings(): Promise<{ hooks: Record<string, Array<{ description?:
function cursorSettings(): Promise<{ hooks: Record<string, Array<{ command: string }>> }> {
return fse.readJson(path.join(project, '.cursor', 'hooks.json'));
}
function codexSettings(): Promise<{ hooks: Record<string, Array<{ matcher?: string; hooks: Array<{ command: string; timeout?: number }> }>> }> {
return fse.readJson(path.join(project, '.codex', 'hooks.json'));
}
function manifest(): Promise<Record<string, Array<{ id: string }>>> {
return fse.readJson(path.join(project, '.teamai', 'managed-hooks.json'));
}
Expand Down Expand Up @@ -75,9 +78,14 @@ hooks:
expect(cursor.hooks.stop).toHaveLength(2);
expect(cursor.hooks.stop.some((h) => h.command === 'npm run lint')).toBe(true);

const codex = await codexSettings();
expect(codex.hooks.Stop).toHaveLength(2);
expect(codex.hooks.Stop.some((h) => h.hooks[0].command === 'npm run lint')).toBe(true);

const m = await manifest();
expect(m.claude.map((r) => r.id)).toEqual(['lint']);
expect(m.cursor.map((r) => r.id)).toEqual(['lint']);
expect(m.codex.map((r) => r.id)).toEqual(['lint']);
});

it('applies hooks.yaml edits on the next reconcile (add/remove), built-in untouched', async () => {
Expand All @@ -102,9 +110,14 @@ hooks:
expect(cursor.hooks.stop.some((h) => h.command === 'npm run lint')).toBe(false);
expect(cursor.hooks.stop).toHaveLength(1);

const codex = await codexSettings();
expect(codex.hooks.Stop.some((h) => h.hooks[0].command === 'npm run lint')).toBe(false);
expect(codex.hooks.Stop).toHaveLength(1);

const m = await manifest();
expect(m.claude).toBeUndefined();
expect(m.cursor).toBeUndefined();
expect(m.codex).toBeUndefined();
});

it('removeAll clears built-in + team hooks', async () => {
Expand All @@ -122,6 +135,10 @@ hooks:
for (const entries of Object.values(claude.hooks)) {
expect(entries).toHaveLength(0);
}
const codex = await codexSettings();
for (const entries of Object.values(codex.hooks)) {
expect(entries).toHaveLength(0);
}
});

it('applies §4.8 builtin disabled + timeout overrides from hooks.yaml', async () => {
Expand Down
8 changes: 7 additions & 1 deletion src/__tests__/hooks-team-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ function readJson(p: string): Record<string, never> {
return JSON.parse(fs.readFileSync(path.join(home, p), 'utf-8'));
}

const TEAM_HOOK = `hooks:\n - id: lint\n description: run lint at stop\n event: Stop\n command: npm run lint\n tools: [claude, cursor]\n`;
const TEAM_HOOK = `hooks:\n - id: lint\n description: run lint at stop\n event: Stop\n command: npm run lint\n tools: [claude, cursor, codex]\n`;

beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-hooks-e2e-home-'));
Expand Down Expand Up @@ -77,8 +77,14 @@ describe('teamai hooks — unified A+B end-to-end', () => {
const cursor = readJson('.cursor/hooks.json') as unknown as { hooks: Record<string, Array<{ command: string }>> };
expect(cursor.hooks.stop.some((h) => h.command === 'npm run lint')).toBe(true);

const codex = readJson('.codex/hooks.json') as unknown as {
hooks: Record<string, Array<{ hooks: Array<{ command: string }> }>>;
};
expect(codex.hooks.Stop.some((h) => h.hooks[0].command === 'npm run lint')).toBe(true);

const manifest = readJson('.teamai/managed-hooks.json') as unknown as Record<string, Array<{ id: string }>>;
expect(manifest.claude.map((r) => r.id)).toContain('lint');
expect(manifest.codex.map((r) => r.id)).toContain('lint');
});

it('`hooks list` audits built-in and team hooks', async () => {
Expand Down
29 changes: 26 additions & 3 deletions src/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ describe('hooks', () => {
expect(result.hooks['beforeSubmitPrompt']).toHaveLength(1);
});

it('Codex format: injects PascalCase events into hooks.json', async () => {
await injectHooks('/test/codex-hooks.json', 'codex');

const result = mockFiles['/test/codex-hooks.json'] as { hooks: Record<string, Array<{ matcher?: string; description?: string; hooks: Array<{ command: string }> }>> };
expect(result.hooks).toBeDefined();
expect(Object.keys(result.hooks)).toEqual(['SessionStart', 'Stop', 'PostToolUse', 'UserPromptSubmit']);
expect(result.hooks.PostToolUse).toHaveLength(7);
expect(result.hooks.SessionStart[0].hooks[0].command).toContain('--tool codex');
expect(result.hooks.SessionStart[0].description).toBeUndefined();
});

it('Claude uses PascalCase event names', async () => {
await injectHooks('/test/settings.json', 'claude');
const result = mockFiles['/test/settings.json'] as { hooks: Record<string, unknown[]> };
Expand Down Expand Up @@ -332,23 +343,35 @@ describe('hooks', () => {
expect(cmd).toContain('--tool codebuddy');
}
});

it('codex hooks contain --tool codex', async () => {
await injectHooks('/test/hooks.json', 'codex');
const result = mockFiles['/test/hooks.json'] as { hooks: Record<string, unknown[]> };
const cmds = extractCommands(result.hooks);
const toolCmds = cmds.filter((c) => c.includes('--tool'));
expect(toolCmds.length).toBeGreaterThan(0);
for (const cmd of toolCmds) {
expect(cmd).toContain('--tool codex');
}
});
});

describe('injectHooksToAllTools', () => {
it('injects into tools with settings path, skips those without', async () => {
it('injects into all configured settings paths including Codex hooks.json', async () => {
const originalHome = process.env.HOME;
process.env.HOME = '/test-home';

try {
await injectHooksToAllTools({
claude: { settings: '.claude/settings.json' },
codex: {},
codex: { settings: '.codex/hooks.json' },
cursor: { settings: '.cursor/hooks.json' },
});

expect(mockFiles[path.join('/test-home', '.claude/settings.json')]).toBeDefined();
expect(mockFiles[path.join('/test-home', '.codex/hooks.json')]).toBeDefined();
expect(mockFiles[path.join('/test-home', '.cursor/hooks.json')]).toBeDefined();
expect(Object.keys(mockFiles)).toHaveLength(2);
expect(Object.keys(mockFiles)).toHaveLength(3);
} finally {
process.env.HOME = originalHome;
}
Expand Down
11 changes: 6 additions & 5 deletions src/__tests__/tclaude-tcodex.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ describe('tclaude/tcodex adapter integration', () => {
});
});

it('tcodex has codex-compatible paths (no settings)', async () => {
it('tcodex has codex-compatible paths including hooks.json', async () => {
const { TeamaiConfigSchema } = await import('../types.js');
const config = TeamaiConfigSchema.parse({ team: 'test', repo: 'test/repo' });
expect(config.toolPaths.tcodex).toEqual({
skills: '.tcodex/skills',
rules: '.tcodex/rules',
settings: '.tcodex/hooks.json',
agents: '.tcodex/agents',
});
});
Expand Down Expand Up @@ -73,14 +74,14 @@ describe('tclaude/tcodex adapter integration', () => {
});
});

describe('hooks injection targets tclaude', () => {
it('tclaude settings path enables hook injection', async () => {
describe('hooks injection targets tclaude/tcodex', () => {
it('settings paths enable hook injection', async () => {
const { TeamaiConfigSchema } = await import('../types.js');
const config = TeamaiConfigSchema.parse({ team: 'test', repo: 'test/repo' });
// tclaude has settings → hooks will be injected
expect(config.toolPaths.tclaude.settings).toBe('.tclaude/settings.json');
// tcodex has no settings → hooks will NOT be injected
expect(config.toolPaths.tcodex.settings).toBeUndefined();
// tcodex has Codex hooks.json → hooks will be injected
expect(config.toolPaths.tcodex.settings).toBe('.tcodex/hooks.json');
});
});

Expand Down
1 change: 1 addition & 0 deletions src/__tests__/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ describe('TeamaiConfigSchema', () => {
expect(result.toolPaths['codex-internal']).toEqual({
skills: '.codex-internal/skills',
rules: '.codex-internal/rules',
settings: '.codex-internal/hooks.json',
agents: '.codex-internal/agents',
});
});
Expand Down
100 changes: 95 additions & 5 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,22 @@ interface CursorHooksJson {
hooks: Record<string, CursorHookEntry[]>;
}

interface CodexHookEntry {
type: string;
command: string;
timeout?: number;
}

interface CodexHookMatcher {
matcher?: string;
hooks: CodexHookEntry[];
}

interface CodexHooksJson {
hooks?: Record<string, CodexHookMatcher[]>;
[key: string]: unknown;
}

// ─── Unified reconcile engine (issue #19) ───────────────────
//
// A single engine injects BOTH built-in operational hooks (source: 'builtin',
Expand All @@ -71,18 +87,20 @@ interface CursorHooksJson {
// marker namespaces:
// - built-in: description starts with "[teamai] " / command matches a marker
// - team: description starts with "[teamai:hook:<id>]"
// Cursor's hooks.json carries no description, so team hooks there are tracked
// via the managed-hooks manifest (see ManagedHooksManifest).
// Cursor and Codex hook files carry no description, so team hooks there are
// tracked via the managed-hooks manifest (see ManagedHooksManifest).
//
// Reconcile is idempotent and only writes when content actually changes, so an
// upgraded CLI re-running over an already-injected file produces a zero-diff.

type ToolFormat = 'claude' | 'cursor';
type ToolFormat = 'claude' | 'cursor' | 'codex';
export type HookStatus = 'installed' | 'missing';

const CURSOR_TOOLS = new Set(['cursor']);
const CODEX_TOOLS = new Set(['codex', 'codex-internal', 'tcodex']);

function detectFormat(tool: string): ToolFormat {
if (CODEX_TOOLS.has(tool)) return 'codex';
return CURSOR_TOOLS.has(tool) ? 'cursor' : 'claude';
}

Expand Down Expand Up @@ -177,6 +195,20 @@ function toCursorEntry(def: HookDef): CursorHookEntry {
return entry;
}

function toCodexEntry(def: HookDef): CodexHookMatcher {
const entry: CodexHookMatcher = {
hooks: [
{
type: 'command',
command: def.command,
...(def.timeout !== undefined ? { timeout: def.timeout } : {}),
},
],
};
if (def.matcher && def.matcher !== '*') entry.matcher = def.matcher;
return entry;
}

/** Ordered, de-duplicated list of events appearing in the desired defs. */
function desiredEventOrder(defs: HookDef[], mapEvent: (e: string) => string | undefined): string[] {
const seen = new Set<string>();
Expand Down Expand Up @@ -322,6 +354,49 @@ async function reconcileCursorFormat(
}
}

// ─── Codex (hooks.json) reconcile ───────────────────────────

async function reconcileCodexFormat(
hooksPath: string,
tool: string,
teamDefs: HookDef[],
opts: ReconcileHooksOptions,
priorTeamCommands: Set<string>,
): Promise<void> {
const expanded = expandHome(hooksPath);
await ensureDir(path.dirname(expanded));
const hooksJson: CodexHooksJson = (await readJson<CodexHooksJson>(expanded)) ?? {};
if (!hooksJson.hooks) hooksJson.hooks = {};

const isManaged = (entry: CodexHookMatcher): boolean => {
const cmd = entry.hooks?.[0]?.command ?? '';
return TEAMAI_COMMAND_MARKERS.some((marker) => cmd.includes(marker)) || priorTeamCommands.has(cmd);
};

const defs = opts.removeAll ? [] : desiredDefs(tool, teamDefs, opts.builtinOverride);
const eventOrder = desiredEventOrder(defs, (e) => e);
const events = [...eventOrder, ...Object.keys(hooksJson.hooks).filter((e) => !eventOrder.includes(e))];

let changed = false;
for (const event of events) {
const existing = hooksJson.hooks[event] ?? [];
const untouched = existing.filter((e) => !isManaged(e));
const desiredEntries = defs.filter((d) => d.event === event).map(toCodexEntry);
const newArr = [...untouched, ...desiredEntries];
if (JSON.stringify(existing) !== JSON.stringify(newArr)) {
hooksJson.hooks[event] = newArr;
changed = true;
}
}

if (changed) {
await writeJson(expanded, hooksJson);
log.success(`${opts.removeAll ? 'Removed' : 'Updated'} teamai hooks in ${hooksPath}`);
} else {
log.debug(`teamai hooks already up-to-date in ${hooksPath}`);
}
}

// ─── Public reconcile API ───────────────────────────────────

/**
Expand All @@ -338,8 +413,11 @@ export async function reconcileHooks(
const manifest = opts.manifestPath ? await readManifest(opts.manifestPath) : null;
const priorTeamCommands = new Set((manifest?.[tool] ?? []).map((r) => r.command));

if (detectFormat(tool) === 'cursor') {
const format = detectFormat(tool);
if (format === 'cursor') {
await reconcileCursorFormat(settingsPath, tool, teamDefs, opts, priorTeamCommands);
} else if (format === 'codex') {
await reconcileCodexFormat(settingsPath, tool, teamDefs, opts, priorTeamCommands);
} else {
await reconcileClaudeFormat(settingsPath, tool, teamDefs, opts, teamActive);
}
Expand Down Expand Up @@ -384,7 +462,8 @@ export async function getHookStatus(settingsPath: string, tool?: string): Promis
const expanded = expandHome(settingsPath);
const defs = builtinHookDefs(toolName);

if (detectFormat(toolName) === 'cursor') {
const format = detectFormat(toolName);
if (format === 'cursor') {
const hooksJson = await readJson<CursorHooksJson>(expanded);
if (!hooksJson?.hooks) return 'missing';
const present = defs.every((def) => {
Expand All @@ -397,6 +476,17 @@ export async function getHookStatus(settingsPath: string, tool?: string): Promis
return present ? 'installed' : 'missing';
}

if (format === 'codex') {
const hooksJson = await readJson<CodexHooksJson>(expanded);
if (!hooksJson?.hooks) return 'missing';
const present = defs.every((def) => {
const want = toCodexEntry(def);
const entries = hooksJson.hooks?.[def.event] ?? [];
return entries.some((e) => e.matcher === want.matcher && e.hooks?.[0]?.command === want.hooks[0].command);
});
return present ? 'installed' : 'missing';
}

const settings = await readJson<ClaudeSettingsJson>(expanded);
if (!settings?.hooks) return 'missing';
const present = defs.every((def) => {
Expand Down
6 changes: 3 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,11 @@ export const TeamaiConfigSchema = z.object({
autoUpdate: z.boolean().optional(),
toolPaths: z.record(z.string(), ToolPathsSchema).default({
claude: { skills: '.claude/skills', rules: '.claude/rules', settings: '.claude/settings.json', claudemd: '.claude/CLAUDE.md', agents: '.claude/agents' },
codex: { skills: '.codex/skills', rules: '.codex/rules', agents: '.codex/agents' },
'codex-internal': { skills: '.codex-internal/skills', rules: '.codex-internal/rules', agents: '.codex-internal/agents' },
codex: { skills: '.codex/skills', rules: '.codex/rules', settings: '.codex/hooks.json', agents: '.codex/agents' },
'codex-internal': { skills: '.codex-internal/skills', rules: '.codex-internal/rules', settings: '.codex-internal/hooks.json', agents: '.codex-internal/agents' },
'claude-internal': { skills: '.claude-internal/skills', rules: '.claude-internal/rules', settings: '.claude-internal/settings.json', claudemd: '.claude-internal/CLAUDE.md', agents: '.claude-internal/agents' },
tclaude: { skills: '.tclaude/skills', rules: '.tclaude/rules', settings: '.tclaude/settings.json', claudemd: '.tclaude/CLAUDE.md', agents: '.tclaude/agents' },
tcodex: { skills: '.tcodex/skills', rules: '.tcodex/rules', agents: '.tcodex/agents' },
tcodex: { skills: '.tcodex/skills', rules: '.tcodex/rules', settings: '.tcodex/hooks.json', agents: '.tcodex/agents' },
cursor: { skills: '.cursor/skills', rules: '.cursor/rules', settings: '.cursor/hooks.json', agents: '.cursor/agents' },
codebuddy: { skills: '.codebuddy/skills', rules: '.codebuddy/rules', settings: '.codebuddy/settings.json', claudemd: '.codebuddy/CODEBUDDY.md', agents: '.codebuddy/agents' },
openclaw: { skills: '.openclaw/skills', rules: '.openclaw/rules' },
Expand Down
Loading