-- 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 ] [--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 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)