diff --git a/docs/usage-guide.md b/docs/usage-guide.md index 81767a5d..ccfd03aa 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -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 `~/./` 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`. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 504a93f9..75977e3d 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -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 只会操作你实际已安装的工具(即 `~/./` 根目录已存在的工具)。对于 `toolPaths` 中已配置但未安装的工具,命令不会为其凭空创建根目录。 在 Windows 上,经由 bash 执行的内置 hook 派发命令(如 Claude、Codex、Cursor、Copilot CLI)会以绝对路径引用 Git Bash——先查标准安装位置,再回退到 `HKLM\SOFTWARE\GitForWindows` 注册表——从而避免解析到 WSL 的 `bash.exe`;若找不到 Git Bash,则退回裸 `bash`。 diff --git a/src/__tests__/hooks-cmd.test.ts b/src/__tests__/hooks-cmd.test.ts index 79a7d894..8ade1508 100644 --- a/src/__tests__/hooks-cmd.test.ts +++ b/src/__tests__/hooks-cmd.test.ts @@ -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', () => ({ @@ -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'; @@ -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 = { @@ -85,6 +90,19 @@ function copilotConfig() { }; } +/** Hook lines listed under `:` 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; @@ -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', () => { @@ -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 { @@ -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 { @@ -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 { @@ -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'); @@ -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(); @@ -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, ); }); @@ -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 @@ -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. @@ -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 () => { @@ -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 >/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 ', + 'UserPromptSubmit → teamai hook-dispatch prompt-submit --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', () => { diff --git a/src/__tests__/openclaw-hooks.test.ts b/src/__tests__/openclaw-hooks.test.ts index aafaf684..4e8c1468 100644 --- a/src/__tests__/openclaw-hooks.test.ts +++ b/src/__tests__/openclaw-hooks.test.ts @@ -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; @@ -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; + + 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); + }); +}); diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index 59e56077..05793928 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -398,6 +398,68 @@ export function builtinHookDefs(tool: string): HookDef[] { })); } +/** + * Built-in hooks each non-settings tool really receives from the hook + * reconciliation pipeline, and how the installed artifact runs them. + * + * Tools driven by a settings/hooks file get the full `builtinHookDefs(tool)` + * set through `reconcileHooks`. The adapters below own their own format, each + * cover a narrower slice, and spawn the dispatcher directly — so the shell + * wrapper the settings tools carry would misreport what is on disk. + * + * A tool in neither place is not listed: JoyCode has no hook surface at all, + * and Kiro's session-start command is embedded per agent by the agent sync + * (`renderForKiro`), so it exists only for agents that were actually synced + * rather than coming from the hook pipeline. + */ +const ADAPTER_BUILTIN_HOOKS: Record = { + // hermes-hooks.ts registers one on_session_start script whose single line is + // the dispatch command with errors swallowed (buildReportScript). + hermes: { keys: ['Hook dispatch session-start'], suffix: ' >/dev/null 2>&1 || true' }, + // omp-hooks.ts subscribes to four OMP extension events and spawns the + // dispatcher with argv; `tool_result` carries no matcher, so the Skill / + // TodoWrite passes do not exist there. + omp: { + keys: [ + 'Hook dispatch session-start', + 'Hook dispatch stop', + 'Hook dispatch post-tool-use wildcard', + 'Hook dispatch prompt-submit', + ], + }, + // opencode-hooks.ts covers the same four events plus the matcher-scoped + // post-tool-use passes (TOOL_MATCHER), i.e. the whole built-in set. + opencode: { keys: BUILTIN_HOOK_SPECS.map((spec) => spec.key) }, + // openclaw-hooks.ts EVENT_MAP maps session:start and command:new only, and + // its generated handler spawns the dispatcher with argv. Only `openclaw`: + // the other claw variants share its workspace resolver, so reconciliation + // does not route them (see reconcileHooksToAllTools). + openclaw: { keys: ['Hook dispatch session-start', 'Hook dispatch prompt-submit'] }, +}; + +/** + * Built-in hook definitions a tool actually receives, for reporting + * (`teamai hooks list`). + * + * `settingsDriven` tools go through the settings-file reconcile path and get + * the full set; the others are limited to what their own adapter installs, and + * a tool the pipeline never installs a built-in hook for gets an empty list so + * callers can omit it instead of advertising hooks it never receives (#717). + */ +export function installedBuiltinHookDefs(tool: string, settingsDriven: boolean): HookDef[] { + if (settingsDriven) return builtinHookDefs(tool); + const adapter = ADAPTER_BUILTIN_HOOKS[tool]; + if (!adapter) return []; + return BUILTIN_HOOK_SPECS.filter((spec) => adapter.keys.includes(spec.key)).map((spec) => ({ + source: 'builtin' as const, + key: spec.key, + event: spec.event, + matcher: spec.matcher, + command: getRawDispatchCommand(spec.dispatchEvent, tool, spec.matcher) + (adapter.suffix ?? ''), + description: `${TEAMAI_HOOK_DESCRIPTION_PREFIX} ${spec.key}`, + })); +} + /** §4.8 team override of built-in hooks. Only whitelisted fields are honored. */ export interface BuiltinHookOverride { /** Built-in hook keys to disable (drop entirely). */ diff --git a/src/hermes-hooks.ts b/src/hermes-hooks.ts index 7ef6b970..76186215 100644 --- a/src/hermes-hooks.ts +++ b/src/hermes-hooks.ts @@ -24,7 +24,7 @@ import { const REPORT_EVENT = 'on_session_start'; /** Absolute path to the generated status-report shell script. */ -function getReportScriptPath(): string { +export function getReportScriptPath(): string { return path.join(getHermesHome(), 'hooks', 'teamai-status-report.sh'); } diff --git a/src/hooks-cmd.ts b/src/hooks-cmd.ts index 98915ea4..87698e7e 100644 --- a/src/hooks-cmd.ts +++ b/src/hooks-cmd.ts @@ -1,10 +1,10 @@ import path from 'node:path'; import { autoDetectInit } from './config.js'; import { reconcileHooks, reconcileHooksToAllTools, reconcileTeamHooksForConfig, sweepLegacyProjectHooks, getHookStatus, hasInstalledCodexTrustGatedTool, codexTrustReminder, type HookStatus } from './hooks.js'; -import { builtinHookDefs } from './builtin-hooks.js'; -import { parseTeamHooks } from './resources/hooks.js'; +import { applyBuiltinOverride, installedBuiltinHookDefs } from './builtin-hooks.js'; +import { parseTeamHooksConfig } from './resources/hooks.js'; import { log } from './utils/logger.js'; -import type { GlobalOptions } from './types.js'; +import type { GlobalOptions, HookDef } from './types.js'; import { COPILOT_TOOL_ID, getManagedHooksPath, @@ -22,6 +22,8 @@ interface HookListRow { tool: string; status: HookListStatus; settingsPath: string; + /** Built-in hooks this tool really receives (empty = no hook surface). */ + builtinDefs: HookDef[]; } function formatDisplayPath(settingsPath: string): string { @@ -52,6 +54,40 @@ function formatHooksList(rows: HookListRow[]): string { return lines.join('\n'); } +/** + * Generated files an adapter-driven tool's built-in hooks live in, or null when + * the tool is reconciled through a settings file (or its target location cannot + * be resolved, e.g. no OpenClaw workspace on this machine). The tool counts as + * installed only when every one of them is present, so a half-written + * installation does not read as `installed`. + */ +async function adapterHookArtifacts(tool: string): Promise { + if (tool === 'omp') { + const { resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); + return [path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE)]; + } + if (tool === 'opencode') { + // reconcileOpencodePlugin always installs the single plugin under the + // user path, whatever the config scope, so probe there. + const { resolveOpencodePluginDir, OPENCODE_HOOK_FILE } = await import('./opencode-hooks.js'); + return [path.join(resolveOpencodePluginDir(getUserHome(), 'user'), OPENCODE_HOOK_FILE)]; + } + if (tool === 'hermes') { + const { getReportScriptPath } = await import('./hermes-hooks.js'); + return [getReportScriptPath()]; + } + if (tool === 'openclaw') { + const { resolveOpenclawWorkspaceDir, OPENCLAW_HOOK_DIR } = await import('./openclaw-hooks.js'); + const workspace = await resolveOpenclawWorkspaceDir(); + if (!workspace) return null; + // The engine needs both halves: the HOOK.md descriptor and the handler + // it points at. + const dir = path.join(workspace, 'hooks', OPENCLAW_HOOK_DIR); + return [path.join(dir, 'HOOK.md'), path.join(dir, 'handler.ts')]; + } + return null; +} + /** * Handler for `teamai hooks inject`. * Reconciles built-in (A) + team (B) hooks into all configured AI tool settings. @@ -95,6 +131,10 @@ export async function hooksList(_options: GlobalOptions): Promise { // `~/.qoder-cn` vs `/.qoder`) would otherwise be probed in the *other* // build's file and always reported missing. const hookScopedPaths = scopedToolPaths(teamConfig, { ...localConfig, scope: hookScope }); + // The team's `builtin:` block can disable built-in hooks (§4.8); the + // reconcile engine applies it, so the listing must too or it shows hooks + // that were just removed from the settings files. + const { defs: teamDefs, builtin: builtinOverride } = await parseTeamHooksConfig(localConfig.repo.localPath); const rows: HookListRow[] = []; // One settings file is one install, so list it once, for the target that owns // it — the same rule the write path applies. Qoder CN shares Qoder's project @@ -121,39 +161,72 @@ export async function hooksList(_options: GlobalOptions): Promise { if (seenSettingsFiles.has(hookPath)) continue; seenSettingsFiles.add(hookPath); } - // OMP has no settings/hooks file to parse: its hooks are a single - // generated extension under the user agent dir, so presence of the - // file (with our marker) is the whole status. - if (tool === 'omp') { - const { resolveOmpExtensionsDir, OMP_HOOK_FILE } = await import('./omp-hooks.js'); - const extFile = path.join(resolveOmpExtensionsDir(), OMP_HOOK_FILE); + // The adapter-driven tools have no settings/hooks file to parse: each + // installs a single generated artifact, so its presence is the whole + // status. + const artifacts = await adapterHookArtifacts(tool); + if (artifacts) { + const present = await Promise.all(artifacts.map((file) => pathExists(file))); rows.push({ tool, - status: await pathExists(extFile) ? 'installed' : 'missing', - settingsPath: formatDisplayPath(extFile), + status: present.every(Boolean) ? 'installed' : 'missing', + settingsPath: formatDisplayPath(artifacts[0] as string), + builtinDefs: installedBuiltinHookDefs(tool, false), }); continue; } if (!hookPath) { - rows.push({ tool, status: 'not configured', settingsPath: 'no settings configured' }); + rows.push({ + tool, + status: 'not configured', + settingsPath: 'no settings configured', + builtinDefs: installedBuiltinHookDefs(tool, false), + }); continue; } rows.push({ tool, - status: await getHookStatus(hookPath, tool), + status: await getHookStatus(hookPath, tool, builtinOverride), settingsPath: formatDisplayPath(hookPath), + // The override is applied to the settings-driven defs only: the + // standalone adapters generate a fixed handler and ignore it, so + // filtering their rows would hide hooks they still install. + builtinDefs: applyBuiltinOverride(installedBuiltinHookDefs(tool, true), builtinOverride), }); } console.log(formatHooksList(rows)); - const teamDefs = await parseTeamHooks(localConfig.repo.localPath); - console.log(''); - console.log('Built-in hooks (A) — teamai operational (injected into every tool):'); - for (const d of builtinHookDefs('claude')) { - const matcher = d.matcher && d.matcher !== '*' ? ` [${d.matcher}]` : ''; - console.log(` ${d.event}${matcher} → ${d.command}`); + console.log('Built-in hooks (A) — teamai operational, per tool:'); + // The built-in set is per tool, not universal: Copilot carries an extra + // SessionEnd entry, the dispatch command differs for ZCode (raw) and the + // shell-dependent GUI tools (PATH wrapper), and the standalone adapters + // (Hermes, OMP, OpenClaw) install only part of the set. Rendering one + // hardcoded tool's set both hid hooks that `hooks inject` really installs + // and advertised hooks tools without that surface never receive (#717), so + // tools with no built-in hooks at all are omitted here. Tools whose set is + // identical once the tool id is folded out share one block, so the listing + // stays short instead of repeating the same rows per tool. + const builtinGroups = new Map(); + for (const { tool, builtinDefs } of rows) { + if (builtinDefs.length === 0) continue; + const lines = builtinDefs.map((d) => { + const matcher = d.matcher && d.matcher !== '*' ? ` [${d.matcher}]` : ''; + const command = d.command.split(`--tool ${tool}`).join('--tool '); + return ` ${d.event}${matcher} → ${command}`; + }); + const key = lines.join('\n'); + const group = builtinGroups.get(key); + if (group) group.tools.push(tool); + else builtinGroups.set(key, { tools: [tool], lines }); + } + if (builtinGroups.size === 0) { + console.log(' (none)'); + } + for (const group of builtinGroups.values()) { + console.log(` ${group.tools.join(', ')}:`); + for (const line of group.lines) console.log(line); } console.log(''); diff --git a/src/hooks.ts b/src/hooks.ts index 59175a67..23327a20 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1158,11 +1158,19 @@ export async function removeHooks(settingsPath: string, tool?: string): Promise< * Report whether the current built-in (A) hook set is present in a tool settings * file. Computed against the unified HookDef model: every built-in entry for the * tool must already exist on disk. + * + * `builtinOverride` is the team's §4.8 override. Reconciliation applies it when + * writing, so the status check must apply it too — otherwise a hook the team + * disabled is still expected on disk and every tool reads as `missing`. */ -export async function getHookStatus(settingsPath: string, tool?: string): Promise { +export async function getHookStatus( + settingsPath: string, + tool?: string, + builtinOverride?: BuiltinHookOverride, +): Promise { const toolName = tool ?? 'claude'; const expanded = expandHome(settingsPath); - const defs = builtinHookDefs(toolName); + const defs = applyBuiltinOverride(builtinHookDefs(toolName), builtinOverride); const format = detectFormat(toolName); if (format === 'cursor') { @@ -1482,6 +1490,29 @@ export async function reconcileHooksToAllTools( } continue; } + // OpenClaw has no settings hook list either: its hook is a HOOK.md + + // handler.ts pair under the resolved workspace dir. Route it to that + // adapter, which no-ops when the workspace cannot be resolved, so an + // uninstalled OpenClaw never grows a config dir. Only `openclaw` itself: + // resolveOpenclawWorkspaceDir resolves the OpenClaw workspace, so routing + // the other claw variants here would make them overwrite that one handler + // with each other's --tool value. + if (tool === 'openclaw') { + if (opts.settingsOnly) continue; + try { + if (opts.removeAll) { + const { removeOpenClawHooks, resolveOpenclawWorkspaceDir } = await import('./openclaw-hooks.js'); + const wsDir = await resolveOpenclawWorkspaceDir(); + if (wsDir) await removeOpenClawHooks(path.join(wsDir, 'hooks')); + } else { + const { injectOpenClawHooks } = await import('./openclaw-hooks.js'); + await injectOpenClawHooks(undefined, tool); + } + } catch (e) { + log.warn(`Failed to reconcile OpenClaw hooks for ${tool}: ${(e as Error).message}`); + } + continue; + } // OpenCode has no settings.json hook list; it auto-loads JS/TS plugins from // its config dirs. Route it to the plugin-file adapter instead of the // settings-based path.