From 47371407069f546bb7da8bc4bbbf280ba4b05729 Mon Sep 17 00:00:00 2001 From: BuXiuDN <43026038+BuXiuDN@users.noreply.github.com> Date: Fri, 18 Sep 2026 17:10:29 +0800 Subject: [PATCH 1/5] fix(hooks): resolve Git Bash by absolute path on Windows On Windows, CreateProcess resolves a bare `bash` to %SystemRoot%\System32\bash.exe (the WSL launcher) before any PATH entry, so every rendered `bash -lc "teamai hook-dispatch ..."` hook ran inside WSL instead of Git Bash. There the user's npm-global `teamai`/Node 20 are typically absent, and the trailing `2>/dev/null || true` silently swallowed the failure, so hooks appeared to succeed but did nothing. ZCode already sidesteps this via a `cmd /c` fallback and WorkBuddy/CodeBuddy use a PATH-prefix wrapper, but shell-string tools had no protection. On win32 we now emit the resolved Git Bash path (quoted, forward slashes so it stays JSON-safe and tolerates the "Program Files" space), discovered from the standard install locations first, then the HKLM GitForWindows InstallPath. When Git truly is not found we fall back to bare `bash` (the old form was already inert there). POSIX keeps the bare `bash`, so the golden fixtures stay byte-identical. The golden comparison is skipped on win32 since the rendered path is machine-specific. --- src/__tests__/hooks-golden.test.ts | 6 +- src/__tests__/hooks-windows-bash.test.ts | 116 +++++++++++++++++++++++ src/builtin-hooks.ts | 79 ++++++++++++++- 3 files changed, 199 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/hooks-windows-bash.test.ts diff --git a/src/__tests__/hooks-golden.test.ts b/src/__tests__/hooks-golden.test.ts index 9221516bc..2d2e76f64 100644 --- a/src/__tests__/hooks-golden.test.ts +++ b/src/__tests__/hooks-golden.test.ts @@ -13,6 +13,10 @@ import { injectHooks } from '../hooks.js'; // fixtures/hooks/.json were captured from the pre-refactor injector. // Any refactor of the injection engine MUST keep these byte-identical so that // already-installed machines see a zero-diff reconcile after a CLI upgrade. +// +// Windows skips: there a bare `bash` resolves to the WSL launcher, so the +// injector renders an absolute Git Bash path instead — machine-specific, and +// the Linux-captured fixtures cannot match it. const fixturesDir = path.resolve(__dirname, 'fixtures', 'hooks'); const cases: Array<[string, string]> = [ @@ -23,7 +27,7 @@ const cases: Array<[string, string]> = [ ['workbuddy', 'settings.json'], ]; -describe('hooks golden — built-in output is byte-identical to the captured baseline', () => { +describe.skipIf(process.platform === 'win32')('hooks golden — built-in output is byte-identical to the captured baseline', () => { let tmp: string; beforeEach(async () => { tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'hooks-golden-')); diff --git a/src/__tests__/hooks-windows-bash.test.ts b/src/__tests__/hooks-windows-bash.test.ts new file mode 100644 index 000000000..9adf58115 --- /dev/null +++ b/src/__tests__/hooks-windows-bash.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; + +vi.mock('../utils/logger.js', () => ({ + log: { info: vi.fn(), success: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +// A developer machine may have a real Git for Windows InstallPath in HKLM, so +// the default registry locator would succeed and defeat the negative case. +// Replace execFileSync at the module boundary (builtin-hooks imports it as a +// named binding, which a bare spyOn on the default export cannot intercept); +// the registry-success path is instead covered by injecting a fake locator. +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + const boom = (() => { throw new Error('registry unreadable'); }) as unknown as typeof actual.execFileSync; + return { ...actual, default: actual, execFileSync: boom }; +}); + +import { getDispatchCommand, findGitBashWindows, _resetShellCache } from '../builtin-hooks.js'; + +// Windows resolves a bare `bash` to System32's WSL launcher before any PATH +// entry, and the WSL side has a different $HOME and no npm-global teamai — the +// hook then dies inside `2>/dev/null || true` with nothing logged. The +// injector must instead name Git Bash with an absolute path. + +function makeFakeGit(root: string, relative: string[] = ['Programs', 'Git']): string { + const bin = path.join(root, ...relative, 'bin'); + fs.mkdirSync(bin, { recursive: true }); + fs.writeFileSync(path.join(bin, 'bash.exe'), ''); + return path.join(bin, 'bash.exe'); +} + +const noRegistry = () => { + throw new Error('unreachable in tests'); +}; + +describe('findGitBashWindows', () => { + it('finds Git under the LOCALAPPDATA candidate', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-')); + const exe = makeFakeGit(root); + expect(findGitBashWindows({ LOCALAPPDATA: root }, path.join(root, 'nohome'), noRegistry)).toBe(exe); + }); + + it('finds Git under the machine-wide ProgramFiles candidate', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-')); + const exe = makeFakeGit(path.join(root, 'PF'), ['Git']); + expect(findGitBashWindows({ ProgramFiles: path.join(root, 'PF') }, path.join(root, 'nohome'), noRegistry)).toBe(exe); + }); + + it('falls back to the registry InstallPath when no candidate exists', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-')); + const exe = makeFakeGit(root, ['Git']); + const installDir = path.dirname(path.dirname(exe)); + const read = () => installDir; + expect( + findGitBashWindows( + { ProgramFiles: path.join(root, 'm1'), 'ProgramFiles(x86)': path.join(root, 'm2'), LOCALAPPDATA: path.join(root, 'm3') }, + path.join(root, 'nohome'), + read, + ), + ).toBe(exe); + }); + + it('returns null when neither candidates nor registry find Git', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-empty-')); + expect( + findGitBashWindows({ ProgramFiles: root, 'ProgramFiles(x86)': root, LOCALAPPDATA: root }, root, () => null), + ).toBeNull(); + }); +}); + +describe('getDispatchCommand shell resolution', () => { + afterEach(() => { + _resetShellCache(); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('POSIX keeps the bare bash form (golden-fixture compatibility)', () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('linux'); + expect(getDispatchCommand('stop', 'claude')).toBe( + 'bash -lc "teamai hook-dispatch stop --tool claude 2>/dev/null" || true', + ); + }); + + it('Windows names Git Bash by absolute, quoted, forward-slash path', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-')); + const exe = makeFakeGit(root); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.stubEnv('HOME', root); + vi.stubEnv('ProgramFiles', path.join(root, 'missing-pf')); + vi.stubEnv('ProgramFiles(x86)', path.join(root, 'missing-pf86')); + vi.stubEnv('LOCALAPPDATA', root); + const cmd = getDispatchCommand('stop', 'qoder'); + expect(cmd).toBe( + '"' + exe.split(path.sep).join('/') + '" -lc "teamai hook-dispatch stop --tool qoder 2>/dev/null" || true', + ); + }); + + it('Windows keeps bare bash when Git Bash cannot be found', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'teamai-gitbash-empty-')); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + vi.stubEnv('HOME', root); + vi.stubEnv('ProgramFiles', root); + vi.stubEnv('ProgramFiles(x86)', root); + vi.stubEnv('LOCALAPPDATA', root); + // No execFileSync stub: the registry probe really runs, and no machine + // records InstallPath under a freshly-created temp dir, so the locator + // exhausts every source and the command must degrade to bare `bash`. + expect(getDispatchCommand('stop', 'claude')).toBe( + 'bash -lc "teamai hook-dispatch stop --tool claude 2>/dev/null" || true', + ); + }); +}); diff --git a/src/builtin-hooks.ts b/src/builtin-hooks.ts index 27c214901..f01672c01 100644 --- a/src/builtin-hooks.ts +++ b/src/builtin-hooks.ts @@ -1,5 +1,6 @@ import fs from 'node:fs'; import path from 'node:path'; +import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { TEAMAI_HOOK_DESCRIPTION_PREFIX } from './types.js'; import type { HookDef } from './types.js'; @@ -60,6 +61,7 @@ export function hasShell(): boolean { /** Reset the cached shell results. Test-only. */ export function _resetShellCache(): void { _hasShellCache = undefined; + _winBashLauncherCache = undefined; resetBundledRuntimeCache(); } @@ -159,11 +161,86 @@ export function skipToolsWithoutShell(tools: string[]): Set { return skipped; } +/** + * Read the machine-wide InstallPath the Git for Windows installer records + * in HKLM. Exported so tests stub it at the module boundary instead of + * shelling out to a real reg.exe. Returns null on any failure — an + * unreadable registry just means "no extra candidate". + */ +export function queryGitInstallPath(): string | null { + try { + const out = execFileSync( + 'reg.exe', + ['query', 'HKLM\\SOFTWARE\\GitForWindows', '/v', 'InstallPath'], + { timeout: 5000, windowsHide: true, encoding: 'utf8' }, + ); + const match = out.match(/InstallPath\s+REG_SZ\s+(.+)/); + return match?.[1]?.trim() || null; + } catch { + return null; + } +} + +/** + * Locate Git Bash on Windows. CreateProcess resolves a bare `bash` to + * System32's WSL launcher before any PATH entry (the rationale already + * documented for ZCode in hooks.ts), and the ZCode cmd fallback is not + * available to rendered shell-string commands, so on Windows the + * interpreter has to be an absolute path. Standard install locations + * first; the HKLM `GitForWindows` key covers custom InstallPath. + * Returns the exe path, or null when Git is not found. + */ +export function findGitBashWindows( + env: NodeJS.ProcessEnv = process.env, + home: string = getUserHome(), + readInstallPath: () => string | null = queryGitInstallPath, +): string | null { + const candidates: string[] = []; + if (env.ProgramFiles) candidates.push(path.join(env.ProgramFiles, 'Git', 'bin', 'bash.exe')); + if (env['ProgramFiles(x86)']) candidates.push(path.join(env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe')); + if (env.LOCALAPPDATA) candidates.push(path.join(env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe')); + candidates.push(path.join(home, 'AppData', 'Local', 'Programs', 'Git', 'bin', 'bash.exe')); + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + + const installPath = readInstallPath(); + if (installPath) { + const candidate = path.join(installPath, 'bin', 'bash.exe'); + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +let _winBashLauncherCache: string | undefined; + +/** + * The shell word to emit in a rendered hook command. POSIX keeps the bare + * `bash`; Windows substitutes the resolved Git Bash path (quoted, forward + * slashes so it stays JSON-safe — the default install location contains a + * space) so the command never reaches the WSL launcher. Falls back to + * plain `bash` only when Git is absent — there the old form was already + * dead anyway. + */ +function getHookShellCommand(): string { + if (process.platform !== 'win32') return 'bash'; + if (_winBashLauncherCache === undefined) { + const found = findGitBashWindows(); + if (found) { + _winBashLauncherCache = `"${found.split(path.sep).join('/')}"`; + } else { + _winBashLauncherCache = 'bash'; + log.debug('teamai hooks: Git Bash not found on this Windows machine; hook commands keep bare `bash` and may resolve to the WSL launcher.'); + } + } + return _winBashLauncherCache; +} + /** Generate the hook-dispatch command for a given event, tool, and optional matcher. */ export function getDispatchCommand(event: string, tool: string, matcher?: string, binPath?: string): string { const bin = binPath ?? 'teamai'; const matcherArg = matcher && matcher !== '*' ? ` --matcher ${matcher}` : ''; - return `bash -lc "${bin} hook-dispatch ${event} --tool ${tool}${matcherArg} 2>/dev/null" || true`; + return `${getHookShellCommand()} -lc "${bin} hook-dispatch ${event} --tool ${tool}${matcherArg} 2>/dev/null" || true`; } /** From aeca18222ceff55d89d42d2d5dcbce362446c198 Mon Sep 17 00:00:00 2001 From: BuXiuDN <43026038+BuXiuDN@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:50:36 +0800 Subject: [PATCH 2/5] fix(hooks): render Copilot powershell field with the call operator on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getDispatchCommand() now prefixes the launcher with a quoted Git Bash path on Windows, which COPILOT_BUILTIN_COMMAND_RE did not recognize — the raw bash command landed verbatim in the powershell field, where PowerShell cannot parse a quoted executable without the call operator (&). Accept the quoted launcher in the regex and render it back with & in front and ; exit 0 in place of || true; the bare-bash form keeps the existing POSIX output byte-for-byte. Add a Windows rendering test that asserts both fields exactly (stringContaining could not catch this). --- src/__tests__/copilot-adapter.test.ts | 44 +++++++++++++++++++++++++++ src/hooks.ts | 14 +++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/__tests__/copilot-adapter.test.ts b/src/__tests__/copilot-adapter.test.ts index 2e911ecbf..d31c09b5e 100644 --- a/src/__tests__/copilot-adapter.test.ts +++ b/src/__tests__/copilot-adapter.test.ts @@ -9,6 +9,7 @@ import { reconcileHooks, reconcileTeamHooksForConfig, } from '../hooks.js'; +import { _resetShellCache } from '../builtin-hooks.js'; import { RulesHandler } from '../resources/rules.js'; import { SkillsHandler } from '../resources/skills.js'; import { teamRuleToCopilotInstructions } from '../resources/copilot-instructions.js'; @@ -343,4 +344,47 @@ describe('GitHub Copilot adapter', () => { await handler.pullItem(missingTeamRule, teamConfig, localConfig('user')); expect(await fse.pathExists(destination)).toBe(false); }); + + describe('Windows Git Bash launcher rendering', () => { + let fakeGitRoot: string; + + beforeEach(async () => { + // A host may carry a real Git for Windows under ProgramFiles; point every + // candidate at the fake tree so the resolved launcher is deterministic. + fakeGitRoot = await fse.mkdtemp(path.join(os.tmpdir(), 'teamai-copilot-gitbash-')); + const bin = path.join(fakeGitRoot, 'Programs', 'Git', 'bin'); + await fse.ensureDir(bin); + await fse.writeFile(path.join(bin, 'bash.exe'), ''); + vi.stubEnv('ProgramFiles', path.join(fakeGitRoot, 'missing-pf')); + vi.stubEnv('ProgramFiles(x86)', path.join(fakeGitRoot, 'missing-pf86')); + vi.stubEnv('LOCALAPPDATA', fakeGitRoot); + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); + _resetShellCache(); + }); + + afterEach(async () => { + _resetShellCache(); + vi.restoreAllMocks(); + await fse.remove(fakeGitRoot); + }); + + it('renders the powershell field with the call operator when Git Bash is found', async () => { + await reconcileTeamHooksForConfig(teamConfig, localConfig('user')); + const hookPath = path.join(copilotHome, 'hooks', COPILOT_HOOK_FILE); + const first = await fse.readFile(hookPath, 'utf8'); + await reconcileTeamHooksForConfig(teamConfig, localConfig('user')); + const second = await fse.readFile(hookPath, 'utf8'); + expect(second).toBe(first); + + const parsed = JSON.parse(second) as { + hooks: Record>; + }; + const entry = parsed.hooks.SessionStart[0]; + const bashExe = path.join(fakeGitRoot, 'Programs', 'Git', 'bin', 'bash.exe').split(path.sep).join('/'); + const dispatch = 'teamai hook-dispatch session-start --tool copilot'; + expect(entry.bash).toBe(`"${bashExe}" -lc "${dispatch} 2>/dev/null" || true`); + expect(entry.powershell).toBe(`& "${bashExe}" -lc "${dispatch} 2>/dev/null"; exit 0`); + expect(entry.command).toBe(entry.bash); + }); + }); }); diff --git a/src/hooks.ts b/src/hooks.ts index 8e7208a74..5c9c7204e 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -344,12 +344,22 @@ function toCodexEntry(def: HookDef): CodexHookMatcher { return entry; } -const COPILOT_BUILTIN_COMMAND_RE = /^bash -lc "(teamai hook-dispatch [^"]+) 2>\/dev\/null" \|\| true$/; +// getDispatchCommand() prefixes the launcher with a quoted, forward-slash Git +// Bash path on Windows and keeps bare `bash` everywhere else (and on Windows +// machines where Git Bash cannot be found). +const COPILOT_BUILTIN_COMMAND_RE = /^("[^"]+"|bash) -lc "(teamai hook-dispatch [^"]+) 2>\/dev\/null" \|\| true$/; /** Render a valid PowerShell equivalent for TeamAI's generated bash wrapper. */ function copilotPowershellCommand(command: string): string { const match = command.match(COPILOT_BUILTIN_COMMAND_RE); - return match ? `${match[1]} 2>$null; exit 0` : command; + if (!match) return command; + // PowerShell needs the call operator before a quoted executable path, and + // `|| true` maps to `; exit 0`. The dispatch command inside is + // builtin-generated (no `$`, backticks or double quotes), so echoing it + // inside a double-quoted PowerShell string is interpolation-safe. + return match[1] === 'bash' + ? `${match[2]} 2>$null; exit 0` + : `& ${match[1]} -lc "${match[2]} 2>/dev/null"; exit 0`; } function toCopilotEntry(def: HookDef): CopilotHookEntry { From 42553c1ca1d3094f98fc3b476d96037d78b0d998 Mon Sep 17 00:00:00 2001 From: BuXiuDN <43026038+BuXiuDN@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:51:36 +0800 Subject: [PATCH 3/5] test(hooks): skip only machine-dependent golden cases on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The suite-level skipIf(win32) also dropped codebuddy and workbuddy, whose wrapper dispatch commands stay machine-independent — restore their compatibility coverage by skipping only the getDispatchCommand cases (claude, claude-internal, cursor) whose rendered launcher differs per machine on Windows. --- src/__tests__/hooks-golden.test.ts | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/__tests__/hooks-golden.test.ts b/src/__tests__/hooks-golden.test.ts index 2d2e76f64..346203d15 100644 --- a/src/__tests__/hooks-golden.test.ts +++ b/src/__tests__/hooks-golden.test.ts @@ -14,20 +14,23 @@ import { injectHooks } from '../hooks.js'; // Any refactor of the injection engine MUST keep these byte-identical so that // already-installed machines see a zero-diff reconcile after a CLI upgrade. // -// Windows skips: there a bare `bash` resolves to the WSL launcher, so the -// injector renders an absolute Git Bash path instead — machine-specific, and -// the Linux-captured fixtures cannot match it. +// Windows: the dispatch shell resolves a bare `bash` to the WSL launcher, so +// the injector names Git Bash by absolute path instead — machine-specific, and +// the Linux-captured fixtures cannot match it. Only the dispatch-command +// renderers (claude, claude-internal, cursor) skip there; the wrapper +// renderers (codebuddy, workbuddy) stay machine-independent and keep coverage. const fixturesDir = path.resolve(__dirname, 'fixtures', 'hooks'); -const cases: Array<[string, string]> = [ - ['claude', 'settings.json'], - ['claude-internal', 'settings.json'], - ['codebuddy', 'settings.json'], - ['cursor', 'hooks.json'], - ['workbuddy', 'settings.json'], +// true = the command carries the dispatch shell prefix (`getDispatchCommand`). +const cases: Array<[string, string, boolean]> = [ + ['claude', 'settings.json', true], + ['claude-internal', 'settings.json', true], + ['codebuddy', 'settings.json', false], + ['cursor', 'hooks.json', true], + ['workbuddy', 'settings.json', false], ]; -describe.skipIf(process.platform === 'win32')('hooks golden — built-in output is byte-identical to the captured baseline', () => { +describe('hooks golden — built-in output is byte-identical to the captured baseline', () => { let tmp: string; beforeEach(async () => { tmp = await fse.mkdtemp(path.join(os.tmpdir(), 'hooks-golden-')); @@ -36,8 +39,8 @@ describe.skipIf(process.platform === 'win32')('hooks golden — built-in output await fse.remove(tmp); }); - for (const [tool, file] of cases) { - it(`${tool} output matches golden fixture`, async () => { + for (const [tool, file, usesDispatchCommand] of cases) { + it.skipIf(process.platform === 'win32' && usesDispatchCommand)(`${tool} output matches golden fixture`, async () => { const p = path.join(tmp, tool, file); await injectHooks(p, tool); const got = await fse.readFile(p, 'utf-8'); From ddf982a9b51ef730fced1cde82802e4168487d99 Mon Sep 17 00:00:00 2001 From: BuXiuDN <43026038+BuXiuDN@users.noreply.github.com> Date: Fri, 18 Sep 2026 20:51:36 +0800 Subject: [PATCH 4/5] docs: sync usage guides with the Windows Git Bash hook resolution Document the absolute-path Git Bash dispatch on Windows in both languages, and stop presenting the WSL-bash sidestep as a ZCode-only perk now that every shell-string target avoids the same trap. --- docs/usage-guide.md | 4 +++- docs/usage-guide.zh-CN.md | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/usage-guide.md b/docs/usage-guide.md index bdb7e4315..172042f6b 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1361,6 +1361,8 @@ teamai hooks remove # Remove 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 dispatch commands 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`. + > **Codex trust gate** — Codex (the OpenAI / ChatGPT Codex app, tool id `codex`) gates non-managed hooks behind an explicit user trust step. After teamai writes `~/.codex/hooks.json`, Codex may skip a newly added or changed hook until you review/trust it in `/hooks` or Settings → Hooks. `teamai hooks inject` and `teamai doctor` print a reminder when Codex hooks are installed; teamai never edits Codex's `[hooks.state]` to auto-trust — trusting is left to you. ### Team Hooks Declaration @@ -1460,7 +1462,7 @@ Kiro is available as a built-in target. TeamAI deploys skills, rules, and subage ZCode is available as a built-in target. Skills deploy to `.zcode/skills/` (ZCode also reads the central `~/.agents/skills/`, which the `agents` entry covers), and subagents deploy as Claude-style Markdown to `.zcode/agents/`. Hooks are merged into the shared `~/.zcode/cli/config.json`, preserving unrelated keys such as plugin state. Two ZCode specifics the writer handles for you: - Config-file hooks are **disabled by default** in ZCode — TeamAI forces `hooks.enabled: true` so the entries it writes actually fire. -- Hook entries use the `process` type (`bash -lc ` as an argv vector) rather than a shell string, which sidesteps Windows PATH resolution landing on the WSL `bash.exe` instead of Git Bash. +- Hook entries use the `process` type (`bash -lc ` as an argv vector) rather than a shell string; the shell-string targets instead sidestep the same Windows trap by naming Git Bash absolutely in their commands (see the Hooks section). These paths are verified against the ZCode desktop app: profiles created in its Subagents settings page land in `~/.zcode/agents/*.md`, and files placed there (e.g. by TeamAI) show up in the page's installed list. MCP servers deploy to `~/.agents/mcp.json` (user scope, Claude `mcpServers` shape — the same file ZCode's own MCP settings page reads). Project scope is not wired: ZCode stores workspace MCP under a different key (`mcp.servers` inside `.zcode/config.json`), which the Claude writer cannot emit. ZCode has no user-level rules directory convention, so rules are not synced. diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index f99b91397..08f118f22 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1321,6 +1321,8 @@ teamai hooks remove # 移除 inject 和 remove 只会操作你实际已安装的工具(即 `~/./` 根目录已存在的工具)。对于 `toolPaths` 中已配置但未安装的工具,命令不会为其凭空创建根目录。 +在 Windows 上,内置派发命令会以绝对路径引用 Git Bash——先查标准安装位置,再回退到 `HKLM\SOFTWARE\GitForWindows` 注册表——从而避免解析到 WSL 的 `bash.exe`;若找不到 Git Bash,则退回裸 `bash`。 + > **Codex 信任门槛** — Codex(OpenAI / ChatGPT Codex 应用,工具 id 为 `codex`)对非托管 hooks 设有显式的用户信任机制。teamai 写入 `~/.codex/hooks.json` 后,对于新增或变更的 hook,Codex 可能会跳过执行,直到你在 `/hooks` 或 Settings → Hooks 中 review/trust。当检测到 Codex hooks 已安装时,`teamai hooks inject` 与 `teamai doctor` 会输出提示;teamai 从不修改 Codex 的 `[hooks.state]` 来自动信任 —— 信任操作交由你手动完成。 ### 团队 Hooks 声明 @@ -1420,7 +1422,7 @@ Kiro 已作为内置目标支持。TeamAI 会将 Skills、Rules 和 Subagents ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode 同时会读取中央目录 `~/.agents/skills/`,该目录由 `agents` 条目覆盖),Subagents 以 Claude 风格 Markdown 下发到 `.zcode/agents/`。Hooks 会合并进共享的 `~/.zcode/cli/config.json`,并保留插件状态等无关键值。写入器为你处理了两个 ZCode 特有的细节: - ZCode 的配置文件钩子**默认禁用**——TeamAI 会强制置 `hooks.enabled: true`,确保写入的条目真正生效。 -- 钩子条目使用 `process` 类型(`bash -lc ` 以 argv 向量执行)而非 shell 字符串,规避 Windows 下 PATH 解析命中 WSL `bash.exe` 而非 Git Bash 的陷阱。 +- 钩子条目使用 `process` 类型(`bash -lc ` 以 argv 向量执行)而非 shell 字符串;其他以 shell 字符串写入的目标则通过在命令中使用 Git Bash 绝对路径来规避同一 Windows 陷阱(见 Hooks 一节)。 以上路径已对照 ZCode 桌面端实测验证:设置页「新建子智能体」写入的就是 `~/.zcode/agents/*.md`,反向放入的文件也会出现在页面的已安装列表中。MCP Server 下发到 `~/.agents/mcp.json`(用户级,Claude 的 `mcpServers` 结构——正是 ZCode 自己的 MCP 设置页读取的文件)。项目级暂未接入:ZCode 的工作区 MCP 使用不同的键(`.zcode/config.json` 内的 `mcp.servers`),Claude 写入器无法生成该结构。ZCode 暂无用户级 Rules 目录约定,因此 Rules 不同步。 From cf74c3bb5035ffed2c40be5a1945c1d0cb726fb5 Mon Sep 17 00:00:00 2001 From: BuXiuDN <43026038+BuXiuDN@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:36:29 +0000 Subject: [PATCH 5/5] fix(hooks): also probe %ProgramW6432% for 64-bit Git Bash on Windows A 32-bit Node process on 64-bit Windows resolves %ProgramFiles% to the x86 tree, so a standard 64-bit Git install stayed undiscovered and hooks degraded to bare bash (the WSL launcher). Cover it with a test.