From 77dc4d435fba2c999b7f04a98d70b150295d2c24 Mon Sep 17 00:00:00 2001 From: Yong-yuan-X <2463436064@qq.com> Date: Sun, 28 Jun 2026 14:31:45 +0800 Subject: [PATCH] feat(hooks): add hooks list command --- README.md | 2 +- README.zh-CN.md | 2 +- src/__tests__/hooks-cmd.test.ts | 83 ++++++++++++++++++++++++++++++++- src/__tests__/hooks.test.ts | 47 ++++++++++++++----- src/hooks-cmd.ts | 71 +++++++++++++++++++++++++++- src/hooks.ts | 50 ++++++++++++++++++++ src/index.ts | 9 ++++ 7 files changed, 247 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index a7f55a2..623d8c4 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ The CLI picks a provider automatically from the repo URL: | `teamai review [id] [--apply \| --reject \| --all-apply]` | Inspect and process pending codebase changes from `.teamai/pending-review.jsonl`; `--apply` patches in place via section anchors | | `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | Inspect and resolve domain-drift signals; `--apply` reassigns the repo to the recommended domain and refreshes the aggregate views | | `teamai digest` | Generate a team AI usage weekly digest (skill leaderboard, new/updated skills, session summaries) | -| `teamai hooks` | Manage AI-tool hooks (list / inject / remove) | +| `teamai hooks` | Manage AI-tool hooks (`list` shows installation status; `inject`/`remove` update settings) | | `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI pipeline integration: extract knowledge from MR/PR, post as comments, and write to team repo after merge. With `--individual-comments`, each suggestion is posted separately with reaction/reject support (GitHub ๐Ÿ‘Ž / TGit โ˜๏ธ) | | `teamai uninstall [--force]` | Uninstall teamai: remove hooks, rules, skills, env, docs, and `~/.teamai/` | | `teamai doctor` | Diagnose configuration problems | diff --git a/README.zh-CN.md b/README.zh-CN.md index 8c42e7a..179f454 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -92,7 +92,7 @@ CLI ไผšๆ นๆฎ็”จๆˆทไผ ๅ…ฅ็š„ repo URL ่‡ชๅŠจ้€‰ๆ‹ฉ provider๏ผš | `teamai review [id] [--apply \| --reject \| --all-apply]` | ๆต่งˆๅนถๅค„็† `.teamai/pending-review.jsonl` ไธญ็š„ๅพ…ๅฎก codebase ๅ˜ๆ›ด๏ผ›`--apply` ้€š่ฟ‡็ซ ่Š‚้”š็‚นๅŽŸๅœฐๅ†™ๅ…ฅ | | `teamai domains drift [url] [--apply \| --lock \| --apply-all]` | ๆต่งˆๅนถๅค„็†ๅŸŸๆผ‚็งปไฟกๅท๏ผ›`--apply` ๆŠŠไป“ๅบ“้‡ๆ–ฐๅฝ’็ฑปๅˆฐๆŽจ่ๅŸŸๅนถๅˆทๆ–ฐ่šๅˆ่ง†ๅ›พ | | `teamai digest` | ็”Ÿๆˆๅ›ข้˜Ÿ AI ไฝฟ็”จๅ‘จๆŠฅ๏ผˆskill ๆŽ’่กŒใ€ๆ–ฐๅขž/ๆ›ดๆ–ฐ skillใ€session ๆ‘˜่ฆ๏ผ‰ | -| `teamai hooks` | ็ฎก็† AI ๅทฅๅ…ท hooks๏ผˆlist / inject / remove๏ผ‰ | +| `teamai hooks` | ็ฎก็† AI ๅทฅๅ…ท hooks๏ผˆ`list` ๆŸฅ็œ‹ๅฎ‰่ฃ…็Šถๆ€๏ผ›`inject`/`remove` ๆ›ดๆ–ฐ้…็ฝฎ๏ผ‰ | | `teamai ci extract-mr --url [--mode comment\|write\|both] [--individual-comments]` | CI ๆตๆฐด็บฟ้›†ๆˆ๏ผšไปŽ MR/PR ไธญๆๅ–็Ÿฅ่ฏ†๏ผŒๅ‘ๅธƒไธบ่ฏ„่ฎบ๏ผŒๅˆๅนถๅŽๅ†™ๅ…ฅๅ›ข้˜Ÿ็Ÿฅ่ฏ†ไป“ๅบ“ใ€‚ไฝฟ็”จ `--individual-comments` ๆ—ถๆฏๆกๅปบ่ฎฎๅ•็‹ฌๅ‘ๅธƒ๏ผŒๆ”ฏๆŒ reaction/reject ไบคไบ’๏ผˆGitHub ๐Ÿ‘Ž / TGit โ˜๏ธ๏ผ‰ | | `teamai uninstall [--force]` | ๅธ่ฝฝ teamai๏ผš็งป้™ค hooksใ€rulesใ€skillsใ€envใ€docsใ€~/.teamai/ | | `teamai doctor` | ่ฏŠๆ–ญ้…็ฝฎ้—ฎ้ข˜ | diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index 6c4a273..a4d8718 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -8,6 +8,7 @@ vi.mock('../config.js', () => ({ })); vi.mock('../hooks.js', () => ({ + getHookStatus: vi.fn(), injectHooksToAllTools: vi.fn(), removeHooks: vi.fn(), })); @@ -25,11 +26,12 @@ vi.mock('../utils/logger.js', () => ({ // โ”€โ”€ Imports (after mocks) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ import { autoDetectInit } from '../config.js'; -import { injectHooksToAllTools, removeHooks } from '../hooks.js'; +import { getHookStatus, injectHooksToAllTools, removeHooks } from '../hooks.js'; import { log } from '../utils/logger.js'; -import { hooksInject, hooksRemove } from '../hooks-cmd.js'; +import { hooksInject, hooksList, hooksRemove } from '../hooks-cmd.js'; const mockedAutoDetectInit = autoDetectInit as Mock; +const mockedGetHookStatus = getHookStatus as Mock; const mockedInjectHooksToAllTools = injectHooksToAllTools as Mock; const mockedRemoveHooks = removeHooks as Mock; const mockedLog = log as unknown as { @@ -52,6 +54,7 @@ const mockTeamConfig = { claude: { settings: '.claude/settings.json', skills: '.claude/skills' }, 'claude-internal': { settings: '.claude-internal/settings.json', skills: '.claude-internal/skills' }, cursor: { settings: '.cursor/hooks.json', skills: '.cursor/skills' }, + codex: { skills: '.codex/skills' }, }, }; @@ -72,6 +75,7 @@ function mockHome(home: string): () => void { beforeEach(() => { vi.clearAllMocks(); mockedAutoDetectInit.mockResolvedValue({ localConfig: mockLocalConfig, teamConfig: mockTeamConfig }); + mockedGetHookStatus.mockResolvedValue('missing'); mockedInjectHooksToAllTools.mockResolvedValue(undefined); mockedRemoveHooks.mockResolvedValue(undefined); }); @@ -134,6 +138,81 @@ describe('hooksInject', () => { }); }); +describe('hooksList', () => { + it('should list hook status for configured tools', async () => { + const restoreHome = mockHome('/home/testuser'); + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + mockedGetHookStatus + .mockResolvedValueOnce('installed') + .mockResolvedValueOnce('missing') + .mockResolvedValueOnce('installed'); + + try { + await hooksList({}); + + expect(mockedGetHookStatus).toHaveBeenCalledTimes(3); + expect(mockedGetHookStatus).toHaveBeenCalledWith( + path.join('/home/testuser', '.claude/settings.json'), + 'claude', + ); + expect(mockedGetHookStatus).toHaveBeenCalledWith( + path.join('/home/testuser', '.claude-internal/settings.json'), + 'claude-internal', + ); + expect(mockedGetHookStatus).toHaveBeenCalledWith( + path.join('/home/testuser', '.cursor/hooks.json'), + 'cursor', + ); + + const output = consoleLog.mock.calls.map((call) => String(call[0])).join('\n'); + expect(output).toContain('claude'); + expect(output).toContain('installed'); + expect(output).toContain('claude-internal'); + expect(output).toContain('missing'); + expect(output).toContain('codex'); + expect(output).toContain('not configured'); + expect(output).toContain('no settings configured'); + } finally { + restoreHome(); + consoleLog.mockRestore(); + } + }); + + it('should list project and user base dirs when project config detected', async () => { + const restoreHome = mockHome('/home/testuser'); + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const projectConfig = { + ...mockLocalConfig, + scope: 'project', + projectRoot: '/path/to/project', + }; + mockedAutoDetectInit.mockResolvedValue({ localConfig: projectConfig, teamConfig: mockTeamConfig }); + + try { + await hooksList({}); + + expect(mockedGetHookStatus).toHaveBeenCalledTimes(6); + expect(mockedGetHookStatus).toHaveBeenCalledWith( + path.join('/path/to/project', '.claude/settings.json'), + 'claude', + ); + expect(mockedGetHookStatus).toHaveBeenCalledWith( + path.join('/home/testuser', '.claude/settings.json'), + 'claude', + ); + } finally { + restoreHome(); + consoleLog.mockRestore(); + } + }); + + it('should propagate error when not initialized', async () => { + mockedAutoDetectInit.mockRejectedValue(new Error('teamai is not initialized')); + + await expect(hooksList({})).rejects.toThrow('not initialized'); + }); +}); + describe('hooksRemove', () => { it('should remove hooks from all tools with settings', async () => { await hooksRemove({}); diff --git a/src/__tests__/hooks.test.ts b/src/__tests__/hooks.test.ts index 3ce38da..fbf2658 100644 --- a/src/__tests__/hooks.test.ts +++ b/src/__tests__/hooks.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'node:path'; // โ”€โ”€ Mocks โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -23,7 +24,7 @@ vi.mock('../utils/logger.js', () => ({ }, })); -import { injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, TEAMAI_LEGACY_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS } from '../hooks.js'; +import { getHookStatus, injectHooks, removeHooks, injectHooksToAllTools, TEAMAI_HOOK_SUBCOMMANDS, TEAMAI_LEGACY_HOOK_SUBCOMMANDS, CLAUDE_TO_CURSOR_EVENTS } from '../hooks.js'; // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -338,17 +339,19 @@ describe('hooks', () => { const originalHome = process.env.HOME; process.env.HOME = '/test-home'; - await injectHooksToAllTools({ - claude: { settings: '.claude/settings.json' }, - codex: {}, - cursor: { settings: '.cursor/hooks.json' }, - }); - - expect(mockFiles['/test-home/.claude/settings.json']).toBeDefined(); - expect(mockFiles['/test-home/.cursor/hooks.json']).toBeDefined(); - expect(Object.keys(mockFiles)).toHaveLength(2); - - process.env.HOME = originalHome; + try { + await injectHooksToAllTools({ + claude: { settings: '.claude/settings.json' }, + codex: {}, + cursor: { settings: '.cursor/hooks.json' }, + }); + + expect(mockFiles[path.join('/test-home', '.claude/settings.json')]).toBeDefined(); + expect(mockFiles[path.join('/test-home', '.cursor/hooks.json')]).toBeDefined(); + expect(Object.keys(mockFiles)).toHaveLength(2); + } finally { + process.env.HOME = originalHome; + } }); }); @@ -478,6 +481,26 @@ describe('hooks', () => { }); }); + describe('getHookStatus', () => { + it('reports installed for current Claude hooks', async () => { + await injectHooks('/test/settings.json', 'claude'); + + await expect(getHookStatus('/test/settings.json', 'claude')).resolves.toBe('installed'); + }); + + it('reports installed for current Cursor hooks', async () => { + await injectHooks('/test/hooks.json', 'cursor'); + + await expect(getHookStatus('/test/hooks.json', 'cursor')).resolves.toBe('installed'); + }); + + it('reports missing when settings exist without teamai hooks', async () => { + mockFiles['/test/settings.json'] = { hooks: {} }; + + await expect(getHookStatus('/test/settings.json', 'claude')).resolves.toBe('missing'); + }); + }); + describe('edge cases', () => { it('handles settings.json with non-hooks fields', async () => { mockFiles['/test/settings.json'] = { diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index a661cae..0a7b78e 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -1,10 +1,18 @@ import path from 'node:path'; import { autoDetectInit } from './config.js'; -import { injectHooksToAllTools, removeHooks } from './hooks.js'; +import { getHookStatus, injectHooksToAllTools, removeHooks, type HookStatus } from './hooks.js'; import { log } from './utils/logger.js'; import type { GlobalOptions, LocalConfig } from './types.js'; import { resolveBaseDir } from './types.js'; +type HookListStatus = HookStatus | 'not configured'; + +interface HookListRow { + tool: string; + status: HookListStatus; + settingsPath: string; +} + function resolveHookBaseDirs(localConfig: LocalConfig): string[] { const baseDir = resolveBaseDir(localConfig) ?? ''; if (localConfig.scope !== 'project') { @@ -35,6 +43,35 @@ async function removeHooksFromAllTools( } } +function formatDisplayPath(settingsPath: string): string { + const home = process.env.HOME; + if (!home) return settingsPath; + + if (settingsPath === home) return '~'; + if (settingsPath.startsWith(home + path.sep) || settingsPath.startsWith(home + '/')) { + return `~${settingsPath.slice(home.length)}`; + } + return settingsPath; +} + +function formatHooksList(rows: HookListRow[]): string { + const toolWidth = Math.max('tool'.length, ...rows.map((row) => row.tool.length)); + const statusWidth = Math.max('status'.length, ...rows.map((row) => row.status.length)); + + const lines = [ + `${'tool'.padEnd(toolWidth)} ${'status'.padEnd(statusWidth)} settings`, + `${'-'.repeat(toolWidth)} ${'-'.repeat(statusWidth)} ${'-'.repeat('settings'.length)}`, + ]; + + for (const row of rows) { + lines.push( + `${row.tool.padEnd(toolWidth)} ${row.status.padEnd(statusWidth)} ${row.settingsPath}`, + ); + } + + return lines.join('\n'); +} + /** * Handler for `teamai hooks inject`. * Loads config and injects teamai hooks into all configured AI tool settings. @@ -64,3 +101,35 @@ export async function hooksRemove(_options: GlobalOptions): Promise { log.success('Hooks removed from all AI tool settings'); } + +/** + * Handler for `teamai hooks list`. + * Lists hook installation status for each configured AI tool. + */ +export async function hooksList(_options: GlobalOptions): Promise { + const { localConfig, teamConfig } = await autoDetectInit(); + const baseDirs = resolveHookBaseDirs(localConfig); + const rows: HookListRow[] = []; + + for (const [tool, paths] of Object.entries(teamConfig.toolPaths)) { + if (!paths.settings) { + rows.push({ + tool, + status: 'not configured', + settingsPath: 'no settings configured', + }); + continue; + } + + for (const baseDir of baseDirs) { + const settingsPath = path.join(baseDir, paths.settings); + rows.push({ + tool, + status: await getHookStatus(settingsPath, tool), + settingsPath: formatDisplayPath(settingsPath), + }); + } + } + + console.log(formatHooksList(rows)); +} diff --git a/src/hooks.ts b/src/hooks.ts index f09aeaf..95bc534 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -200,6 +200,7 @@ function buildCursorHooks(tool: string): Record { // โ”€โ”€โ”€ Tool format detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ type ToolFormat = 'claude' | 'cursor'; +export type HookStatus = 'installed' | 'missing'; const CURSOR_TOOLS = new Set(['cursor']); @@ -208,6 +209,24 @@ function detectFormat(tool: string): ToolFormat { return 'claude'; } +function hasExpectedClaudeHook(settings: ClaudeSettingsJson, def: ClaudeHookDef): boolean { + const matchers = settings.hooks?.[def.eventType] ?? []; + const expectedCmd = def.hook.hooks[0].command; + const expectedMatcher = def.hook.matcher; + + return matchers.some((h) => + h.matcher === expectedMatcher && + h.hooks?.some((hook) => hook.command === expectedCmd), + ); +} + +function hasExpectedCursorHook(entries: CursorHookEntry[], expected: CursorHookEntry): boolean { + return entries.some((entry) => + entry.command === expected.command && + entry.matcher === expected.matcher, + ); +} + function extractTeamaiSubcommand(command: string): string | null { const match = command.match(/teamai\s+([\w-]+)/); return match ? match[1] : null; @@ -496,6 +515,37 @@ export async function removeHooks(settingsPath: string, tool?: string): Promise< } } +/** + * Report whether the current teamai hook set is present in a tool settings file. + */ +export async function getHookStatus(settingsPath: string, tool?: string): Promise { + const toolName = tool ?? 'claude'; + const format = detectFormat(toolName); + const expanded = expandHome(settingsPath); + + if (format === 'cursor') { + const hooksJson = await readJson(expanded); + if (!hooksJson?.hooks) return 'missing'; + + const desiredHooks = buildCursorHooks(toolName); + const installed = Object.entries(desiredHooks).every(([event, expectedEntries]) => { + const entries = hooksJson.hooks[event] ?? []; + return expectedEntries.every((expected) => hasExpectedCursorHook(entries, expected)); + }); + + return installed ? 'installed' : 'missing'; + } + + const settings = await readJson(expanded); + if (!settings?.hooks) return 'missing'; + + const installed = getClaudeHooks(toolName).every((def) => + hasExpectedClaudeHook(settings, def), + ); + + return installed ? 'installed' : 'missing'; +} + /** * Inject teamai hooks into all AI tool settings */ diff --git a/src/index.ts b/src/index.ts index 2823e71..8fc661d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -408,6 +408,15 @@ const hooksCmd = program .command('hooks') .description('Manage teamai hooks in AI tool settings'); +hooksCmd + .command('list') + .description('List teamai hook installation status') + .action(async () => { + const globalOpts = program.opts() as GlobalOptions; + const { hooksList } = await import('./hooks-cmd.js'); + await hooksList(globalOpts); + }); + hooksCmd .command('inject') .description('Inject teamai hooks into all AI tool settings')