main: non-interactive -p/--prompt one-shot mode (closes #4)

Adds `aish -p "<text>"` for Unix-pipeline composability:

  tail app.log | aish -p "any anomalies?"
  aish -p "summarize: $(curl -sS https://...)"

The flag bypasses repl.lua entirely. On invocation:

  1. Stdin: when not a TTY, read to EOF and prepend to the prompt as a
     fenced block. ffi.libc.isatty(0) gates the read so interactive
     `aish -p "..."` (no pipe) doesn't hang.
  2. Resolve config.models[config.default_model].
  3. Stream broker.chat_stream replies to stdout; finalize with newline.
  4. Exit 0 on success, 1 on broker error, 2 on arg / config error.

Behavior NOT in -p mode (kept simple per the issue's "no repl.lua
involvement"):
  - No MCP, no tool loop, no Norris, no routing, no memory injection.
  - "CMD:" lines in the reply are printed verbatim, NOT executed —
    callers can grep / pipe them as they wish.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 21:06:27 +00:00
parent 0700dce881
commit 81c3b1b44a
2 changed files with 71 additions and 2 deletions
+9
View File
@@ -38,6 +38,10 @@ int poll(struct pollfd *fds, unsigned long nfds, int timeout);
enforcement via LOCK_EX | LOCK_NB — fail-fast if another aish
process holds the lock. */
int flock(int fd, int operation);
/* TTY detection for non-interactive mode (`aish -p`). Returns 1 if the
fd refers to a terminal, 0 otherwise (sets errno on error). */
int isatty(int fd);
]]
local C = ffi.C
@@ -174,4 +178,9 @@ function M.flock(fd, op)
return false, ffi.string(C.strerror(C.__errno_location()[0]))
end
-- ---------------------------------------------------------------- isatty
function M.isatty(fd)
return C.isatty(fd) == 1
end
return M
+62 -2
View File
@@ -1,6 +1,6 @@
-- main.lua — entry point
-- Phase 0: arg parsing, config load, REPL start.
-- See docs/PHASE0.md §4, §10.
-- See docs/PHASE0.md §4, §10. -p one-shot mode lands per issue #4.
-- Make project modules and the vendored dkjson resolvable from the repo root.
-- Run aish with the repo root as cwd; PTY-relative resolution lands later.
@@ -9,7 +9,13 @@ package.path = "./?.lua;./vendor/?.lua;" .. package.path
local USAGE = [[
aish — AI-augmented conversational shell.
Usage: luajit main.lua [--config <path>] [--help]
Usage:
luajit main.lua [--config <path>] [--help] -- interactive REPL
luajit main.lua -p "<prompt>" [--config <path>] -- one-shot, print + exit
In -p mode, if stdin is not a TTY it's read as additional context and
prepended to the prompt as a fenced block — composes with Unix pipes:
tail app.log | aish -p "any anomalies?"
Config resolution order (PHASE0.md §10):
1. --config <path>
@@ -29,6 +35,13 @@ local function parse_args(argv)
elseif a == "--help" or a == "-h" then
out.help = true
i = i + 1
elseif a == "-p" or a == "--prompt" then
out.prompt = argv[i + 1]
if not out.prompt then
io.stderr:write("aish: -p requires a prompt argument\n")
os.exit(2)
end
i = i + 2
else
io.stderr:write("aish: unrecognized argument: " .. a .. "\n")
os.exit(2)
@@ -63,6 +76,48 @@ local function load_config(opts)
.. table.concat(candidates, ", ") .. ")")
end
-- One-shot mode: read non-TTY stdin (if any), compose prompt, stream
-- broker reply to stdout, exit. Bypasses repl.lua entirely — no REPL,
-- no MCP, no tool loop, no Norris. The model's reply is printed
-- verbatim (including any "CMD:" lines, which are NOT executed in
-- this mode by design — the user can pipe-grep them as they wish).
local function run_one_shot(config, user_prompt)
local libc = require("ffi.libc")
local broker = require("broker")
local composed = user_prompt
if not libc.isatty(0) then
local piped = io.read("*a") or ""
if piped ~= "" then
composed = "```\n" .. piped .. "\n```\n\n" .. user_prompt
end
end
local model_name = config.default_model
local model_cfg = config.models and config.models[model_name]
if not model_cfg then
io.stderr:write(("aish: default_model '%s' not found in models{}\n")
:format(tostring(model_name)))
os.exit(2)
end
local messages = { { role = "user", content = composed } }
local got_any = false
local ok, err = broker.chat_stream(model_cfg, messages,
function(kind, payload)
if kind == "text" and payload and payload ~= "" then
io.write(payload); io.flush()
got_any = true
end
end)
if not ok then
if got_any then io.write("\n") end
io.stderr:write("aish: broker error: " .. tostring(err) .. "\n")
os.exit(1)
end
if got_any then io.write("\n") end
end
local function main(argv)
local opts = parse_args(argv or {})
if opts.help then io.write(USAGE); return end
@@ -70,6 +125,11 @@ local function main(argv)
local config, config_path = load_config(opts)
io.stderr:write(("aish: loaded config from %s\n"):format(config_path))
if opts.prompt then
run_one_shot(config, opts.prompt)
return
end
local repl = require("repl")
repl.run(config)
end