From fb15f7a6906bad737ff6be20e09ff5eeddef8cc3 Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 16 May 2026 21:16:11 +0000 Subject: [PATCH] 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) --- config.lua | 10 ++++++++++ repl.lua | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/config.lua b/config.lua index 0246bf1..ae06aab 100644 --- a/config.lua +++ b/config.lua @@ -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. diff --git a/repl.lua b/repl.lua index e1bbd65..732d2a7 100644 --- a/repl.lua +++ b/repl.lua @@ -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