8fb5954bc5
main.lua now resolves package.path relative to its own script directory rather than cwd, so the packaged install at /usr/share/lua/5.1/aish/ finds its siblings regardless of where the user invokes aish from. Dev mode (luajit main.lua from the repo root) is preserved: arg[0] is "main.lua" with no "/" so the regex returns nil and _dir falls back to "./" — identical to the previous behavior. bin/aish is a POSIX-sh wrapper that execs luajit against $AISH_LIB/main.lua (default /usr/share/lua/5.1/aish). The AISH_LIB env override lets users point at a dev checkout without uninstalling the package. Wrapper emits distinct errors when AISH_LIB is missing or when luajit isn't on PATH so broken installs surface clearly instead of through a bare sh: not found. examples/config.lua is the canonical commented reference, shipped at /usr/share/doc/aish/examples/config.lua. Stripped of the two live MCP bearer tokens carried by the in-tree config.lua and switched to the auth_env env-var indirection form; mcp.servers entries are commented out so a copy-to-~/.config/aish/config.lua produces a working starting point on first uncomment. HOSSENFELDER URL flagged as maintainer-LAN. LICENSE: MIT, copyright 2026 Markus Fritsche. README updated to match. Sonnet review of the changeset (per feedback_reviews_use_sonnet.md + bugfix-process step 4): no blockers; the two Important findings (USAGE text still said "luajit main.lua", bin/aish didn't pre-check luajit) and one Nit (unredacted HOSSENFELDER URL) were folded in before commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
273 lines
9.5 KiB
Lua
273 lines
9.5 KiB
Lua
-- main.lua — entry point
|
|
-- Phase 0: arg parsing, config load, REPL start.
|
|
-- See docs/PHASE0.md §4, §10. -p one-shot mode lands per issue #4.
|
|
|
|
-- Resolve modules + vendored dkjson relative to this script's directory,
|
|
-- not cwd. Packaged install puts main.lua at /usr/share/lua/5.1/aish/ and
|
|
-- the /usr/bin/aish wrapper execs `luajit /usr/share/lua/5.1/aish/main.lua`
|
|
-- from whatever cwd the user is in — siblings must still resolve. Dev mode
|
|
-- (`luajit main.lua` from repo root) keeps working because arg[0] is then
|
|
-- "main.lua" with no "/" — _dir falls back to "./".
|
|
local _dir = arg[0]:match("(.*/)") or "./"
|
|
package.path = _dir .. "?.lua;" .. _dir .. "vendor/?.lua;" .. package.path
|
|
|
|
local USAGE = [[
|
|
aish — AI-augmented conversational shell.
|
|
|
|
Usage:
|
|
aish [--config <path>] [--help] -- interactive REPL
|
|
aish -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>
|
|
2. $AISH_CONFIG
|
|
3. ~/.config/aish/config.lua
|
|
4. ./config.lua
|
|
]]
|
|
|
|
local function parse_args(argv)
|
|
local out = {}
|
|
local i = 1
|
|
while i <= #argv do
|
|
local a = argv[i]
|
|
if a == "--config" then
|
|
out.config = argv[i + 1]
|
|
i = i + 2
|
|
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)
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
local function load_config(opts)
|
|
-- --config is explicit: use exactly that path or fail. No silent fallback.
|
|
if opts.config then
|
|
local f = io.open(opts.config, "r")
|
|
if not f then
|
|
error("aish: --config " .. opts.config .. ": cannot open")
|
|
end
|
|
f:close()
|
|
return dofile(opts.config), opts.config
|
|
end
|
|
|
|
local home = os.getenv("HOME") or ""
|
|
local candidates = {}
|
|
local function push(p) if p and p ~= "" then candidates[#candidates + 1] = p end end
|
|
push(os.getenv("AISH_CONFIG"))
|
|
push(home .. "/.config/aish/config.lua")
|
|
push("./config.lua")
|
|
|
|
for _, path in ipairs(candidates) do
|
|
local f = io.open(path, "r")
|
|
if f then f:close(); return dofile(path), path end
|
|
end
|
|
error("aish: no config.lua found (tried: "
|
|
.. table.concat(candidates, ", ") .. ")")
|
|
end
|
|
|
|
-- ---------------------------------------------------------------- Phase 9 project overlay
|
|
|
|
-- Walk-up from libc.getcwd() looking for .aish.lua. Stops at $HOME
|
|
-- OR filesystem root (whichever comes first). Returns the first
|
|
-- found path or nil. Per R1 (review fold-in), uses a proper-prefix
|
|
-- check (NOT bare bytes-prefix) to avoid false positive when HOME
|
|
-- is "/home/user" and cwd is "/home/user2/...".
|
|
local function _find_project_config()
|
|
local home = os.getenv("HOME")
|
|
if not home or home == "" then return nil end
|
|
-- Lazy-require so the existing load_config path stays untouched
|
|
-- when no project overlay considered.
|
|
local libc_ok, libc = pcall(require, "ffi.libc")
|
|
if not libc_ok then return nil end
|
|
local dir = libc.getcwd()
|
|
if not dir then return nil end
|
|
-- R1: proper prefix (dir == home OR dir starts with home .. "/")
|
|
if dir ~= home and dir:sub(1, #home + 1) ~= home .. "/" then
|
|
return nil
|
|
end
|
|
while dir and #dir > 0 do
|
|
local candidate = dir .. "/.aish.lua"
|
|
local f = io.open(candidate, "rb")
|
|
if f then f:close(); return candidate end
|
|
if dir == home or dir == "/" then return nil end
|
|
-- Walk up one level
|
|
dir = dir:gsub("/[^/]*$", "")
|
|
if dir == "" then dir = "/" end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function _trust_file_path()
|
|
return os.getenv("AISH_TRUST_FILE")
|
|
or ((os.getenv("HOME") or "") .. "/.aish/trusted-projects")
|
|
end
|
|
|
|
-- Interactive trust prompt. R2: caller must NOT invoke this in
|
|
-- one-shot (-p) mode — io.read or rl.readline would consume piped
|
|
-- stdin. Returns true on user accept (and persists the trust).
|
|
-- Per A8, uses rl.readline; if it misbehaves at this early call
|
|
-- site, the function returns false (skip overlay) rather than
|
|
-- falling back to io.read.
|
|
local function _check_and_maybe_prompt(project_path, history)
|
|
local sha = history._sha256_file(project_path)
|
|
if not sha then
|
|
io.stderr:write("aish: project config " .. project_path
|
|
.. ": sha256 failed; skipping\n")
|
|
return false
|
|
end
|
|
local tpath = _trust_file_path()
|
|
if history.is_trusted(tpath, project_path, sha) then
|
|
return true
|
|
end
|
|
-- Trust prompt.
|
|
io.stderr:write("aish: project config found: " .. project_path .. "\n")
|
|
io.stderr:write("aish: UNTRUSTED. Loading it runs arbitrary Lua code.\n")
|
|
local rl_ok, rl = pcall(require, "ffi.readline")
|
|
if not rl_ok then
|
|
io.stderr:write("aish: readline unavailable; declining trust prompt\n")
|
|
return false
|
|
end
|
|
local ans = rl.readline("[aish] trust this project config? [y/N] ")
|
|
if ans and ans:lower():sub(1, 1) == "y" then
|
|
history.add_trusted(tpath, project_path, sha)
|
|
return true
|
|
end
|
|
return false
|
|
end
|
|
|
|
-- Wrap load_config with a project-overlay step. Always-on (no
|
|
-- config flag); overlay activates only when a trusted .aish.lua
|
|
-- is found in/above cwd. In one-shot (-p) mode the trust prompt
|
|
-- is SKIPPED to avoid io consuming piped stdin (R2) — only pre-
|
|
-- trusted overlays load in -p.
|
|
local function load_config_with_overlay(opts)
|
|
local user_cfg, user_path = load_config(opts)
|
|
local sources = {}
|
|
for k, _ in pairs(user_cfg) do sources[k] = "user" end
|
|
|
|
local proj_path = _find_project_config()
|
|
if not proj_path then
|
|
user_cfg._sources = sources
|
|
return user_cfg, user_path, nil
|
|
end
|
|
|
|
local history_ok, history = pcall(require, "history")
|
|
if not history_ok then
|
|
user_cfg._sources = sources
|
|
return user_cfg, user_path, nil
|
|
end
|
|
|
|
-- R2: skip trust prompt in -p mode.
|
|
local trusted
|
|
if opts.prompt then
|
|
local sha = history._sha256_file(proj_path)
|
|
local tpath = _trust_file_path()
|
|
trusted = sha and history.is_trusted(tpath, proj_path, sha)
|
|
if not trusted then
|
|
io.stderr:write("aish: project config " .. proj_path
|
|
.. " skipped in -p mode (untrusted; run aish interactively to trust)\n")
|
|
end
|
|
else
|
|
trusted = _check_and_maybe_prompt(proj_path, history)
|
|
end
|
|
if not trusted then
|
|
user_cfg._sources = sources
|
|
return user_cfg, user_path, nil
|
|
end
|
|
|
|
local ok, proj_cfg = pcall(dofile, proj_path)
|
|
if not ok or type(proj_cfg) ~= "table" then
|
|
io.stderr:write("aish: project config " .. proj_path
|
|
.. " load failed: " .. tostring(proj_cfg) .. "\n")
|
|
user_cfg._sources = sources
|
|
return user_cfg, user_path, nil
|
|
end
|
|
-- Shallow merge: project replaces user at top level. Update sources map.
|
|
for k, v in pairs(proj_cfg) do
|
|
user_cfg[k] = v
|
|
sources[k] = "project"
|
|
end
|
|
user_cfg._sources = sources
|
|
return user_cfg, user_path, proj_path
|
|
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
|
|
|
|
local config, config_path, project_path = load_config_with_overlay(opts)
|
|
io.stderr:write(("aish: loaded config from %s\n"):format(config_path))
|
|
if project_path then
|
|
io.stderr:write(("aish: project config: %s (overlaid on %s)\n")
|
|
:format(project_path, config_path))
|
|
end
|
|
|
|
if opts.prompt then
|
|
run_one_shot(config, opts.prompt)
|
|
return
|
|
end
|
|
|
|
local repl = require("repl")
|
|
repl.run(config)
|
|
end
|
|
|
|
main(arg)
|