LLeMbas CLI 1.0.0
ci / check (push) Waiting to run

The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a
link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64
and arm64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-09 21:59:03 +00:00
commit f9bad01ed7
355 files changed
+47028

No files matched your search

+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bun
// Builds the single-file `lembas` binary — for the host, or for another platform:
// bun run scripts/build.ts [outfile] [--target linux-arm64]
// Targets: linux-x64, linux-x64-baseline (x86-64 CPUs without AVX2), linux-arm64, and the -musl
// variants; Bun also knows darwin-* and windows-*, which LLeMbas CLI does not support yet.
// Another platform needs OpenTUI's native package for it in node_modules: Bun skips the ones for
// other machines, so fetch them with `bun install --frozen-lockfile --os=linux --cpu=arm64` (the
// lockfile already lists them; nothing in the repository changes).
import type { BunPlugin } from "bun"
import solidPlugin from "@opentui/solid/bun-plugin"
import pkg from "../package.json"
const args = process.argv.slice(2)
const at = args.indexOf("--target")
const target = at >= 0 ? args.splice(at, 2)[1] : undefined
if (target !== undefined && !/^(linux|darwin|windows)-(x64|arm64)(-musl)?(-baseline)?$/.test(target)) {
console.error(`--target ${target}: expected e.g. linux-arm64, linux-x64, linux-x64-baseline, linux-x64-musl`)
process.exit(2)
}
const outfile = args[0] ?? "dist/lembas"
// -baseline is the CPU level of Bun itself; OpenTUI's library is the same one.
const [os, arch, libc] = (target ?? `${process.platform}-${process.arch}`).replace(/-baseline$/, "").split("-")
// OpenTUI imports the native library of whichever platform it runs on, so the bundle would carry
// every variant found in node_modules. Only the target's goes in; the others stay out.
const nativeOnly: BunPlugin = {
name: "opentui-native-for-target",
setup(build) {
const want = `@opentui/core-${os === "windows" ? "win32" : os}-${arch}${libc ? `-${libc}` : ""}`
build.onResolve({ filter: /^@opentui\/core-(linux|darwin|win32)-/ }, (a) => (a.path === want || a.path === `${want}-musl` ? undefined : { path: a.path, external: true }))
void Bun.resolveSync(want, process.cwd()) // fail here, by name, when it is not installed
},
}
// A release build (HEAD exactly at the tag vX.Y.Z) reports X.Y.Z; anything else reports
// X.Y.Z+<commit>, so a tester can tell which build they have. An export without .git (a deploy
// script may build one) passes the commit in LEMBAS_BUILD.
function buildId(): string {
const given = process.env.LEMBAS_BUILD
if (given !== undefined) return given
const r = Bun.spawnSync(["git", "describe", "--tags", "--always", "--dirty"], { stdout: "pipe", stderr: "ignore" })
return r.exitCode === 0 ? r.stdout.toString().trim() : ""
}
const id = buildId()
const version = id === `v${pkg.version}` || id === "" ? pkg.version : `${pkg.version}+${id.replace(/^v[\d.]+-\d+-/, "")}`
const result = await Bun.build({
entrypoints: ["./src/cli.ts"],
tsconfig: "./tsconfig.json",
plugins: [solidPlugin, nativeOnly],
format: "esm",
minify: true,
compile: {
outfile,
...(target ? { target: `bun-${target}` as Bun.Build.CompileTarget } : {}),
autoloadBunfig: false,
autoloadDotenv: false,
// --use-system-ca: a root installed in the OS store (a private CA) is trusted, as OpenCode does.
execArgv: [`--user-agent=lembas-cli/${version}`, "--use-system-ca", "--"],
},
define: {
LEMBAS_VERSION: JSON.stringify(version),
// Reserved: an instance a build could offer on its first-run screen. Empty, and read by
// nothing yet — rule 1, nothing predefined, holds for every build this script makes.
LEMBAS_DEFAULT_INSTANCE: JSON.stringify(""),
},
})
for (const log of result.logs) console.error(log)
if (!result.success) process.exit(1)
console.log(`built ${outfile} (${version}${target ? `, ${target}` : ""})`)
Executable
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# The gate every change passes, here or on a runner: types, tests, the compiled binary in a PTY,
# the shell scripts, and the history for secrets. Fetches the tested Bun when there is none.
set -euo pipefail
cd "$(dirname "$0")/.."
step() { printf '\n== %s ==\n' "$*"; }
if ! command -v bun >/dev/null 2>&1; then
if [ -x "$HOME/.bun/bin/bun" ]; then
PATH="$HOME/.bun/bin:$PATH"
else
# The version install.sh fetches, checked against the release's own SHA256SUMS.
version="$(sed -n 's/^BUN_VERSION=//p' install.sh)"
case "$(uname -m)" in
x86_64) arch=x64; grep -qw avx2 /proc/cpuinfo 2>/dev/null || arch=x64-baseline ;;
aarch64 | arm64) arch=aarch64 ;;
*) echo "ci.sh: no Bun for $(uname -m)" >&2; exit 1 ;;
esac
step "Bun $version (bun-linux-$arch)"
dir="${RUNNER_TEMP:-$(mktemp -d)}/bun-$version"
mkdir -p "$dir"
base="https://github.com/oven-sh/bun/releases/download/bun-v$version"
curl -fsSL -o "$dir/bun.zip" "$base/bun-linux-$arch.zip"
curl -fsSL -o "$dir/SHASUMS256.txt" "$base/SHASUMS256.txt"
# The SHA-256 install.sh pins for this version, not the list downloaded beside the zip.
want="$(sed -n "s/^BUN_SHA256_$(echo "$arch" | tr 'a-z-' 'A-Z_')=//p" install.sh)"
[ -n "$want" ] && (cd "$dir" && echo "$want bun.zip" | sha256sum -c --quiet -)
unzip -q -o "$dir/bun.zip" -d "$dir"
PATH="$dir/bun-linux-$arch:$PATH"
fi
fi
bun --version
step "dependencies"
bun install --frozen-lockfile
step "types"
bun x tsc --noEmit # `bun x`: the release zip CI fetches has no bunx link
step "tests"
bun test
step "the compiled binary (smoke)"
bun run smoke
step "licences of what the binary contains"
bun run licenses --check
# On a CI runner (CI set) a missing linter or scanner fails the run; by hand, it is said and skipped.
missing() {
if [ -n "${CI:-}" ]; then echo "ci.sh: $1 is not installed on this runner" >&2; exit 1; fi
echo "(no $1 here: $2)"
}
if command -v shellcheck >/dev/null 2>&1; then
step "shell scripts"
shellcheck install.sh get.sh scripts/*.sh
else
missing shellcheck "the shell scripts were not linted"
fi
if command -v gitleaks >/dev/null 2>&1; then
step "secrets in the history"
gitleaks detect --source . --log-opts=--all --redact --no-banner
else
missing gitleaks "the history was not scanned"
fi
step "all passed"
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bun
// The files a Release carries: one binary per supported platform, gzipped (100 MB → 40), the
// installer, get.sh, SHA256SUMS over them all, and SHA256SUMS.sig — the release key's signature,
// which get.sh and the updater check. Run from a clean checkout at the release tag:
// bun run dist -- --sign ~/.ssh/projecthor-release → dist/release/
// (The path is where the release key is kept on the machine that signs; it is the key built in as
// RELEASE_KEYS, and it signs in the namespace lembas-release.)
// (--sign KEY with a key of your own for your own builds; then set update.public_key to its .pub.)
// The arm64 build needs OpenTUI's arm64 library: bun install --frozen-lockfile --os=linux --cpu=arm64
import { createHash } from "node:crypto"
import { copyFileSync, mkdirSync, readFileSync, rmSync } from "node:fs"
import { join } from "node:path"
import pkg from "../package.json"
import { RELEASE_KEYS, SIG_NAMESPACE } from "../src/update/index.ts"
import { verifySshSig } from "../src/update/sshsig.ts"
export const TARGETS = ["linux-x64", "linux-x64-baseline", "linux-arm64"] as const
const root = join(import.meta.dir, "..")
const out = join(root, "dist", "release")
const dirty = Bun.spawnSync(["git", "status", "--porcelain", "--untracked-files=no"], { cwd: root, stdout: "pipe" }).stdout.toString().trim()
if (dirty && !process.argv.includes("--dirty")) {
console.error(`dist: the checkout has uncommitted changes — release files come from a commit (--dirty to build anyway)\n${dirty}`)
process.exit(1)
}
rmSync(out, { recursive: true, force: true })
mkdirSync(out, { recursive: true })
const files: string[] = []
for (const t of TARGETS) {
const name = `lembas-${t}`
const r = Bun.spawnSync(["bun", "run", "scripts/build.ts", join(out, name), "--target", t], { cwd: root, stdout: "inherit", stderr: "inherit" })
if (r.exitCode !== 0) process.exit(r.exitCode ?? 1)
await Bun.write(join(out, `${name}.gz`), Bun.gzipSync(readFileSync(join(out, name)), { level: 9 }))
files.push(`${name}.gz`)
}
for (const f of ["install.sh", "get.sh", "LICENSES.txt"]) {
copyFileSync(join(root, f), join(out, f))
files.push(f)
}
// The first line names the version: it is signed with the rest, so an old release's files cannot
// be passed off under a newer tag. (sha256sum -c skips it as a comment.)
const sums = `# lembas ${pkg.version}\n` + files.map((f) => `${createHash("sha256").update(readFileSync(join(out, f))).digest("hex")} ${f}`).join("\n") + "\n"
await Bun.write(join(out, "SHA256SUMS"), sums)
// The signature: made with ssh-keygen, and checked here the way the updater will check it.
const signIdx = process.argv.indexOf("--sign")
const key = signIdx > 0 ? process.argv[signIdx + 1] : undefined
if (key) {
const r = Bun.spawnSync(["ssh-keygen", "-q", "-Y", "sign", "-f", key.replace(/^~(?=\/)/, process.env.HOME ?? "~"), "-n", SIG_NAMESPACE, join(out, "SHA256SUMS")], { stdout: "inherit", stderr: "inherit" })
if (r.exitCode !== 0) process.exit(1)
const pub = readFileSync(`${key.replace(/^~(?=\/)/, process.env.HOME ?? "~")}.pub`, "utf8")
verifySshSig(readFileSync(join(out, "SHA256SUMS")), readFileSync(join(out, "SHA256SUMS.sig"), "utf8"), SIG_NAMESPACE, [pub])
const ours = RELEASE_KEYS.some((k) => k.split(/\s+/)[1] === pub.split(/\s+/)[1])
console.log(`SHA256SUMS.sig — signed by ${pub.trim().split(/\s+/).slice(2).join(" ") || "the key"}${ours ? " (the release key built into LLeMbas CLI)" : " (NOT the built-in release key: users need update.public_key)"}`)
} else console.warn("dist: no --sign KEY — this release is unsigned, and neither get.sh nor the updater will install it")
// What runs here is checked to run: the x64 builds on an x64 machine.
for (const t of TARGETS) {
if (!t.startsWith(`linux-${process.arch}`)) continue
const v = Bun.spawnSync([join(out, `lembas-${t}`), "--version"], { stdout: "pipe" })
if (v.exitCode !== 0) {
console.error(`dist: lembas-${t} does not run here`)
process.exit(1)
}
console.log(`lembas-${t} --version → ${v.stdout.toString().trim()}`)
}
// The plain binaries stay beside them for a look; only what SHA256SUMS lists is published.
console.log(`\n${out} — publish these (and SHA256SUMS.sig):\n${sums}`)
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bun
// Drive the TUI in a real PTY against a scripted fake endpoint and print the screens.
// bun scripts/drive.ts [cols] [rows] [--bin path] (development aid; smoke.ts asserts)
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { Terminal } from "@xterm/headless"
import { Unicode11Addon } from "@xterm/addon-unicode11"
import { delta, fakeProvider, toolCall, usage } from "../tests/fake-provider.ts"
export interface Driver {
screen(): string[]
/** Rows that are continuations of the row above: something was wider than the terminal. */
wrapped(): number[]
type(s: string): Promise<void>
key(seq: string): Promise<void>
waitFor(text: string, ms?: number): Promise<boolean>
close(): Promise<number>
/** The terminal's modes as the program left them: mouse reporting, alternate screen, cursor. */
modes(): { mouse: string; altScreen: boolean; cursorHidden: boolean }
}
export async function drive(opts: { cols: number; rows: number; bin?: string; home: string; cwd: string; args?: string[] }): Promise<Driver> {
const cmd = opts.bin ? resolve(opts.bin) : `bun --config=${resolve(import.meta.dir, "../bunfig.toml")} ${resolve(import.meta.dir, "../src/cli.ts")}`
const p = Bun.spawn(["script", "-qfec", `stty cols ${opts.cols} rows ${opts.rows}; exec ${cmd} ${(opts.args ?? []).join(" ")}`, "/dev/null"], {
cwd: opts.cwd,
env: { ...process.env, LEMBAS_HOME: opts.home, TERM: "xterm-256color" },
stdin: "pipe",
stdout: "pipe",
})
const term = new Terminal({ cols: opts.cols, rows: opts.rows, allowProposedApi: true })
// Emoji are 2 columns in real terminals and in OpenTUI; xterm defaults to Unicode 6 widths.
term.loadAddon(new Unicode11Addon())
term.unicode.activeVersion = "11"
const reader = (async () => {
for await (const c of p.stdout) await new Promise<void>((ok) => term.write(c, ok))
})()
const screen = () => Array.from({ length: opts.rows }, (_, y) => term.buffer.active.getLine(y)?.translateToString(true) ?? "")
const send = async (s: string) => {
p.stdin.write(s)
await p.stdin.flush()
}
return {
screen,
wrapped: () => Array.from({ length: opts.rows }, (_, y) => y).filter((y) => term.buffer.active.getLine(y)?.isWrapped),
async type(s) {
for (const ch of s) {
await send(ch)
await Bun.sleep(8)
}
},
key: send,
async waitFor(text, ms = 8000) {
const until = Date.now() + ms
while (Date.now() < until) {
if (screen().some((l) => l.includes(text))) return true
await Bun.sleep(50)
}
return false
},
modes: () => ({ mouse: term.modes.mouseTrackingMode, altScreen: term.buffer.active.type === "alternate", cursorHidden: (term as unknown as { _core: { coreService: { isCursorHidden: boolean } } })._core.coreService.isCursorHidden }),
async close() {
const code = await Promise.race([p.exited, Bun.sleep(4000).then(() => (p.kill(), -1))])
await reader
return code
},
}
}
export function fakeHome(script: Parameters<typeof fakeProvider>[0]) {
const fake = fakeProvider(script)
const home = mkdtempSync(join(tmpdir(), "lembas-drive-"))
mkdirSync(join(home, ".config/lembas"), { recursive: true })
writeFileSync(
join(home, ".config/lembas/connections.yaml"),
`connections:\n fake:\n dialect: openai-chat\n base_url: ${fake.url}\n models:\n coder: { context: 32768, efforts: [low, high], effort: low }\n other: { context: 8192 }\n`,
{ mode: 0o600 },
)
writeFileSync(join(home, ".config/lembas/config.yaml"), "model: fake/coder\nupdate: { auto: off }\n")
const cwd = mkdtempSync(join(tmpdir(), "lembas-drive-proj-"))
Bun.spawnSync(["git", "init", "-q", cwd])
writeFileSync(join(cwd, "hello.txt"), "hello world\n")
return { fake, home, cwd }
}
if (import.meta.main) {
const cols = Number(process.argv[2] ?? 100)
const rows = Number(process.argv[3] ?? 30)
const binAt = process.argv.indexOf("--bin")
const { fake, home, cwd } = fakeHome([
{ chunks: [delta({ reasoning_content: "The user wants a greeting; I will check the file first." }), delta({ content: "Let me look." }), toolCall(0, "c1", "bash", '{"command":"cat hello.txt","description":"Show the file"}'), toolCall(1, "c2", "bash", '{"command":"echo hi > out.txt","description":"Write a file"}'), usage(9000, 50)] },
{ chunks: [delta({ content: "## Done\n\nThe file says **hello world**. I wrote `out.txt` as well.\n\n- one\n- two\n" }), usage(12000, 80)] },
])
const d = await drive({ cols, rows, home, cwd, bin: binAt > 0 ? process.argv[binAt + 1] : undefined })
const show = (label: string) => console.log(`\n=== ${label}\n` + d.screen().map((l) => l.trimEnd()).join("\n"))
await d.waitFor("☤ LLeMbas")
show("start")
await d.type("greet me")
await d.key("\r")
await d.waitFor("Allow once")
show("permission")
await d.key("y")
await d.waitFor("Done", 8000)
await Bun.sleep(300)
show("answer")
await d.type("/mo")
await Bun.sleep(300)
show("suggestions")
await d.key("\x15") // ctrl+u clears? fall back to backspaces
for (let i = 0; i < 3; i++) await d.key("\x7f")
await d.key("\x03")
await d.key("\x03")
console.log("exit", await d.close())
fake.stop()
}
+35
View File
@@ -0,0 +1,35 @@
// The harness spec's conformance cases (harness/conformance/*.json), shared with LLeMbas.
// bun run harness record fill in the `expect` of every case that has none, from LLeMbas CLI
// bun run harness record --all record every case again, and say which ones changed
// Recording is how a new case gets its answer; a changed answer is a change of behaviour, which
// the spec's version and both projects must then follow.
import { readdirSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { AREAS } from "../tests/conformance.ts"
const dir = join(import.meta.dir, "..", "harness", "conformance")
const [cmd, flag] = process.argv.slice(2)
if (cmd !== "record") {
console.error("usage: bun run harness record [--all]")
process.exit(2)
}
let filled = 0
let changed = 0
for (const f of readdirSync(dir).filter((x) => x.endsWith(".json")).sort()) {
const area = f.replace(/\.json$/, "")
const run = AREAS[area]
if (!run) throw new Error(`no runner for ${area}`)
const doc = JSON.parse(readFileSync(join(dir, f), "utf8")) as { about: string; cases: Record<string, unknown>[] }
for (const c of doc.cases) {
if ("expect" in c && flag !== "--all") continue
const { expect: before, ...input } = c
const now = run(input)
if ("expect" in c && JSON.stringify(before) !== JSON.stringify(now)) {
changed++
console.log(`${area}: ${JSON.stringify(input)}\n was ${JSON.stringify(before)}\n now ${JSON.stringify(now)}`)
} else if (!("expect" in c)) filled++
c.expect = now
}
writeFileSync(join(dir, f), JSON.stringify(doc, null, 2) + "\n")
}
console.log(`recorded: ${filled} new, ${changed} changed`)
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bun
// LICENSES.txt: every licence a release binary carries — LLeMbas CLI's, each npm package the bundle
// actually contains (found by building it, not by reading the lockfile), OpenTUI's native
// libraries, and the Bun runtime the binary is compiled with (third_party/bun-<version>-LICENSE.md,
// copied from Bun's repository at that tag).
// bun run licenses → writes LICENSES.txt
// bun run licenses --check → fails if LICENSES.txt is not what it would write (CI)
import solidPlugin from "@opentui/solid/bun-plugin"
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
const root = join(import.meta.dir, "..")
const BUN = "1.4.2"
const out = mkdtempSync(join(tmpdir(), "lembas-licenses-"))
const r = await Bun.build({
entrypoints: [join(root, "src/cli.ts")],
tsconfig: join(root, "tsconfig.json"),
target: "bun",
outdir: out,
metafile: true,
// Every native package but x64 glibc's left out of the build: they need not be installed (a
// runner has only its own), and the arm64 one carries the same notices.
plugins: [solidPlugin, { name: "other-platforms", setup: (b) => void b.onResolve({ filter: /^@opentui\/core-(?!linux-x64$)/ }, (a) => ({ path: a.path, external: true })) }],
} as Parameters<typeof Bun.build>[0])
rmSync(out, { recursive: true, force: true })
const inputs = Object.keys((r as unknown as { metafile: { inputs: Record<string, unknown> } }).metafile.inputs)
// Only the x64/arm64 glibc builds are released; their native packages are the same but for the library.
const pkgs = [...new Set(inputs.map((f) => /node_modules\/((?:@[^/]+\/)?[^/]+)/.exec(f)?.[1]).filter((p): p is string => !!p))].sort()
const licenceFiles = (dir: string) => readdirSync(dir).filter((f) => /^(licen[cs]e|copying|notice|patents|authors)/i.test(f)).sort()
const rule = "=".repeat(78)
const parts: string[] = [
`LLeMbas CLI — the licences of what its release binaries contain.\nGenerated by scripts/licenses.ts; \`lembas --licenses\` prints this.\n`,
`${rule}\nLLeMbas CLI\n${rule}\n${readFileSync(join(root, "LICENSE"), "utf8").trim()}\n\nLLeMbas CLI builds on OpenCode, Hermes Agent and LLeMbas; see THIRD_PARTY.md in its source.`,
]
for (const p of pkgs) {
const dir = join(root, "node_modules", p)
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")) as { version: string; license?: string; homepage?: string }
const files = licenceFiles(dir)
const native = p.startsWith("@opentui/core-linux") ? "\n(libopentui.so: OpenTUI's native library, with Ghostty's terminal code, Little CMS, libwebp, stb, Wuffs and Meta's Yoga layout compiled in; their notices follow.)" : ""
parts.push(`${rule}\n${p} ${pkg.version} — ${pkg.license ?? "see below"}${native}\n${rule}\n${files.map((f) => `--- ${f} ---\n${readFileSync(join(dir, f), "utf8").trim()}`).join("\n\n") || "(no licence file in the package)"}`)
}
parts.push(
`${rule}\nYoga (in libopentui.so) — MIT\n${rule}\nCopyright (c) Meta Platforms, Inc. and affiliates. Licensed under the MIT license: https://github.com/facebook/yoga/blob/main/LICENSE`,
`${rule}\nTree-sitter grammars (OpenTUI's syntax highlighting: javascript, typescript, markdown, zig) — MIT\n${rule}\nhttps://github.com/tree-sitter/tree-sitter-javascript, https://github.com/tree-sitter/tree-sitter-typescript, https://github.com/tree-sitter-grammars/tree-sitter-markdown, https://github.com/tree-sitter-grammars/tree-sitter-zig — each MIT.`,
`${rule}\nBun ${BUN} (the runtime compiled into the binary)\n${rule}\n${readFileSync(join(root, `third_party/bun-${BUN}-LICENSE.md`), "utf8").trim()}\n\nRelinking: LLeMbas CLI's whole source is public, so the binary can be rebuilt against a modified Bun (and so a modified JavaScriptCore) with \`bun run build\` — https://github.com/oven-sh/bun at bun-v${BUN}, https://github.com/oven-sh/WebKit for its JavaScriptCore.`,
)
const text = `${parts.join("\n\n")}\n`
const file = join(root, "LICENSES.txt")
if (process.argv.includes("--check")) {
if (!existsSync(file) || readFileSync(file, "utf8") !== text) {
console.error("LICENSES.txt is not current: bun run licenses")
process.exit(1)
}
console.log(`LICENSES.txt is current (${pkgs.length} packages)`)
} else {
writeFileSync(file, text)
console.log(`LICENSES.txt: ${pkgs.length} packages — ${pkgs.join(", ")}`)
}
+9
View File
@@ -0,0 +1,9 @@
// Regenerate schema/*.json from src/config/schema.ts: bun run schema. A test fails while they differ.
import { join } from "node:path"
import { jsonSchemas, SCHEMA_FILES, schemaText } from "../src/config/jsonschema.ts"
const s = jsonSchemas()
for (const [name, file] of Object.entries(SCHEMA_FILES) as [keyof typeof SCHEMA_FILES, string][]) {
await Bun.write(join(import.meta.dir, "..", "schema", file), schemaText(s[name]))
console.log(`schema/${file}`)
}
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env bun
// Smoke test of the COMPILED binary — what `bun test` cannot see: that the bundle, OpenTUI's
// native library and bun:sqlite FTS5 all survive `bun build --compile`.
// bun run scripts/smoke.ts [path/to/binary]
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join, resolve } from "node:path"
import { delta, fakeProvider, toolCall, usage } from "../tests/fake-provider.ts"
import { drive, fakeHome } from "./drive.ts"
const bin = resolve(process.argv[2] ?? "dist/lembas")
const home = mkdtempSync(join(tmpdir(), "lembas-smoke-"))
const env = { ...process.env, LEMBAS_HOME: home }
let failed = 0
const check = (name: string, ok: boolean, detail = "") => {
console.log(`${ok ? "✓" : "✗"} ${name}${ok ? "" : " " + detail}`)
if (!ok) failed++
}
const run = (args: string[], cwd = home) => {
const r = Bun.spawnSync([bin, ...args], { cwd, env, stdout: "pipe", stderr: "pipe" })
return { code: r.exitCode, out: r.stdout.toString(), err: r.stderr.toString() }
}
// 1. --version
const v = run(["--version"])
check("--version", v.code === 0 && /^\d+\.\d+\.\d+/.test(v.out), v.err)
// 2. a headless run against a fake endpoint, with the session store on (FTS5 inside the binary)
const fake = fakeProvider([{ chunks: [delta({ content: "smoke ok" }), usage(5, 2)] }])
mkdirSync(join(home, ".config/lembas"), { recursive: true })
writeFileSync(
join(home, ".config/lembas/connections.yaml"),
`connections:\n fake:\n dialect: openai-chat\n base_url: ${fake.url}\n models:\n m: {}\n`,
{ mode: 0o600 },
)
writeFileSync(join(home, ".config/lembas/config.yaml"), "model: fake/m\nupdate: { auto: off }\n")
const r = await new Promise<{ code: number; out: string; err: string }>((res) => {
const p = Bun.spawn([bin, "run", "say hi"], { cwd: home, env, stdout: "pipe", stderr: "pipe" })
Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]).then(([out, err, code]) => res({ code, out, err }))
})
fake.stop()
check("run → streamed answer", r.code === 0 && r.out === "smoke ok\n", JSON.stringify(r))
const s = run(["sessions"])
check("sessions → recorded (sqlite + FTS5 in the binary)", s.code === 0 && s.out.includes("fake/m"), s.err || s.out)
// 2b. serve --stdio: the ACP agent inside the binary answers initialize, opens a session and
// streams a reply — the code the LLeMbas link runs too.
{
const f = fakeProvider([{ chunks: [delta({ content: "acp ok" })] }])
writeFileSync(
join(home, ".config/lembas/connections.yaml"),
`connections:\n fake:\n dialect: openai-chat\n base_url: ${f.url}\n models:\n m: {}\n`,
{ mode: 0o600 },
)
const p = Bun.spawn([bin, "serve", "--stdio"], { cwd: home, env, stdin: "pipe", stdout: "pipe", stderr: "pipe" })
const lines: any[] = []
const reader = (async () => {
let buf = ""
for await (const chunk of p.stdout as unknown as AsyncIterable<Uint8Array>) {
buf += new TextDecoder().decode(chunk)
let nl: number
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim()
buf = buf.slice(nl + 1)
if (line) lines.push(JSON.parse(line))
}
}
})()
const send = (m: unknown) => p.stdin.write(JSON.stringify(m) + "\n")
const answer = async (id: number) => {
for (let i = 0; i < 300; i++) {
const hit = lines.find((l) => l.id === id)
if (hit) return hit
await Bun.sleep(20)
}
return undefined
}
send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: 1, clientCapabilities: {} } })
const init = await answer(1)
send({ jsonrpc: "2.0", id: 2, method: "session/new", params: { cwd: home, mcpServers: [] } })
const session = await answer(2)
send({ jsonrpc: "2.0", id: 3, method: "session/prompt", params: { sessionId: session?.result?.sessionId, prompt: [{ type: "text", text: "hi" }] } })
const done = await answer(3)
const text = lines.filter((l) => l.method === "session/update" && l.params.update.sessionUpdate === "agent_message_chunk").map((l) => l.params.update.content.text).join("")
p.stdin.end()
await Promise.race([p.exited, Bun.sleep(3000)])
p.kill()
await reader.catch(() => {})
f.stop()
check("serve --stdio → ACP session streams a reply", init?.result?.protocolVersion === 1 && done?.result?.stopReason === "end_turn" && text === "acp ok", JSON.stringify({ init, session, done, text }))
}
// 3. the real TUI in a real PTY, read back through a headless xterm, at two sizes
for (const [cols, rows] of [
[120, 40],
[80, 24],
] as const) {
const { fake: f, home: h, cwd } = fakeHome([
{ chunks: [delta({ content: "Let me check." }), toolCall(0, "c1", "bash", '{"command":"echo smoke > out.txt","description":"Write out.txt"}'), usage(9000, 40)] },
{ chunks: [delta({ content: "## Done\n\nWrote **out.txt**." }), usage(12000, 60)] },
])
const d = await drive({ cols, rows, bin, home: h, cwd })
// A fresh directory opens with the first-run question; trust it (the default), then work.
const firstRun = await d.waitFor("Trust this directory?")
await d.key("\r")
const drawn = await d.waitFor("☤ LLeMbas")
await d.type("make a file")
await d.key("\r")
const asked = await d.waitFor("Allow once")
await d.key("y")
const answered = await d.waitFor("Wrote out.txt.")
await Bun.sleep(200)
const screen = d.screen()
const wrapped = d.wrapped()
await d.key("\x03")
await d.key("\x03")
const code = await d.close()
f.stop()
const file = await Bun.file(join(cwd, "out.txt")).text().catch(() => "")
check(
`TUI ${cols}x${rows}: first run → prompt → approval → answer → quit`,
firstRun && drawn && asked && answered && file === "smoke\n" && code === 0,
`firstRun=${firstRun} drawn=${drawn} asked=${asked} answered=${answered} file=${JSON.stringify(file)} exit=${code}\n${screen.join("\n")}`,
)
check(`TUI ${cols}x${rows}: nothing wider than the terminal`, wrapped.length === 0, `wrapped rows: ${wrapped.join(", ")}`)
check(`TUI ${cols}x${rows}: status bar on the last row`, (screen[rows - 1] ?? "").includes("manual · fake/coder"), screen[rows - 1] ?? "")
}
// 4. a fresh install: no model yet. A screen that says what to add (not an exception with the
// terminal left in mouse-reporting mode); r once it is added opens the session; the terminal is
// given back as it was found.
{
const { fake: f, home: h, cwd } = fakeHome([{ chunks: [delta({ content: "hello after setup" }), usage(5, 2)] }])
const cfgDir = join(h, ".config/lembas")
const connections = await Bun.file(join(cfgDir, "connections.yaml")).text()
await Bun.write(join(cfgDir, "connections.yaml"), "connections: {}\n")
await Bun.write(join(cfgDir, "config.yaml"), "mode: manual\nupdate: { auto: off }\n")
const d = await drive({ cols: 100, rows: 30, bin, home: h, cwd })
await d.waitFor("Trust this directory?")
await d.key("\r")
const setup = await d.waitFor("no model to talk to yet")
await Bun.write(join(cfgDir, "connections.yaml"), connections)
await Bun.write(join(cfgDir, "config.yaml"), "model: fake/coder\nupdate: { auto: off }\n")
// The screen opens on the sign-in, its address focused: ctrl+r tries again from there.
const focused = await d.waitFor("Log in to a LLeMbas instance")
await d.key("\x12")
const opened = await d.waitFor("fake/coder")
await d.type("hi")
await d.key("\r")
const answered = await d.waitFor("hello after setup")
await d.key("\x03")
await d.key("\x03")
const code = await d.close()
const modes = d.modes()
f.stop()
check("fresh install: no model → setup screen on the sign-in; ctrl+r after adding one → a session", setup && focused && opened && answered && code === 0, `setup=${setup} focused=${focused} opened=${opened} answered=${answered} exit=${code}\n${d.screen().join("\n")}`)
check("fresh install: the terminal is given back (mouse reporting off, normal screen)", modes.mouse === "none" && !modes.altScreen, JSON.stringify(modes))
}
// 5. /reload: the same process starts again (execv) on the same session — the conversation is
// there, the terminal is the TUI's again, and quitting leaves it as it was found.
{
const { fake: f, home: h, cwd } = fakeHome([
{ chunks: [delta({ content: "first answer" }), usage(5, 2)] },
{ chunks: [delta({ content: "second answer" }), usage(9, 2)] },
])
const d = await drive({ cols: 100, rows: 30, bin, home: h, cwd })
await d.waitFor("Trust this directory?")
await d.key("\r")
await d.waitFor("fake/coder")
await d.type("hello")
await d.key("\r")
const answered = await d.waitFor("first answer")
await d.type("/reload")
await d.key("\r")
// The new program draws its welcome box again, with the conversation loaded under it.
await Bun.sleep(300)
const back = await d.waitFor("☤ LLeMbas")
// The new program's next request carries the conversation from before the reload.
await d.type("again")
await d.key("\r")
await d.waitFor("second answer")
const history = JSON.stringify(f.requests[1]?.messages ?? [])
const kept = history.includes("hello") && history.includes("first answer")
const screen = d.screen()
await d.key("\x03")
await d.key("\x03")
const code = await d.close()
const modes = d.modes()
f.stop()
check("/reload: started again, back on the same session", answered && back && kept && code === 0, `answered=${answered} back=${back} kept=${kept} exit=${code}\n${screen.join("\n")}`)
check("/reload: the terminal is given back on quit", modes.mouse === "none" && !modes.altScreen, JSON.stringify(modes))
}
process.exit(failed ? 1 : 0)