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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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 |
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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` | 诊断配置问题 |
Expand Down
83 changes: 81 additions & 2 deletions src/__tests__/hooks-cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ vi.mock('../config.js', () => ({
}));

vi.mock('../hooks.js', () => ({
getHookStatus: vi.fn(),
injectHooksToAllTools: vi.fn(),
removeHooks: vi.fn(),
}));
Expand All @@ -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 {
Expand All @@ -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' },
},
};

Expand All @@ -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);
});
Expand Down Expand Up @@ -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({});
Expand Down
47 changes: 35 additions & 12 deletions src/__tests__/hooks.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import path from 'node:path';

// ── Mocks ────────────────────────────────────────────────

Expand All @@ -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 ──────────────────────────────────────────────

Expand Down Expand Up @@ -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;
}
});
});

Expand Down Expand Up @@ -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'] = {
Expand Down
71 changes: 70 additions & 1 deletion src/hooks-cmd.ts
Original file line number Diff line number Diff line change
@@ -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') {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -64,3 +101,35 @@ export async function hooksRemove(_options: GlobalOptions): Promise<void> {

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<void> {
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));
}
50 changes: 50 additions & 0 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ function buildCursorHooks(tool: string): Record<string, CursorHookEntry[]> {
// ─── Tool format detection ──────────────────────────────────

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

const CURSOR_TOOLS = new Set(['cursor']);

Expand All @@ -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;
Expand Down Expand Up @@ -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<HookStatus> {
const toolName = tool ?? 'claude';
const format = detectFormat(toolName);
const expanded = expandHome(settingsPath);

if (format === 'cursor') {
const hooksJson = await readJson<CursorHooksJson>(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<ClaudeSettingsJson>(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
*/
Expand Down
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
Loading