repl: pre/post CMD hooks via config.hooks (closes #3)

Optional shell scripts trigger around every CMD: execution. Use cases:
audit logging, auto-format-after-edit, custom safety gates beyond the
existing confirm_cmd boolean.

Config shape:

    hooks = {
        pre_cmd  = "/path/to/pre-script",
        post_cmd = "/path/to/post-script",
    }

Contract per hook invocation:
  - The command line is piped to the hook on stdin.
  - Env vars: AISH_CMD (the command), AISH_TURN (#ctx.turns at the
    moment of dispatch), AISH_CWD (libc.getcwd() result).
  - Hook stdout is streamed live to the terminal via executor.exec
    (so the user sees its output regardless of exit status).

Pre-hook: non-zero exit aborts the command and emits a status line
including the exit code. last_exec_code is set to the hook's exit
so the {last_status} prompt template variable reflects the abort.

Post-hook: exit code is ignored (the spec says so); only the visible
stdout matters. Runs after the command's exec_end frame.

Tested with success, abort, and stdin-matches-env paths.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:16:11 +00:00
parent ce1378edee
commit fb15f7a690
2 changed files with 44 additions and 0 deletions
+10
View File
@@ -61,6 +61,16 @@ return {
dir = (os.getenv("HOME") or ".") .. "/.local/share/aish",
},
-- Issue #3: pre/post CMD hooks. Optional shell scripts triggered around
-- every CMD: execution. Each hook receives the command on stdin and
-- AISH_CMD / AISH_TURN / AISH_CWD as env vars. Non-zero exit on pre_cmd
-- aborts execution; post_cmd's exit code is ignored but its stdout is
-- logged. Default off (no hooks). Uncomment to enable.
-- hooks = {
-- pre_cmd = (os.getenv("HOME") or ".") .. "/.aish/hooks/pre-cmd",
-- post_cmd = (os.getenv("HOME") or ".") .. "/.aish/hooks/post-cmd",
-- },
-- Phase 2 (docs/PHASE2.md): MCP server registry + tool-call policy.
-- The block is OFF by default — connect-at-startup happens only when
-- `servers` is non-empty. Uncomment + adjust per your fleet.
+34
View File
@@ -564,6 +564,27 @@ function M.run(config)
-- a [exec output] block pending until ask_ai flushes it via append_user.
-- Direct user-role injection violated chat-template alternation (mistral-
-- nemo's Jinja rejects user/user back-to-back); see PHASE0.md §6.
--
-- Issue #3: pre_cmd / post_cmd hooks fire around exec. Each hook
-- receives the command on stdin and AISH_CMD/AISH_TURN/AISH_CWD as
-- env vars. Non-zero exit on pre_cmd aborts. post_cmd exit is
-- ignored; its stdout is logged via renderer.status.
local function _shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end
local function _run_hook(script, cmd, want_output)
local cwd = (require("ffi.libc").getcwd()) or os.getenv("PWD") or "?"
local pipeline = string.format(
"printf '%%s' %s | AISH_CMD=%s AISH_TURN=%d AISH_CWD=%s %s 2>&1",
_shq(cmd), _shq(cmd), #ctx.turns, _shq(cwd), _shq(script))
if want_output then
local out, code = executor.exec(pipeline)
return code, out
else
local out, code = executor.exec(pipeline)
-- Even when we don't *want* output, surface it if the hook
-- aborts so the user sees why.
return code, out
end
end
local function run_shell(cmd)
local chd, err = executor.maybe_chdir(cmd)
if chd ~= nil then
@@ -575,6 +596,16 @@ function M.run(config)
end
return
end
local hooks = config.hooks or {}
if hooks.pre_cmd then
local rc = _run_hook(hooks.pre_cmd, cmd, false)
if rc ~= 0 then
renderer.status(("pre_cmd hook aborted (exit %d): %s")
:format(rc, cmd))
last_exec_code = rc
return
end
end
renderer.exec_begin()
local out, code = executor.exec(cmd)
last_exec_code = code
@@ -582,6 +613,9 @@ function M.run(config)
if config.shell and config.shell.capture_output then
ctx:append_exec_output(out)
end
if hooks.post_cmd then
_run_hook(hooks.post_cmd, cmd, true)
end
end
-- Send user text to the active model and process the response. If MCP