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:
355 files changed
+47028
No files matched your search
@@ -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
@@ -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"
|
||||
@@ -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}`)
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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`)
|
||||
@@ -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(", ")}`)
|
||||
}
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user