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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string \| string[] \| matcher>` | 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
Expand All @@ -108,6 +109,7 @@ toast:
- `{stdout}` - Command stdout (truncated)
- `{stderr}` - Command stderr (truncated)
- `{exitCode}` - Command exit code
- `{args.<key>}` - Direct tool argument value (own properties only; strings, numbers, and booleans as text; arrays and objects as JSON)

### Complete Example

Expand Down Expand Up @@ -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
{
Expand All @@ -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
Expand Down
24 changes: 14 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 9 additions & 7 deletions src/execution/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> }
): Promise<HookExecutionResult> {
const truncateLimit = options?.truncateOutput ?? DEFAULT_TRUNCATE_LIMIT
const hookId = "command" // Will be set by caller
Expand All @@ -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)
Expand Down Expand Up @@ -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<string, string> }
): Promise<HookExecutionResult[]> {
const truncateLimit = options?.truncateOutput ?? DEFAULT_TRUNCATE_LIMIT
const commandArray = Array.isArray(commands) ? commands : [commands]
Expand All @@ -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)
Expand Down Expand Up @@ -178,14 +178,16 @@ export async function executeCommands(
const executeShellCommand = async (
command: string,
cwd?: string,
env?: Record<string, string>,
): 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
Expand Down
49 changes: 33 additions & 16 deletions src/execution/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
* - {stdout} - command stdout (if available)
* - {stderr} - command stderr (if available)
* - {exitCode} - command exit code (if available)
* - {args.<key>} - direct tool argument value (if available; own properties only)
*/

import type { TemplateContext } from "../types/hooks.js"
Expand All @@ -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)
}

/**
Expand All @@ -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.<key>} - direct tool argument value (optional)
*
* Missing values are replaced with empty strings. The function never throws
* and always returns a valid string.
Expand Down Expand Up @@ -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}`)
Expand Down
Loading
Loading