From 81c3b1b44a117f3e217dbbfea20b5d7edca18f4f Mon Sep 17 00:00:00 2001 From: Markus Fritsche Date: Sat, 16 May 2026 21:06:27 +0000 Subject: [PATCH] main: non-interactive `-p`/`--prompt` one-shot mode (closes #4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `aish -p ""` 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) --- ffi/libc.lua | 9 ++++++++ main.lua | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/ffi/libc.lua b/ffi/libc.lua index 732b1f0..da8196a 100644 --- a/ffi/libc.lua +++ b/ffi/libc.lua @@ -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 diff --git a/main.lua b/main.lua index 1af821d..8ed6766 100644 --- a/main.lua +++ b/main.lua @@ -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 ] [--help] +Usage: + luajit main.lua [--config ] [--help] -- interactive REPL + luajit main.lua -p "" [--config ] -- 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 @@ -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