Files
aish/main.lua
T
marfrit 34b465d6dc main: project-overlay loader (Phase 9 commit #2)
Wires the project-overlay step around the existing load_config.
Activates only when a trusted .aish.lua is found in/above cwd.

Changes:

- _find_project_config() walks libc.getcwd() up to $HOME, returning
  first .aish.lua found. R1 fix folded: proper-prefix check (`dir ==
  home OR dir starts with home .. "/"`) avoids the false positive
  where /home/user2 matches HOME=/home/user via byte prefix.

- _trust_file_path() resolves via $AISH_TRUST_FILE env override,
  else ~/.aish/trusted-projects. Plan-time decision per N3.

- _check_and_maybe_prompt(project_path, history) — calls
  history._sha256_file ONCE; routes through history.is_trusted; on
  miss prompts via rl.readline; on accept persists via
  history.add_trusted. A8 mitigation: if rl.readline fails to load,
  decline silently (no io.read fallback that would consume stdin).

- load_config_with_overlay(opts):
    * Calls existing load_config; seeds sources={k="user", ...}
    * Walks for .aish.lua; if found:
      - In opts.prompt mode (-p, R2): skip the prompt entirely;
        only PRE-TRUSTED overlays load. Avoids io consuming the
        piped stdin that -p will read for context.
      - Else: interactive trust check + prompt.
    * On accept + successful dofile: shallow-merge top-level keys
      ONTO user config; update sources[k]="project" for overlapping.
    * R3: embeds sources on cfg._sources for repl.lua's :config
      show meta to read. No global.
    * Returns (cfg, user_path, project_path | nil).

- main() now calls load_config_with_overlay; on project layer
  active, emits the "[aish] project config: <path> (overlaid on
  <user>)" status line per A4 (AFTER the user-config status).

E2E verified across 4 scenarios with AISH_TRUST_FILE + isolated HOME:
  1. Decline -> overlay skipped; user config active.
  2. Accept -> overlay loaded; project_model active; status line
     "[aish] project config: ... (overlaid on ...)" visible.
  3. Re-startup -> NO prompt (cached via sha); overlay loaded
     transparently. R4 single-sha-call confirmed.
  4. -p mode with untrusted overlay -> skipped silently; piped
     stdin preserved for run_one_shot.

Regression: test_safety 87/87, test_router_model 31/31, repl loads.

Commit #3 lands :config show + HELP next; commit #4 the config
template comment + status -> Implement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 23:48:22 +00:00

268 lines
9.2 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.
-- 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.
package.path = "./?.lua;./vendor/?.lua;" .. package.path
local USAGE = [[
aish — AI-augmented conversational shell.
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>
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)