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: 2 additions & 0 deletions docs/usage-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -1409,6 +1409,8 @@ teamai hooks inject # Re-inject
teamai hooks remove # Remove
```

`hooks list` prints the built-in set per tool, because the set is not universal: Copilot also gets `SessionEnd`, OMP's extension covers four events without the `Skill` / `TodoWrite` matchers, OpenClaw maps only `SessionStart` + `UserPromptSubmit`, and Hermes only `SessionStart`. Tools the hook pipeline installs nothing for (e.g. JoyCode) are omitted, and so is Kiro — its `SessionStart` command is embedded as `hooks.agentSpawn` by the agent sync, so it exists only for the agents you actually synced.

The inject and remove commands only touch tools you actually have installed (i.e. whose `~/.<tool>/` root directory already exists). They never create root directories for tools listed in `toolPaths` but not installed.

On Windows, the built-in hook dispatch commands that shell out through bash (e.g. Claude, Codex, Cursor, Copilot CLI) reference Git Bash by absolute path — standard install locations first, then the `HKLM\SOFTWARE\GitForWindows` registry as fallback — so they never resolve to the WSL `bash.exe` launcher; if Git Bash cannot be found they degrade to bare `bash`.
Expand Down
2 changes: 2 additions & 0 deletions docs/usage-guide.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,8 @@ teamai hooks inject # 重新注入
teamai hooks remove # 移除
```

`hooks list` 按工具分别列出内置 hooks,因为各工具的集合并不相同:Copilot 额外有 `SessionEnd`,OMP 扩展覆盖四个事件且没有 `Skill` / `TodoWrite` matcher,OpenClaw 只映射 `SessionStart` + `UserPromptSubmit`,Hermes 只有 `SessionStart`。hook 注入流程不会为其安装任何内置 hook 的工具(如 JoyCode)不会列出;Kiro 也不列出——它的 `SessionStart` 由 agent 同步以 `hooks.agentSpawn` 形式内嵌,只存在于你实际同步过的 agent 中。

inject 和 remove 只会操作你实际已安装的工具(即 `~/.<tool>/` 根目录已存在的工具)。对于 `toolPaths` 中已配置但未安装的工具,命令不会为其凭空创建根目录。

在 Windows 上,经由 bash 执行的内置 hook 派发命令(如 Claude、Codex、Cursor、Copilot CLI)会以绝对路径引用 Git Bash——先查标准安装位置,再回退到 `HKLM\SOFTWARE\GitForWindows` 注册表——从而避免解析到 WSL 的 `bash.exe`;若找不到 Git Bash,则退回裸 `bash`。
Expand Down
204 changes: 188 additions & 16 deletions src/__tests__/hooks-cmd.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ vi.mock('../hooks.js', async () => {
});

vi.mock('../resources/hooks.js', () => ({
parseTeamHooks: vi.fn(),
parseTeamHooksConfig: vi.fn(),
}));

vi.mock('../utils/logger.js', () => ({
Expand All @@ -39,7 +39,7 @@ vi.mock('../utils/logger.js', () => ({

import { autoDetectInit } from '../config.js';
import { getHookStatus, reconcileHooks, reconcileHooksToAllTools, reconcileTeamHooksForConfig, sweepLegacyProjectHooks, hasInstalledCodexTrustGatedTool } from '../hooks.js';
import { parseTeamHooks } from '../resources/hooks.js';
import { parseTeamHooksConfig } from '../resources/hooks.js';
import { log } from '../utils/logger.js';
import { hooksInject, hooksRemove, hooksList } from '../hooks-cmd.js';
import { TeamaiConfigSchema } from '../types.js';
Expand All @@ -51,7 +51,12 @@ const mockedReconcileStandalone = reconcileHooks as Mock;
const mockedReconcile = reconcileHooksToAllTools as Mock;
const mockedReconcileForConfig = reconcileTeamHooksForConfig as Mock;
const mockedHasCodexTrustGated = hasInstalledCodexTrustGatedTool as Mock;
const mockedParseTeamHooks = parseTeamHooks as Mock;
const mockedParseTeamHooks = parseTeamHooksConfig as Mock;

/** hooks.yaml parse result: team defs (B) plus the optional builtin override. */
function hooksYaml(defs: unknown[], builtin?: unknown) {
return { defs, builtin };
}
const mockedLog = log as unknown as { info: Mock; success: Mock; warn: Mock; error: Mock; debug: Mock };

const mockLocalConfig = {
Expand Down Expand Up @@ -85,6 +90,19 @@ function copilotConfig() {
};
}

/** Hook lines listed under `<tool>:` in the built-in (A) block. */
function builtinBlock(text: string, tool: string): string[] | undefined {
const lines = text.split('\n');
const start = lines.findIndex((l) => /^ {2}\S/.test(l) && l.trim().slice(0, -1).split(', ').includes(tool));
if (start === -1) return undefined;
const block: string[] = [];
for (const line of lines.slice(start + 1)) {
if (!line.startsWith(' ')) break;
block.push(line.trim());
}
return block;
}

function mockHome(home: string): () => void {
const originalHome = process.env.HOME;
process.env.HOME = home;
Expand All @@ -102,7 +120,7 @@ beforeEach(() => {
mockedReconcile.mockResolvedValue(undefined);
mockedReconcileForConfig.mockResolvedValue(undefined);
mockedHasCodexTrustGated.mockResolvedValue(false);
mockedParseTeamHooks.mockResolvedValue(TEAM_DEFS);
mockedParseTeamHooks.mockResolvedValue(hooksYaml(TEAM_DEFS));
});

describe('hooksInject', () => {
Expand Down Expand Up @@ -179,9 +197,9 @@ describe('hooksInject', () => {

describe('hooksList', () => {
it('prints built-in hooks and team hooks from hooks.yaml', async () => {
mockedParseTeamHooks.mockResolvedValue([
mockedParseTeamHooks.mockResolvedValue(hooksYaml([
{ source: 'team', key: 'lint', event: 'Stop', command: 'npm run lint', description: '[teamai:hook:lint] lint', tools: ['claude'] },
]);
]));
const out: string[] = [];
const spy = vi.spyOn(console, 'log').mockImplementation((m?: unknown) => { out.push(String(m)); });
try {
Expand All @@ -199,10 +217,10 @@ describe('hooksList', () => {
});

it('prints the roles restriction next to the tools one', async () => {
mockedParseTeamHooks.mockResolvedValue([
mockedParseTeamHooks.mockResolvedValue(hooksYaml([
{ source: 'team', key: 'guard-tf', event: 'PreToolUse', matcher: 'Bash', command: 'guard-tf.sh', description: '[teamai:hook:guard-tf] x', roles: ['devops'] },
{ source: 'team', key: 'lint', event: 'Stop', command: 'npm run lint', description: '[teamai:hook:lint] lint' },
]);
]));
const out: string[] = [];
const spy = vi.spyOn(console, 'log').mockImplementation((m?: unknown) => { out.push(String(m)); });
try {
Expand All @@ -216,11 +234,11 @@ describe('hooksList', () => {
});

it('prints the projects restriction next to the roles one', async () => {
mockedParseTeamHooks.mockResolvedValue([
mockedParseTeamHooks.mockResolvedValue(hooksYaml([
{ source: 'team', key: 'checkout-lint', event: 'Stop', command: 'echo checkout', description: '[teamai:hook:checkout-lint] x', projects: ['checkout'] },
{ source: 'team', key: 'both', event: 'Stop', command: 'echo both', description: '[teamai:hook:both] x', roles: ['frontend'], projects: ['checkout', 'billing'] },
{ source: 'team', key: 'nobody', event: 'Stop', command: 'echo none', description: '[teamai:hook:nobody] x', projects: [] },
]);
]));
const out: string[] = [];
const spy = vi.spyOn(console, 'log').mockImplementation((m?: unknown) => { out.push(String(m)); });
try {
Expand Down Expand Up @@ -251,14 +269,17 @@ describe('hooksList', () => {
expect(mockedGetHookStatus).toHaveBeenCalledWith(
path.join('/home/testuser', '.claude/settings.json'),
'claude',
undefined,
);
expect(mockedGetHookStatus).toHaveBeenCalledWith(
path.join('/home/testuser', '.claude-internal/settings.json'),
'claude-internal',
undefined,
);
expect(mockedGetHookStatus).toHaveBeenCalledWith(
path.join('/home/testuser', '.cursor/hooks.json'),
'cursor',
undefined,
);

const output = consoleLog.mock.calls.map((call) => String(call[0])).join('\n');
Expand Down Expand Up @@ -297,10 +318,12 @@ describe('hooksList', () => {
expect(mockedGetHookStatus).toHaveBeenCalledWith(
path.join('/home/testuser', '.claude/settings.json'),
'claude',
undefined,
);
expect(mockedGetHookStatus).not.toHaveBeenCalledWith(
path.join('/path/to/project', '.claude/settings.json'),
'claude',
undefined,
);
} finally {
restoreHome();
Expand Down Expand Up @@ -336,10 +359,12 @@ describe('hooksList', () => {
expect(mockedGetHookStatus).toHaveBeenCalledWith(
path.join('/home/testuser', '.qoder-cn', 'settings.json'),
'qoder-cn',
undefined,
);
expect(mockedGetHookStatus).not.toHaveBeenCalledWith(
path.join('/home/testuser', '.qoder', 'settings.json'),
'qoder-cn',
undefined,
);
});

Expand Down Expand Up @@ -368,8 +393,8 @@ describe('hooksList', () => {
}

const shared = path.join(projectRoot, '.qoder', 'settings.json');
expect(mockedGetHookStatus).toHaveBeenCalledWith(shared, 'qoder');
expect(mockedGetHookStatus).not.toHaveBeenCalledWith(shared, 'qoder-cn');
expect(mockedGetHookStatus).toHaveBeenCalledWith(shared, 'qoder', undefined);
expect(mockedGetHookStatus).not.toHaveBeenCalledWith(shared, 'qoder-cn', undefined);
});

// #667: ownership of the file shared by Qoder and Qoder CN follows the
Expand Down Expand Up @@ -402,9 +427,9 @@ describe('hooksList', () => {
}

const shared = path.join(projectRoot, '.qoder', 'settings.json');
expect(mockedGetHookStatus).toHaveBeenCalledWith(shared, 'qoder-cn');
expect(mockedGetHookStatus).toHaveBeenCalledWith(shared, 'qoder-cn', undefined);
// `qoder` is not enabled, so it must not claim — and mis-probe — the file.
expect(mockedGetHookStatus).not.toHaveBeenCalledWith(shared, 'qoder');
expect(mockedGetHookStatus).not.toHaveBeenCalledWith(shared, 'qoder', undefined);
});

// The same rule with no whitelist: `disabledAgents` alone moves ownership.
Expand All @@ -431,8 +456,8 @@ describe('hooksList', () => {
}

const shared = path.join(projectRoot, '.qoder', 'settings.json');
expect(mockedGetHookStatus).toHaveBeenCalledWith(shared, 'qoder-cn');
expect(mockedGetHookStatus).not.toHaveBeenCalledWith(shared, 'qoder');
expect(mockedGetHookStatus).toHaveBeenCalledWith(shared, 'qoder-cn', undefined);
expect(mockedGetHookStatus).not.toHaveBeenCalledWith(shared, 'qoder', undefined);
});

it('lists standalone Copilot hooks under COPILOT_HOME', async () => {
Expand All @@ -455,8 +480,155 @@ describe('hooksList', () => {
expect(mockedGetHookStatus).toHaveBeenCalledWith(
path.join(COPILOT_HOME_FIXTURE, 'hooks/teamai.json'),
'copilot',
undefined,
);
});

it('prints the built-in hook set of each listed tool, including Copilot SessionEnd', async () => {
const originalCopilotHome = process.env.COPILOT_HOME;
process.env.COPILOT_HOME = COPILOT_HOME_FIXTURE;
const out: string[] = [];
const consoleLog = vi.spyOn(console, 'log').mockImplementation((m?: unknown) => { out.push(String(m)); });
mockedAutoDetectInit.mockResolvedValue({
localConfig: { ...mockLocalConfig, enabledAgents: ['copilot'] },
teamConfig: copilotConfig(),
});

try {
await hooksList({});
} finally {
if (originalCopilotHome === undefined) delete process.env.COPILOT_HOME;
else process.env.COPILOT_HOME = originalCopilotHome;
consoleLog.mockRestore();
}

// Copilot's built-in set carries an extra SessionEnd entry that no other
// tool has; listing a hardcoded tool's set hides it even though inject
// really installs it.
const text = out.join('\n');
expect(text).toContain(' copilot:');
expect(text).toContain('SessionEnd');
expect(text).toContain('hook-dispatch session-end');
});

it('hides a built-in hook the team disabled in hooks.yaml', async () => {
// The reconcile engine applies `builtin.disabled`, so a hook listed
// here that is no longer in the settings file would be a lie.
mockedParseTeamHooks.mockResolvedValue(hooksYaml([], { disabled: ['Hook dispatch stop'] }));
const out: string[] = [];
const consoleLog = vi.spyOn(console, 'log').mockImplementation((m?: unknown) => { out.push(String(m)); });

try {
await hooksList({});
} finally {
consoleLog.mockRestore();
}

const text = out.join('\n');
const builtin = text.slice(text.indexOf('Built-in hooks (A)'), text.indexOf('Team hooks (B)'));
const claude = builtinBlock(builtin, 'claude') ?? [];
expect(claude).toHaveLength(5);
expect(claude.join('\n')).not.toContain('Stop →');
});

it('reports adapter-driven tools by their generated artifact, not "not configured"', async () => {
// Hermes / OpenCode / OMP / OpenClaw have no settings file to probe:
// reconciliation writes one generated artifact each, so its presence
// is the status. Falling through to the generic branch printed
// "not configured" for tools the pipeline does install hooks for.
const restoreHome = mockHome('/home/testuser');
const out: string[] = [];
const consoleLog = vi.spyOn(console, 'log').mockImplementation((m?: unknown) => { out.push(String(m)); });
mockedAutoDetectInit.mockResolvedValue({
localConfig: mockLocalConfig,
teamConfig: { toolPaths: { hermes: { skills: '.hermes/skills' } } },
});

try {
await hooksList({});
} finally {
restoreHome();
consoleLog.mockRestore();
}

const text = out.join('\n');
expect(text).toContain('~/.hermes/hooks/teamai-status-report.sh');
expect(text).not.toContain('no settings configured');
});

it('checks tool status against the overridden built-in set', async () => {
// Reconciliation applies `builtin.disabled` when writing, so a status
// check that still expects the disabled hook reads `missing` forever.
mockedParseTeamHooks.mockResolvedValue(hooksYaml([], { disabled: ['Hook dispatch stop'] }));
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined);

try {
await hooksList({});
} finally {
consoleLog.mockRestore();
}

expect(mockedGetHookStatus).toHaveBeenCalledWith(
expect.any(String),
'claude',
{ disabled: ['Hook dispatch stop'] },
);
});

it('lists only the built-in hooks a tool actually receives (#717)', async () => {
const out: string[] = [];
const consoleLog = vi.spyOn(console, 'log').mockImplementation((m?: unknown) => { out.push(String(m)); });
mockedAutoDetectInit.mockResolvedValue({
localConfig: mockLocalConfig,
teamConfig: {
toolPaths: {
claude: { settings: '.claude/settings.json', skills: '.claude/skills' },
// Standalone adapters: each covers a narrower slice of the
// built-in set than the settings-file tools.
hermes: { skills: '.hermes/skills' },
omp: { skills: '.omp/skills' },
openclaw: { skills: '.openclaw/skills' },
// No built-in hook from the hook pipeline.
joycode: { skills: '.joycode/skills' },
kiro: { skills: '.kiro/skills', agents: '.kiro/agents' },
},
},
});

try {
await hooksList({});
} finally {
consoleLog.mockRestore();
}

const text = out.join('\n');
const builtin = text.slice(text.indexOf('Built-in hooks (A)'), text.indexOf('Team hooks (B)'));

// Claude is reconciled through its settings file: the whole set.
expect(builtinBlock(builtin, 'claude')).toHaveLength(6);
// Hermes installs a single on_session_start script running the raw
// dispatch command (hermes-hooks.ts).
expect(builtinBlock(builtin, 'hermes')).toEqual([
'SessionStart → teamai hook-dispatch session-start --tool <tool> >/dev/null 2>&1 || true',
]);
// OMP's extension subscribes to four events and has no matcher-scoped
// PostToolUse pass (omp-hooks.ts).
const omp = builtinBlock(builtin, 'omp') ?? [];
expect(omp).toHaveLength(4);
expect(omp.join('\n')).not.toContain('[Skill]');
expect(omp.join('\n')).not.toContain('[TodoWrite]');
// OpenClaw's handler maps session:start and command:new only
// (openclaw-hooks.ts EVENT_MAP).
expect(builtinBlock(builtin, 'openclaw')).toEqual([
'SessionStart → teamai hook-dispatch session-start --tool <tool>',
'UserPromptSubmit → teamai hook-dispatch prompt-submit --tool <tool>',
]);
// JoyCode has no hook surface, and Kiro's session-start command is
// embedded per agent by the agent sync instead of the hook pipeline:
// neither may be listed.
expect(builtinBlock(builtin, 'joycode')).toBeUndefined();
expect(builtinBlock(builtin, 'kiro')).toBeUndefined();
});
});

describe('hooksRemove', () => {
Expand Down
34 changes: 34 additions & 0 deletions src/__tests__/openclaw-hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { injectOpenClawHooks, removeOpenClawHooks, OPENCLAW_HOOK_DIR } from '../openclaw-hooks.js';
import { reconcileHooksToAllTools } from '../hooks.js';

let tmpDir: string;
let wsDir: string;
Expand Down Expand Up @@ -75,3 +76,36 @@ describe('removeOpenClawHooks', () => {
await expect(removeOpenClawHooks(hooksDir)).resolves.toBeUndefined();
});
});

describe('reconcileHooksToAllTools routes the OpenClaw family to its adapter', () => {
// `hooks inject` / `init` / `pull` all go through this path. Without an
// OpenClaw branch it skipped the claw variants for lack of a `settings`
// path, so their hooks were only ever written by the legacy migration.
const toolPaths = { openclaw: { skills: '.openclaw/skills' } } as Record<string, { settings?: string }>;

it('injects, then removeAll deletes, the workspace hook dir', async () => {
const manifest = path.join(tmpDir, 'managed-hooks.json');
const hookDir = path.join(wsDir, 'hooks', OPENCLAW_HOOK_DIR);

await reconcileHooksToAllTools(toolPaths, tmpDir, [], manifest);
expect(fs.existsSync(path.join(hookDir, 'handler.ts'))).toBe(true);

await reconcileHooksToAllTools(toolPaths, tmpDir, [], manifest, { removeAll: true });
expect(fs.existsSync(hookDir)).toBe(false);
});

it('does nothing when the workspace cannot be resolved', async () => {
delete process.env.OPENCLAW_STATE_DIR;
const home = path.join(tmpDir, 'empty-home');
fs.mkdirSync(home, { recursive: true });
const prevHome = process.env.HOME;
process.env.HOME = home;
try {
await reconcileHooksToAllTools(toolPaths, home, [], path.join(tmpDir, 'managed-hooks.json'));
} finally {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
}
expect(fs.existsSync(path.join(home, '.openclaw'))).toBe(false);
});
});
Loading
Loading