LLeMbas CLI 1.0.0
ci / check (push) Canceled after 0s

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

+274
View File
@@ -0,0 +1,274 @@
// Updating LLeMbas CLI itself. On start (TUI, update.auto: install) the newest release of the chosen
// channel is looked for — at most once an hour — and, when it is newer than what runs:
// 1. its SHA256SUMS is fetched with SHA256SUMS.sig, and the signature checked against the
// release key built in here (or update.public_key);
// 2. this machine's binary is fetched and checked against SHA256SUMS, unpacked, and asked its
// --version;
// 3. the running binary is kept as <binary>.prev and the new one renamed over it.
// The running process keeps the old file open and carries on; /reload starts the new one. Nothing
// is ever installed that is older than what runs, unless asked for by name (lembas update vX).
import { createHash } from "node:crypto"
import { accessSync, closeSync, constants, copyFileSync, existsSync, linkSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
import { basename, dirname, join } from "node:path"
import { paths } from "../config/paths.ts"
import type { Update } from "../config/schema.ts"
import { VERSION } from "../version.ts"
import { assetUrl, DEFAULT_SOURCE, fetcher, listReleases, resolveSource, type Fetcher, type Release, type Source } from "./source.ts"
import { verifySshSig } from "./sshsig.ts"
import { compareVersions, newestFor, parseVersion, versionText, type Version } from "./version.ts"
/** The keys a release must be signed with. More than one, so the key can be changed: a release
* signed with the old key carries the new one, and the one after is signed with the new. */
export const RELEASE_KEYS = ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDSZO3Byow7R3ZpOH3MCvpPIga2pZBzhfX5wXfNP1cLa projecthor-release"]
export const SIG_NAMESPACE = "lembas-release"
const CHECK_EVERY_MS = 60 * 60 * 1000
const LOCK_STALE_MS = 10 * 60 * 1000
export type Outcome =
| { kind: "current"; version: string }
| { kind: "available"; version: string }
| { kind: "installed"; version: string; previous: string }
| { kind: "skipped"; reason: string }
/** `security`: the release did not prove itself (signature, checksum) — said loudly. */
| { kind: "failed"; reason: string; security?: boolean; /** failing again and again: say so */ repeated?: boolean }
export interface UpdateOptions {
config?: Update
/** The version running (default: this build's). */
current?: string
/** The binary to replace (default: this one, when it is a compiled binary). */
target?: string
fetcher?: Fetcher
/** This machine's asset name (default: worked out). */
asset?: string
/** Where update.json and the lock live (default: the state directory). */
stateDir?: string
}
class Refused extends Error {}
/** This machine's release binary: x86-64 with AVX2, x86-64 without (baseline), or arm64. */
export function machineAsset(): string | undefined {
if (process.platform !== "linux") return undefined
if (process.arch === "arm64") return "lembas-linux-arm64"
if (process.arch !== "x64") return undefined
let cpu = ""
try {
cpu = readFileSync("/proc/cpuinfo", "utf8")
} catch {}
return /\bavx2\b/.test(cpu) ? "lembas-linux-x64" : "lembas-linux-x64-baseline"
}
/** The binary this process runs from — undefined when run from source (bun src/cli.ts). */
export function installedBinary(): string | undefined {
return Bun.main.startsWith("/$bunfs/") ? process.execPath : undefined
}
const sourceOf = (c?: Update): Source => resolveSource(c?.source ?? DEFAULT_SOURCE)
const keysOf = (c?: Update) => (c?.public_key === undefined ? RELEASE_KEYS : [c.public_key].flat())
/** The newest release of the configured channel, if it is newer than `current`. */
export async function check(o: UpdateOptions = {}): Promise<{ release: Release; version: Version } | Outcome> {
const current = parseVersion(o.current ?? VERSION)
if (!current) return { kind: "skipped", reason: `this is a build from source (${o.current ?? VERSION}); update it with get.sh or by rebuilding` }
let releases: Release[]
try {
const source = sourceOf(o.config)
releases = await listReleases(source, o.fetcher ?? fetcher(source))
} catch (e) {
return { kind: "failed", reason: (e as Error).message }
}
const newest = newestFor(releases, o.config?.channel ?? "stable")
if (!newest || compareVersions(newest.version, current) <= 0) return { kind: "current", version: versionText(current) }
return { release: newest, version: newest.version }
}
/** The signed list: file → SHA-256, and the version it names (`# lembas X.Y.Z`). */
function parseSums(text: string): { sums: Map<string, string>; version?: string } {
const sums = new Map<string, string>()
let version: string | undefined
for (const line of text.split("\n")) {
const v = /^# lembas (\S+)$/.exec(line.trim())
if (v) version ??= v[1]
const m = /^([0-9a-f]{64}) (\S+)$/.exec(line.trim())
if (!m) continue
if (sums.has(m[2]!)) throw new Refused(`SHA256SUMS lists ${m[2]} twice`)
sums.set(m[2]!, m[1]!)
}
return { sums, version }
}
/** Fetch, prove and put in place one release. The binary is replaced only once all of it is right. */
export async function install(release: Release, version: Version, o: UpdateOptions = {}): Promise<Outcome> {
const target = o.target ?? installedBinary()
if (!target) return { kind: "skipped", reason: "this is a build from source; update it with get.sh or by rebuilding" }
const asset = o.asset ?? machineAsset()
if (!asset) return { kind: "skipped", reason: `no release binary for ${process.platform}/${process.arch}` }
try {
accessSync(dirname(target), constants.W_OK)
accessSync(target, constants.W_OK)
} catch {
return { kind: "skipped", reason: `${target} is not writable by this user; install the update the way it was installed` }
}
let f: Fetcher
try {
f = o.fetcher ?? fetcher(sourceOf(o.config))
} catch (e) {
return { kind: "failed", reason: (e as Error).message }
}
const url = (name: string) => {
const u = assetUrl(release, name)
if (!u) throw new Refused(`release ${release.tag} has no ${name}`)
return u
}
const tmp = join(dirname(target), `.${basename(target)}.update-${process.pid}`)
try {
const sums = await f.bytes(url("SHA256SUMS"))
if (o.config?.verify !== "checksum") {
const sigUrl = assetUrl(release, "SHA256SUMS.sig")
const sig = sigUrl ? await f.text(sigUrl) : undefined
if (!sig) throw new Refused(`release ${release.tag} is not signed (no SHA256SUMS.sig) — not installed`)
try {
verifySshSig(sums, sig, SIG_NAMESPACE, keysOf(o.config))
} catch (e) {
throw new Refused(`release ${release.tag}: the signature of SHA256SUMS is not valid (${(e as Error).message}) — not installed`)
}
}
const list = parseSums(new TextDecoder().decode(sums))
// The signed list names its version: an old release's files under a newer tag are refused here,
// before anything is run. (Required where the list is signed.)
if (list.version !== versionText(version) && (list.version !== undefined || o.config?.verify !== "checksum"))
throw new Refused(`release ${release.tag} carries the files of ${list.version ?? "an unnamed version"} — not installed`)
const name = asset
const want = list.sums.get(`${name}.gz`)
if (!want) throw new Refused(`release ${release.tag}: SHA256SUMS lists no ${asset}.gz`)
const gz = await f.bytes(url(`${name}.gz`))
if (createHash("sha256").update(gz).digest("hex") !== want) throw new Refused(`release ${release.tag}: ${name}.gz does not match SHA256SUMS — not installed`)
const bin = Bun.gunzipSync(gz as Uint8Array<ArrayBuffer>)
rmSync(tmp, { force: true })
writeFileSync(tmp, bin, { mode: 0o755, flag: "wx" })
const ran = Bun.spawnSync([tmp, "--version"], { stdout: "pipe", stderr: "pipe", timeout: 20_000 })
const said = ran.stdout.toString().trim()
if (ran.exitCode !== 0) throw new Error(`the new binary does not run here (${ran.stderr.toString().trim().slice(0, 200) || `exit ${ran.exitCode}`})`)
if (said !== versionText(version)) throw new Refused(`release ${release.tag}'s binary says it is ${said || "nothing"} — not installed`)
// The one before stays as .prev: lembas update --rollback puts it back.
const prev = `${target}.prev`
rmSync(prev, { force: true })
try {
linkSync(target, prev)
} catch {
copyFileSync(target, prev)
}
renameSync(tmp, target)
return { kind: "installed", version: versionText(version), previous: o.current ?? VERSION }
} catch (e) {
return { kind: "failed", reason: (e as Error).message, security: e instanceof Refused }
} finally {
rmSync(tmp, { force: true })
}
}
/** One update at a time, across every session on this machine (and `lembas update`). A lock
* left by a process that died is moved aside under a name of its own before a new one is made, so
* two processes that both find it stale cannot both go ahead. */
export function lock(dir = paths.state): (() => void) | undefined {
mkdirSync(dir, { recursive: true })
const file = join(dir, "update.lock")
for (let i = 0; i < 2; i++) {
try {
closeSync(openSync(file, "wx"))
return () => rmSync(file, { force: true })
} catch {
try {
if (Date.now() - statSync(file).mtimeMs < LOCK_STALE_MS) return undefined
const aside = `${file}.stale-${process.pid}`
renameSync(file, aside)
rmSync(aside, { force: true })
} catch {}
}
}
return undefined
}
interface State {
checked?: number
/** Failed looks in a row, and the last reason: said once there are three. */
failures?: number
reason?: string
}
function readState(dir: string): State {
try {
return JSON.parse(readFileSync(join(dir, "update.json"), "utf8"))
} catch {
return {}
}
}
/** On start: look, at most once an hour, and install or announce per update.auto. */
export async function autoUpdate(o: UpdateOptions & { force?: boolean } = {}): Promise<Outcome> {
const mode = o.config?.auto ?? "install"
if (mode === "off") return { kind: "skipped", reason: "update.auto is off" }
// A build from source is updated with git, not from releases: nothing to look for.
if (!(o.target ?? installedBinary())) return { kind: "skipped", reason: "a build from source" }
const dir = o.stateDir ?? paths.state
if (!o.force && Date.now() - (readState(dir).checked ?? 0) < CHECK_EVERY_MS) return { kind: "skipped", reason: "checked within the hour" }
const unlock = lock(dir)
if (!unlock) return { kind: "skipped", reason: "another session is updating" }
try {
const before = readState(dir)
const save = (s: State) => writeFileSync(join(dir, "update.json"), JSON.stringify(s))
save({ ...before, checked: Date.now() })
let r: Outcome
try {
const found = await check(o)
r = "kind" in found ? found : mode === "notify" ? { kind: "available", version: versionText(found.version) } : await install(found.release, found.version, o)
} catch (e) {
r = { kind: "failed", reason: (e as Error).message }
}
// A failure now and then (no network) is not worth a word; updates failing again and again
// are — or a user could go without them for good and never know.
if (r.kind === "failed") {
const failures = (before.failures ?? 0) + 1
save({ checked: Date.now(), failures, reason: r.reason })
if (failures >= 3 && !r.security) return { ...r, reason: `updates have failed ${failures} times in a row: ${r.reason}`, repeated: true }
} else save({ checked: Date.now() })
return r
} finally {
unlock()
}
}
/** `lembas update vX.Y.Z`: that release, older or newer, on purpose. */
export async function installTag(tag: string, o: UpdateOptions = {}): Promise<Outcome> {
const version = parseVersion(tag)
if (!version) return { kind: "failed", reason: `${tag} is not a release tag (vX.Y.Z or vX.Y.Z-beta.N)` }
let source: Source
let f: Fetcher
try {
source = sourceOf(o.config)
f = o.fetcher ?? fetcher(source)
} catch (e) {
return { kind: "failed", reason: (e as Error).message }
}
const release = (await listReleases(source, f)).find((r) => parseVersion(r.tag) && compareVersions(parseVersion(r.tag)!, version) === 0)
if (!release) return { kind: "failed", reason: `no release ${tag} at the update source` }
return install(release, version, { ...o, fetcher: f })
}
/** Put <binary>.prev back (and keep the one it replaces as .prev: twice is a no-op). */
export function rollback(target = installedBinary()): string {
if (!target) throw new Error("this is a build from source; there is nothing to roll back")
const prev = `${target}.prev`
if (!existsSync(prev)) throw new Error(`no earlier version kept (${prev})`)
const ran = Bun.spawnSync([prev, "--version"], { stdout: "pipe", timeout: 20_000 })
if (ran.exitCode !== 0) throw new Error(`${prev} does not run`)
// Never a moment without a binary: the current one is linked aside, the old one renamed over it
// (atomic), and the aside becomes .prev.
const swap = `${target}.swap-${process.pid}`
rmSync(swap, { force: true })
linkSync(target, swap)
renameSync(prev, target)
renameSync(swap, prev)
return ran.stdout.toString().trim()
}
+115
View File
@@ -0,0 +1,115 @@
// Where releases are listed and fetched from: GitHub's or Gitea's (Forgejo's) releases API, or a
// plain web directory. LLeMbas CLI's own default is the public repository on GitHub; anyone can
// point `update.source` at a fork, a mirror, or a directory of their own builds.
import type { Update } from "../config/schema.ts"
import { runKeyCmd, substituteDeep } from "../config/substitute.ts"
import { tlsFor } from "../provider/http.ts"
import { VERSION } from "../version.ts"
export type Source = NonNullable<Update["source"]>
/** The public releases: the GitHub mirror of the LLeMbas organisation's repository. */
export const DEFAULT_SOURCE: Source = { type: "github", repo: "LLeMbas/LLeMbas-CLI" }
export interface Release {
tag: string
/** Asset name → where to download it (a forge's release). */
assets?: Record<string, string>
/** A static source's directory for this tag: every asset is `<dir>/<name>`. */
dir?: string
}
export const assetUrl = (r: Release, name: string): string | undefined => (r.dir ? `${r.dir}/${name}` : r.assets?.[name])
export interface Fetcher {
json(url: string): Promise<unknown>
bytes(url: string): Promise<Uint8Array>
/** Undefined when the file is not there (404); throws on anything else. */
text(url: string): Promise<string | undefined>
}
class HttpError extends Error {
constructor(
readonly status: number,
url: string,
) {
super(`${url}: HTTP ${status}`)
}
}
/** {env:}/{file:} in the source filled in (left until now: a missing token file is a failed
* update, not a failed start). */
export function resolveSource(source: Source): Source {
return substituteDeep(source, "update.source")
}
export function fetcher(source: Source, timeoutMs = 30_000): Fetcher {
const headers: Record<string, string> = { "user-agent": `lembas-cli/${VERSION}` }
const token = source.token ? source.token : source.key_cmd ? runKeyCmd(source.key_cmd, "update.source.key_cmd") : undefined
if (token) headers.authorization = source.type === "gitea" ? `token ${token}` : `Bearer ${token}`
if (source.type === "github") headers.accept = "application/vnd.github+json"
const tls = tlsFor(source)
// The token goes only where the source is: an API that names another host for its downloads (a
// forge with a wrong ROOT_URL, plain http) does not get it.
const own = new Set([source.url ? new URL(source.url).origin : undefined, source.type === "github" ? new URL(source.url ?? "https://api.github.com").origin : undefined, source.type === "github" ? "https://github.com" : undefined].filter(Boolean) as string[])
const get = async (url: string, accept?: string) => {
const h: Record<string, string> = { ...headers, ...(accept ? { accept } : {}) }
if (!own.has(new URL(url).origin)) delete h.authorization
const r = await fetch(url, { headers: h, tls, signal: AbortSignal.timeout(timeoutMs) } as RequestInit)
if (!r.ok) throw new HttpError(r.status, url)
return r
}
return {
json: async (url) => (await get(url)).json(),
bytes: async (url) => new Uint8Array(await (await get(url, source.type === "github" && token ? "application/octet-stream" : "*/*")).arrayBuffer()),
async text(url) {
try {
return await (await get(url, "text/plain")).text()
} catch (e) {
if (e instanceof HttpError && e.status === 404) return undefined
throw e
}
},
}
}
type ApiRelease = { tag_name: string; draft?: boolean; assets?: { name: string; browser_download_url: string; url?: string }[] }
/** The releases the source has, newest first or in any order (the caller picks by version). */
export async function listReleases(source: Source, f: Fetcher): Promise<Release[]> {
if (source.type === "static") {
const base = (source.url ?? "").replace(/\/+$/, "")
if (!base) throw new Error("update.source: a static source needs url")
const out: Release[] = []
for (const ch of ["stable", "beta"]) {
const tag = (await f.text(`${base}/${ch}`))?.trim()
if (!tag) continue
// A directory has no listing; the assets are found by LLeMbas CLI's own names.
out.push({ tag, dir: `${base}/${encodeURIComponent(tag)}` })
}
return out
}
const authed = source.token !== undefined || source.key_cmd !== undefined
if (!source.repo) throw new Error(`update.source: ${source.type} needs repo (owner/name)`)
const api =
source.type === "github"
? `${(source.url ?? "https://api.github.com").replace(/\/+$/, "")}/repos/${source.repo}/releases?per_page=30`
: `${(source.url ?? "").replace(/\/+$/, "")}/api/v1/repos/${source.repo}/releases?limit=30`
if (source.type === "gitea" && !source.url) throw new Error("update.source: gitea needs url (the forge)")
let list: ApiRelease[]
try {
list = (await f.json(api)) as ApiRelease[]
} catch (e) {
if (e instanceof HttpError && e.status === 404) throw new Error(`${source.type} ${source.repo}: no such repository, or it is private and update.source has no token (404)`)
throw e
}
if (!Array.isArray(list)) throw new Error("update.source: the releases API did not answer with a list")
return list
.filter((r) => !r.draft)
.map((r) => ({
tag: r.tag_name,
// A private GitHub repository serves an asset through its API address (with the token);
// browser_download_url would want a browser session.
assets: Object.fromEntries((r.assets ?? []).map((a) => [a.name, source.type === "github" && authed && a.url ? a.url : a.browser_download_url])),
}))
}
+77
View File
@@ -0,0 +1,77 @@
// SSH signatures (`ssh-keygen -Y sign`), checked without ssh-keygen: OpenSSH's PROTOCOL.sshsig.
// A release's SHA256SUMS is signed with the release key; this is what tells an update it came from
// whoever holds that key, not merely from whoever could upload to the release.
import { createHash, createPublicKey, verify } from "node:crypto"
const MAGIC = Buffer.from("SSHSIG")
class Reader {
private at = 0
constructor(private b: Buffer) {}
bytes(n: number): Buffer {
if (this.at + n > this.b.length) throw new Error("truncated signature")
const out = this.b.subarray(this.at, this.at + n)
this.at += n
return out
}
done(): boolean {
return this.at === this.b.length
}
u32(): number {
return this.bytes(4).readUInt32BE(0)
}
string(): Buffer {
return this.bytes(this.u32())
}
}
const sshString = (b: Buffer | string) => {
const body = typeof b === "string" ? Buffer.from(b) : b
const len = Buffer.alloc(4)
len.writeUInt32BE(body.length)
return Buffer.concat([len, body])
}
/** The raw 32-byte ed25519 key of an `ssh-ed25519 AAAA… comment` line. */
export function ed25519Key(line: string): Buffer {
const [type, b64] = line.trim().split(/\s+/)
if (type !== "ssh-ed25519" || !b64) throw new Error(`not an ssh-ed25519 public key: ${line.slice(0, 40)}`)
const r = new Reader(Buffer.from(b64, "base64"))
if (r.string().toString() !== "ssh-ed25519") throw new Error("not an ssh-ed25519 public key")
const key = r.string()
if (key.length !== 32) throw new Error("bad ed25519 key length")
return key
}
/** Whether `armored` is a valid signature of `message` in `namespace` by one of `keys`
* (ssh-ed25519 public key lines). Throws with the reason when it is not. */
export function verifySshSig(message: Uint8Array, armored: string, namespace: string, keys: string[]): void {
// Exactly one armored block, nothing but base64 inside, nothing left over after the fields.
const m = /^\s*-----BEGIN SSH SIGNATURE-----\n([A-Za-z0-9+/=\s]+?)\n-----END SSH SIGNATURE-----\s*$/.exec(armored.replace(/\r\n/g, "\n"))
if (!m) throw new Error("not one armored SSH signature")
const body = m[1]!.replace(/\s+/g, "")
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(body)) throw new Error("the signature is not valid base64")
const blob = Buffer.from(body, "base64")
const r = new Reader(blob)
if (!r.bytes(6).equals(MAGIC)) throw new Error("not an SSH signature")
if (r.u32() !== 1) throw new Error("unknown SSH signature version")
const pub = new Reader(r.string())
if (pub.string().toString() !== "ssh-ed25519") throw new Error("the signature is not ed25519")
const signer = pub.string()
const ns = r.string().toString()
const reserved = r.string()
const hashAlg = r.string().toString()
const sig = new Reader(r.string())
if (sig.string().toString() !== "ssh-ed25519") throw new Error("the signature is not ed25519")
const raw = sig.string()
if (!r.done() || !pub.done() || !sig.done()) throw new Error("the signature has bytes it should not")
if (ns !== namespace) throw new Error(`signed for "${ns}", not "${namespace}"`)
if (hashAlg !== "sha512" && hashAlg !== "sha256") throw new Error(`unknown hash ${hashAlg}`)
const trusted = keys.map(ed25519Key)
if (!trusted.some((k) => k.equals(signer))) throw new Error("signed by a key that is not trusted")
const signed = Buffer.concat([MAGIC, sshString(ns), sshString(reserved), sshString(hashAlg), sshString(createHash(hashAlg).update(message).digest())])
const key = createPublicKey({ key: { kty: "OKP", crv: "Ed25519", x: signer.toString("base64url") }, format: "jwk" })
if (!verify(null, signed, key, raw)) throw new Error("the signature does not match")
}
+39
View File
@@ -0,0 +1,39 @@
// Release versions: vX.Y.Z (stable) and vX.Y.Z-beta.N (beta). Compared as SemVer — git's own
// version sort puts v1.1.0-beta.1 above v1.1.0, which would hand a stable install a pre-release.
export interface Version {
major: number
minor: number
patch: number
/** The N of -beta.N; undefined for a stable release. */
beta?: number
}
const TAG = /^v?(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/
/** A release tag or version; undefined for anything else (a source build's 0.9.2+g1234abc, an rc). */
export function parseVersion(s: string): Version | undefined {
const m = TAG.exec(s.trim())
if (!m) return undefined
return { major: +m[1]!, minor: +m[2]!, patch: +m[3]!, beta: m[4] === undefined ? undefined : +m[4] }
}
/** Negative when a is older than b. A beta is older than its own release. */
export function compareVersions(a: Version, b: Version): number {
const beta = (v: Version) => v.beta ?? Number.MAX_SAFE_INTEGER
return a.major - b.major || a.minor - b.minor || a.patch - b.patch || beta(a) - beta(b)
}
export const isStable = (v: Version) => v.beta === undefined
export const versionText = (v: Version) => `${v.major}.${v.minor}.${v.patch}${v.beta === undefined ? "" : `-beta.${v.beta}`}`
/** The newest tag the channel takes: stable, or (beta) the newest of stable and beta. */
export function newestFor<T extends { tag: string }>(releases: T[], channel: "stable" | "beta"): (T & { version: Version }) | undefined {
let best: (T & { version: Version }) | undefined
for (const r of releases) {
const v = parseVersion(r.tag)
if (!v || (channel === "stable" && !isStable(v))) continue
if (!best || compareVersions(v, best.version) > 0) best = { ...r, version: v }
}
return best
}