diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f5a9e1..b178b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ All notable changes to LLeMbas CLI. The format follows ## [Unreleased] +### Added + +- `lembas login --json` prints the code to approve and the outcome as one JSON object per line, + for a program that drives the login (LLeMbas Desktop). + ## [1.0.0] — 2026-10-09 The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM diff --git a/src/cli.ts b/src/cli.ts index f2b5021..54b366a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -32,9 +32,10 @@ Usage: settings (the same as /settings in the TUI) lembas config schema [--link] write JSON Schemas for editors (--link: point the files at them) - lembas login [address] [--ca file] [--default] [--keep-services] + lembas login [address] [--ca file] [--default] [--keep-services] [--json] sign in to a LLeMbas instance: its models, voice and search - (again: read them now; --default pins its default model) + (again: read them now; --default pins its default model; + --json: one JSON object per line, for a program to read) lembas logout [connection] sign out of one, and remove what login wrote lembas serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP lembas service install | uninstall | status | logs [-f] | run @@ -70,13 +71,15 @@ function fail(msg: string, code = 1): never { } async function loginCommand(cmd: "login" | "logout", args: string[]) { - const { codeInstructions, instances, isWebuiEntry, login, loginSummary, logout, sync, LembasError } = await import("./lembas/login.ts") + const { codeEvent, codeInstructions, doneEvent, instances, isWebuiEntry, login, loginSummary, logout, sync, LembasError } = await import("./lembas/login.ts") const { values, positionals } = parseArgs({ args, allowPositionals: true, - options: { ca: { type: "string" }, default: { type: "boolean" }, "keep-services": { type: "boolean" } }, + options: { ca: { type: "string" }, default: { type: "boolean" }, "keep-services": { type: "boolean" }, json: { type: "boolean" } }, }) const known = instances() + const json = cmd === "login" && values.json + const emit = (o: object) => console.log(JSON.stringify(o)) try { if (cmd === "logout") { const name = positionals[0] ?? (Object.keys(known).length === 1 ? Object.keys(known)[0]! : "") @@ -91,14 +94,15 @@ async function loginCommand(cmd: "login" | "logout", args: string[]) { const again = address ? Object.values(known).find((i) => i.base_url.replace(/^https?:\/\//, "") === address.replace(/^https?:\/\//, "").replace(/\/+$/, "")) : Object.keys(known).length === 1 ? Object.values(known)[0] : undefined if (again && !values.ca && isWebuiEntry(again.connection)) { const r = await sync(again.connection, { setDefault: values.default }) - console.log(loginSummary(r, false).join("\n")) + if (json) emit(doneEvent(r, false)) + else console.log(loginSummary(r, false).join("\n")) return } // An older kind of login (the models copied into connections.yaml) is replaced by signing in // again, at the address it had. if (again && !address) address = again.base_url if (!address) { - if (!process.stdin.isTTY) fail("usage: lembas login ", 2) + if (!process.stdin.isTTY || json) fail("usage: lembas login ", 2) process.stdout.write("LLeMbas instance address: ") for await (const line of console) { address = line.trim() @@ -110,11 +114,15 @@ async function loginCommand(cmd: "login" | "logout", args: string[]) { ca: values.ca ?? again?.ca, setDefault: values.default, keepServices: values["keep-services"], - onCode: (start, d) => console.log(`\n${codeInstructions(start, d)}\n`), + onCode: (start, d) => (json ? emit(codeEvent(start, d)) : console.log(`\n${codeInstructions(start, d)}\n`)), }) - console.log(loginSummary(r, true).join("\n")) + if (json) emit(doneEvent(r, true)) + else console.log(loginSummary(r, true).join("\n")) } catch (e) { - if (e instanceof LembasError) fail(e.message) + if (e instanceof LembasError) { + if (json) emit({ event: "error", message: e.message }) + fail(e.message) + } throw e } } diff --git a/src/lembas/login.ts b/src/lembas/login.ts index a2be18a..e4098b0 100644 --- a/src/lembas/login.ts +++ b/src/lembas/login.ts @@ -313,6 +313,31 @@ export function codeInstructions(start: DeviceStart, d: Discovery): string { ].join("\n") } +/** `lembas login --json`: what a program driving the login (LLeMbas Desktop) reads, one JSON + * object per line on stdout — the code to approve, then the outcome. */ +export function codeEvent(start: DeviceStart, d: Discovery) { + return { + event: "code" as const, + user_code: start.user_code, + verification_uri: start.verification_uri, + verification_uri_complete: start.verification_uri_complete ?? start.verification_uri, + expires_in: start.expires_in, + instance: { name: d.name ?? null, version: d.version, base_url: d.base_url }, + } +} + +export function doneEvent(r: LoginResult, fresh: boolean) { + return { + event: "done" as const, + fresh, + connection: r.connection, + base_url: r.instance.base_url, + version: r.version, + models: r.models.length, + changes: r.changes, + } +} + /** Whether a login's connection is already the webui kind — not an older kind of entry, with * the models copied in, which a login again replaces. */ export function isWebuiEntry(connection: string): boolean { diff --git a/tests/lembas-login.test.ts b/tests/lembas-login.test.ts index f0b602c..54b1efc 100644 --- a/tests/lembas-login.test.ts +++ b/tests/lembas-login.test.ts @@ -9,7 +9,7 @@ import { parse } from "yaml" import { loadConfig } from "../src/config/load.ts" import { paths } from "../src/config/paths.ts" import { normalise, PROTOCOL } from "../src/lembas/client.ts" -import { codeInstructions, instances, keyFile, login, logout, specFor, sync } from "../src/lembas/login.ts" +import { codeEvent, codeInstructions, doneEvent, instances, keyFile, login, logout, specFor, sync } from "../src/lembas/login.ts" import { cacheFile, refreshWebui } from "../src/lembas/webui.ts" import { resolveModel } from "../src/provider/index.ts" import { search } from "../src/search/index.ts" @@ -153,6 +153,20 @@ test("the code is shown as a link and as a code for User → Security → Device expect(shown).toContain("2. Copy the code CDFG-HJKM into User → Security → Devices") }) +test("--json: the code and the outcome, each one line a program can read", async () => { + fake = lembas() + const lines: string[] = [] + const r = await login(fake.url, { ...quick, onCode: (s, d) => lines.push(JSON.stringify(codeEvent(s, d))) }) + lines.push(JSON.stringify(doneEvent(r, true))) + const [code, done] = lines.map((l) => JSON.parse(l)) + expect(lines.every((l) => !l.includes("\n"))).toBe(true) + expect(code).toMatchObject({ event: "code", user_code: "CDFG-HJKM", verification_uri_complete: `${fake.url}/device?code=CDFG-HJKM` }) + expect(code.instance).toEqual({ name: "Example", version: "1.21.0", base_url: fake.url }) + expect(done).toMatchObject({ event: "done", fresh: true, connection: "example", base_url: fake.url, models: 2 }) + // The key never appears in what is printed. + expect(lines.join("")).not.toContain("lmb_secret") +}) + test("login writes one webui connection named after the instance, and no models", async () => { fake = lembas() const r = await login(fake.url, { ...quick, onCode: () => {} })