login --json: the code and the outcome as JSON lines, for LLeMbas Desktop
ci / check (push) Canceled after 0s
ci / check (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4 files changed
+62
-10
No files matched your search
@@ -6,6 +6,11 @@ All notable changes to LLeMbas CLI. The format follows
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [1.0.0] — 2026-10-09
|
||||||
|
|
||||||
The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
|
The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
|
||||||
|
|||||||
+17
-9
@@ -32,9 +32,10 @@ Usage:
|
|||||||
settings (the same as /settings in the TUI)
|
settings (the same as /settings in the TUI)
|
||||||
lembas config schema [--link]
|
lembas config schema [--link]
|
||||||
write JSON Schemas for editors (--link: point the files at them)
|
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
|
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 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 serve --stdio an ACP agent on stdin/stdout, for an editor that speaks ACP
|
||||||
lembas service install | uninstall | status | logs [-f] | run
|
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[]) {
|
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({
|
const { values, positionals } = parseArgs({
|
||||||
args,
|
args,
|
||||||
allowPositionals: true,
|
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 known = instances()
|
||||||
|
const json = cmd === "login" && values.json
|
||||||
|
const emit = (o: object) => console.log(JSON.stringify(o))
|
||||||
try {
|
try {
|
||||||
if (cmd === "logout") {
|
if (cmd === "logout") {
|
||||||
const name = positionals[0] ?? (Object.keys(known).length === 1 ? Object.keys(known)[0]! : "")
|
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
|
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)) {
|
if (again && !values.ca && isWebuiEntry(again.connection)) {
|
||||||
const r = await sync(again.connection, { setDefault: values.default })
|
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
|
return
|
||||||
}
|
}
|
||||||
// An older kind of login (the models copied into connections.yaml) is replaced by signing in
|
// An older kind of login (the models copied into connections.yaml) is replaced by signing in
|
||||||
// again, at the address it had.
|
// again, at the address it had.
|
||||||
if (again && !address) address = again.base_url
|
if (again && !address) address = again.base_url
|
||||||
if (!address) {
|
if (!address) {
|
||||||
if (!process.stdin.isTTY) fail("usage: lembas login <instance address>", 2)
|
if (!process.stdin.isTTY || json) fail("usage: lembas login <instance address>", 2)
|
||||||
process.stdout.write("LLeMbas instance address: ")
|
process.stdout.write("LLeMbas instance address: ")
|
||||||
for await (const line of console) {
|
for await (const line of console) {
|
||||||
address = line.trim()
|
address = line.trim()
|
||||||
@@ -110,11 +114,15 @@ async function loginCommand(cmd: "login" | "logout", args: string[]) {
|
|||||||
ca: values.ca ?? again?.ca,
|
ca: values.ca ?? again?.ca,
|
||||||
setDefault: values.default,
|
setDefault: values.default,
|
||||||
keepServices: values["keep-services"],
|
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) {
|
} 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
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,6 +313,31 @@ export function codeInstructions(start: DeviceStart, d: Discovery): string {
|
|||||||
].join("\n")
|
].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
|
/** 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. */
|
* the models copied in, which a login again replaces. */
|
||||||
export function isWebuiEntry(connection: string): boolean {
|
export function isWebuiEntry(connection: string): boolean {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { parse } from "yaml"
|
|||||||
import { loadConfig } from "../src/config/load.ts"
|
import { loadConfig } from "../src/config/load.ts"
|
||||||
import { paths } from "../src/config/paths.ts"
|
import { paths } from "../src/config/paths.ts"
|
||||||
import { normalise, PROTOCOL } from "../src/lembas/client.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 { cacheFile, refreshWebui } from "../src/lembas/webui.ts"
|
||||||
import { resolveModel } from "../src/provider/index.ts"
|
import { resolveModel } from "../src/provider/index.ts"
|
||||||
import { search } from "../src/search/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")
|
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 () => {
|
test("login writes one webui connection named after the instance, and no models", async () => {
|
||||||
fake = lembas()
|
fake = lembas()
|
||||||
const r = await login(fake.url, { ...quick, onCode: () => {} })
|
const r = await login(fake.url, { ...quick, onCode: () => {} })
|
||||||
|
|||||||
Reference in new issue
Block a user