shell-builtins ยท diff
git:20260420.0a86d5f to git:20260427.af002dc
34 added, 13 removed. Audit A to A.
---
name: shell-builtins
description: Use when creating a new shell builtin command for Crush (internal/shell/), editing an existing one, or when the user needs to understand how commands are intercepted in Crush's embedded shell.
---
# Shell Builtins
Crush's shell (`internal/shell/`) uses `mvdan.cc/sh/v3` for POSIX shell
emulation. Commands can be intercepted before they reach the OS by adding
**builtins** โ functions handled in-process.
## How Builtins Work
- Builtins live in `Shell.builtinHandler()` in `internal/shell/shell.go`.
- This is an `interp.ExecHandlerFunc` middleware registered in
- `execHandlers()` **before** the block handler, so builtins run even for
- commands that would otherwise be blocked.
+ Builtins live in `builtinHandler()` in `internal/shell/run.go`. This is an
+ `interp.ExecHandlerFunc` middleware registered in `standardHandlers()`
+ **before** the block handler, so builtins run even for commands that would
+ otherwise be blocked. The same handler chain is shared by the stateful
+ `Shell` type and the stateless `Run` entrypoint used by the hook runner,
+ so builtins are available identically in the `bash` tool and in hooks.
The handler is a switch on `args[0]`. Each case either handles the command
inline or delegates to a helper function.
## Adding a New Builtin
- 1. **Add the case** to the switch in `builtinHandler()` in `shell.go`.
+ 1. **Add the case** to the switch in `builtinHandler()` in `run.go`.
2. **Get I/O from the handler context**, not from `os.Stdin`/`os.Stdout`.
This ensures the builtin works with pipes and redirections:
```go
case "mycommand":
hc := interp.HandlerCtx(ctx)
- return handleMyCommand(args, hc.Stdin, hc.Stdout, hc.Stderr)
+ return handleMyCommand(ctx, args, hc.Stdin, hc.Stdout, hc.Stderr)
```
3. **Implement the handler** in its own file (e.g.,
- `internal/shell/mycommand.go`). The function signature should accept
- args, stdin, stdout, and stderr:
+ `internal/shell/mycommand.go`). The function signature must accept a
+ `context.Context` as the first parameter, plus args, stdin, stdout, and
+ stderr:
```go
- func handleMyCommand(args []string, stdin io.Reader, stdout, stderr io.Writer) error {
+ func handleMyCommand(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error {
// args[0] is the command name ("mycommand"), args[1:] are arguments.
// Write output to stdout, errors to stderr.
// Return nil on success, or interp.ExitStatus(n) for non-zero exit codes.
}
```
- 4. **Return values**: return `nil` for success, `interp.ExitStatus(n)` for
- non-zero exit codes. Write error messages to `stderr` before returning.
- 5. **No extra wiring needed** โ `builtinHandler()` is already registered
- in `execHandlers()`.
+ 4. **Poll `ctx` in every unbounded loop.** Builtins that iterate over
+ input, emit values in a generator-style loop, or do any other work
+ that can exceed a few milliseconds MUST check `ctx.Err()` on each
+ iteration and return it verbatim when non-nil. Hook timeouts rely on
+ this: an unbounded builtin that never polls ctx cannot be interrupted
+ by a hook's `timeout_sec`, and the hook runner will have to abandon
+ the goroutine (see `internal/hooks/runner.go`). Returning `ctx.Err()`
+ (not `interp.ExitStatus(n)`) lets callers distinguish "command exited
+ non-zero" from "we ran out of time".
+ ```go
+ for _, item := range items {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ // ... process item
+ }
+ ```
+ 5. **Return values**: return `nil` for success, `interp.ExitStatus(n)` for
+ non-zero exit codes, or `ctx.Err()` on cancellation. Write error
+ messages to `stderr` before returning.
+ 6. **No extra wiring needed** โ `builtinHandler()` is already registered
+ in `standardHandlers()`.
## Existing Builtins
| Command | File | Description |
|---------|------|-------------|
| `jq` | `jq.go` | JSON processor using `github.com/itchyny/gojq` |