abc993aa49
Addresses three concerns + one nit from the Phase 0 review pass.
executor.lua:
- M.exec guards empty / whitespace-only cmd up front, returns
"(empty command)" / -1 instead of running the wrapper on nothing.
- On sentinel-parse failure with empty output (typical of shell
parse errors — the syntax error itself escapes to the popen
parent's stderr because 2>&1 is inside the unparsable subshell),
surface "(no output — possible shell parse error)" rather than
a silent empty frame.
- extract_cmd_lines now skips whitespace-only / empty bodies; a
bare `CMD: ` line in assistant output no longer turns into an
"execute ''? [y/N]" prompt.
- "what" comments cleaned in maybe_chdir.
router.lua:
- path_like now matches `~` and `~/foo` so `~/scripts/build.sh`
classifies as shell (was: ai). Restores symmetry with executor's
maybe_chdir, which already expands `~` on `cd`.
repl.lua:
- :exec and :ask trim args and renderer.status a usage line on
empty rather than running an empty cmd / sending an empty turn
to broker.
Regression: full prior smoke suite still passes — known_commands
shell paths, all maybe_chdir branches, CMD: extraction with non-empty
bodies, exec exit-code recovery, all router branches.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
75 lines
2.8 KiB
Lua
75 lines
2.8 KiB
Lua
-- executor.lua — command execution.
|
|
-- Phase 0: io.popen with stderr merged. PTY (forkpty) lands in Phase 1.
|
|
-- `cd` is intercepted before popen and routed through libc.chdir so the
|
|
-- working directory persists across calls (popen forks; cd inside it would
|
|
-- otherwise be discarded). See docs/PHASE0.md §6, §7.
|
|
|
|
local libc = require("ffi.libc")
|
|
|
|
local M = {}
|
|
|
|
-- LuaJIT 2.1's io.popen():close() returns only `true` (Lua 5.1 ABI) — it
|
|
-- does not surface the child exit status. Recover it via a sentinel echo
|
|
-- appended after the command. Phase 1's PTY work will wire waitpid via FFI
|
|
-- and replace this hack.
|
|
local EXIT_SENTINEL = "__AISH_EXIT_4F8E91__"
|
|
|
|
-- Execute a shell command.
|
|
-- Returns: (output_string, exit_code).
|
|
-- exit_code == 0 on success; non-zero on failure; -1 on no-output / sentinel-
|
|
-- parse failure (popen failed, empty cmd, shell parse error, sentinel collision).
|
|
function M.exec(cmd)
|
|
if not cmd or cmd:match("^%s*$") then
|
|
return "(empty command)", -1
|
|
end
|
|
local wrapped = string.format("(%s) 2>&1; echo %s$?", cmd, EXIT_SENTINEL)
|
|
local handle, err = io.popen(wrapped, "r")
|
|
if not handle then return ("popen failed: " .. tostring(err)), -1 end
|
|
local output = handle:read("*a") or ""
|
|
handle:close()
|
|
|
|
local body, code = output:match("^(.-)" .. EXIT_SENTINEL .. "(%-?%d+)%s*$")
|
|
if code then return body, tonumber(code) end
|
|
if output == "" then
|
|
return "(no output — possible shell parse error)", -1
|
|
end
|
|
return output, -1
|
|
end
|
|
|
|
-- Intercept and apply `cd <path>` (or bare `cd` -> $HOME) without forking.
|
|
-- Returns:
|
|
-- nil : the command is not a `cd` (caller falls through to exec)
|
|
-- true : it was a cd, libc.chdir succeeded
|
|
-- false, err : it was a cd, libc.chdir failed with errmsg
|
|
function M.maybe_chdir(cmd)
|
|
local rest = cmd:match("^%s*cd%s*$") and ""
|
|
or cmd:match("^%s*cd%s+(.+)$")
|
|
if not rest then return nil end
|
|
|
|
local target = rest:match("^%s*(.-)%s*$") or ""
|
|
|
|
-- Phase 0: no $OLDPWD support, so `cd -` is not handled.
|
|
if target == "" then target = os.getenv("HOME") or "/" end
|
|
if target == "~" then target = os.getenv("HOME") or "/" end
|
|
if target:sub(1, 2) == "~/" then
|
|
target = (os.getenv("HOME") or "") .. target:sub(2)
|
|
end
|
|
|
|
return libc.chdir(target)
|
|
end
|
|
|
|
-- Extract `CMD: ` lines from an assistant response per the §6 broker contract.
|
|
-- The "CMD: " prefix is a §3 substrate invariant: exact prefix, single space,
|
|
-- start-of-line only. Leading whitespace before CMD: does NOT match.
|
|
function M.extract_cmd_lines(text)
|
|
local cmds = {}
|
|
for line in (text or ""):gmatch("[^\n]+") do
|
|
local cmd = line:match("^CMD: (.*)$")
|
|
-- Skip whitespace-only / empty bodies; "CMD: " alone is degenerate.
|
|
if cmd and cmd:match("%S") then cmds[#cmds + 1] = cmd end
|
|
end
|
|
return cmds
|
|
end
|
|
|
|
return M
|