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 @@ -1367,6 +1367,8 @@ teamai hooks remove # Remove

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`.

> **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
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 @@ -1327,6 +1327,8 @@ teamai hooks remove # 移除

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

在 Windows 上,经由 bash 执行的内置 hook 派发命令(如 Claude、Codex、Cursor、Copilot CLI)会以绝对路径引用 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 声明
Expand Down
44 changes: 44 additions & 0 deletions src/__tests__/copilot-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -345,4 +346,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<string, Array<{ bash: string; powershell: string; command: string }>>;
};
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);
});
});
});
23 changes: 15 additions & 8 deletions src/__tests__/hooks-golden.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@ import { injectHooks } from '../hooks.js';
// fixtures/hooks/<tool>.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: 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('hooks golden — built-in output is byte-identical to the captured baseline', () => {
Expand All @@ -32,8 +39,8 @@ describe('hooks golden — built-in output is byte-identical to the captured bas
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');
Expand Down
116 changes: 116 additions & 0 deletions src/__tests__/hooks-windows-bash.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:child_process')>();
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',
);
});
});
79 changes: 78 additions & 1 deletion src/builtin-hooks.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -60,6 +61,7 @@ export function hasShell(): boolean {
/** Reset the cached shell results. Test-only. */
export function _resetShellCache(): void {
_hasShellCache = undefined;
_winBashLauncherCache = undefined;
resetBundledRuntimeCache();
}

Expand Down Expand Up @@ -159,11 +161,86 @@ export function skipToolsWithoutShell(tools: string[]): Set<string> {
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`;
}

/**
Expand Down
14 changes: 12 additions & 2 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,12 +345,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 {
Expand Down
Loading