diff --git a/README.md b/README.md index 73e31c73..b144ac2f 100644 --- a/README.md +++ b/README.md @@ -434,8 +434,8 @@ npx -y @dotcontext/cli@latest hook doctor codex --json | Host | Config | Dispatch | | --- | --- | --- | -| `claude-code` | `.claude/settings.json` | `npx -y @dotcontext/cli@latest hook dispatch --source claude-code` | -| `codex` | `.codex/hooks.json` or inline in `.codex/config.toml` | `npx -y @dotcontext/cli@latest hook dispatch --source codex` | +| `claude-code` | `.claude/settings.json` | `dotcontext hook dispatch --source claude-code` (falls back to `npx -y @dotcontext/cli@ ...` when no global binary is on PATH) | +| `codex` | `.codex/hooks.json` or inline in `.codex/config.toml` | `dotcontext hook dispatch --source codex` (same fallback) | | `pi` | `pi install npm:@dotcontext/pi` | In-process TypeScript extension | Codex-specific activation step: diff --git a/docs/src/content/docs/en/about/architecture.md b/docs/src/content/docs/en/about/architecture.md index 2e9b024a..3eef17a2 100644 --- a/docs/src/content/docs/en/about/architecture.md +++ b/docs/src/content/docs/en/about/architecture.md @@ -85,7 +85,7 @@ This is the surface an AI client (Claude Code, Cursor, Windsurf, and others) tal ### The integrations boundary -**Integrations** connect host lifecycle events to the harness without going through MCP. Claude Code and Codex CLI use shell dispatch (`npx -y @dotcontext/cli@latest hook dispatch`); Pi loads the `@dotcontext/pi` npm extension in-process. +**Integrations** connect host lifecycle events to the harness without going through MCP. Claude Code and Codex CLI use shell dispatch (`dotcontext hook dispatch`, with a version-pinned npx fallback when no global binary is on PATH); Pi loads the `@dotcontext/pi` npm extension in-process. Integrations call the harness only — they never import `cli` or `mcp`. See [using dotcontext with hooks](/guides/using-with-hooks/) and [using dotcontext with Pi](/guides/using-with-pi/). diff --git a/docs/src/content/docs/en/guides/using-with-hooks.md b/docs/src/content/docs/en/guides/using-with-hooks.md index 70b5fde9..33edbc87 100644 --- a/docs/src/content/docs/en/guides/using-with-hooks.md +++ b/docs/src/content/docs/en/guides/using-with-hooks.md @@ -90,12 +90,16 @@ Repeated trace append failures are recorded under `.context/runtime/hooks/trace- ## Claude Code -The installer writes `hooks` entries to Claude Code settings. Each entry runs: +The installer writes `hooks` entries to Claude Code settings. When a global `dotcontext` binary is on PATH, each entry runs it directly; otherwise the entry falls back to npx pinned to the installed CLI version: ```bash -npx -y @dotcontext/cli@latest hook dispatch --source claude-code +dotcontext hook dispatch --source claude-code +# or, when no global binary is available: +npx -y @dotcontext/cli@ hook dispatch --source claude-code ``` +Running the binary directly (or a pinned version) avoids re-resolving the npm `latest` tag on every SessionStart, PostToolUse, and Stop event. + Wired events (v1): | Event | Matcher | @@ -119,7 +123,9 @@ Start a Claude Code session in a repository with `.context/` initialized and con Codex hooks use the same dispatch command with `--source codex`: ```bash -npx -y @dotcontext/cli@latest hook dispatch --source codex +dotcontext hook dispatch --source codex +# or, when no global binary is available: +npx -y @dotcontext/cli@ hook dispatch --source codex ``` The installer writes either: diff --git a/docs/src/content/docs/pt-br/guides/hook-session-flow.md b/docs/src/content/docs/pt-br/guides/hook-session-flow.md index 4598da13..d2e289a9 100644 --- a/docs/src/content/docs/pt-br/guides/hook-session-flow.md +++ b/docs/src/content/docs/pt-br/guides/hook-session-flow.md @@ -24,13 +24,18 @@ Pi extension -> resposta in-process para Pi ``` -Para Claude Code e Codex CLI, o instalador escreve comandos shell que chamam: +Para Claude Code e Codex CLI, o instalador escreve comandos shell que preferem o binário global `dotcontext` quando presente no PATH, com fallback para npx pinado na versão instalada: ```bash -npx -y @dotcontext/cli@latest hook dispatch --source claude-code -npx -y @dotcontext/cli@latest hook dispatch --source codex +dotcontext hook dispatch --source claude-code +dotcontext hook dispatch --source codex +# ou, quando não há binário global disponível: +npx -y @dotcontext/cli@ hook dispatch --source claude-code +npx -y @dotcontext/cli@ hook dispatch --source codex ``` +Executar o binário direto (ou uma versão pinada) evita resolver a tag `latest` do npm a cada evento de SessionStart, PostToolUse e Stop. + Por padrão, a instalação de hooks é no projeto atual: ```bash diff --git a/docs/src/content/docs/pt-br/guides/using-with-hooks.md b/docs/src/content/docs/pt-br/guides/using-with-hooks.md index 0f12c02f..c2fcdfe1 100644 --- a/docs/src/content/docs/pt-br/guides/using-with-hooks.md +++ b/docs/src/content/docs/pt-br/guides/using-with-hooks.md @@ -80,7 +80,9 @@ Falhas repetidas de append trace são registradas em `.context/runtime/hooks/tra O instalador grava entradas `hooks` em `.claude/settings.json` por padrão. Cada entrada chama: ```bash -npx -y @dotcontext/cli@latest hook dispatch --source claude-code +dotcontext hook dispatch --source claude-code +# ou, quando não há binário global disponível: +npx -y @dotcontext/cli@ hook dispatch --source claude-code ``` Eventos configurados: @@ -98,7 +100,9 @@ Depois de instalar, reinicie o Claude Code. Em um projeto com `.context/` inicia Hooks do Codex usam o mesmo dispatch com `--source codex`: ```bash -npx -y @dotcontext/cli@latest hook dispatch --source codex +dotcontext hook dispatch --source codex +# ou, quando não há binário global disponível: +npx -y @dotcontext/cli@ hook dispatch --source codex ``` O instalador escreve uma destas configurações: diff --git a/src/cli/services/__tests__/hookInstallService.test.ts b/src/cli/services/__tests__/hookInstallService.test.ts index 4d9c805d..876cf398 100644 --- a/src/cli/services/__tests__/hookInstallService.test.ts +++ b/src/cli/services/__tests__/hookInstallService.test.ts @@ -7,6 +7,7 @@ import { resolveHookInstallHostSelection, } from '../hookInstallService'; import { CODEX_HOOK_TRUST_REMINDER } from '../../../integrations/codex'; +import { buildHookDispatchCommand } from '../../../integrations/shared'; import type { CLIInterface } from '../../../utils/cliUI'; const createMockUI = (): CLIInterface => ({ @@ -138,7 +139,7 @@ describe('HookInstallService', () => { const command = config.hooks.SessionStart[0].hooks[0]; expect(command.type).toBe('command'); - expect(command.command).toContain('npx -y @dotcontext/cli@latest hook dispatch --source claude-code'); + expect(command.command).toBe(buildHookDispatchCommand('claude-code')); }); it('supports dry-run mode for Claude Code', async () => { @@ -214,7 +215,7 @@ describe('HookInstallService', () => { expect(config).toContain('[features]'); expect(config).toContain('hooks = true'); expect(config).toContain('[[hooks.SessionStart]]'); - expect(config).toContain('npx -y @dotcontext/cli@latest hook dispatch --source codex'); + expect(config).toContain(buildHookDispatchCommand('codex')); }); it('prints Pi instructions and writes .mcp.json snippet', async () => { diff --git a/src/integrations/__tests__/hookInstallServices.test.ts b/src/integrations/__tests__/hookInstallServices.test.ts index af99bde6..cdd6f1d7 100644 --- a/src/integrations/__tests__/hookInstallServices.test.ts +++ b/src/integrations/__tests__/hookInstallServices.test.ts @@ -16,6 +16,7 @@ import { previewCodexHooks, } from '../codex'; import { + buildHookDispatchCommand, normalizeToolEvent, resolveHarnessHookFromHostEvent, } from '../shared'; @@ -132,7 +133,7 @@ describe('hook install services', () => { const written = await fs.readFile(configPath, 'utf8'); expect(written).toContain('[features]'); expect(written).toContain('hooks = true'); - expect(written).toContain('npx -y @dotcontext/cli@latest hook dispatch --source codex'); + expect(written).toContain(buildHookDispatchCommand('codex')); expect(written).toContain('[mcp_servers.dotcontext]'); }); @@ -192,9 +193,9 @@ describe('hook install services', () => { expect(result.action).toBe('skipped'); }); - it('upgrades legacy Claude Code hook commands to the current npx dispatch command', async () => { + it('upgrades legacy Claude Code hook commands to the current dispatch command', async () => { const configPath = path.join(tempDir, '.claude', 'settings.json'); - const legacyCommand = 'dotcontext hook dispatch --source claude-code'; + const legacyCommand = 'npx -y @dotcontext/cli@latest hook dispatch --source claude-code'; await fs.outputJson(configPath, { hooks: { SessionStart: [{ hooks: [{ type: 'command', command: legacyCommand }] }], @@ -211,19 +212,19 @@ describe('hook install services', () => { expect(result.action).toBe('updated'); const written = await fs.readJson(configPath); - expect(written.hooks.SessionStart[0].hooks[0].command).toContain( - 'npx -y @dotcontext/cli@latest hook dispatch --source claude-code' + expect(written.hooks.SessionStart[0].hooks[0].command).toBe( + buildHookDispatchCommand('claude-code') ); }); - it('upgrades legacy Codex TOML hook commands to the current npx dispatch command', async () => { + it('upgrades legacy Codex TOML hook commands to the current dispatch command', async () => { const configPath = path.join(tempDir, '.codex', 'config.toml'); await fs.outputFile( configPath, [ '[[hooks.SessionStart]]', 'matcher = "*"', - 'command = "dotcontext hook dispatch --source codex"', + 'command = "npx -y @dotcontext/cli@latest hook dispatch --source codex"', '', ].join('\n') ); @@ -237,8 +238,8 @@ describe('hook install services', () => { expect(result.action).toBe('updated'); const written = await fs.readFile(configPath, 'utf8'); - expect(written).toContain('npx -y @dotcontext/cli@latest hook dispatch --source codex'); - expect(written).not.toContain('command = "dotcontext hook dispatch --source codex"'); + expect(written).toContain(buildHookDispatchCommand('codex')); + expect(written).not.toContain('@latest'); }); it('previews Codex TOML append output', async () => { diff --git a/src/integrations/claude-code/hooks/claudeCodeHookTemplates.ts b/src/integrations/claude-code/hooks/claudeCodeHookTemplates.ts index 6b527f05..ca4e764a 100644 --- a/src/integrations/claude-code/hooks/claudeCodeHookTemplates.ts +++ b/src/integrations/claude-code/hooks/claudeCodeHookTemplates.ts @@ -1,7 +1,9 @@ import { + buildHookDispatchCommand, CLAUDE_CODE_HOOK_DISPATCH_COMMAND, isCurrentDotcontextHookDispatchCommand, isDotcontextHookDispatchCommand, + type ResolveHookDispatchCommandOptions, } from '../../shared/hookDispatchCommands'; export { CLAUDE_CODE_HOOK_DISPATCH_COMMAND }; @@ -18,41 +20,53 @@ export interface ClaudeCodeHookMatcherEntry { export type ClaudeCodeHookTemplate = ClaudeCodeHookMatcherEntry[]; +export function buildClaudeCodeHookTemplates( + command: string = buildHookDispatchCommand('claude-code') +): Record<'SessionStart' | 'PostToolUse' | 'Stop', ClaudeCodeHookTemplate> { + return { + SessionStart: [ + { + matcher: '*', + hooks: [{ type: 'command', command }], + }, + ], + PostToolUse: [ + { + matcher: '^Write$|^Edit$|^Bash$', + hooks: [{ type: 'command', command }], + }, + ], + Stop: [ + { + hooks: [{ type: 'command', command }], + }, + ], + }; +} + +/** + * Static template shape built with the pinned npx command. Prefer + * buildClaudeCodeHookTemplates() when writing configs so the command can + * resolve to the local dotcontext binary when it is available. + */ export const CLAUDE_CODE_HOOK_TEMPLATES: Record< 'SessionStart' | 'PostToolUse' | 'Stop', ClaudeCodeHookTemplate -> = { - SessionStart: [ - { - matcher: '*', - hooks: [{ type: 'command', command: CLAUDE_CODE_HOOK_DISPATCH_COMMAND }], - }, - ], - PostToolUse: [ - { - matcher: '^Write$|^Edit$|^Bash$', - hooks: [{ type: 'command', command: CLAUDE_CODE_HOOK_DISPATCH_COMMAND }], - }, - ], - Stop: [ - { - hooks: [{ type: 'command', command: CLAUDE_CODE_HOOK_DISPATCH_COMMAND }], - }, - ], -}; - -export function buildClaudeCodeHooksFragment(): Record { - return { - SessionStart: CLAUDE_CODE_HOOK_TEMPLATES.SessionStart, - PostToolUse: CLAUDE_CODE_HOOK_TEMPLATES.PostToolUse, - Stop: CLAUDE_CODE_HOOK_TEMPLATES.Stop, - }; +> = buildClaudeCodeHookTemplates(CLAUDE_CODE_HOOK_DISPATCH_COMMAND); + +export function buildClaudeCodeHooksFragment( + options?: ResolveHookDispatchCommandOptions +): Record { + return buildClaudeCodeHookTemplates(buildHookDispatchCommand('claude-code', options)); } export function isDotcontextClaudeCodeHookCommand(command: unknown): boolean { return isDotcontextHookDispatchCommand(command, 'claude-code'); } -export function isCurrentClaudeCodeHookCommand(command: unknown): boolean { - return isCurrentDotcontextHookDispatchCommand(command, 'claude-code'); +export function isCurrentClaudeCodeHookCommand( + command: unknown, + options?: ResolveHookDispatchCommandOptions +): boolean { + return isCurrentDotcontextHookDispatchCommand(command, 'claude-code', options); } diff --git a/src/integrations/claude-code/hooks/index.ts b/src/integrations/claude-code/hooks/index.ts index 5681fee9..3328807e 100644 --- a/src/integrations/claude-code/hooks/index.ts +++ b/src/integrations/claude-code/hooks/index.ts @@ -19,5 +19,6 @@ export { export { CLAUDE_CODE_HOOK_TEMPLATES, CLAUDE_CODE_HOOK_DISPATCH_COMMAND, + buildClaudeCodeHookTemplates, buildClaudeCodeHooksFragment, } from './claudeCodeHookTemplates'; diff --git a/src/integrations/codex/hooks/codexHookTemplates.ts b/src/integrations/codex/hooks/codexHookTemplates.ts index 7ad617ea..7216841a 100644 --- a/src/integrations/codex/hooks/codexHookTemplates.ts +++ b/src/integrations/codex/hooks/codexHookTemplates.ts @@ -1,7 +1,9 @@ import { + buildHookDispatchCommand, CODEX_HOOK_DISPATCH_COMMAND, isCurrentDotcontextHookDispatchCommand, isDotcontextHookDispatchCommand, + type ResolveHookDispatchCommandOptions, } from '../../shared/hookDispatchCommands'; export { CODEX_HOOK_DISPATCH_COMMAND }; @@ -18,52 +20,67 @@ export interface CodexHookMatcherEntry { export type CodexHookTemplate = CodexHookMatcherEntry[]; +export function buildCodexHookTemplates( + command: string = buildHookDispatchCommand('codex') +): Record<'SessionStart' | 'PostToolUse' | 'Stop', CodexHookTemplate> { + return { + SessionStart: [ + { + matcher: '*', + hooks: [{ type: 'command', command }], + }, + ], + PostToolUse: [ + { + matcher: '^Write$|^Edit$|^Bash$', + hooks: [{ type: 'command', command }], + }, + ], + Stop: [ + { + matcher: '*', + hooks: [{ type: 'command', command }], + }, + ], + }; +} + +/** + * Static template shape built with the pinned npx command. Prefer + * buildCodexHookTemplates() when writing configs so the command can resolve + * to the local dotcontext binary when it is available. + */ export const CODEX_HOOK_TEMPLATES: Record< 'SessionStart' | 'PostToolUse' | 'Stop', CodexHookTemplate -> = { - SessionStart: [ - { - matcher: '*', - hooks: [{ type: 'command', command: CODEX_HOOK_DISPATCH_COMMAND }], - }, - ], - PostToolUse: [ - { - matcher: '^Write$|^Edit$|^Bash$', - hooks: [{ type: 'command', command: CODEX_HOOK_DISPATCH_COMMAND }], - }, - ], - Stop: [ - { - matcher: '*', - hooks: [{ type: 'command', command: CODEX_HOOK_DISPATCH_COMMAND }], - }, - ], -}; - -export function buildCodexHooksFragment(): Record { - return { - SessionStart: CODEX_HOOK_TEMPLATES.SessionStart, - PostToolUse: CODEX_HOOK_TEMPLATES.PostToolUse, - Stop: CODEX_HOOK_TEMPLATES.Stop, - }; +> = buildCodexHookTemplates(CODEX_HOOK_DISPATCH_COMMAND); + +export function buildCodexHooksFragment( + options?: ResolveHookDispatchCommandOptions +): Record { + return buildCodexHookTemplates(buildHookDispatchCommand('codex', options)); } -export function buildCodexHooksDocument(): { hooks: Record } { - return { hooks: buildCodexHooksFragment() }; +export function buildCodexHooksDocument( + options?: ResolveHookDispatchCommandOptions +): { hooks: Record } { + return { hooks: buildCodexHooksFragment(options) }; } export function isDotcontextCodexHookCommand(command: unknown): boolean { return isDotcontextHookDispatchCommand(command, 'codex'); } -export function isCurrentCodexHookCommand(command: unknown): boolean { - return isCurrentDotcontextHookDispatchCommand(command, 'codex'); +export function isCurrentCodexHookCommand( + command: unknown, + options?: ResolveHookDispatchCommandOptions +): boolean { + return isCurrentDotcontextHookDispatchCommand(command, 'codex', options); } export interface BuildCodexTomlHookBlocksOptions { includeFeatures?: boolean; + resolveOptions?: ResolveHookDispatchCommandOptions; } export function buildCodexTomlHookBlocks( @@ -75,7 +92,11 @@ export function buildCodexTomlHookBlocks( lines.push('[features]', 'hooks = true', ''); } - for (const [eventName, entries] of Object.entries(CODEX_HOOK_TEMPLATES)) { + const templates = buildCodexHookTemplates( + buildHookDispatchCommand('codex', options.resolveOptions) + ); + + for (const [eventName, entries] of Object.entries(templates)) { for (const entry of entries) { lines.push(`[[hooks.${eventName}]]`); if (entry.matcher) { diff --git a/src/integrations/codex/hooks/index.ts b/src/integrations/codex/hooks/index.ts index 038ec197..09d63866 100644 --- a/src/integrations/codex/hooks/index.ts +++ b/src/integrations/codex/hooks/index.ts @@ -20,6 +20,7 @@ export { CODEX_HOOK_TEMPLATES, CODEX_HOOK_DISPATCH_COMMAND, CODEX_HOOK_TRUST_REMINDER, + buildCodexHookTemplates, buildCodexHooksDocument, buildCodexTomlHookBlocks, } from './codexHookTemplates'; diff --git a/src/integrations/shared/__tests__/hookDispatchCommands.test.ts b/src/integrations/shared/__tests__/hookDispatchCommands.test.ts new file mode 100644 index 00000000..aded8cfb --- /dev/null +++ b/src/integrations/shared/__tests__/hookDispatchCommands.test.ts @@ -0,0 +1,154 @@ +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; + +import { + buildHookDispatchCommand, + CLAUDE_CODE_HOOK_DISPATCH_COMMAND, + CODEX_HOOK_DISPATCH_COMMAND, + getCanonicalHookDispatchCommands, + HOOK_DISPATCH_LOCAL_CLI, + HOOK_DISPATCH_PINNED_CLI, + isCurrentDotcontextHookDispatchCommand, + isDotcontextBinaryOnPath, + resolveHookDispatchCli, +} from '../hookDispatchCommands'; +import { VERSION } from '../../../version'; + +describe('hookDispatchCommands', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dotcontext-dispatch-cmd-')); + }); + + afterEach(async () => { + await fs.remove(tempDir); + }); + + it('pins the npx fallback to the installed CLI version instead of @latest', () => { + expect(HOOK_DISPATCH_PINNED_CLI).toBe(`npx -y @dotcontext/cli@${VERSION} hook dispatch`); + expect(CLAUDE_CODE_HOOK_DISPATCH_COMMAND).not.toContain('@latest'); + expect(CODEX_HOOK_DISPATCH_COMMAND).not.toContain('@latest'); + }); + + describe('isDotcontextBinaryOnPath', () => { + it('detects a dotcontext executable on PATH', async () => { + const binaryPath = path.join(tempDir, 'dotcontext'); + await fs.outputFile(binaryPath, '#!/bin/sh\n'); + await fs.chmod(binaryPath, 0o755); + + expect(isDotcontextBinaryOnPath({ + env: { PATH: tempDir }, + platform: 'linux', + })).toBe(true); + }); + + it('ignores a non-executable file named dotcontext on POSIX', async () => { + const binaryPath = path.join(tempDir, 'dotcontext'); + await fs.outputFile(binaryPath, 'not a binary\n'); + await fs.chmod(binaryPath, 0o644); + + expect(isDotcontextBinaryOnPath({ + env: { PATH: tempDir }, + platform: 'linux', + })).toBe(false); + }); + + it('detects Windows launcher variants', async () => { + await fs.outputFile(path.join(tempDir, 'dotcontext.cmd'), '@echo off\n'); + + expect(isDotcontextBinaryOnPath({ + env: { PATH: tempDir }, + platform: 'win32', + })).toBe(true); + }); + + it('returns false when the binary is absent', () => { + expect(isDotcontextBinaryOnPath({ + env: { PATH: tempDir }, + platform: 'linux', + })).toBe(false); + }); + + it('ignores directories that match the binary name', async () => { + await fs.ensureDir(path.join(tempDir, 'dotcontext')); + + expect(isDotcontextBinaryOnPath({ + env: { PATH: tempDir }, + platform: 'linux', + })).toBe(false); + }); + }); + + describe('resolveHookDispatchCli / buildHookDispatchCommand', () => { + it('prefers the local binary when it is on PATH', async () => { + const binaryPath = path.join(tempDir, 'dotcontext'); + await fs.outputFile(binaryPath, '#!/bin/sh\n'); + await fs.chmod(binaryPath, 0o755); + const options = { env: { PATH: tempDir }, platform: 'linux' as const }; + + expect(resolveHookDispatchCli(options)).toBe(HOOK_DISPATCH_LOCAL_CLI); + expect(buildHookDispatchCommand('claude-code', options)).toBe( + `${HOOK_DISPATCH_LOCAL_CLI} --source claude-code` + ); + }); + + it('falls back to the pinned npx command when the binary is absent', () => { + const options = { env: { PATH: tempDir }, platform: 'linux' as const }; + + expect(resolveHookDispatchCli(options)).toBe(HOOK_DISPATCH_PINNED_CLI); + expect(buildHookDispatchCommand('codex', options)).toBe( + `${HOOK_DISPATCH_PINNED_CLI} --source codex` + ); + }); + }); + + describe('isCurrentDotcontextHookDispatchCommand', () => { + it('accepts both canonical command forms when the binary is on PATH', async () => { + const binaryPath = path.join(tempDir, 'dotcontext'); + await fs.outputFile(binaryPath, '#!/bin/sh\n'); + await fs.chmod(binaryPath, 0o755); + const options = { env: { PATH: tempDir }, platform: 'linux' as const }; + + for (const command of getCanonicalHookDispatchCommands('claude-code')) { + expect(isCurrentDotcontextHookDispatchCommand(command, 'claude-code', options)).toBe(true); + } + }); + + it('treats the pinned form as current even without the binary on PATH', () => { + const options = { env: { PATH: tempDir }, platform: 'linux' as const }; + + expect(isCurrentDotcontextHookDispatchCommand( + `${HOOK_DISPATCH_PINNED_CLI} --source claude-code`, + 'claude-code', + options + )).toBe(true); + }); + + it('treats the local-binary form as stale when the binary left PATH', () => { + const options = { env: { PATH: tempDir }, platform: 'linux' as const }; + + expect(isCurrentDotcontextHookDispatchCommand( + `${HOOK_DISPATCH_LOCAL_CLI} --source claude-code`, + 'claude-code', + options + )).toBe(false); + }); + + it('rejects the legacy @latest command so installs upgrade it', () => { + expect(isCurrentDotcontextHookDispatchCommand( + 'npx -y @dotcontext/cli@latest hook dispatch --source claude-code', + 'claude-code' + )).toBe(false); + }); + + it('rejects commands for another source and non-strings', () => { + expect(isCurrentDotcontextHookDispatchCommand( + `${HOOK_DISPATCH_LOCAL_CLI} --source codex`, + 'claude-code' + )).toBe(false); + expect(isCurrentDotcontextHookDispatchCommand(undefined, 'codex')).toBe(false); + }); + }); +}); diff --git a/src/integrations/shared/__tests__/hookSessionStore.test.ts b/src/integrations/shared/__tests__/hookSessionStore.test.ts index 795881b7..3115bb52 100644 --- a/src/integrations/shared/__tests__/hookSessionStore.test.ts +++ b/src/integrations/shared/__tests__/hookSessionStore.test.ts @@ -11,11 +11,14 @@ import { ensureHookHarnessSession, getHookHarnessSessionId, } from '../hookSessionStore'; +import { VERSION } from '../../../version'; describe('hookDispatchCommands', () => { - it('uses npx for shell hook dispatch', () => { - expect(CLAUDE_CODE_HOOK_DISPATCH_COMMAND).toContain('npx -y @dotcontext/cli@latest'); - expect(CODEX_HOOK_DISPATCH_COMMAND).toContain('npx -y @dotcontext/cli@latest'); + it('pins the npx dispatch command to the installed CLI version', () => { + expect(CLAUDE_CODE_HOOK_DISPATCH_COMMAND).toContain(`npx -y @dotcontext/cli@${VERSION}`); + expect(CODEX_HOOK_DISPATCH_COMMAND).toContain(`npx -y @dotcontext/cli@${VERSION}`); + expect(CLAUDE_CODE_HOOK_DISPATCH_COMMAND).not.toContain('@latest'); + expect(CODEX_HOOK_DISPATCH_COMMAND).not.toContain('@latest'); }); }); diff --git a/src/integrations/shared/hookDispatchCommands.ts b/src/integrations/shared/hookDispatchCommands.ts index 197c5a4f..d4e147f3 100644 --- a/src/integrations/shared/hookDispatchCommands.ts +++ b/src/integrations/shared/hookDispatchCommands.ts @@ -1,14 +1,133 @@ -export const HOOK_DISPATCH_CLI = 'npx -y @dotcontext/cli@latest hook dispatch'; +import * as fs from 'fs'; +import * as path from 'path'; + +import { VERSION } from '../../version'; + +export type HookDispatchCommandSource = 'claude-code' | 'codex'; + +export const HOOK_DISPATCH_BINARY_NAME = 'dotcontext'; + +/** + * Dispatch command used when a `dotcontext` executable is available on PATH. + * Invoking the binary directly avoids spawning npx (and re-resolving the + * package) on every host lifecycle event. + */ +export const HOOK_DISPATCH_LOCAL_CLI = `${HOOK_DISPATCH_BINARY_NAME} hook dispatch`; + +/** + * Fallback dispatch command pinned to the version of the CLI that performed + * the install. Pinning avoids re-resolving the `latest` dist-tag from the npm + * registry on every hook invocation, which adds startup latency and registry + * traffic to every SessionStart/PostToolUse/Stop event. + */ +export const HOOK_DISPATCH_PINNED_CLI = `npx -y @dotcontext/cli@${VERSION} hook dispatch`; + +export const HOOK_DISPATCH_CLI = HOOK_DISPATCH_PINNED_CLI; export const CLAUDE_CODE_HOOK_DISPATCH_COMMAND = - `${HOOK_DISPATCH_CLI} --source claude-code`; + `${HOOK_DISPATCH_PINNED_CLI} --source claude-code`; export const CODEX_HOOK_DISPATCH_COMMAND = - `${HOOK_DISPATCH_CLI} --source codex`; + `${HOOK_DISPATCH_PINNED_CLI} --source codex`; + +export interface ResolveHookDispatchCommandOptions { + env?: NodeJS.ProcessEnv; + platform?: NodeJS.Platform; +} + +function candidateBinaryNames(platform: NodeJS.Platform): string[] { + if (platform === 'win32') { + return [ + `${HOOK_DISPATCH_BINARY_NAME}.cmd`, + `${HOOK_DISPATCH_BINARY_NAME}.exe`, + `${HOOK_DISPATCH_BINARY_NAME}.bat`, + HOOK_DISPATCH_BINARY_NAME, + ]; + } + + return [HOOK_DISPATCH_BINARY_NAME]; +} + +function isExecutableFile(filePath: string, platform: NodeJS.Platform): boolean { + try { + if (!fs.statSync(filePath).isFile()) { + return false; + } + + if (platform !== 'win32') { + // A regular file named dotcontext is not enough: hooks invoke it + // directly, so it must carry execute permission for this process. + fs.accessSync(filePath, fs.constants.X_OK); + } + + return true; + } catch { + // Missing, unreadable, or non-executable entries are expected while + // scanning PATH. + return false; + } +} + +/** + * Detects a globally installed `dotcontext` executable by scanning PATH. + * Detection is filesystem-only so install stays fast and side-effect free. + */ +export function isDotcontextBinaryOnPath( + options: ResolveHookDispatchCommandOptions = {} +): boolean { + const env = options.env ?? process.env; + const platform = options.platform ?? process.platform; + const pathValue = env.PATH ?? env.Path ?? ''; + const directories = pathValue.split(path.delimiter).filter(Boolean); + const names = candidateBinaryNames(platform); + + for (const directory of directories) { + for (const name of names) { + if (isExecutableFile(path.join(directory, name), platform)) { + return true; + } + } + } + + return false; +} + +export function resolveHookDispatchCli( + options: ResolveHookDispatchCommandOptions = {} +): string { + return isDotcontextBinaryOnPath(options) + ? HOOK_DISPATCH_LOCAL_CLI + : HOOK_DISPATCH_PINNED_CLI; +} + +/** + * Resolves the dispatch command persisted into host hook configs. + * Prefers the local binary; falls back to version-pinned npx. + */ +export function buildHookDispatchCommand( + source: HookDispatchCommandSource, + options: ResolveHookDispatchCommandOptions = {} +): string { + return `${resolveHookDispatchCli(options)} --source ${source}`; +} + +/** + * Both command forms recognized for this CLI version. The pinned-npx form is + * always current; the local-binary form is only current while a `dotcontext` + * executable is on PATH (see isCurrentDotcontextHookDispatchCommand). + */ +export function getCanonicalHookDispatchCommands( + source: HookDispatchCommandSource +): string[] { + return [ + `${HOOK_DISPATCH_LOCAL_CLI} --source ${source}`, + `${HOOK_DISPATCH_PINNED_CLI} --source ${source}`, + ]; +} export function isDotcontextHookDispatchCommand( command: unknown, - source: 'claude-code' | 'codex' + source: HookDispatchCommandSource ): boolean { if (typeof command !== 'string') { return false; @@ -20,11 +139,20 @@ export function isDotcontextHookDispatchCommand( export function isCurrentDotcontextHookDispatchCommand( command: unknown, - source: 'claude-code' | 'codex' + source: HookDispatchCommandSource, + options: ResolveHookDispatchCommandOptions = {} ): boolean { - const expected = source === 'claude-code' - ? CLAUDE_CODE_HOOK_DISPATCH_COMMAND - : CODEX_HOOK_DISPATCH_COMMAND; + if (typeof command !== 'string') { + return false; + } + + if (command === `${HOOK_DISPATCH_PINNED_CLI} --source ${source}`) { + return true; + } - return command === expected; + // The local-binary form is only current while the binary is actually on + // PATH; otherwise re-running the installer must repair the config instead + // of skipping it as up to date. + return command === `${HOOK_DISPATCH_LOCAL_CLI} --source ${source}` + && isDotcontextBinaryOnPath(options); } diff --git a/src/integrations/shared/index.ts b/src/integrations/shared/index.ts index a9b9a458..e3f15613 100644 --- a/src/integrations/shared/index.ts +++ b/src/integrations/shared/index.ts @@ -35,11 +35,20 @@ export { } from './mapHostHookResponse'; export { + HOOK_DISPATCH_BINARY_NAME, HOOK_DISPATCH_CLI, + HOOK_DISPATCH_LOCAL_CLI, + HOOK_DISPATCH_PINNED_CLI, CLAUDE_CODE_HOOK_DISPATCH_COMMAND, CODEX_HOOK_DISPATCH_COMMAND, + buildHookDispatchCommand, + getCanonicalHookDispatchCommands, + isDotcontextBinaryOnPath, isDotcontextHookDispatchCommand, isCurrentDotcontextHookDispatchCommand, + resolveHookDispatchCli, + type HookDispatchCommandSource, + type ResolveHookDispatchCommandOptions, } from './hookDispatchCommands'; export {