diff --git a/README.md b/README.md index 1cdc285..042f00b 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ hooks: | `run` | `string` \| `string[]` | Command(s) to execute | | `inject` | `string` | Message injected into the session | | `toast` | `object` | Toast notification configuration | +| `when.toolArgs` | `Record` | Exact argument filters, or `{ glob: "..." }` / `{ regex: "..." }` matchers | | `overrideGlobal` | `boolean` | When `true`, suppresses global hooks matching the same event/phase+tool. Must be a JSON boolean (`true`/`false`), not a string. | ### Toast Configuration @@ -108,6 +109,7 @@ toast: - `{stdout}` - Command stdout (truncated) - `{stderr}` - Command stderr (truncated) - `{exitCode}` - Command exit code +- `{args.}` - Direct tool argument value (own properties only; strings, numbers, and booleans as text; arrays and objects as JSON) ### Complete Example @@ -138,6 +140,11 @@ If `inject` is set, the command output is posted into the session, so your agent ### Filter by Tool Arguments You can set up tool hooks to only trigger on specific arguments via `when.toolArgs`. +String and string-array values retain exact matching (and `"*"` matches any +value). Matcher objects support full-string glob matching and JavaScript regex +search matching. Every configured argument must match, and pattern matchers +only match arguments whose runtime value is a string. Leading `!` is not an +implicit negation and leading `#` is not treated as a comment. ```jsonc { @@ -154,6 +161,32 @@ You can set up tool hooks to only trigger on specific arguments via `when.toolAr } ``` +Matchers work with arbitrary argument names and custom tools: + +```yaml +when: + phase: before + tool: write_file + toolArgs: + filePath: + glob: "**/*.{ts,js}" + content: + regex: "TODO" +``` + +Argument values are also available in `inject` and `toast` templates through +direct placeholders such as `{args.filePath}`. Strings, numbers, and booleans +are rendered as text; arrays and objects are rendered as JSON. Missing and +`null` values render as empty strings. Argument placeholders are not expanded +inside `run` commands. Instead, every tool-hook command receives the complete +argument object in a private temporary JSON file. Its path is available in the +`OPENCODE_HOOK_ARGS_FILE` environment variable; read it from the command, for +example with `cat "$OPENCODE_HOOK_ARGS_FILE"`. Tool hooks without arguments receive +`{}`. The file is unique to the tool-hook execution and removed after all of its +commands finish. Supplying an argument that looks like shell syntax does not +execute it or interpolate it into the command source, and the complete payload +is not placed in the environment. + ## Features - Tool hooks (`before`/`after`) and session hooks (`start`/`idle`) via simple JSON/YAML frontmatter config diff --git a/bun.lock b/bun.lock index 266ed8c..82b9934 100644 --- a/bun.lock +++ b/bun.lock @@ -5,21 +5,23 @@ "": { "name": "opencode-command-hooks", "dependencies": { - "@opencode-ai/plugin": "^1.0.0", - "@opencode-ai/sdk": "^1.0.65", - "js-yaml": "^4.1.0", + "@opencode-ai/plugin": "^1.18.29", + "@opencode-ai/sdk": "^1.18.29", + "js-yaml": "^4.3.2", "jsonc-parser": "^3.3.1", - "zod": "^3.22.4", + "picomatch": "^4.0.3", + "zod": "^3.25.76", }, "devDependencies": { - "@eslint/js": "^9.39.1", + "@eslint/js": "^9.39.5", "@types/bun": "latest", - "@types/js-yaml": "^4.0.5", - "eslint": "^9.39.1", + "@types/js-yaml": "^4.0.9", + "@types/picomatch": "^4.0.3", + "eslint": "^9.39.5", "globals": "^16.5.0", - "jiti": "^2.6.1", - "typescript": "^5.8.2", - "typescript-eslint": "^8.47.0", + "jiti": "^2.7.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.69.0", }, }, }, @@ -80,6 +82,8 @@ "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@types/picomatch": ["@types/picomatch@4.0.3", "", {}, "sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.69.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/type-utils": "8.69.0", "@typescript-eslint/utils": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.69.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.69.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/types": "8.69.0", "@typescript-eslint/typescript-estree": "8.69.0", "@typescript-eslint/visitor-keys": "8.69.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw=="], diff --git a/package.json b/package.json index ffac623..3c92dc0 100644 --- a/package.json +++ b/package.json @@ -51,12 +51,14 @@ "@opencode-ai/sdk": "^1.18.29", "js-yaml": "^4.3.2", "jsonc-parser": "^3.3.1", + "picomatch": "^4.0.3", "zod": "^3.25.76" }, "devDependencies": { "@eslint/js": "^9.39.5", "@types/bun": "latest", "@types/js-yaml": "^4.0.9", + "@types/picomatch": "^4.0.3", "eslint": "^9.39.5", "globals": "^16.5.0", "jiti": "^2.7.0", diff --git a/src/execution/shell.ts b/src/execution/shell.ts index d7b04de..4018496 100644 --- a/src/execution/shell.ts +++ b/src/execution/shell.ts @@ -48,7 +48,7 @@ const truncateText = (text: string | undefined, limit: number): string => { */ export async function executeCommand( command: string, - options?: { truncateOutput?: number; cwd?: string } + options?: { truncateOutput?: number; cwd?: string; env?: Record } ): Promise { const truncateLimit = options?.truncateOutput ?? DEFAULT_TRUNCATE_LIMIT const hookId = "command" // Will be set by caller @@ -60,7 +60,7 @@ export async function executeCommand( try { // Execute command using Bun's $ template literal with nothrow to prevent throwing on non-zero exit // We need to use dynamic template literal evaluation - const result = await executeShellCommand(command, options?.cwd) + const result = await executeShellCommand(command, options?.cwd, options?.env) const stdout = truncateText(result.stdout, truncateLimit) const stderr = truncateText(result.stderr, truncateLimit) @@ -114,7 +114,7 @@ export async function executeCommand( export async function executeCommands( commands: string | string[], hookId: string, - options?: { truncateOutput?: number; cwd?: string } + options?: { truncateOutput?: number; cwd?: string; env?: Record } ): Promise { const truncateLimit = options?.truncateOutput ?? DEFAULT_TRUNCATE_LIMIT const commandArray = Array.isArray(commands) ? commands : [commands] @@ -131,7 +131,7 @@ export async function executeCommands( logger.debug(`[${hookId}] Executing: ${command}`) } - const result = await executeShellCommand(command, options?.cwd) + const result = await executeShellCommand(command, options?.cwd, options?.env) const stdout = truncateText(result.stdout, truncateLimit) const stderr = truncateText(result.stderr, truncateLimit) @@ -178,14 +178,16 @@ export async function executeCommands( const executeShellCommand = async ( command: string, cwd?: string, + env?: Record, ): Promise<{ stdout: string; stderr: string; exitCode: number }> => { try { // Use Bun's $ template literal to execute the command // The nothrow() method prevents throwing on non-zero exit codes // The quiet() method suppresses output and returns Buffers - const shell = Bun.$`${{ raw: command }}` - if (cwd) shell.cwd(cwd) - const result = await shell.nothrow().quiet() + const shell = Bun.$`${{ raw: command }}` + if (cwd) shell.cwd(cwd) + if (env) shell.env({ ...process.env, ...env }) + const result = await shell.nothrow().quiet() // Extract stdout and stderr as text // result.stdout and result.stderr are Buffers, convert to string diff --git a/src/execution/template.ts b/src/execution/template.ts index 17409dd..499f74b 100644 --- a/src/execution/template.ts +++ b/src/execution/template.ts @@ -13,6 +13,7 @@ * - {stdout} - command stdout (if available) * - {stderr} - command stderr (if available) * - {exitCode} - command exit code (if available) + * - {args.} - direct tool argument value (if available; own properties only) */ import type { TemplateContext } from "../types/hooks.js" @@ -26,13 +27,19 @@ import { logger, isDebugEnabled } from "../logging.js" * @param value - The value to replace with (undefined becomes empty string) * @returns The template with the placeholder replaced */ -const replacePlaceholder = (template: string, placeholder: string, value: unknown): string => { - // Convert value to string, handling undefined/null - const stringValue = value === undefined || value === null ? "" : String(value) - - // Create regex to match {placeholder} globally - const regex = new RegExp(`\\{${placeholder}\\}`, "g") - return template.replace(regex, stringValue) +const formatTemplateValue = (value: unknown): string => { + if (value === undefined || value === null) return "" + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + return String(value) + } + if (typeof value === "object") { + try { + return JSON.stringify(value) ?? "" + } catch { + return "" + } + } + return String(value) } /** @@ -46,6 +53,7 @@ const replacePlaceholder = (template: string, placeholder: string, value: unknow * - {stdout} - command stdout (optional) * - {stderr} - command stderr (optional) * - {exitCode} - command exit code (optional) + * - {args.} - direct tool argument value (optional) * * Missing values are replaced with empty strings. The function never throws * and always returns a valid string. @@ -91,15 +99,24 @@ export const interpolateTemplate = (template: string | undefined, context: Templ let result = template - // Replace all placeholders - // Order doesn't matter since each placeholder is unique - result = replacePlaceholder(result, "id", context.id) - result = replacePlaceholder(result, "agent", context.agent) - result = replacePlaceholder(result, "tool", context.tool) - result = replacePlaceholder(result, "cmd", context.cmd) - result = replacePlaceholder(result, "stdout", context.stdout) - result = replacePlaceholder(result, "stderr", context.stderr) - result = replacePlaceholder(result, "exitCode", context.exitCode) + const args = context.args + result = result.replace( + /\{(id|agent|tool|cmd|stdout|stderr|exitCode|args\.[^{}]+)\}/g, + (_placeholder, name: string) => { + if (name.startsWith("args.")) { + const key = name.slice("args.".length) + if ( + !args || + !Object.prototype.hasOwnProperty.call(args, key) + ) { + return "" + } + return formatTemplateValue(args[key]) + } + + return formatTemplateValue(context[name]) + }, + ) if (isDebugEnabled()) { logger.debug(`Template interpolation complete, result length: ${result.length}`) diff --git a/src/executor.ts b/src/executor.ts index 21e32a5..e4c1ae0 100644 --- a/src/executor.ts +++ b/src/executor.ts @@ -16,15 +16,21 @@ */ import type { OpencodeClient } from "@opencode-ai/sdk" +import { open, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" import type { ToolHook, SessionHook, TemplateContext, HookExecutionContext, -} from "./types/hooks.js" + ToolHookWhen, + HookExecutionResult, + } from "./types/hooks.js" import { executeCommands } from "./execution/shell.js" import { interpolateTemplate } from "./execution/template.js" import { logger } from "./logging.js" +import { compileToolArgGlob, compileToolArgRegex } from "./matcher.js" /** * Check if a value matches a pattern (string, array of strings, or wildcard) @@ -36,6 +42,35 @@ export const matches = (pattern: string | string[] | undefined, value: string | return value === pattern } +const matchesToolArg = ( + pattern: NonNullable[string], + value: unknown, +): boolean => { + if (typeof pattern === "object" && pattern !== null) { + if (Array.isArray(pattern)) { + return matches(pattern, value as string | undefined) + } + + if (typeof value !== "string") return false + + if ("glob" in pattern && pattern.glob !== undefined) { + try { + return compileToolArgGlob(pattern.glob).test(value) + } catch { + return false + } + } + + try { + return compileToolArgRegex(pattern.regex).test(value) + } catch { + return false + } + } + + return matches(pattern, value as string | undefined) +} + /** * Filter session hooks that match the given criteria */ @@ -95,7 +130,7 @@ export const filterToolHooks = ( for (const [key, expectedValue] of Object.entries(hook.when.toolArgs)) { const actualValue = criteria.toolArgs[key] - if (!matches(expectedValue, actualValue as string | undefined)) { + if (!matchesToolArg(expectedValue, actualValue)) { logger.debug( `Hook ${hook.id} toolArgs mismatch for ${key}: expected ${JSON.stringify(expectedValue)}, actual ${JSON.stringify(actualValue)}` ) @@ -124,6 +159,56 @@ const formatErrorMessage = (hookId: string, error: unknown): string => { return `[opencode-command-hooks] Hook "${hookId}" failed: ${errorText}` } +type HookArgsFile = { + path: string + cleanup: () => Promise +} + +const serializeHookArgs = (args: Record | undefined): string => { + if (!args) return "{}" + + try { + return JSON.stringify(args) ?? "{}" + } catch { + return "{}" + } +} + +/** + * Create a private temporary directory and JSON file for one hook execution. + * The directory is 0700 and the file is created exclusively with 0600 mode, + * so the path can safely be passed to child processes without exposing the + * argument payload through the process environment. + */ +const createHookArgsFile = async ( + args: Record | undefined, +): Promise => { + const directory = await mkdtemp(join(tmpdir(), "opencode-command-hooks-")) + const path = join(directory, "args.json") + let fileHandle: Awaited> | undefined + + try { + fileHandle = await open(path, "wx", 0o600) + await fileHandle.writeFile(serializeHookArgs(args), "utf8") + await fileHandle.chmod(0o600) + await fileHandle.close() + fileHandle = undefined + + return { + path, + cleanup: async () => { + await rm(directory, { recursive: true, force: true }) + }, + } + } catch (error) { + if (fileHandle) { + await fileHandle.close().catch(() => undefined) + } + await rm(directory, { recursive: true, force: true }).catch(() => undefined) + throw error + } +} + /** * Show a toast notification to the user * @@ -214,7 +299,7 @@ const injectMessage = async ( * @param truncationLimit - Optional truncation limit for command output * @returns Promise that resolves when hook execution is complete */ -const executeHook = async ( + const executeHook = async ( hook: ToolHook | SessionHook, context: HookExecutionContext, client: OpencodeClient, @@ -225,7 +310,7 @@ const executeHook = async ( `Executing ${hookType} hook "${hook.id}"${context.tool ? ` for tool "${context.tool}"` : ""}, truncationLimit: ${truncationLimit}` ) - try { + try { // Execute optional hook commands if (truncationLimit !== undefined) { logger.debug(`Executing with truncateOutput: ${truncationLimit}`) @@ -233,16 +318,37 @@ const executeHook = async ( logger.debug(`Executing with default truncation (30000)`) } - const results = hook.run - ? await executeCommands( + let results: HookExecutionResult[] = [] + if (isToolHook(hook) && hook.run) { + const argsFile = await createHookArgsFile(context.toolArgs) + try { + results = await executeCommands( hook.run, hook.id, { truncateOutput: truncationLimit, cwd: context.directory, + env: { + OPENCODE_HOOK_ARGS_FILE: argsFile.path, + }, }, ) - : [] + } finally { + try { + await argsFile.cleanup() + } catch (cleanupError) { + const cleanupMessage = cleanupError instanceof Error + ? cleanupError.message + : String(cleanupError) + logger.error(`Failed to clean up hook argument file: ${cleanupMessage}`) + } + } + } else if (hook.run) { + results = await executeCommands(hook.run, hook.id, { + truncateOutput: truncationLimit, + cwd: context.directory, + }) + } logger.debug( `Hook "${hook.id}" executed ${results.length} command(s)` @@ -253,12 +359,13 @@ const executeHook = async ( const templateContext: TemplateContext = { id: hook.id, agent: context.agent, - tool: context.tool, - cmd: firstCmd, - stdout: lastResult?.stdout, - stderr: lastResult?.stderr, - exitCode: lastResult?.exitCode, - } + tool: context.tool, + cmd: firstCmd, + stdout: lastResult?.stdout, + stderr: lastResult?.stderr, + exitCode: lastResult?.exitCode, + args: context.toolArgs, + } // If inject is configured, prepare and inject the message if (hook.inject) { @@ -293,8 +400,8 @@ const executeHook = async ( logger.error( `Failed to inject error message for hook "${hook.id}": ${injectionErrorMsg}` ) - } - } + } + } } /** diff --git a/src/index.ts b/src/index.ts index a94479c..ac885ba 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ const notifyConfigError = async ( configError: string | null, sessionId: string | undefined, client: OpencodeClient, + notifiedConfigErrors: Set, ): Promise => { if (!configError) return @@ -39,13 +40,21 @@ type ToolArgsCacheEntry = { savedAt: number } +type PluginCacheState = { + toolCallArgsCache: Map + afterHookCallCache: Map + notifiedConfigErrors: Set +} + const TOOL_ARGS_TTL_MS = 10 * 60 * 1000 const TOOL_ARGS_MAX = 1000 const AFTER_HOOK_DEDUPE_TTL_MS = 60 * 1000 -const toolCallArgsCache = new Map() -const afterHookCallCache = new Map() -const notifiedConfigErrors = new Set() +const createPluginCacheState = (): PluginCacheState => ({ + toolCallArgsCache: new Map(), + afterHookCallCache: new Map(), + notifiedConfigErrors: new Set(), +}) type SessionParentId = string | null | undefined @@ -59,69 +68,103 @@ const stripHookProviderOptions = (output: ChatParamsOutput): void => { delete output.options.command_hooks } -const pruneToolArgsCache = (): void => { +const getToolCallCacheKey = ( + sessionId: string | undefined, + callId: string | undefined, +): string | undefined => { + if (!sessionId || !callId) return undefined + return JSON.stringify([sessionId, callId]) +} + +const pruneToolArgsCache = (cache: PluginCacheState): void => { const now = Date.now() - for (const [callId, entry] of toolCallArgsCache) { + for (const [callKey, entry] of cache.toolCallArgsCache) { if (now - entry.savedAt > TOOL_ARGS_TTL_MS) { - toolCallArgsCache.delete(callId) + cache.toolCallArgsCache.delete(callKey) } } - while (toolCallArgsCache.size > TOOL_ARGS_MAX) { - const oldest = toolCallArgsCache.keys().next().value + while (cache.toolCallArgsCache.size > TOOL_ARGS_MAX) { + const oldest = cache.toolCallArgsCache.keys().next().value if (!oldest) break - toolCallArgsCache.delete(oldest) + cache.toolCallArgsCache.delete(oldest) } } -const pruneAfterHookCache = (): void => { +const pruneAfterHookCache = (cache: PluginCacheState): void => { const now = Date.now() - for (const [callId, seenAt] of afterHookCallCache) { + for (const [callKey, seenAt] of cache.afterHookCallCache) { if (now - seenAt > AFTER_HOOK_DEDUPE_TTL_MS) { - afterHookCallCache.delete(callId) + cache.afterHookCallCache.delete(callKey) } } } -const markAfterHookProcessed = (callId: string | undefined): void => { - if (!callId) return - pruneAfterHookCache() - afterHookCallCache.set(callId, Date.now()) +const markAfterHookProcessed = ( + sessionId: string | undefined, + callId: string | undefined, + cache: PluginCacheState, +): void => { + const callKey = getToolCallCacheKey(sessionId, callId) + if (!callKey) return + pruneAfterHookCache(cache) + cache.afterHookCallCache.set(callKey, Date.now()) } -const wasAfterHookProcessed = (callId: string | undefined): boolean => { - if (!callId) return false - pruneAfterHookCache() - const seenAt = afterHookCallCache.get(callId) +const wasAfterHookProcessed = ( + sessionId: string | undefined, + callId: string | undefined, + cache: PluginCacheState, +): boolean => { + const callKey = getToolCallCacheKey(sessionId, callId) + if (!callKey) return false + pruneAfterHookCache(cache) + const seenAt = cache.afterHookCallCache.get(callKey) if (!seenAt) return false return Date.now() - seenAt <= AFTER_HOOK_DEDUPE_TTL_MS } -const storeToolArgs = (callId: string | undefined, args: Record | undefined): void => { - if (!callId || !args) return - pruneToolArgsCache() - toolCallArgsCache.set(callId, { +const storeToolArgs = ( + sessionId: string | undefined, + callId: string | undefined, + args: Record | undefined, + cache: PluginCacheState, +): void => { + const callKey = getToolCallCacheKey(sessionId, callId) + if (!callKey || !args) return + pruneToolArgsCache(cache) + cache.toolCallArgsCache.set(callKey, { args, savedAt: Date.now(), }) } -const getToolArgs = (callId: string | undefined): Record | undefined => { - if (!callId) return undefined - pruneToolArgsCache() - const entry = toolCallArgsCache.get(callId) +const getToolArgs = ( + sessionId: string | undefined, + callId: string | undefined, + cache: PluginCacheState, +): Record | undefined => { + const callKey = getToolCallCacheKey(sessionId, callId) + if (!callKey) return undefined + pruneToolArgsCache(cache) + const entry = cache.toolCallArgsCache.get(callKey) if (!entry) return undefined if (Date.now() - entry.savedAt > TOOL_ARGS_TTL_MS) { - toolCallArgsCache.delete(callId) + cache.toolCallArgsCache.delete(callKey) return undefined } return entry.args } -const deleteToolArgs = (callId: string | undefined): void => { - if (!callId) return - toolCallArgsCache.delete(callId) +const deleteToolArgs = ( + sessionId: string | undefined, + callId: string | undefined, + cache: PluginCacheState, +): void => { + const callKey = getToolCallCacheKey(sessionId, callId) + if (!callKey) return + cache.toolCallArgsCache.delete(callKey) } @@ -163,6 +206,7 @@ const handleSessionEvent = async ( agent: string | undefined, client: OpencodeClient, projectDirectory: string, + cache: PluginCacheState, parentId: SessionParentId = undefined, ): Promise => { if (!sessionId) { @@ -176,7 +220,7 @@ const handleSessionEvent = async ( try { const { config: globalConfig, error: globalConfigError } = await loadGlobalConfig(projectDirectory) - await notifyConfigError(globalConfigError, sessionId, client) + await notifyConfigError(globalConfigError, sessionId, client, cache.notifiedConfigErrors) const markdownConfig = { tool: [], session: [] } const { config: mergedConfig } = mergeConfigs(globalConfig, markdownConfig) @@ -219,18 +263,19 @@ const handleToolExecutionHook = async ( toolArgs: Record | undefined, client: OpencodeClient, projectDirectory: string, + cache: PluginCacheState, ): Promise => { if (phase === "before") { - storeToolArgs(input.callID, toolArgs) + storeToolArgs(input.sessionID, input.callID, toolArgs, cache) } if (phase === "after") { - markAfterHookProcessed(input.callID) + markAfterHookProcessed(input.sessionID, input.callID, cache) } try { const { config: globalConfig, error: globalConfigError } = await loadGlobalConfig(projectDirectory) - await notifyConfigError(globalConfigError, input.sessionID, client) + await notifyConfigError(globalConfigError, input.sessionID, client, cache.notifiedConfigErrors) let agentConfig: CommandHooksConfig = { tool: [], session: [] } let subagentType: string | undefined @@ -270,7 +315,7 @@ const handleToolExecutionHook = async ( logger.error(`Error handling tool.execute.${phase}: ${errorMessage}`) } finally { if (phase === "after") { - deleteToolArgs(input.callID) + deleteToolArgs(input.sessionID, input.callID, cache) } } } @@ -296,6 +341,7 @@ export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { const clientLogger = createLogger(client) setGlobalLogger(clientLogger) const projectDirectory = directory || process.cwd() + const cache = createPluginCacheState() try { logger.info("Initializing OpenCode Command Hooks plugin...") @@ -345,6 +391,7 @@ export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { agent, client as OpencodeClient, projectDirectory, + cache, parentId, ) } @@ -362,6 +409,8 @@ export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { agent, client as OpencodeClient, projectDirectory, + cache, + undefined, ) } @@ -387,19 +436,20 @@ export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { return } - if (wasAfterHookProcessed(callId)) { + if (wasAfterHookProcessed(sessionId, callId, cache)) { logger.debug(`Skipping duplicate after-hook execution for callID: ${callId}`) - deleteToolArgs(callId) + deleteToolArgs(sessionId, callId, cache) return } - const storedToolArgs = getToolArgs(callId) + const storedToolArgs = getToolArgs(sessionId, callId, cache) await handleToolExecutionHook( "after", { tool: toolName, sessionID: sessionId, callID: callId }, storedToolArgs, client as OpencodeClient, projectDirectory, + cache, ) } }, @@ -425,6 +475,7 @@ export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { output.args, client as OpencodeClient, projectDirectory, + cache, ) }, @@ -433,7 +484,7 @@ export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { * Runs after a tool completes. */ "tool.execute.after": async ( - input: { tool: string; sessionID: string; callID: string }, + input: { tool: string; sessionID: string; callID: string; args?: Record }, toolOutput?: { title: string; output: string; metadata: Record } ) => { logger.debug( @@ -442,17 +493,18 @@ export const CommandHooksPlugin: Plugin = async ({ client, directory }) => { if (!toolOutput) { logger.debug( - `tool.execute.after for ${input.tool} has no output payload; running hooks with cached args only` + `tool.execute.after for ${input.tool} has no output payload; running hooks with input or cached args` ) } - const storedToolArgs = getToolArgs(input.callID) + const storedToolArgs = input.args ?? getToolArgs(input.sessionID, input.callID, cache) await handleToolExecutionHook( "after", input, storedToolArgs, client as OpencodeClient, projectDirectory, + cache, ) }, } diff --git a/src/matcher.ts b/src/matcher.ts new file mode 100644 index 0000000..02ea7c3 --- /dev/null +++ b/src/matcher.ts @@ -0,0 +1,19 @@ +import picomatch from "picomatch" + +/** + * Keep glob parsing and matching on the same deliberately explicit options. + * Picomatch does not treat leading `#` as a comment; `nonegate` also makes a + * leading `!` literal instead of implicitly negating the pattern. + */ +const TOOL_ARG_GLOB_OPTIONS = { + nonegate: true, + strictBrackets: true, +} as const + +/** Compile a tool-argument glob using the options used by runtime matching. */ +export const compileToolArgGlob = (pattern: string): RegExp => + picomatch.makeRe(pattern, TOOL_ARG_GLOB_OPTIONS) + +/** Compile a tool-argument regex using the runtime's JavaScript semantics. */ +export const compileToolArgRegex = (pattern: string): RegExp => + new RegExp(pattern) diff --git a/src/schemas.ts b/src/schemas.ts index 7ceb418..0b55667 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -6,6 +6,12 @@ */ import { z } from "zod"; +import type { + ToolArgGlobMatcher, + ToolArgMatcher, + ToolArgRegexMatcher, +} from "./types/hooks.js"; +import { compileToolArgGlob, compileToolArgRegex } from "./matcher.js"; // ============================================================================ // PRIMITIVE SCHEMAS @@ -17,6 +23,43 @@ import { z } from "zod"; const StringOrArray = z.union([z.string(), z.array(z.string())]); const RunSchema = z.union([z.string(), z.array(z.string())]); +const GlobMatcherSchema: z.ZodType = z + .object({ glob: z.string() }) + .strict() + .superRefine(({ glob }, context) => { + try { + compileToolArgGlob(glob); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid glob pattern: ${message}`, + }); + } + }); + +const RegexMatcherSchema: z.ZodType = z + .object({ regex: z.string() }) + .strict() + .superRefine(({ regex }, context) => { + try { + compileToolArgRegex(regex); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid regex pattern: ${message}`, + }); + } + }); + +const ToolArgMatcherSchema: z.ZodType = z.union([ + z.string(), + z.array(z.string()), + GlobMatcherSchema, + RegexMatcherSchema, +]); + /** * Phase for tool hooks: "before" or "after" */ @@ -40,7 +83,7 @@ const ToolHookWhenSchema = z.object({ tool: StringOrArray.optional(), callingAgent: StringOrArray.optional(), slashCommand: StringOrArray.optional(), - toolArgs: z.record(StringOrArray).optional(), + toolArgs: z.record(ToolArgMatcherSchema).optional(), }); /** diff --git a/src/types/hooks.ts b/src/types/hooks.ts index b7b556f..9ad60ef 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -66,7 +66,7 @@ export interface ToolHook { /** * Optional template for injecting hook results into the session. - * Supports placeholders: {id}, {agent}, {tool}, {cmd}, {stdout}, {stderr}, {exitCode} + * Supports placeholders: {id}, {agent}, {tool}, {cmd}, {stdout}, {stderr}, {exitCode}, and {args.} * Unavailable values are replaced with empty string. */ inject?: string @@ -115,8 +115,8 @@ export interface ToolHookWhen { /** Slash command name(s) to match. Omitted matches all contexts. */ slashCommand?: string | string[] - /** Tool argument filters (only in tool.execute.before). Match by input arguments. */ - toolArgs?: Record + /** Tool argument filters (in tool.execute.before and tool.execute.after). Match by input arguments. */ + toolArgs?: Record } /** @@ -190,6 +190,19 @@ export interface SessionHookWhen { rootSessionOnly?: boolean } +/** A glob matcher. The `regex` key is intentionally unavailable. */ +export type ToolArgGlobMatcher = { glob: string; regex?: never } + +/** A regex matcher. The `glob` key is intentionally unavailable. */ +export type ToolArgRegexMatcher = { regex: string; glob?: never } + +/** Exact, list, glob, or regex matching for a tool argument. */ +export type ToolArgMatcher = + | string + | string[] + | ToolArgGlobMatcher + | ToolArgRegexMatcher + /** * Top-level command hooks configuration * @@ -265,6 +278,9 @@ export interface TemplateContext { /** Exit code from command execution (0 = success) */ exitCode?: number + /** Tool arguments, available for direct {args.} placeholders */ + args?: Record + /** Additional context fields for future expansion */ [key: string]: unknown } @@ -322,6 +338,6 @@ export interface HookExecutionContext { /** Tool call ID provided by OpenCode (if available) */ callId?: string - /** Tool arguments (available for tool.execute.before hooks) */ + /** Tool arguments (available for tool.execute.before and tool.execute.after hooks) */ toolArgs?: Record } diff --git a/tests/execution.shell.test.ts b/tests/execution.shell.test.ts index a0b50b3..76b247a 100644 --- a/tests/execution.shell.test.ts +++ b/tests/execution.shell.test.ts @@ -1,4 +1,7 @@ import { describe, it, expect } from "bun:test" +import { mkdtempSync, rmSync, writeFileSync } from "fs" +import { tmpdir } from "os" +import { join } from "path" import { executeCommand, executeCommands } from "../src/execution/shell.js" describe("Shell command execution", () => { @@ -64,6 +67,40 @@ describe("Shell command execution", () => { expect(result.success).toBe(true) expect(result.stdout).toContain("hello") }) + + it("preserves inherited environment variables alongside per-command hook arguments", async () => { + const inheritedKey = "OPENCODE_HOOKS_TEST_INHERITED_SENTINEL" + const originalInherited = process.env[inheritedKey] + const originalArgsFile = process.env.OPENCODE_HOOK_ARGS_FILE + const args = { + nested: { enabled: true, values: ["one", 2] }, + shellLooking: "$(touch should-not-exist)", + } + const tempDirectory = mkdtempSync(join(tmpdir(), "opencode-hooks-shell-")) + const argsFile = join(tempDirectory, "args.json") + writeFileSync(argsFile, JSON.stringify(args), { mode: 0o600 }) + process.env[inheritedKey] = "inherited-value" + + try { + const result = await executeCommand( + `printf '%s\\n' "$${inheritedKey}"; cat "$OPENCODE_HOOK_ARGS_FILE"`, + { env: { OPENCODE_HOOK_ARGS_FILE: argsFile } }, + ) + + const [inheritedValue, serializedArgs] = result.stdout.split("\n") + expect(inheritedValue).toBe("inherited-value") + expect(JSON.parse(serializedArgs)).toEqual(args) + expect(process.env[inheritedKey]).toBe("inherited-value") + expect(process.env.OPENCODE_HOOK_ARGS_FILE).toBe(originalArgsFile) + } finally { + if (originalInherited === undefined) { + delete process.env[inheritedKey] + } else { + process.env[inheritedKey] = originalInherited + } + rmSync(tempDirectory, { recursive: true, force: true }) + } + }) }) describe("executeCommands", () => { diff --git a/tests/session.events.test.ts b/tests/session.events.test.ts index 20cd04f..775a72e 100644 --- a/tests/session.events.test.ts +++ b/tests/session.events.test.ts @@ -86,6 +86,32 @@ describe("session event hooks", () => { expect(promptCalls).toHaveLength(1); }); + it("runs session commands without a tool-hook argument environment", async () => { + const originalArgsFile = process.env.OPENCODE_HOOK_ARGS_FILE; + process.env.OPENCODE_HOOK_ARGS_FILE = "inherited-session-value"; + + try { + const { promptCalls } = await dispatchIdle( + { id: "root-session" }, + [{ + id: "idle-command", + when: { event: "session.idle" }, + run: `printf '%s' "$OPENCODE_HOOK_ARGS_FILE"`, + inject: "{stdout}", + }], + ); + + const parts = (promptCalls[0].body as { parts: Array<{ text: string }> }).parts; + expect(parts[0].text).toBe("inherited-session-value"); + } finally { + if (originalArgsFile === undefined) { + delete process.env.OPENCODE_HOOK_ARGS_FILE; + } else { + process.env.OPENCODE_HOOK_ARGS_FILE = originalArgsFile; + } + } + }); + it("skips a default session.idle hook for a child session", async () => { const { promptCalls, sessionGetCalls } = await dispatchIdle( { id: "child-session", parentID: "root-session" }, diff --git a/tests/tool-arguments.test.ts b/tests/tool-arguments.test.ts new file mode 100644 index 0000000..b797174 --- /dev/null +++ b/tests/tool-arguments.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from "bun:test"; +import { filterToolHooks } from "../src/executor.js"; +import { interpolateTemplate } from "../src/execution/template.js"; +import { getConfigValidationErrors, parseToolHook } from "../src/schemas.js"; + +describe("tool argument support", () => { + it("accepts exact, glob, and regex argument filters", () => { + const hook = parseToolHook({ + id: "argument-matchers", + when: { + phase: "before", + toolArgs: { + exact: "value", + path: { glob: "**/*.{ts,js}" }, + command: { regex: "^git\\s+" }, + }, + }, + run: "echo matched", + }); + + expect(hook).not.toBeNull(); + }); + + it("rejects ambiguous argument matchers", () => { + const errors = getConfigValidationErrors({ + tool: [ + { + id: "ambiguous-matcher", + when: { + phase: "before", + toolArgs: { + ambiguous: { glob: "*.ts", regex: "ts" }, + }, + }, + run: "echo invalid", + }, + ], + }); + + expect(errors).not.toBeNull(); + expect(errors?.issues.some((issue) => issue.message.includes("Unrecognized key"))).toBe(true); + }); + + it("rejects invalid regular expressions", () => { + const errors = getConfigValidationErrors({ + tool: [ + { + id: "invalid-regex", + when: { phase: "before", toolArgs: { command: { regex: "[" } } }, + run: "echo invalid", + }, + ], + }); + + expect(errors).not.toBeNull(); + expect(errors?.issues.some((issue) => issue.message.startsWith("Invalid regex pattern:"))).toBe(true); + }); + + it("rejects unbalanced glob syntax supported by picomatch validation", () => { + const errors = getConfigValidationErrors({ + tool: [ + { + id: "invalid-glob", + when: { phase: "before", toolArgs: { path: { glob: "[" } } }, + run: "echo invalid", + }, + ], + }); + + expect(errors).not.toBeNull(); + expect(errors?.issues.some((issue) => issue.message.startsWith("Invalid glob pattern:"))).toBe(true); + }); + + it("matches glob and regex filters generically with AND semantics", () => { + const hooks = [ + parseToolHook({ + id: "matches", + when: { + phase: "before", + tool: "custom_tool", + toolArgs: { + file: { glob: "**/*.{ts,js}" }, + command: { regex: "^git\\s+(commit|push)\\b" }, + }, + }, + run: "echo matched", + })!, + ]; + + expect( + filterToolHooks(hooks, { + phase: "before", + toolName: "custom_tool", + callingAgent: undefined, + slashCommand: undefined, + toolArgs: { file: "src/index.ts", command: "git commit -m ok" }, + }), + ).toHaveLength(1); + + expect( + filterToolHooks(hooks, { + phase: "before", + toolName: "custom_tool", + callingAgent: undefined, + slashCommand: undefined, + toolArgs: { file: "src/index.ts", command: "npm test" }, + }), + ).toHaveLength(0); + + const ordinaryStringHook = [ + parseToolHook({ + id: "ordinary-string", + when: { phase: "before", toolArgs: { label: { glob: "release-*" } } }, + run: "echo matched", + })!, + ]; + expect( + filterToolHooks(ordinaryStringHook, { + phase: "before", + toolName: "custom_tool", + callingAgent: undefined, + slashCommand: undefined, + toolArgs: { label: "release-v1" }, + }), + ).toHaveLength(1); + }); + + it("preserves exact and wildcard argument matching", () => { + const hooks = [ + parseToolHook({ + id: "exact", + when: { phase: "before", toolArgs: { mode: ["safe", "strict"] } }, + run: "echo exact", + })!, + parseToolHook({ + id: "wildcard", + when: { phase: "before", toolArgs: { mode: "*" } }, + run: "echo wildcard", + })!, + ]; + + const matched = filterToolHooks(hooks, { + phase: "before", + toolName: "custom_tool", + callingAgent: undefined, + slashCommand: undefined, + toolArgs: { mode: "safe" }, + }); + expect(matched.map((hook) => hook.id)).toEqual(["exact", "wildcard"]); + }); + + it("treats leading negation and comment characters as literal glob input", () => { + const hooks = [ + parseToolHook({ + id: "literal-prefixes", + when: { + phase: "before", + toolArgs: { + negated: { glob: "!release-*" }, + commented: { glob: "#release-*" }, + }, + }, + run: "echo matched", + })!, + ]; + + expect( + filterToolHooks(hooks, { + phase: "before", + toolName: "custom_tool", + callingAgent: undefined, + slashCommand: undefined, + toolArgs: { negated: "!release-v1", commented: "#release-v1" }, + }), + ).toHaveLength(1); + + expect( + filterToolHooks(hooks, { + phase: "before", + toolName: "custom_tool", + callingAgent: undefined, + slashCommand: undefined, + toolArgs: { negated: "release-v1", commented: "release-v1" }, + }), + ).toHaveLength(0); + }); + + it("does not apply pattern matchers to missing or non-string values", () => { + const hooks = [ + parseToolHook({ + id: "string-only", + when: { phase: "before", toolArgs: { value: { regex: "match" } } }, + run: "echo matched", + })!, + ]; + + for (const value of [undefined, null, [], {}, 42, true]) { + expect( + filterToolHooks(hooks, { + phase: "before", + toolName: "custom_tool", + callingAgent: undefined, + slashCommand: undefined, + toolArgs: { value }, + }), + ).toHaveLength(0); + } + }); + + it("renders direct argument values in one pass without replacement expansion", () => { + const result = interpolateTemplate( + "{args.text}|{args.number}|{args.boolean}|{args.array}|{args.object}|{args.null}|{args.missing}|{args.nested.value}", + { + id: "hook", + args: { + text: "$& {id} {args.number}", + number: 12, + boolean: false, + array: ["a", 2], + object: { key: "value" }, + null: null, + nested: { value: "not-resolved" }, + }, + }, + ); + + expect(result).toBe( + "$& {id} {args.number}|12|false|[\"a\",2]|{\"key\":\"value\"}|||", + ); + }); + +}); diff --git a/tests/tool.after-result.test.ts b/tests/tool.after-result.test.ts index b607f47..99a3b83 100644 --- a/tests/tool.after-result.test.ts +++ b/tests/tool.after-result.test.ts @@ -162,7 +162,7 @@ describe("tool after hooks", () => { { id: "after-hook", when: { phase: "after", tool: ["bash"] }, - run: ["echo 'Hook executed'"], + run: ["cat \"$OPENCODE_HOOK_ARGS_FILE\""], inject: "Tool result: {stdout}", }, ], @@ -180,10 +180,45 @@ describe("tool after hooks", () => { expect(promptCalls).toHaveLength(1); const promptParts = (promptCalls[0].body as { parts: Array<{ text: string }> }).parts; - expect(promptParts[0].text).toContain("Tool result: Hook executed"); + expect(promptParts[0].text).toBe("Tool result: {}"); expect(toastCalls).toHaveLength(0); }); + it("uses direct input args for an after-only event", async () => { + writeConfig({ + tool: [ + { + id: "direct-after-args", + when: { + phase: "after", + tool: ["custom-tool"], + toolArgs: { target: ["prod"] }, + }, + inject: "direct target={args.target}", + }, + ], + session: [], + }); + + const { CommandHooksPlugin } = await import("../src/index.js"); + const { client, promptCalls } = createMockClient(); + const plugin = await CommandHooksPlugin({ client } as never); + + await plugin["tool.execute.after"]?.( + { + tool: "custom-tool", + sessionID: "s-direct-args", + callID: "c-direct-args", + args: { target: "prod" }, + }, + undefined as never, + ); + + expect(promptCalls).toHaveLength(1); + const promptParts = (promptCalls[0].body as { parts: Array<{ text: string }> }).parts; + expect(promptParts[0].text).toBe("direct target=prod"); + }); + it("matches after hooks that require toolArgs when args are available", async () => { writeConfig({ tool: [ @@ -268,6 +303,260 @@ describe("tool after hooks", () => { expect(promptCalls).toHaveLength(0); }); + it("scopes cached args and after deduplication by session and call ID", async () => { + writeConfig({ + tool: [ + { + id: "session-scoped-after", + when: { + phase: "after", + tool: ["custom-tool"], + toolArgs: { target: ["first", "second"] }, + }, + inject: "target={args.target}", + }, + ], + session: [], + }); + + const { CommandHooksPlugin } = await import("../src/index.js"); + const { client, promptCalls } = createMockClient(); + const plugin = await CommandHooksPlugin({ client } as never); + + await plugin["tool.execute.before"]?.( + { tool: "custom-tool", sessionID: "s-first", callID: "shared-call" }, + { args: { target: "first" } }, + ); + + await plugin["tool.execute.after"]?.( + { + tool: "custom-tool", + sessionID: "s-second", + callID: "shared-call", + args: { target: "second" }, + }, + undefined as never, + ); + + await plugin.event?.({ + event: { + type: "tool.result", + properties: { + name: "custom-tool", + sessionID: "s-first", + callID: "shared-call", + }, + }, + } as never); + + const promptTexts = promptCalls.map( + (call) => (call.body as { parts: Array<{ text: string }> }).parts[0].text, + ); + expect(promptTexts).toEqual(["target=second", "target=first"]); + }); + + it("passes generic arguments to before and after hooks without shell interpolation", async () => { + writeConfig({ + tool: [ + { + id: "generic-arguments-before", + when: { + phase: "before", + tool: "custom-tool", + toolArgs: { + filePath: { glob: "**/*.ts" }, + command: { regex: "^git\\s+commit" }, + }, + }, + inject: "before path={args.filePath}", + }, + { + id: "generic-arguments", + when: { + phase: "after", + tool: "custom-tool", + toolArgs: { + filePath: { glob: "**/*.ts" }, + command: { regex: "^git\\s+commit" }, + }, + }, + run: "cat \"$OPENCODE_HOOK_ARGS_FILE\"", + inject: "path={args.filePath}; args={stdout}", + }, + ], + session: [], + }); + + const { CommandHooksPlugin } = await import("../src/index.js"); + const { client, promptCalls } = createMockClient(); + const plugin = await CommandHooksPlugin({ client } as never); + const shellLookingValue = "$(touch should-not-exist)"; + + await plugin["tool.execute.before"]?.( + { tool: "custom-tool", sessionID: "s-generic", callID: "c-generic" }, + { + args: { + filePath: "src/index.ts", + command: "git commit -m ok", + content: shellLookingValue, + nested: { enabled: true }, + }, + }, + ); + await plugin["tool.execute.after"]?.( + { tool: "custom-tool", sessionID: "s-generic", callID: "c-generic" }, + undefined as never, + ); + + expect(promptCalls).toHaveLength(2); + const beforeParts = (promptCalls[0].body as { parts: Array<{ text: string }> }).parts; + const afterParts = (promptCalls[1].body as { parts: Array<{ text: string }> }).parts; + expect(beforeParts[0].text).toBe("before path=src/index.ts"); + const afterPrefix = "path=src/index.ts; args="; + expect(afterParts[0].text.startsWith(afterPrefix)).toBe(true); + expect(JSON.parse(afterParts[0].text.slice(afterPrefix.length))).toEqual({ + filePath: "src/index.ts", + command: "git commit -m ok", + content: shellLookingValue, + nested: { enabled: true }, + }); + expect(existsSync(join(testDir, "should-not-exist"))).toBe(false); + }); + + it("isolates complete hook arguments across concurrent plugin executions", async () => { + writeConfig({ + tool: [ + { + id: "concurrent-arguments", + when: { phase: "before", tool: "custom-tool" }, + run: "sleep 0.05; cat \"$OPENCODE_HOOK_ARGS_FILE\"", + inject: "{stdout}", + }, + ], + session: [], + }); + + const { CommandHooksPlugin } = await import("../src/index.js"); + const { client, promptCalls } = createMockClient(); + const plugin = await CommandHooksPlugin({ client } as never); + const firstArgs = { + id: "first", + nested: { enabled: true, values: ["one", 1] }, + }; + const secondArgs = { + id: "second", + nested: { enabled: false, values: ["two", 2] }, + }; + + await Promise.all([ + plugin["tool.execute.before"]?.( + { tool: "custom-tool", sessionID: "s-concurrent", callID: "c-first" }, + { args: firstArgs }, + ), + plugin["tool.execute.before"]?.( + { tool: "custom-tool", sessionID: "s-concurrent", callID: "c-second" }, + { args: secondArgs }, + ), + ]); + + const outputs = promptCalls.map((call) => { + const parts = (call.body as { parts: Array<{ text: string }> }).parts; + return JSON.parse(parts[0].text) as Record; + }); + expect(outputs).toHaveLength(2); + expect(outputs).toEqual(expect.arrayContaining([firstArgs, secondArgs])); + }); + + it("creates a private argument file and cleans it up after successful commands", async () => { + if (process.platform === "win32") return; + + writeConfig({ + tool: [ + { + id: "argument-file-lifecycle", + when: { phase: "before", tool: "custom-tool" }, + run: "node -e 'const fs=require(\"fs\");const p=process.env.OPENCODE_HOOK_ARGS_FILE;console.log((fs.statSync(p).mode & 0o777).toString(8));console.log(p)'", + inject: "{stdout}", + }, + ], + session: [], + }); + + const originalPath = process.env.OPENCODE_HOOK_ARGS_FILE; + const { CommandHooksPlugin } = await import("../src/index.js"); + const { client, promptCalls } = createMockClient(); + const plugin = await CommandHooksPlugin({ client } as never); + + await plugin["tool.execute.before"]?.( + { tool: "custom-tool", sessionID: "s-lifecycle", callID: "c-lifecycle" }, + { args: { nested: { enabled: true } } }, + ); + + const output = (promptCalls[0].body as { parts: Array<{ text: string }> }).parts[0].text; + const [mode, argsFile] = output.trim().split("\n"); + expect(mode).toBe("600"); + expect(argsFile).toMatch(/opencode-command-hooks-.*\/args\.json$/); + expect(existsSync(argsFile)).toBe(false); + expect(process.env.OPENCODE_HOOK_ARGS_FILE).toBe(originalPath); + }); + + it("cleans up the argument file when a command fails", async () => { + writeConfig({ + tool: [ + { + id: "argument-file-failure-cleanup", + when: { phase: "before", tool: "custom-tool" }, + run: "printf '%s' \"$OPENCODE_HOOK_ARGS_FILE\"; exit 17", + inject: "{stdout}", + }, + ], + session: [], + }); + + const { CommandHooksPlugin } = await import("../src/index.js"); + const { client, promptCalls } = createMockClient(); + const plugin = await CommandHooksPlugin({ client } as never); + + await plugin["tool.execute.before"]?.( + { tool: "custom-tool", sessionID: "s-failure-cleanup", callID: "c-failure-cleanup" }, + { args: { value: "failure" } }, + ); + + const argsFile = (promptCalls[0].body as { parts: Array<{ text: string }> }).parts[0].text; + expect(argsFile).toMatch(/opencode-command-hooks-.*\/args\.json$/); + expect(existsSync(argsFile)).toBe(false); + }); + + it("passes large nested arguments through the file without environment-size limits", async () => { + writeConfig({ + tool: [ + { + id: "large-argument-file", + when: { phase: "before", tool: "custom-tool" }, + run: "wc -c < \"$OPENCODE_HOOK_ARGS_FILE\" | tr -d ' '", + inject: "{stdout}", + }, + ], + session: [], + }); + + const largeArgs = { + nested: { values: ["one", { two: true }] }, + payload: "x".repeat(300_000), + }; + const { CommandHooksPlugin } = await import("../src/index.js"); + const { client, promptCalls } = createMockClient(); + const plugin = await CommandHooksPlugin({ client } as never); + + await plugin["tool.execute.before"]?.( + { tool: "custom-tool", sessionID: "s-large", callID: "c-large" }, + { args: largeArgs }, + ); + + const output = (promptCalls[0].body as { parts: Array<{ text: string }> }).parts[0].text; + expect(Number(output.trim())).toBe(Buffer.byteLength(JSON.stringify(largeArgs))); + }); + it("does not double-run after hooks when tool.execute.after and tool.result both fire", async () => { writeConfig({ tool: [