From 1f7c2788c0919df0554e426730f37993247e2fdc Mon Sep 17 00:00:00 2001 From: SoryAK Date: Thu, 10 Sep 2026 17:07:30 -0400 Subject: [PATCH] feat: boot family+model only and attach the client with connect A leftover pairing or the Copilot default was merging overlay dials at boot, so operators could generate the wrong configs without choosing a client. Boot is now bare; mba connect attaches the client, and the TTY (boot card, BOOTED next, status slots) follows that sequence. --- ...model-folders-and-environment-overrides.md | 8 + docs/adr/0098-model-pull-capability.md | 2 + docs/architecture/README.md | 2 +- docs/architecture/views.c4 | 8 +- docs/workflows/add-interactive-cli-prompt.md | 3 + docs/workflows/cli-development.md | 117 ++++++++++++++ packages/core/src/cli/boot-preview.ts | 147 ++++++++++++++++++ packages/core/src/cli/clients.ts | 37 ++--- packages/core/src/cli/flag-pairs.test.ts | 145 +++++++++++++++-- packages/core/src/cli/flag-pairs.ts | 80 ++++++++++ packages/core/src/cli/help.test.ts | 21 +++ packages/core/src/cli/help.ts | 20 +-- packages/core/src/cli/home.ts | 3 +- packages/core/src/cli/list-print.test.ts | 67 ++++++++ packages/core/src/cli/list-print.ts | 40 +++++ packages/core/src/cli/models.ts | 46 +++--- packages/core/src/cli/restart.ts | 11 +- packages/core/src/cli/servers.ts | 121 ++++---------- packages/core/src/cli/slot-print.test.ts | 97 ++++++++++++ packages/core/src/cli/slot-print.ts | 74 +++++++++ packages/core/src/cli/status.ts | 47 ++---- packages/core/src/cli/style.test.ts | 19 +++ packages/core/src/cli/style.ts | 13 ++ packages/core/src/cli/types.ts | 10 ++ packages/core/src/mba/env-folders.test.ts | 28 ++++ packages/core/src/mba/resolver.ts | 9 +- packages/core/src/model/model-pull.test.ts | 1 + .../core/src/service/ctx-size-resolver.ts | 19 ++- packages/core/src/service/env-context.test.ts | 41 ++++- packages/core/src/service/env-context.ts | 78 ++++++++-- .../core/src/service/recipe-resolution.ts | 48 ++++-- .../core/src/service/resolve-server-recipe.ts | 17 +- packages/core/src/service/server-boot.ts | 90 +++++++---- .../core/src/service/server-connect.test.ts | 12 +- .../core/src/service/server-servers.test.ts | 6 + packages/core/src/service/server.ts | 25 +-- 36 files changed, 1216 insertions(+), 296 deletions(-) create mode 100644 docs/workflows/cli-development.md create mode 100644 packages/core/src/cli/boot-preview.ts create mode 100644 packages/core/src/cli/help.test.ts create mode 100644 packages/core/src/cli/list-print.test.ts create mode 100644 packages/core/src/cli/list-print.ts create mode 100644 packages/core/src/cli/slot-print.test.ts create mode 100644 packages/core/src/cli/slot-print.ts diff --git a/docs/adr/0091-model-folders-and-environment-overrides.md b/docs/adr/0091-model-folders-and-environment-overrides.md index c7291f0..1760f1c 100644 --- a/docs/adr/0091-model-folders-and-environment-overrides.md +++ b/docs/adr/0091-model-folders-and-environment-overrides.md @@ -183,6 +183,14 @@ bindings: multiple environment folders match, the most-specific (most segments) wins; ties are broken by the existing deterministic rules and surfaced as diagnostics. + **Overlays are sparse.** Pull does not create `environments/`. A folder + exists only when that client (or a more specific combo) has dials that + differ from family/model. The default name is harness-only + (`environments/cursor/`). Add `+ide` or `+runtime` only when that + combination needs different dials. Do not pre-create a cartesian product + of harness × ide × runtime, and do not nest those segments as directories + (`environments/cursor/cli/llamacpp` is not the shape — one folder, `+`-joined + name). 4. **`file` lives with `profile`** — one fact: "these weights, at this path, with this spec sheet." The MCP server's existing `identity.model.file` contract is unchanged. diff --git a/docs/adr/0098-model-pull-capability.md b/docs/adr/0098-model-pull-capability.md index 7734723..4bc51d8 100644 --- a/docs/adr/0098-model-pull-capability.md +++ b/docs/adr/0098-model-pull-capability.md @@ -110,6 +110,8 @@ and **`POST /models/pull`** (service route, same parameters) perform: (`family.yaml` + empty binding files + the same two empty markdown files). Family markdown is the default; the model's files replace it. Live injection of instructions is a later cut — this cut only stores the files. + Pull does **not** create `environments/` folders (ADR-0091: overlays are + sparse; a shelf exists only when that client has extra dials). New modules in `@mba-ai/core` (`src/model/`): diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a79f318..1bb580b 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -18,7 +18,7 @@ Then open the URL it prints (usually `http://localhost:5173`). Views: `/view/ind | --- | --- | | `index` | Landscape | | `house` / `pull` | Store: pull, scaffold, the two cards | -| `boot` | Paired client env (else Copilot/VS Code) → spawn llama-server | +| `boot` | Family + model dials only → spawn llama-server. Connect attaches a client. | | `pairing` | `sessions.json` (token hash) and `clients.json` (added envelopes) | | `clients-landscape` / `connect` | Stage card, mint token, proxy requires Bearer. Add a client is name + envelope. | diff --git a/docs/architecture/views.c4 b/docs/architecture/views.c4 index da4392d..68f33c4 100644 --- a/docs/architecture/views.c4 +++ b/docs/architecture/views.c4 @@ -51,16 +51,16 @@ views { dynamic view boot { title "2 — Boot the model" - description "Paired client supplies harness+ide+runtime. No pairing → Copilot + VS Code + llama.cpp. Env folders overlay dials, not instructions.md." + description "Family + model dials only. No pairing overlay. The boot card tells the operator to mba connect if they want a client attached." variant sequence operator -> mba.cli "mba s boot " - mba.cli -> mba.service "POST boot" - mba.service -> mba.state.sessions "newest pairing for this model" - mba.service -> mba.resolver "family, model, then env folder" + mba.cli -> mba.service "POST /servers/resolve then POST /servers/boot" + mba.service -> mba.resolver "family + model (skip environments/)" mba.resolver -> mba.store "yaml, bcb, tcb, server_setup" mba.service -> llama "spawn llama-server (ctx, GPU, slot-save-path)" mba.service -> mba.store.kv "use existing slot dirs" + mba.cli -> operator "BOOTED · next mba connect " } dynamic view connect { diff --git a/docs/workflows/add-interactive-cli-prompt.md b/docs/workflows/add-interactive-cli-prompt.md index d93fa28..3e7211c 100644 --- a/docs/workflows/add-interactive-cli-prompt.md +++ b/docs/workflows/add-interactive-cli-prompt.md @@ -6,6 +6,9 @@ **Last Updated:** 2026-08-29 +Parent playbook for the whole CLI (build, JSON vs TTY, pairing identity): +[cli-development.md](./cli-development.md). + --- ## Successful Sequence diff --git a/docs/workflows/cli-development.md b/docs/workflows/cli-development.md new file mode 100644 index 0000000..8ef4884 --- /dev/null +++ b/docs/workflows/cli-development.md @@ -0,0 +1,117 @@ +# Workflow: CLI development + +**Workflow Type:** `cli-development` — changing the `mba` command (TTY, JSON, or both) + +**Stack Context:** TypeScript, `packages/core/src/cli/`, Vitest, no Ink/fzf/blessed + +**Last Updated:** 2026-09-10 + +**Related:** [ADR-0096](../adr/0096-mba-config-cli.md) (CLI is a thin door over the service). Interactive prompt primitives: [add-interactive-cli-prompt.md](./add-interactive-cli-prompt.md). Whole-surface restyle is parked (`docs/backlog/parked-ideas.md`, CLI polish) — follow this file when that cut starts. + +Industry notes (Claude Code architecture review + git/gh/kubectl): steal TTY-vs-JSON, one paint kit, fast `--help`, streaming with cancel, status as doctor. Do **not** adopt Ink, Commander, or a forked renderer. `mba` is a door, not a REPL product. + +--- + +## What `mba` is + +A **thin client** of the MBA daemon. It does not own adapter files, sessions, or llama-server. Reads and writes go through the service (`GET` / `POST`). Local exceptions (no daemon): `migrate-paths`, `estimate-memory`, `completion`, `--help`. + +Nouns: `models` (`m`), `servers` (`s`), `clients` (`c`), `machine`, `status`. Old flat verbs stay as aliases. + +TTY and `--json` are two skins of the same route. JSON field names are the contract. TTY labels can change in a polish; JSON must not. + +--- + +## Layout + +| File | Owns | +| --- | --- | +| `mba.ts` | argv → command; service URL; fail | +| `route.ts` | parse argv (`parseMbaArgv`) | +| `help.ts` | `--help` text | +| `client.ts` | `fail`, `serviceGet` / `servicePost`, `resolveServiceUrl` | +| `style.ts` | paint, `brand`, `kv`, `heading`, `shortenHome` | +| `interactive.ts` | raw-mode pickers and one-line prompts | +| `status.ts` / `clients.ts` / `models.ts` / `servers.ts` / `machine.ts` | one noun each | +| `slot-print.ts` | TTY grouping for paired sessions (status + clients) | +| `list-print.ts` | TTY rows for servers, models, registered clients | +| `harness-choices.ts` | built-in + operator envelopes for pickers | + +Paint lives in `style.ts`. Do not add a second color kit. Honors `NO_COLOR` / `FORCE_COLOR` / TTY. + +--- + +## Successful sequence (any CLI change) + +1. **Decide TTY vs JSON vs both.** If the operator needs a new fact, add it to the service payload first, then print it. Do not scrape files from the CLI. +2. **Route.** New subcommand → `route.ts` + `mba.ts` switch + `help.ts`. Test `parseMbaArgv` in `route.test.ts`. +3. **Print with `style.ts`.** `brand("status")`, `kv`, `heading`, `dim`, `paint(..., BOLD)`. New chrome copies an existing screen; it does not invent one. +4. **Interactive?** Use `interactive.ts` (`pickLabeledInteractive`, `askValueInteractive`, …). New prompt kinds follow [add-interactive-cli-prompt.md](./add-interactive-cli-prompt.md). +5. **Tests.** Route/parse always. Interactive against a fake stdin. Display helpers (`formatClientLabel`, envelope paths) as unit tests — do not snapshot whole TTY dumps unless the polish cut says so. +6. **Build the binary the operator runs.** `mba` on PATH is `packages/core/dist/cli/mba.js` (`npm link`). The daemon is `tsx src/service/main.ts` and does not need this build. + ```sh + npm run build -w @mba-ai/core + mba status + ``` + +--- + +## Identity on screen (pairing) + +Pairing is **many keys** (several models on one Cursor project). The envelope is **one playbook per (harness, project)**. + +- Built-in filename is the slot the harness already injects (`.cursor/rules/mba.mdc`, `CLAUDE.local.md`, …). Model id lives **inside** the card (``), not in the filename. +- TTY names **the app** and **the file**, then lists models under that slot: `card` (this model owns the playbook) vs `pair` (token only). Kind is `ide` or `cli` (`harnessKind`). A non-default ide prints as `· cursor` on the header, not `copilot+cursor`. +- `formatClientLabel` is the short harness tag (`cursor`, not `cursor+cursor`). Do not use it as the only identity on status/clients — it hid the file. +- JSON keeps `harness`, `ide`, `card`, `projectRoot`. `envelope` is additive on `GET /status` sessions. Do not rename those fields. + +Shipped on status + `mba clients` list (`formatPairedSlotLines`): + +```text + clients + cursor ide .cursor/rules/mba.mdc + deepseek_test card ~/Dev_Projects/MBA + nomic-embed-text-v1.5 pair ~/Dev_Projects/MBA +``` + +Still parked (CLI polish card): picker chrome (`interactive.ts` / `previewBox`). Same paint kit — do not invent a second look. + +## Cuts + +1. **Done.** Status / clients group by harness + kind + envelope. Service adds `envelope` on each session. +2. **Done.** Compact boot preview: one header (model, port, GPU), dense dials, hide usual `--jinja` / kv q8_0, one `BOOTED` line. Process unchanged. +3. **Done.** Remaining list/show/help screens share that vocabulary: `mba models` / `show`, `mba servers`, registered clients, connect slot line, compact `mba servers --help`, one `PULLED` line. Pickers unchanged. +4. **Done.** Boot is bare (`env not set`). Pairing does not pick an overlay. `BOOTED` next is `mba connect ` (restart still points at logs). JSON adds `envAttached: false`; `env.harness` is `none`. + +--- + +## First-attempt failures + +- **Stale `mba status` after a merge.** PATH still has yesterday’s `dist`. Rebuild `@mba-ai/core`. Confirm with `which mba` → `…/packages/core` via npm link. +- **Typecheck `Cannot find name 'fail'` / `serviceGet`.** Command files must import from `./client.js`. Dropping that import while adding a display helper fails CI (`tsc --noEmit` in the test job). +- **CodeQL ReDoS / bad HTML-filter regexp** on envelope markers. Parse `mba-model` / file stems with string walks (`envelope.ts`), not `[-.]+` or `-->` regexes. Same rule as `version.ts`. +- **Interactive menu does nothing.** `mba` is `dist/`, not `tsx`. Rebuild. See [add-interactive-cli-prompt.md](./add-interactive-cli-prompt.md). +- **Daemon on a new port, CLI talking to the old one.** `resolveServiceUrl` reads the discovery file the service just wrote. After `systemctl --user restart mba.service`, run `mba status` again — do not export a stale `MBA_SERVICE_URL`. + +--- + +## Gotchas + +- **Service vs CLI rebuild.** systemd unit runs `tsx …/src/service/main.ts` (source). Operator `mba` is `dist`. Rebuild CLI after UI changes; restart the unit after service-path changes. +- **Raw mode.** Restore `setRawMode(false)` in `finally`. Interactive only when `process.stdin.isTTY`. +- **`--json` stability.** Scripts and tests key on field names. A polish may regroup TTY rows; it must not rename JSON. Additive fields (`envAttached`) are ok; `env.harness` values may change when the product does (`none` on boot). +- **Boot then connect.** Boot never applies `environments/` from a leftover pairing or the Copilot default. Family + model dials only. `mba connect` attaches the client (card + token). Env overlays still apply when a caller passes an explicit harness (stage, proxy, `resolve-server-recipe --harness`). +- **No second writer.** Staging, connect, revoke, ensure, boot flags — service owns the write. CLI prints the result. +- **Envelope extras.** Operator clients (`mba clients add`) are name + path. Do not scan `$PATH` for IDEs. Built-in slots stay the harness filenames; `{model}` is only for extras. +- **Help is part of the CLI.** If a flag or noun changes, `help.ts` changes in the same commit. + +--- + +## Reference + +- Door: `packages/core/src/cli/mba.ts` +- Paint: `packages/core/src/cli/style.ts` +- Pairing label helper: `packages/core/src/service/env-context.ts` (`formatClientLabel`, `defaultIdeForHarness`) +- Envelope slots: `packages/core/src/mba/envelope.ts` +- Product decision: ADR-0096 +- Industry review: [lai3d/claude-code-architecture](https://github.com/lai3d/claude-code-architecture) — patterns above, not their UI stack diff --git a/packages/core/src/cli/boot-preview.ts b/packages/core/src/cli/boot-preview.ts new file mode 100644 index 0000000..c35fa30 --- /dev/null +++ b/packages/core/src/cli/boot-preview.ts @@ -0,0 +1,147 @@ +/** + * Sectioned boot preview TTY (machine / model / env / server / flags). + * JSON lives on POST /servers/resolve; this file only paints. + */ + +import { formatBytes } from "./client.js"; +import { compactBootLines, pairCliArgs } from "./flag-pairs.js"; +import { isBareBootEnv, overlayFolderName } from "../service/env-context.js"; +import { brand, dim, heading, kv, paint, BOLD } from "./style.js"; + +export interface BootPreviewModel { + readonly sizeLabel?: string; + readonly quant?: string; + readonly architecture?: string; + readonly fileBytes?: number; + readonly vision?: boolean; + readonly toolCalling?: boolean; + readonly moe?: string; +} + +export interface BootPreviewExtras { + readonly binary?: { path: string; backend: string; nickname?: string }; + readonly warning?: string; + readonly gpus?: readonly string[]; + readonly vramBytes?: readonly (number | null)[]; + readonly ramBytes?: number; + readonly cpuThreads?: number; + readonly env?: { harness: string; ide: string; serverRuntime: string }; + /** False on a bare boot (family+model only). Omit when env is attached. */ + readonly envAttached?: boolean; + readonly model?: BootPreviewModel; + readonly pinned?: boolean; + readonly picked?: boolean; + readonly machineOverlay?: "enforce" | "warn" | "off"; +} + +function shortGpu(name: string): string { + return name.replace(/^NVIDIA GeForce /i, "").replace(/^NVIDIA /i, "").trim(); +} + +function formatGpuLine(names: readonly string[], vrams?: readonly (number | null)[]): string { + return names + .map((name, i) => { + const short = shortGpu(name); + const vram = vrams?.[i]; + if (typeof vram === "number" && vram > 0) return `${short} ${formatBytes(vram)}`; + return short; + }) + .filter((s) => s.length > 0) + .join(", "); +} + +function envAttached(extras?: BootPreviewExtras): boolean { + if (extras?.envAttached === false) return false; + if (!extras?.env) return false; + return !isBareBootEnv(extras.env); +} + +function overlayHint(mode: "enforce" | "warn" | "off"): string { + if (mode === "enforce") return "enforce clamp flags to this box"; + if (mode === "warn") return "warn log only"; + return "off no clamp"; +} + +function yn(v: boolean): string { + return v ? "true" : "false"; +} + +export function formatBootPreviewLines( + modelId: string, + port: number, + cliArgs: readonly string[], + extras?: BootPreviewExtras, +): string[] { + const lines: string[] = [`${brand("boot")} ${paint(modelId, BOLD)} ${dim(`:${String(port)}`)}`]; + + const gpuNames = extras?.gpus ?? []; + const gpu = gpuNames.length > 0 ? formatGpuLine(gpuNames, extras?.vramBytes) : undefined; + const cpu = + extras?.cpuThreads !== undefined && extras.cpuThreads > 0 + ? `${extras.cpuThreads} threads` + : undefined; + const ram = + extras?.ramBytes !== undefined && extras.ramBytes > 0 + ? formatBytes(extras.ramBytes) + : undefined; + const overlay = extras?.machineOverlay; + if (gpu || cpu || ram || overlay) { + lines.push(` ${heading("machine")}`); + if (gpu) lines.push(kv("gpu", gpu, 8)); + if (cpu) lines.push(kv("cpu", cpu, 8)); + if (ram) lines.push(kv("ram", ram, 8)); + if (overlay) lines.push(kv("overlay", overlayHint(overlay), 8)); + } + + const m = extras?.model; + const sizeBits = [m?.sizeLabel, m?.fileBytes !== undefined ? formatBytes(m.fileBytes) : undefined].filter( + (s): s is string => Boolean(s), + ); + if (m && (sizeBits.length > 0 || m.quant || m.architecture || m.moe || m.vision !== undefined || m.toolCalling !== undefined)) { + lines.push(` ${heading("model")}`); + if (sizeBits.length > 0) lines.push(kv("size", sizeBits.join(" "), 8)); + if (m.quant) lines.push(kv("quant", m.quant, 8)); + if (m.architecture) lines.push(kv("arch", m.architecture, 8)); + if (m.moe) lines.push(kv("moe", m.moe, 8)); + if (m.vision !== undefined) lines.push(kv("vision", yn(m.vision), 8)); + if (m.toolCalling !== undefined) lines.push(kv("tools", yn(m.toolCalling), 8)); + } + + if (envAttached(extras) && extras?.env) { + lines.push(kv("env", overlayFolderName(extras.env), 8)); + } else { + lines.push(kv("env", "not set", 8)); + } + + if (extras?.binary) { + const nick = extras.binary.nickname?.trim(); + const shown = nick + ? `${nick} ${extras.binary.backend}` + : extras.binary.backend; + const set = extras.pinned ? "pinned" : extras.picked ? "picked" : "default"; + lines.push(` ${heading("server")}`); + lines.push(kv("bin", shown, 8)); + lines.push(kv("set", set, 8)); + if (extras.warning) lines.push(kv("note", extras.warning, 8)); + } else if (extras?.warning) { + lines.push(kv("note", extras.warning, 8)); + } + + const flags = compactBootLines(pairCliArgs(cliArgs)); + if (flags.length > 0) { + lines.push(` ${heading("flags")}`); + for (const line of flags) lines.push(` ${line}`); + } + + lines.push(""); + return lines; +} + +export function printBootPreview( + modelId: string, + port: number, + cliArgs: readonly string[], + extras?: BootPreviewExtras, +): void { + process.stdout.write(`${formatBootPreviewLines(modelId, port, cliArgs, extras).join("\n")}\n`); +} diff --git a/packages/core/src/cli/clients.ts b/packages/core/src/cli/clients.ts index 5cce4a1..a8e6f30 100644 --- a/packages/core/src/cli/clients.ts +++ b/packages/core/src/cli/clients.ts @@ -2,43 +2,27 @@ * Client plane: list paired sessions, add operator clients, connect, revoke. */ -import { formatClientLabel } from "../service/env-context.js"; import { fail, serviceGet, servicePost } from "./client.js"; import { listHarnessChoices } from "./harness-choices.js"; import { askValueInteractive, pickLabeledInteractive } from "./interactive.js"; +import { formatRegisteredClientLine } from "./list-print.js"; import { cmdModelsConnect } from "./models.js"; +import { formatPairedSlotLines } from "./slot-print.js"; +import { brand, dim, heading, shortenHome } from "./style.js"; import { defaultStorePaths } from "../service/config-store.js"; import { addOperatorClient, removeOperatorClient, } from "../service/operator-clients.js"; -import { brand, dim, heading, paint, shortenHome, BOLD } from "./style.js"; +import type { PairingSession } from "./types.js"; -interface PublicSession { - readonly id: string; - readonly modelId: string; - readonly harness: string; - readonly ide?: string; - readonly projectRoot: string; - readonly createdAt: string; - readonly card?: boolean; -} - -async function listSessions(baseUrl: string): Promise { +async function listSessions(baseUrl: string): Promise { const st = await serviceGet<{ - pairing?: { sessions?: readonly PublicSession[] }; + pairing?: { sessions?: readonly PairingSession[] }; }>(baseUrl, "/status"); return st.pairing?.sessions ?? []; } -function printSession(s: PublicSession): void { - const who = formatClientLabel(s.harness, s.ide); - const tag = (s.card ? "card" : "pair").padEnd(4); - process.stdout.write( - ` ${paint(who.padEnd(18), BOLD)} ${s.modelId.padEnd(22)} ${s.card ? paint(tag, BOLD) : dim(tag)} ${dim(shortenHome(s.projectRoot))}\n`, - ); -} - async function cmdClientsList(baseUrl: string, json: boolean): Promise { const catalog = listHarnessChoices(); const sessions = await listSessions(baseUrl); @@ -49,17 +33,16 @@ async function cmdClientsList(baseUrl: string, json: boolean): Promise { process.stdout.write(`${brand("clients")}\n`); process.stdout.write(` ${heading("registered")}\n`); for (const h of catalog) { - const tag = h.source === "added" ? " added" : ""; - process.stdout.write( - ` ${paint(h.name.padEnd(18), BOLD)} ${dim(h.envelope)}${tag}\n`, - ); + process.stdout.write(`${formatRegisteredClientLine(h)}\n`); } process.stdout.write(` ${heading("paired")}\n`); if (sessions.length === 0) { process.stdout.write(` ${dim("none — mba connect to pair")}\n`); return; } - for (const s of sessions) printSession(s); + for (const line of formatPairedSlotLines(sessions)) { + process.stdout.write(`${line}\n`); + } } const ADD_USAGE = diff --git a/packages/core/src/cli/flag-pairs.test.ts b/packages/core/src/cli/flag-pairs.test.ts index b5cdd94..2550d05 100644 --- a/packages/core/src/cli/flag-pairs.test.ts +++ b/packages/core/src/cli/flag-pairs.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; -import { groupFlagPairs, pairCliArgs } from "./flag-pairs.js"; +import { compactBootLines, groupFlagPairs, pairCliArgs } from "./flag-pairs.js"; import { printBootPreview, formatLlamaServerLabel, shouldAskLlamaBinary } from "./servers.js"; +import { formatBootPreviewLines } from "./boot-preview.js"; describe("pairCliArgs", () => { it("pairs a flag with the following value", () => { @@ -47,6 +48,51 @@ describe("groupFlagPairs", () => { }); }); +describe("compactBootLines", () => { + it("hides usual --jinja and kv q8_0 and prints dense dials", () => { + const lines = compactBootLines( + pairCliArgs([ + "--ctx-size", + "110000", + "-ngl", + "11", + "--threads", + "8", + "--flash-attn", + "on", + "--parallel", + "1", + "--cache-reuse", + "150", + "--cache-ram", + "9500", + "-ctk", + "q8_0", + "-ctv", + "q8_0", + "--jinja", + "--reasoning-budget", + "512", + "--reasoning-preserve", + "--warmup", + ]), + ); + expect(lines.join("\n")).toContain("ctx 110000"); + expect(lines.join("\n")).toContain("ngl 11"); + expect(lines.join("\n")).toContain("flash on"); + expect(lines.join("\n")).toContain("reason 512"); + expect(lines.join("\n")).toContain("warmup"); + expect(lines.join("\n")).not.toContain("jinja"); + expect(lines.join("\n")).not.toContain("q8_0"); + expect(lines.join("\n")).not.toContain("--ctx-size"); + }); + + it("shows kv when it is not q8_0", () => { + const lines = compactBootLines(pairCliArgs(["-ctk", "q4_0", "-ctv", "q4_0"])); + expect(lines.join(" ")).toContain("kv q4_0/q4_0"); + }); +}); + describe("printBootPreview", () => { const prevNoColor = process.env.NO_COLOR; @@ -55,7 +101,7 @@ describe("printBootPreview", () => { else process.env.NO_COLOR = prevNoColor; }); - it("prints grouped flag rows instead of one token per line", () => { + it("prints a dense header and dials instead of flag tables", () => { process.env.NO_COLOR = "1"; let out = ""; const write = process.stdout.write.bind(process.stdout); @@ -75,14 +121,95 @@ describe("printBootPreview", () => { process.stdout.write = write; } expect(out).toContain("MBA · boot"); - expect(out).toContain("model"); expect(out).toContain("qwen3-coder-30b"); - expect(out).toContain("context"); - expect(out).toContain("--ctx-size"); - expect(out).toContain("110000"); - expect(out).toContain("compute"); - expect(out).toContain("other"); + expect(out).toContain(":8080"); + expect(out).toContain("ctx 110000"); + expect(out).toContain("ngl 100"); + expect(out).not.toContain("context"); + expect(out).not.toContain("--ctx-size"); + expect(out).not.toContain("--jinja"); expect(out).not.toMatch(/--ctx-size\n\s+110000/); + expect(out).toContain("not set"); + expect(out).not.toContain("mba connect"); + }); + + it("points a bare boot at mba connect, not a leftover pairing", () => { + process.env.NO_COLOR = "1"; + const out = formatBootPreviewLines("deepseek_test", 8080, ["--jinja"], { + env: { harness: "none", ide: "none", serverRuntime: "llamacpp" }, + envAttached: false, + }).join("\n"); + expect(out).toMatch(/env\s+not set/); + expect(out).not.toContain("mba connect"); + expect(out).not.toContain("cursor"); + expect(out).not.toContain("copilot"); + }); + + it("still names an attached env as cursor, not cursor+cursor+llamacpp", () => { + process.env.NO_COLOR = "1"; + let out = ""; + const write = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: string | Uint8Array) => { + out += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stdout.write; + try { + printBootPreview("deepseek_test", 8080, ["--jinja"], { + env: { harness: "cursor", ide: "cursor", serverRuntime: "llamacpp" }, + }); + } finally { + process.stdout.write = write; + } + expect(out).toContain("cursor"); + expect(out).not.toContain("cursor+cursor"); + expect(out).not.toContain("llamacpp"); + }); + + it("sections machine, model facts, env, server, and flags", () => { + process.env.NO_COLOR = "1"; + const out = formatBootPreviewLines("deepseek_test", 8080, ["--ctx-size", "100000", "--jinja"], { + gpus: ["NVIDIA GeForce RTX 3060 Ti"], + vramBytes: [8 * 1024 * 1024 * 1024], + ramBytes: 32 * 1024 * 1024 * 1024, + cpuThreads: 16, + machineOverlay: "enforce", + env: { harness: "none", ide: "none", serverRuntime: "llamacpp" }, + envAttached: false, + model: { + sizeLabel: "7B", + fileBytes: 4 * 1024 * 1024 * 1024, + quant: "Q4_K_M", + vision: true, + toolCalling: true, + }, + binary: { + path: "/opt/llama-server", + backend: "cuda", + nickname: "cuda_bigUncSmurf", + }, + }).join("\n"); + expect(out).toContain("machine"); + expect(out).toContain("RTX 3060 Ti"); + expect(out).toContain("8.0 GiB"); + expect(out).toContain("32.0 GiB"); + expect(out).toContain("16 threads"); + expect(out).toContain("enforce"); + expect(out).toContain("clamp flags to this box"); + expect(out).toContain("model"); + expect(out).toContain("7B"); + expect(out).toContain("4.0 GiB"); + expect(out).toContain("Q4_K_M"); + expect(out).toMatch(/vision\s+true/); + expect(out).toMatch(/tools\s+true/); + expect(out).toMatch(/env\s+not set/); + expect(out).not.toContain("mba connect deepseek_test"); + expect(out).not.toContain("cursor"); + expect(out).toContain("server"); + expect(out).toContain("cuda_bigUncSmurf"); + expect(out).toContain("default"); + expect(out).toContain("flags"); + expect(out).toContain("ctx 100000"); + expect(out).not.toContain("/opt/llama-server"); }); it("prints the llama-server backend and a mismatch note", () => { @@ -103,7 +230,7 @@ describe("printBootPreview", () => { process.stdout.write = write; } expect(out).toContain("hip"); - expect(out).toContain("NVIDIA GeForce RTX 5090"); + expect(out).toContain("RTX 5090"); expect(out).toContain("HIP build; no AMD GPU detected"); }); diff --git a/packages/core/src/cli/flag-pairs.ts b/packages/core/src/cli/flag-pairs.ts index bc580fa..b182f3d 100644 --- a/packages/core/src/cli/flag-pairs.ts +++ b/packages/core/src/cli/flag-pairs.ts @@ -49,6 +49,86 @@ export function pairCliArgs(args: readonly string[]): FlagPair[] { return out; } +function pairFor(pairs: readonly FlagPair[], flags: readonly string[]): FlagPair | undefined { + return pairs.find((p) => flags.includes(p.flag)); +} + +const USUAL_KV = "q8_0"; + +/** Hide shipped-default flags from the boot card (--jinja, kv q8_0). */ +export function isUsualBootFlag(pair: FlagPair, all: readonly FlagPair[]): boolean { + if (pair.flag === "--jinja" && pair.value === "on") return true; + const kvFlags = new Set(["-ctk", "-ctv", "--cache-type-k", "--cache-type-v"]); + if (!kvFlags.has(pair.flag)) return false; + const k = pairFor(all, ["-ctk", "--cache-type-k"])?.value ?? USUAL_KV; + const v = pairFor(all, ["-ctv", "--cache-type-v"])?.value ?? USUAL_KV; + return k === USUAL_KV && v === USUAL_KV; +} + +/** + * Dense boot dials. Grouped tables stay in `groupFlagPairs` for tests; + * the TTY card prints these lines instead. + */ +export function compactBootLines(pairs: readonly FlagPair[]): string[] { + const used = new Set(); + const take = (flags: readonly string[]): FlagPair | undefined => { + const p = pairFor(pairs, flags); + if (p) used.add(p.flag); + return p; + }; + + const ctx = take(["--ctx-size", "-c"]); + const ngl = take(["-ngl", "--n-gpu-layers", "--gpu-layers"]); + const threads = take(["--threads"]); + const flash = take(["--flash-attn", "-fa"]); + const parallel = take(["--parallel"]); + const reuse = take(["--cache-reuse"]); + const ram = take(["--cache-ram"]); + const ctk = take(["-ctk", "--cache-type-k"]); + const ctv = take(["-ctv", "--cache-type-v"]); + const reason = take(["--reasoning-budget"]); + const preserve = take(["--reasoning-preserve"]); + const warmup = take(["--warmup"]); + const noWarmup = take(["--no-warmup"]); + + const context: string[] = []; + if (ctx) context.push(`ctx ${ctx.value}`); + if (ngl) context.push(`ngl ${ngl.value}`); + if (threads) context.push(`threads ${threads.value}`); + if (flash) context.push(`flash ${flash.value === "on" ? "on" : flash.value}`); + if (parallel) context.push(`parallel ${parallel.value}`); + + const cache: string[] = []; + if (reuse) cache.push(`cache ${reuse.value}`); + if (ram) cache.push(`ram ${ram.value}`); + const k = ctk?.value; + const v = ctv?.value; + const usualKv = + (!k && !v) || + ((k ?? USUAL_KV) === USUAL_KV && (v ?? USUAL_KV) === USUAL_KV); + if (!usualKv) { + cache.push(`kv ${[k, v].filter(Boolean).join("/")}`); + } + + const thinking: string[] = []; + if (reason) thinking.push(`reason ${reason.value}`); + if (preserve && preserve.value === "on") thinking.push("preserve"); + else if (preserve) thinking.push(`preserve ${preserve.value}`); + if (noWarmup) thinking.push("no-warmup"); + else if (warmup) thinking.push("warmup"); + + const other = pairs + .filter((p) => !used.has(p.flag) && !isUsualBootFlag(p, pairs)) + .map((p) => (p.value === "on" ? p.flag : `${p.flag} ${p.value}`)); + + const lines: string[] = []; + if (context.length > 0) lines.push(context.join(" ")); + if (cache.length > 0) lines.push(cache.join(" ")); + if (thinking.length > 0) lines.push(thinking.join(" ")); + if (other.length > 0) lines.push(`other ${other.join(" ")}`); + return lines; +} + export function groupFlagPairs(pairs: readonly FlagPair[]): FlagGroup[] { const buckets = new Map(); for (const g of GROUP_FLAGS) buckets.set(g.name, []); diff --git a/packages/core/src/cli/help.test.ts b/packages/core/src/cli/help.test.ts new file mode 100644 index 0000000..e2cc460 --- /dev/null +++ b/packages/core/src/cli/help.test.ts @@ -0,0 +1,21 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { usageServers } from "./help.js"; + +describe("help", () => { + const prevNoColor = process.env.NO_COLOR; + + afterEach(() => { + if (prevNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = prevNoColor; + }); + + it("keeps servers help short and still names slots, binaries, and boot flags", () => { + process.env.NO_COLOR = "1"; + const text = usageServers(); + expect(text.split("\n").length).toBeLessThanOrEqual(16); + expect(text).toContain("mba servers slots"); + expect(text).toContain("mba servers binaries"); + expect(text).toContain("MBA_SWITCH_PORT"); + expect(text).toContain("--json"); + }); +}); diff --git a/packages/core/src/cli/help.ts b/packages/core/src/cli/help.ts index 417ae03..27b5030 100644 --- a/packages/core/src/cli/help.ts +++ b/packages/core/src/cli/help.ts @@ -15,7 +15,7 @@ export function usageOverview(): string { cmd("mba servers", "list, boot, stop, logs, slots, builds (s)"), cmd("mba clients", "list, add, connect, revoke (c)"), cmd("mba machine", "hardware clamp mode"), - cmd("mba status", "service, loaded model, pairing"), + cmd("mba status", "service, loaded model, pairing slots"), "", heading("Local"), cmd("mba migrate-paths", "move legacy state + store"), @@ -56,22 +56,16 @@ export function usageServers(): string { "", cmd("mba servers", "list / boot / stop / logs / slots / builds (TTY menu)"), cmd("mba servers list", "registered servers (TTY picker)"), - cmd("mba servers boot [port]", "boot (port defaults to 8080)"), + cmd("mba servers boot [port]", "boot with no client env"), cmd("mba servers stop ", "stop a registered server"), cmd("mba servers logs ", "[--lines N] [--follow]"), cmd("mba servers slots ", "list / erase / save / restore KV"), + cmd("mba servers binaries", "nickname / use / remove / restore a build"), cmd("mba servers builds", "llama-server catalog (TTY picker)"), "", - dim(" mba servers slots erase wipe the live slot (default 0)"), - dim(" mba servers slots save f write f into kv//slots"), - dim(" mba servers slots restore f load f from that folder"), - dim(" mba servers boot pick model + port on a TTY"), - dim(" --yes skip the boot flag confirm"), - dim(" llama-server catalog of builds; rescans every 15m"), - dim(" mba servers binaries nickname / use / remove / restore a build"), - dim(" --type ollama boot an ollama tag"), - dim(" --json on list"), - dim(" MBA_SWITCH_PORT default boot port (8080)"), + dim(" slots erase | save | restore live KV, default slot 0"), + dim(" boot family+model dials; mba connect attaches a client"), + dim(" --type ollama --json on list MBA_SWITCH_PORT (8080)"), ].join("\n"); } @@ -88,7 +82,7 @@ export function usageClients(): string { "", dim(" alias mba c mba client"), dim(" --project project folder (default cwd)"), - dim(" --ide optional; connect/boot share this env key"), + dim(" --ide optional; connect env key (boot stays bare)"), dim(" harness built-in set, or a name from mba clients add"), dim(" --json on list, connect, revoke, add, remove"), ].join("\n"); diff --git a/packages/core/src/cli/home.ts b/packages/core/src/cli/home.ts index 9dc6aac..9978c07 100644 --- a/packages/core/src/cli/home.ts +++ b/packages/core/src/cli/home.ts @@ -13,9 +13,8 @@ export async function cmdHome(baseUrl: string, assumeNo: boolean): Promise { label: "clients", value: "clients", preview: [["do", "add a client, pair, list, revoke"]] }, { label: "machine", value: "machine", preview: [["do", "enforce / warn / off"]] }, { label: "status", value: "status", preview: [["do", "service, loaded models, overlay"]] }, - { label: "quit", value: "quit", preview: [["do", "leave mba"]] }, ]); - if (pick === null || pick === "quit") { + if (pick === null) { process.stdout.write("[mba] done\n"); return; } diff --git a/packages/core/src/cli/list-print.test.ts b/packages/core/src/cli/list-print.test.ts new file mode 100644 index 0000000..9c73723 --- /dev/null +++ b/packages/core/src/cli/list-print.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { homedir } from "node:os"; +import { + formatModelLine, + formatRegisteredClientLine, + formatServerLine, +} from "./list-print.js"; + +describe("list-print", () => { + const prevNoColor = process.env.NO_COLOR; + + afterEach(() => { + if (prevNoColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = prevNoColor; + }); + + it("names a server with health and a shortened GGUF path", () => { + process.env.NO_COLOR = "1"; + const line = formatServerLine({ + id: "llama-cpp-8080", + port: 8080, + pid: 12, + healthy: true, + modelFile: `${homedir()}/models/qwen.gguf`, + }); + expect(line).toContain("llama-cpp-8080"); + expect(line).toContain("8080"); + expect(line).toContain("12"); + expect(line).toContain("ok"); + expect(line).toContain("~/models/qwen.gguf"); + }); + + it("marks a loaded model and leaves others as ·", () => { + process.env.NO_COLOR = "1"; + expect(formatModelLine({ id: "deepseek_test", family: "deepseek", loaded: true })).toContain( + "loaded", + ); + expect(formatModelLine({ id: "nomic-embed-text-v1.5" })).toContain("·"); + }); + + it("prints registered clients with kind and envelope", () => { + process.env.NO_COLOR = "1"; + const cursor = formatRegisteredClientLine({ + name: "cursor", + envelope: ".cursor/rules/mba.mdc", + source: "built-in", + }); + expect(cursor).toContain("cursor"); + expect(cursor).toContain("ide"); + expect(cursor).toContain(".cursor/rules/mba.mdc"); + expect(cursor).not.toContain("added"); + + const claude = formatRegisteredClientLine({ + name: "claude-code", + envelope: "CLAUDE.local.md", + source: "built-in", + }); + expect(claude).toContain("cli"); + + const extra = formatRegisteredClientLine({ + name: "windsurf", + envelope: ".windsurf/mba.md", + source: "added", + }); + expect(extra).toContain("· added"); + }); +}); diff --git a/packages/core/src/cli/list-print.ts b/packages/core/src/cli/list-print.ts new file mode 100644 index 0000000..fa326b3 --- /dev/null +++ b/packages/core/src/cli/list-print.ts @@ -0,0 +1,40 @@ +/** + * Shared TTY rows for lists (status, servers, models, registered clients). + * JSON payloads stay on the command modules. + */ + +import { harnessKind } from "../service/env-context.js"; +import { dim, paint, shortenHome, BOLD, GRN, RED } from "./style.js"; + +export function formatServerLine(s: { + readonly id: string; + readonly port: number; + readonly pid?: number; + readonly healthy: boolean; + readonly modelFile: string; +}): string { + const health = s.healthy ? paint("ok", GRN) : paint("down", RED); + const pid = s.pid !== undefined ? String(s.pid) : "-"; + return ` ${paint(s.id.padEnd(18), BOLD)} ${String(s.port).padEnd(5)} ${pid.padEnd(7)} ${health} ${dim(shortenHome(s.modelFile))}`; +} + +export function formatModelLine(m: { + readonly id: string; + readonly family?: string; + readonly loaded?: boolean; +}): string { + const tag = (m.loaded ? "loaded" : "·").padEnd(6); + const painted = m.loaded ? paint(tag, GRN) : dim(tag); + const family = m.family ? dim(` ${m.family}`) : ""; + return ` ${paint(m.id.padEnd(22), BOLD)} ${painted}${family}`; +} + +/** Catalog row: same header grammar as a paired slot (name, kind, file). */ +export function formatRegisteredClientLine(h: { + readonly name: string; + readonly envelope: string; + readonly source?: "built-in" | "added"; +}): string { + const extra = h.source === "added" ? " · added" : ""; + return ` ${paint(h.name, BOLD)} ${dim(harnessKind(h.name))}${extra} ${dim(h.envelope)}`; +} \ No newline at end of file diff --git a/packages/core/src/cli/models.ts b/packages/core/src/cli/models.ts index e47c2b9..86a1b29 100644 --- a/packages/core/src/cli/models.ts +++ b/packages/core/src/cli/models.ts @@ -3,7 +3,9 @@ */ import { fail, formatBytes, serviceGet, servicePost, servicePostSse } from "./client.js"; -import { brand, dim, doneBox, heading, option, paint, shortenHome, BOLD } from "./style.js"; +import { brand, dim, heading, kv, paint, shortenHome, pulledLine, BOLD } from "./style.js"; +import { formatModelLine } from "./list-print.js"; +import { extraIde, harnessKind } from "../service/env-context.js"; import { askTextInteractive, askValueInteractive, @@ -22,25 +24,24 @@ import { harnessPickerRows } from "./harness-choices.js"; function printConfig(cfg: ModelConfig): void { process.stdout.write(`${brand("show")} ${paint(cfg.modelId, BOLD)}\n`); - process.stdout.write(` ${dim("yaml")} ${cfg.files.yamlPath}\n`); - process.stdout.write(` ${dim("server_setup")} ${cfg.files.serverSetupPath}\n`); + process.stdout.write(`${kv("yaml", shortenHome(cfg.files.yamlPath), 12)}\n`); + process.stdout.write(`${kv("server_setup", shortenHome(cfg.files.serverSetupPath), 12)}\n`); if (cfg.files.blockCount !== undefined) { - process.stdout.write(` ${dim("blockCount")} ${cfg.files.blockCount}\n`); + process.stdout.write(`${kv("blocks", String(cfg.files.blockCount), 12)}\n`); } if (cfg.files.maxContextLength !== undefined) { - process.stdout.write(` ${dim("maxContext")} ${cfg.files.maxContextLength}\n`); + process.stdout.write(`${kv("max ctx", String(cfg.files.maxContextLength), 12)}\n`); } process.stdout.write("\n"); for (const file of ["server_setup", "client"] as const) { const fields = cfg.fields.filter((f) => f.file === file); - const label = file === "server_setup" ? "server_setup (llama.cpp boot flags)" : "client (live-synced)"; - process.stdout.write(` ${heading(label)}\n`); + process.stdout.write(` ${heading(file === "server_setup" ? "server" : "client")}\n`); for (const f of fields) { - const current = f.current === null ? "(unset)" : String(f.current); - const restart = f.restartRequired ? " restart" : ""; + const current = f.current === null ? dim("unset") : String(f.current); + const restart = f.restartRequired ? dim(" restart") : ""; const hints = [f.hint, f.machineHint].filter(Boolean).join("; "); - const hint = hints ? ` ${hints}` : ""; - process.stdout.write(`${option(false, f.field.padEnd(16), `${current}${restart}${hint}`)}\n`); + const hint = hints ? dim(` ${hints}`) : ""; + process.stdout.write(`${kv(f.field, `${current}${restart}${hint}`, 16)}\n`); } process.stdout.write("\n"); } @@ -99,9 +100,9 @@ async function listModels(baseUrl: string): Promise { } function printModelList(models: readonly ModelEntry[]): void { + process.stdout.write(`${brand("models")}\n`); for (const m of models) { - const loaded = m.loaded ? " [loaded]" : ""; - process.stdout.write(`${m.id}${m.family ? ` (${m.family})` : ""}${loaded}\n`); + process.stdout.write(`${formatModelLine(m)}\n`); } } @@ -118,7 +119,8 @@ export async function cmdModelsList(baseUrl: string, json = false): Promise(baseUrl, "/models/pull", body); - process.stdout.write( - doneBox("PULLED", [ - ["id", result.resumed ? `${result.id} · resumed` : result.id], - ["family", result.familyCreated ? `${result.family} · new` : result.family], - ["next", `mba s boot ${result.id}`], - ]) + "\n", - ); + process.stdout.write(`${pulledLine(result.id, result.family)}\n`); process.stdout.write(`${dim(` ${shortenHome(result.modelDir)}`)}\n`); } catch (error) { process.stderr.write(`[mba] error: ${error instanceof Error ? error.message : String(error)}\n`); @@ -556,7 +552,13 @@ export async function cmdModelsConnect( process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); return; } - process.stdout.write(`[mba] connected ${result.modelId} as ${result.harness}\n`); + process.stdout.write(`[mba] connected ${result.modelId}\n`); + const extra = extraIde(result.harness, ide); + const extraBit = extra ? ` · ${extra}` : ""; + const file = result.stage && "envelope" in result.stage ? result.stage.envelope : undefined; + process.stdout.write( + ` ${paint(result.harness, BOLD)} ${dim(harnessKind(result.harness))}${extraBit} ${dim(file ?? "—")}\n`, + ); process.stdout.write(`[mba] token ${result.token}\n`); process.stdout.write(`[mba] point the client at ${baseUrl}/v1 (Authorization: Bearer )\n`); if ("envelope" in result.stage && result.stage.action === "wrote") { diff --git a/packages/core/src/cli/restart.ts b/packages/core/src/cli/restart.ts index a2d9ef8..5d7df72 100644 --- a/packages/core/src/cli/restart.ts +++ b/packages/core/src/cli/restart.ts @@ -7,7 +7,7 @@ import { serviceGet, servicePost } from "./client.js"; import { askYesNoInteractive } from "./interactive.js"; import { resolveModelFile } from "./resolve-model.js"; import { selectRestartTargets } from "./restart-selection.js"; -import { doneBox } from "./style.js"; +import { bootedLine } from "./style.js"; import type { BootResult, ServerEntry, SetResult } from "./types.js"; export async function askYesNo(question: string): Promise { @@ -46,14 +46,7 @@ async function restartServer( } process.stdout.write(`[mba] rebooting ${modelId} on port ${port} (waits for health)…\n`); const entry = await servicePost(baseUrl, "/servers/boot", { modelFile: file, port }); - process.stdout.write( - doneBox("BOOTED", [ - ["id", entry.id], - ["port", String(entry.port)], - ["pid", entry.pid !== undefined ? String(entry.pid) : "-"], - ["next", `mba s logs ${entry.id}`], - ]) + "\n", - ); + process.stdout.write(`${bootedLine(entry.id, entry.pid)}\n`); } export async function handleRestartPrompt( diff --git a/packages/core/src/cli/servers.ts b/packages/core/src/cli/servers.ts index a86df0b..f8f9c19 100644 --- a/packages/core/src/cli/servers.ts +++ b/packages/core/src/cli/servers.ts @@ -3,8 +3,9 @@ */ import { defaultSwitchPort, fail, serviceGet, servicePost } from "./client.js"; -import { groupFlagPairs, pairCliArgs } from "./flag-pairs.js"; -import { brand, dim, doneBox, heading, kv, shortenHome } from "./style.js"; +import { printBootPreview, type BootPreviewExtras } from "./boot-preview.js"; +import { brand, dim, shortenHome, bootedLine } from "./style.js"; +import { formatServerLine } from "./list-print.js"; import { askPortInteractive, askTextInteractive, @@ -23,26 +24,9 @@ function cancelled(): void { } function printServersTable(servers: ServerEntry[]): void { - const header = - "id".padEnd(18) + - "port".padEnd(8) + - "pid".padEnd(10) + - "healthy".padEnd(9) + - "resolved".padEnd(9) + - "dup".padEnd(5) + - "model"; - process.stdout.write(header + "\n"); + process.stdout.write(`${brand("servers")}\n`); for (const s of servers) { - process.stdout.write( - s.id.padEnd(18) + - String(s.port).padEnd(8) + - (s.pid !== undefined ? String(s.pid) : "-").padEnd(10) + - (s.healthy ? "yes" : "no").padEnd(9) + - (s.resolved ? "yes" : "no").padEnd(9) + - (s.duplicate ? "yes" : "-").padEnd(5) + - s.modelFile + - "\n", - ); + process.stdout.write(`${formatServerLine(s)}\n`); } } @@ -53,7 +37,8 @@ async function cmdServersList(baseUrl: string, plain: boolean, json = false): Pr return; } if (servers.length === 0) { - process.stdout.write("[mba] no servers registered\n"); + process.stdout.write(`${brand("servers")}\n`); + process.stdout.write(` ${dim("none")}\n`); return; } if (!process.stdin.isTTY || plain) { @@ -88,6 +73,7 @@ interface LlamaBinaryRow { interface ResolvePreview { readonly cliArgs: string[]; readonly env?: { harness: string; ide: string; serverRuntime: string }; + readonly envAttached?: boolean; readonly binary?: LlamaBinaryRow; readonly binaries?: ReadonlyArray; readonly recommended?: LlamaBackend; @@ -95,6 +81,11 @@ interface ResolvePreview { readonly warning?: string; readonly vendors?: readonly GpuVendor[]; readonly gpus?: readonly string[]; + readonly vramBytes?: readonly (number | null)[]; + readonly ramBytes?: number; + readonly cpuThreads?: number; + readonly machineOverlay?: "enforce" | "warn" | "off"; + readonly model?: BootPreviewExtras["model"]; } export function llamaNickColumnWidth(rows: readonly LlamaBinaryRow[]): number { @@ -142,73 +133,30 @@ export function shouldAskLlamaBinary( return tty && !assumeNo && binCount > 1 && !pinned; } -export function printBootPreview( - modelId: string, - port: number, - cliArgs: readonly string[], - extras?: { - binary?: { path: string; backend: string; nickname?: string }; - warning?: string; - gpus?: readonly string[]; - env?: { harness: string; ide: string; serverRuntime: string }; - }, -): void { - const pairs = pairCliArgs(cliArgs); - const groups = groupFlagPairs(pairs); - const flagWidth = Math.min( - 22, - Math.max(12, ...pairs.map((p) => p.flag.length), 12), - ); - process.stdout.write(`${brand("boot")}\n`); - process.stdout.write(`${kv("model", modelId, 5)}\n`); - process.stdout.write(`${kv("port", String(port), 5)}\n`); - if (extras?.env) { - process.stdout.write( - `${kv("env", `${extras.env.harness}+${extras.env.ide}+${extras.env.serverRuntime}`, 5)}\n`, - ); - } - if (extras?.gpus && extras.gpus.length > 0) { - process.stdout.write(`${kv("gpu", extras.gpus.join(", "), 5)}\n`); - } - if (extras?.binary) { - const nick = extras.binary.nickname?.trim(); - const shown = nick - ? `${extras.binary.backend} ${nick} ${shortenHome(extras.binary.path)}` - : `${extras.binary.backend} ${shortenHome(extras.binary.path)}`; - process.stdout.write(`${kv("bin", shown, 5)}\n`); - } - if (extras?.warning) { - process.stdout.write(`${kv("note", extras.warning, 5)}\n`); - } - for (const group of groups) { - process.stdout.write(`\n ${heading(group.name)}\n`); - for (const { flag, value } of group.pairs) { - process.stdout.write(` ${dim(flag.padEnd(flagWidth))} ${value}\n`); - } - } - process.stdout.write("\n"); -} +export { printBootPreview }; -function previewExtras( - recipe: ResolvePreview, - binaryPath: string | undefined, -): { - binary?: { path: string; backend: string; nickname?: string }; - warning?: string; - gpus?: readonly string[]; - env?: { harness: string; ide: string; serverRuntime: string }; -} { +function previewExtras(recipe: ResolvePreview, binaryPath: string | undefined): BootPreviewExtras { const bin = binaryPath !== undefined ? recipe.binaries?.find((b) => b.path === binaryPath) ?? recipe.binary : recipe.binary; const vendors = new Set(recipe.vendors ?? []); const warning = bin ? binaryMismatchWarning(bin.backend, vendors) : recipe.warning; + const picked = + binaryPath !== undefined && recipe.binary?.path !== undefined && binaryPath !== recipe.binary.path; return { binary: bin, warning, gpus: recipe.gpus, + vramBytes: recipe.vramBytes, + ramBytes: recipe.ramBytes, + cpuThreads: recipe.cpuThreads, + machineOverlay: recipe.machineOverlay, env: recipe.env, + envAttached: recipe.envAttached, + model: recipe.model, + pinned: recipe.pinned, + picked, }; } @@ -309,28 +257,15 @@ async function cmdServersBoot( modelRef, port, }); - process.stdout.write( - doneBox("BOOTED", [ - ["id", entry.id], - ["port", String(entry.port)], - ["next", `mba s logs ${entry.id}`], - ]) + "\n", - ); + process.stdout.write(`${bootedLine(entry.id, undefined, `mba connect ${modelRef}`)}\n`); return entry; } const modelFile = await resolveModelFile(baseUrl, modelRef); - process.stdout.write(`[mba] booting ${modelFile} on port ${port} (waits for health)…\n`); + process.stdout.write(` ${dim("waiting")} health…\n`); const body: Record = { modelFile, port }; if (binaryPath) body.binaryPath = binaryPath; const entry = await servicePost(baseUrl, "/servers/boot", body); - process.stdout.write( - doneBox("BOOTED", [ - ["id", entry.id], - ["port", String(entry.port)], - ["pid", entry.pid !== undefined ? String(entry.pid) : "-"], - ["next", `mba s logs ${entry.id}`], - ]) + "\n", - ); + process.stdout.write(`${bootedLine(entry.id, entry.pid, `mba connect ${modelRef}`)}\n`); return entry; } diff --git a/packages/core/src/cli/slot-print.test.ts b/packages/core/src/cli/slot-print.test.ts new file mode 100644 index 0000000..54f40b4 --- /dev/null +++ b/packages/core/src/cli/slot-print.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { extraIde, formatClientLabel, harnessKind } from "../service/env-context.js"; +import { formatPairedSlotLines, groupPairedSlots } from "./slot-print.js"; + +const PROJECT = "/home/dev/MBA"; + +describe("harnessKind", () => { + it("marks Claude Code as cli and the rest as ide", () => { + expect(harnessKind("claude-code")).toBe("cli"); + expect(harnessKind("claude")).toBe("cli"); + expect(harnessKind("cursor")).toBe("ide"); + expect(harnessKind("copilot")).toBe("ide"); + }); +}); + +describe("extraIde", () => { + it("hides the default and the same-name ide", () => { + expect(extraIde("cursor", "cursor")).toBeUndefined(); + expect(extraIde("copilot", "vscode")).toBeUndefined(); + expect(extraIde("copilot", "cursor")).toBe("cursor"); + expect(formatClientLabel("copilot", "cursor")).toBe("copilot+cursor"); + }); +}); + +describe("groupPairedSlots", () => { + it("stacks models that share harness + project + envelope", () => { + const groups = groupPairedSlots([ + { + harness: "cursor", + ide: "cursor", + projectRoot: PROJECT, + modelId: "nomic-embed-text-v1.5", + card: false, + envelope: ".cursor/rules/mba.mdc", + }, + { + harness: "cursor", + ide: "cursor", + projectRoot: PROJECT, + modelId: "deepseek_test", + card: true, + envelope: ".cursor/rules/mba.mdc", + }, + ]); + expect(groups).toHaveLength(1); + expect(groups[0]!.models.map((m) => m.modelId)).toEqual([ + "deepseek_test", + "nomic-embed-text-v1.5", + ]); + }); +}); + +describe("formatPairedSlotLines", () => { + it("names the app, the kind, and the envelope file", () => { + const lines = formatPairedSlotLines([ + { + harness: "cursor", + ide: "cursor", + projectRoot: PROJECT, + modelId: "deepseek_test", + card: true, + envelope: ".cursor/rules/mba.mdc", + }, + { + harness: "cursor", + ide: "cursor", + projectRoot: PROJECT, + modelId: "nomic-embed-text-v1.5", + card: false, + envelope: ".cursor/rules/mba.mdc", + }, + ]); + expect(lines[0]).toContain("cursor"); + expect(lines[0]).toContain("ide"); + expect(lines[0]).toContain(".cursor/rules/mba.mdc"); + expect(lines[0]).not.toContain("cursor+cursor"); + expect(lines.some((l) => l.includes("deepseek_test") && l.includes("card"))).toBe(true); + expect(lines.some((l) => l.includes("nomic-embed-text-v1.5") && l.includes("pair"))).toBe( + true, + ); + }); + + it("marks Claude Code as cli", () => { + const lines = formatPairedSlotLines([ + { + harness: "claude-code", + projectRoot: PROJECT, + modelId: "deepseek_test", + card: true, + envelope: "CLAUDE.local.md", + }, + ]); + expect(lines[0]).toContain("claude-code"); + expect(lines[0]).toContain("cli"); + expect(lines[0]).toContain("CLAUDE.local.md"); + }); +}); diff --git a/packages/core/src/cli/slot-print.ts b/packages/core/src/cli/slot-print.ts new file mode 100644 index 0000000..fb21daa --- /dev/null +++ b/packages/core/src/cli/slot-print.ts @@ -0,0 +1,74 @@ +/** + * TTY grouping for paired sessions: one slot (app + file), then models. + * + * JSON still has harness / ide / card / envelope / projectRoot per session. + */ + +import { extraIde, harnessKind } from "../service/env-context.js"; +import { dim, paint, shortenHome, BOLD, GRN } from "./style.js"; +import type { PairingSession } from "./types.js"; + +interface SlotGroup { + readonly harness: string; + readonly ide?: string; + readonly envelope: string; + readonly projectRoot: string; + readonly models: PairingSession[]; +} + +function slotKey(s: PairingSession): string { + return `${s.harness}\0${s.projectRoot}\0${s.envelope ?? ""}`; +} + +export function groupPairedSlots(sessions: readonly PairingSession[]): SlotGroup[] { + const order: string[] = []; + const byKey = new Map(); + for (const s of sessions) { + const key = slotKey(s); + const existing = byKey.get(key); + if (existing) { + existing.models.push(s); + continue; + } + order.push(key); + byKey.set(key, { + harness: s.harness, + ide: s.ide, + envelope: s.envelope ?? "", + projectRoot: s.projectRoot, + models: [s], + }); + } + return order.map((key) => { + const g = byKey.get(key)!; + const models = [...g.models].sort((a, b) => { + if (Boolean(a.card) !== Boolean(b.card)) return a.card ? -1 : 1; + return a.modelId.localeCompare(b.modelId); + }); + return { ...g, models }; + }); +} + +function headerLine(g: SlotGroup): string { + const kind = harnessKind(g.harness); + const extra = extraIde(g.harness, g.ide); + const extraBit = extra ? ` · ${extra}` : ""; + const file = g.envelope.length > 0 ? g.envelope : "—"; + return ` ${paint(g.harness, BOLD)} ${dim(kind)}${extraBit} ${dim(file)}`; +} + +function modelLine(s: PairingSession): string { + const tag = (s.card ? "card" : "pair").padEnd(4); + const painted = s.card ? paint(tag, GRN) : dim(tag); + return ` ${s.modelId.padEnd(22)} ${painted} ${dim(shortenHome(s.projectRoot))}`; +} + +/** Lines for status / clients TTY. Empty list → caller prints none. */ +export function formatPairedSlotLines(sessions: readonly PairingSession[]): string[] { + const lines: string[] = []; + for (const g of groupPairedSlots(sessions)) { + lines.push(headerLine(g)); + for (const m of g.models) lines.push(modelLine(m)); + } + return lines; +} diff --git a/packages/core/src/cli/status.ts b/packages/core/src/cli/status.ts index 2141004..e01e0a3 100644 --- a/packages/core/src/cli/status.ts +++ b/packages/core/src/cli/status.ts @@ -1,41 +1,20 @@ -import { formatClientLabel } from "../service/env-context.js"; import { resolveServiceUrl, serviceGet } from "./client.js"; -import { brand, dim, heading, kv, paint, shortenHome, BOLD, GRN, RED } from "./style.js"; +import { formatPairedSlotLines } from "./slot-print.js"; +import { formatServerLine } from "./list-print.js"; +import { brand, dim, heading, kv, paint, GRN, RED } from "./style.js"; import type { ModelEntry } from "./interactive.js"; -import type { ServerEntry } from "./types.js"; - -interface PublicSession { - readonly id: string; - readonly modelId: string; - readonly harness: string; - readonly ide?: string; - readonly projectRoot: string; - readonly createdAt: string; - readonly card?: boolean; -} +import type { PairingSession, ServerEntry } from "./types.js"; interface StatusBody { readonly pairing?: { readonly active: boolean; readonly count: number; - readonly sessions?: readonly PublicSession[]; + readonly sessions?: readonly PairingSession[]; }; } function printServerRow(s: ServerEntry): void { - const health = s.healthy ? paint("ok", GRN) : paint("down", RED); - const pid = s.pid !== undefined ? String(s.pid) : "-"; - process.stdout.write( - ` ${paint(s.id.padEnd(18), BOLD)} ${String(s.port).padEnd(5)} ${pid.padEnd(7)} ${health} ${dim(s.modelFile)}\n`, - ); -} - -function printSessionRow(s: PublicSession): void { - const who = formatClientLabel(s.harness, s.ide); - const tag = (s.card ? "card" : "pair").padEnd(4); - process.stdout.write( - ` ${paint(who.padEnd(18), BOLD)} ${s.modelId.padEnd(22)} ${s.card ? paint(tag, GRN) : dim(tag)} ${dim(shortenHome(s.projectRoot))}\n`, - ); + process.stdout.write(`${formatServerLine(s)}\n`); } export async function cmdStatus(json: boolean): Promise { @@ -120,19 +99,19 @@ export async function cmdStatus(json: boolean): Promise { : dim("off"); process.stdout.write(`${kv("pairing", pairingLabel)}\n`); - process.stdout.write(`\n ${heading("clients")}`); + process.stdout.write(`\n ${heading("clients")}\n`); if (sessions.length === 0) { - process.stdout.write(` ${dim("none")}\n`); + process.stdout.write(` ${dim("none")}\n`); } else { - process.stdout.write(`\n`); - for (const sess of sessions) printSessionRow(sess); + for (const line of formatPairedSlotLines(sessions)) { + process.stdout.write(`${line}\n`); + } } - process.stdout.write(`\n ${heading("servers")}`); + process.stdout.write(`\n ${heading("servers")}\n`); if (servers.length === 0) { - process.stdout.write(` ${dim("none")}\n`); + process.stdout.write(` ${dim("none")}\n`); return; } - process.stdout.write(`\n`); for (const s of servers) printServerRow(s); } diff --git a/packages/core/src/cli/style.test.ts b/packages/core/src/cli/style.test.ts index 71475fd..849d906 100644 --- a/packages/core/src/cli/style.test.ts +++ b/packages/core/src/cli/style.test.ts @@ -9,6 +9,8 @@ import { previewBox, shortenHome, visibleLen, + bootedLine, + pulledLine, BOLD, CYAN, colorEnabled, @@ -42,6 +44,23 @@ describe("cli style", () => { expect(paint("MBA", BOLD, CYAN)).toBe(`${BOLD}${CYAN}MBA\x1b[0m`); }); + it("renders a one-line BOOTED result", () => { + process.env.NO_COLOR = "1"; + expect(bootedLine("llama-cpp-8080", 126627)).toBe( + " BOOTED llama-cpp-8080 pid 126627 next mba s logs llama-cpp-8080", + ); + expect(bootedLine("llama-cpp-8080", 126627, "mba connect deepseek_test")).toBe( + " BOOTED llama-cpp-8080 pid 126627 next mba connect deepseek_test", + ); + }); + + it("renders a one-line PULLED result", () => { + process.env.NO_COLOR = "1"; + expect(pulledLine("deepseek_test", "deepseek")).toBe( + " PULLED deepseek_test deepseek next mba s boot deepseek_test", + ); + }); + it("renders a done box with a title", () => { process.env.NO_COLOR = "1"; const box = doneBox("BOOTED", [ diff --git a/packages/core/src/cli/style.ts b/packages/core/src/cli/style.ts index 791c7f9..219da51 100644 --- a/packages/core/src/cli/style.ts +++ b/packages/core/src/cli/style.ts @@ -104,6 +104,19 @@ export function doneBox(title: string, rows: ReadonlyArray { expect(flags?.warmupTokens).toBe(777); }); + it("skips environment folders when applyEnvFolders is false (bare boot)", () => { + const adapters = join(dir, "mba", "adapters"); + buildTree(adapters); + + const resolved = resolveMbaConfig(join(dir, "mba"), CTX, { applyEnvFolders: false }); + const flags = resolved.server["llama.cpp"]; + + expect(flags?.ctxSize).toBe(100000); + expect(flags?.gpuLayers).toBe(99); + expect(flags?.threads).toBeUndefined(); + expect(flags?.warmupTokens).toBeUndefined(); + }); + + it("does not match copilot overlays when harness is none", () => { + const adapters = join(dir, "mba", "adapters"); + buildTree(adapters); + + const resolved = resolveMbaConfig(join(dir, "mba"), { + ...CTX, + harness: "none", + ide: "none", + }); + const flags = resolved.server["llama.cpp"]; + expect(flags?.ctxSize).toBe(100000); + expect(flags?.threads).toBeUndefined(); + expect(flags?.warmupTokens).toBeUndefined(); + }); + it("selects the exact environment folder by harness-ide-runtime segments", () => { const adapters = join(dir, "mba", "adapters"); buildTree(adapters); diff --git a/packages/core/src/mba/resolver.ts b/packages/core/src/mba/resolver.ts index b3b6823..8a70c58 100644 --- a/packages/core/src/mba/resolver.ts +++ b/packages/core/src/mba/resolver.ts @@ -62,6 +62,11 @@ export interface MbaResolverOptions { * If omitted, built-in defaults are used. */ readonly globalBcbConfig?: ToolCircuitBreakerConfig; + /** + * When false, skip `environments/` overlay folders (bare boot). Default + * true: stage, proxy, and an explicit `--harness` still merge those dials. + */ + readonly applyEnvFolders?: boolean; } /** @@ -291,7 +296,9 @@ export function resolveMbaConfig( // adapters (family / model) carry an environments/ folder; a legacy env // adapter IS the environment, so it has none. The folder holds only the // binding files it overrides; absent files inherit from lower rungs. - if (!isLegacyEnvAdapter) { + // Bare boot skips this rung so a leftover pairing (or Copilot default) + // cannot silently rewrite server_setup / TCB. + if (!isLegacyEnvAdapter && options.applyEnvFolders !== false) { const envDir = selectEnvironmentFolder(scopeDir, enrichedCtx); if (envDir) { for (const [file, fileName] of ENV_BINDING_FILES) { diff --git a/packages/core/src/model/model-pull.test.ts b/packages/core/src/model/model-pull.test.ts index 439a8bf..f86feec 100644 --- a/packages/core/src/model/model-pull.test.ts +++ b/packages/core/src/model/model-pull.test.ts @@ -140,6 +140,7 @@ describe("pullModel", () => { expect(readFileSync(join(familyDir, "notes.md"), "utf8")).toBe(""); expect(fam.bindings.instructions).toBe("./instructions.md"); expect(fam.bindings.notes).toBe("./notes.md"); + expect(existsSync(join(modelDir, "environments"))).toBe(false); } finally { rmSync(store, { recursive: true, force: true }); } diff --git a/packages/core/src/service/ctx-size-resolver.ts b/packages/core/src/service/ctx-size-resolver.ts index da36264..0a99546 100644 --- a/packages/core/src/service/ctx-size-resolver.ts +++ b/packages/core/src/service/ctx-size-resolver.ts @@ -17,6 +17,7 @@ import { readFileSync } from "node:fs"; import { dirname } from "node:path"; import YAML from "yaml"; import { resolveMbaConfig, sanitizeLlamaCppServerFlags } from "../mba/index.js"; +import { BARE_BOOT_ENV } from "./env-context.js"; import { readModelCatalog } from "./model-catalog.js"; import type { CtxSizeResolver } from "./model-endpoint-sync.js"; @@ -33,13 +34,17 @@ export function buildCtxSizeResolver(adapterDir: string): CtxSizeResolver { identity?: { model?: { name?: string; family?: string } }; }; const modelName = raw.identity?.model?.name ?? entry.name; - const resolved = resolveMbaConfig(mbaBaseDir, { - modelName, - modelFamily: raw.identity?.model?.family, - harness: "copilot", - ide: "vscode", - serverRuntime: "llamacpp", - }); + const resolved = resolveMbaConfig( + mbaBaseDir, + { + modelName, + modelFamily: raw.identity?.model?.family, + harness: BARE_BOOT_ENV.harness, + ide: BARE_BOOT_ENV.ide, + serverRuntime: BARE_BOOT_ENV.serverRuntime, + }, + { applyEnvFolders: false }, + ); const { flags } = sanitizeLlamaCppServerFlags(resolved.server["llama.cpp"]); return flags.ctxSize; } catch { diff --git a/packages/core/src/service/env-context.test.ts b/packages/core/src/service/env-context.test.ts index 11075d2..2756384 100644 --- a/packages/core/src/service/env-context.test.ts +++ b/packages/core/src/service/env-context.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "vitest"; -import { DEFAULT_RESOLVE_ENV, defaultIdeForHarness, formatClientLabel, resolveEnvContext } from "./env-context.js"; +import { + BARE_BOOT_ENV, + DEFAULT_RESOLVE_ENV, + bootEnvJson, + defaultIdeForHarness, + formatClientLabel, + isBareBootEnv, + overlayFolderName, + resolveEnvContext, +} from "./env-context.js"; import { hashToken, type ClientSession } from "./sessions.js"; function session(partial: Partial & Pick): ClientSession { @@ -14,8 +23,20 @@ function session(partial: Partial & Pick { + it("is none and skips env folders", () => { + expect(isBareBootEnv(BARE_BOOT_ENV)).toBe(true); + expect(isBareBootEnv(DEFAULT_RESOLVE_ENV)).toBe(false); + expect(bootEnvJson(BARE_BOOT_ENV)).toEqual({ + harness: "none", + ide: "none", + serverRuntime: "llamacpp", + }); + }); +}); + describe("resolveEnvContext", () => { - it("falls back to copilot+vscode+llamacpp when unpaired", () => { + it("falls back to copilot+vscode+llamacpp when unpaired (connect/stage, not boot)", () => { expect(resolveEnvContext({ modelId: "deepseek_test" })).toEqual(DEFAULT_RESOLVE_ENV); }); @@ -64,6 +85,22 @@ describe("defaultIdeForHarness", () => { }); }); +describe("overlayFolderName", () => { + it("stays harness-only unless ide or runtime actually differs", () => { + expect(overlayFolderName({ harness: "cursor", ide: "cursor", serverRuntime: "llamacpp" })).toBe( + "cursor", + ); + expect(overlayFolderName({ harness: "copilot", ide: "vscode", serverRuntime: "llamacpp" })).toBe( + "copilot", + ); + expect(overlayFolderName({ harness: "claude-code", ide: "cli" })).toBe("claude-code"); + expect(overlayFolderName({ harness: "copilot", ide: "cursor" })).toBe("copilot+cursor"); + expect( + overlayFolderName({ harness: "cursor", ide: "cursor", serverRuntime: "ollama" }), + ).toBe("cursor+ollama"); + }); +}); + describe("formatClientLabel", () => { it("drops a redundant or default ide", () => { expect(formatClientLabel("cursor", "cursor")).toBe("cursor"); diff --git a/packages/core/src/service/env-context.ts b/packages/core/src/service/env-context.ts index 5e0a8be..fa82924 100644 --- a/packages/core/src/service/env-context.ts +++ b/packages/core/src/service/env-context.ts @@ -1,9 +1,11 @@ /** - * Shared resolve context for connect (stage) and boot (recipe). + * Shared resolve context for connect (stage) vs boot (recipe). * - * When a model is paired, boot uses that session's harness + ide so the - * environment folder matches the client that connected. No pairing → the - * historical default (copilot + vscode + llamacpp). + * Boot is always bare: family + model dials only. A prior pairing does not + * pick an environment overlay — that is how operators ended up generating + * Copilot/Cursor configs without choosing them. Connect attaches a client + * (card + token). Env folders still apply when a caller passes an explicit + * harness (stage, proxy fingerprint, `resolve-server-recipe --harness`). */ import { compactHarnessKey, normalizeHarness } from "../mba/envelope.js"; @@ -17,6 +19,34 @@ export const DEFAULT_RESOLVE_ENV: RecipeResolutionContext = { serverRuntime: "llamacpp", }; +/** Boot / advertised-ctx recipe: no `environments/` overlay. */ +export const BARE_BOOT_ENV: RecipeResolutionContext = { + harness: "none", + ide: "none", + serverRuntime: "llamacpp", + applyEnvFolders: false, +}; + +export function isBareBootEnv(env: { + readonly harness: string; + readonly applyEnvFolders?: boolean; +}): boolean { + return env.applyEnvFolders === false || env.harness === "none"; +} + +/** JSON `env` on POST /servers/resolve — three strings, no internal flags. */ +export function bootEnvJson(env: RecipeResolutionContext): { + readonly harness: string; + readonly ide: string; + readonly serverRuntime: string; +} { + return { + harness: env.harness, + ide: env.ide, + serverRuntime: env.serverRuntime, + }; +} + /** IDE used when connect/boot omit it, so env folders like copilot+vscode match. */ export function defaultIdeForHarness(harness: string): string { const known = normalizeHarness(harness); @@ -30,11 +60,40 @@ export function defaultIdeForHarness(harness: string): string { * default (`copilot+vscode` → `copilot`). Keep `harness+ide` when the ide * is a real extra. */ +export function extraIde(harness: string, ide?: string): string | undefined { + if (!ide) return undefined; + if (compactHarnessKey(ide) === compactHarnessKey(harness)) return undefined; + if (ide === defaultIdeForHarness(harness)) return undefined; + return ide; +} + export function formatClientLabel(harness: string, ide?: string): string { - if (!ide) return harness; - if (compactHarnessKey(ide) === compactHarnessKey(harness)) return harness; - if (ide === defaultIdeForHarness(harness)) return harness; - return `${harness}+${ide}`; + const extra = extraIde(harness, ide); + return extra ? `${harness}+${extra}` : harness; +} + +/** + * Overlay folder to create when this pairing needs extra dials. + * Harness only (`cursor`). Add `+ide` / `+runtime` only when that combo + * differs from the harness default — never a nested directory tree. + */ +export function overlayFolderName(opts: { + readonly harness: string; + readonly ide?: string; + readonly serverRuntime?: string; +}): string { + const parts = [opts.harness]; + const extra = extraIde(opts.harness, opts.ide); + if (extra) parts.push(extra); + if (opts.serverRuntime && opts.serverRuntime !== DEFAULT_RESOLVE_ENV.serverRuntime) { + parts.push(opts.serverRuntime); + } + return parts.join("+"); +} + +/** `cli` for Claude Code; `ide` for Cursor / Copilot / Continue / extras. */ +export function harnessKind(harness: string): "cli" | "ide" { + return defaultIdeForHarness(harness) === "cli" ? "cli" : "ide"; } function sessionForModel( @@ -47,7 +106,8 @@ function sessionForModel( } /** - * Env folder key for this model: newest pairing, else the shipped default. + * Newest pairing for this model, else the connect/stage default + * (copilot + vscode + llamacpp). Boot does not call this. */ export function resolveEnvContext(opts: { readonly sessions?: readonly ClientSession[]; diff --git a/packages/core/src/service/recipe-resolution.ts b/packages/core/src/service/recipe-resolution.ts index 30999aa..91378a8 100644 --- a/packages/core/src/service/recipe-resolution.ts +++ b/packages/core/src/service/recipe-resolution.ts @@ -3,12 +3,14 @@ * * Both the one-shot `resolve-server-recipe` CLI (sourced by the legacy * external boot script) and the in-daemon `resolveBootRecipe` (server plane) - * need the SAME effective recipe for a weights file — the 4-rung merge the proxy uses at - * runtime. Before this extraction the two entry points each re-implemented the - * chain (catalog lookup → declared identity → `resolveMbaConfig` → - * `sanitizeLlamaCppServerFlags` → `buildLlamaServerFlags`), so a fix to one - * could silently drift from the other. This module is the single source of the - * chain; both entry points are now thin wrappers over `resolveRecipe`. + * need the SAME effective recipe for a weights file. Boot passes a bare + * env (`applyEnvFolders: false`); an explicit harness still runs the 4-rung + * merge the proxy uses at request time. Before this extraction the two + * entry points each re-implemented the chain (catalog lookup → declared + * identity → `resolveMbaConfig` → `sanitizeLlamaCppServerFlags` → + * `buildLlamaServerFlags`), so a fix to one could silently drift from the + * other. This module is the single source of the chain; both entry points + * are now thin wrappers over `resolveRecipe`. * * Pure-ish: fs I/O is confined to the catalog + adapter-YAML reads. It throws * when no adapter under `adapterDir` declares `modelFile` — each wrapper maps @@ -34,6 +36,8 @@ export interface RecipeResolutionContext { readonly harness: string; readonly ide: string; readonly serverRuntime: string; + /** When false, skip `environments/` overlays. Omit to apply them. */ + readonly applyEnvFolders?: boolean; } /** The fully-resolved recipe for one weights file. */ @@ -52,6 +56,11 @@ export interface ResolvedRecipe { readonly declaredName?: string; /** Declared `identity.model.family`. */ readonly declaredFamily?: string; + /** YAML `client.vision` / `client.toolCalling` when declared. */ + readonly client?: { + readonly vision?: boolean; + readonly toolCalling?: boolean; + }; /** The raw 4-rung merge result (profile, selectedIds, diagnostics, …). */ readonly resolved: MbaResolvedConfig; /** Fully-populated, in-range LlamaCppServerFlags (post-sanitize). */ @@ -96,12 +105,20 @@ export function resolveRecipe( // the declared name omits). let declaredName: string | undefined; let declaredFamily: string | undefined; + let client: ResolvedRecipe["client"]; try { const raw = YAML.parse(readFileSync(entry.yamlPath, "utf8")) as { identity?: { model?: { name?: string; family?: string } }; + client?: { vision?: unknown; toolCalling?: unknown }; }; declaredName = raw.identity?.model?.name; declaredFamily = raw.identity?.model?.family; + const vision = typeof raw.client?.vision === "boolean" ? raw.client.vision : undefined; + const toolCalling = + typeof raw.client?.toolCalling === "boolean" ? raw.client.toolCalling : undefined; + if (vision !== undefined || toolCalling !== undefined) { + client = { vision, toolCalling }; + } } catch { // Unreadable/malformed YAML — fall through to the catalog name below. } @@ -109,13 +126,17 @@ export function resolveRecipe( // resolveMbaConfig wants the MBA *base* dir (parent of `adapters/`); the // catalog wants the adapters dir itself. Keep the two distinct. const mbaBaseDir = dirname(adapterDir); - const resolved = resolveMbaConfig(mbaBaseDir, { - modelName: declaredName ?? entry.name, - modelFamily: declaredFamily, - harness: ctx.harness, - ide: ctx.ide, - serverRuntime: ctx.serverRuntime, - }); + const resolved = resolveMbaConfig( + mbaBaseDir, + { + modelName: declaredName ?? entry.name, + modelFamily: declaredFamily, + harness: ctx.harness, + ide: ctx.ide, + serverRuntime: ctx.serverRuntime, + }, + { applyEnvFolders: ctx.applyEnvFolders }, + ); const { flags, dropped, clamped } = sanitizeLlamaCppServerFlags(resolved.server["llama.cpp"]); @@ -152,6 +173,7 @@ export function resolveRecipe( catalogName: entry.name, declaredName, declaredFamily, + client, resolved, flags: effectiveFlags, dropped, diff --git a/packages/core/src/service/resolve-server-recipe.ts b/packages/core/src/service/resolve-server-recipe.ts index 1c961a0..6a45f74 100644 --- a/packages/core/src/service/resolve-server-recipe.ts +++ b/packages/core/src/service/resolve-server-recipe.ts @@ -19,8 +19,9 @@ * --model-file (required) Absolute path to the .gguf to boot. * --adapter-dir Adapters root (default: derived from --model-file, * falling back to the OS-aware model store, see service/paths.ts). - * --harness Harness for env-folder selection (default: copilot). - * --ide IDE for env-folder selection (default: vscode). + * --harness Harness for env-folder selection (default: none — + * family+model only, matching in-daemon boot). + * --ide IDE for env-folder selection (default: none). * --runtime Inference runtime (default: llamacpp). * * Output (stdout, single JSON object): @@ -43,6 +44,7 @@ import { existsSync } from "node:fs"; import { dirname, resolve, sep } from "node:path"; import { readClientBlock } from "./model-endpoint-sync.js"; import { resolveRecipe } from "./recipe-resolution.js"; +import { BARE_BOOT_ENV } from "./env-context.js"; import { defaultStorePaths, type MbaStorePaths } from "./config-store.js"; import { readMachineInfo } from "./machine-store.js"; import { readGlobalConfig } from "./config-store.js"; @@ -59,9 +61,9 @@ interface CliArgs { function parseArgs(argv: readonly string[]): CliArgs { const args: CliArgs = { modelFile: "", - harness: "copilot", - ide: "vscode", - runtime: "llamacpp", + harness: BARE_BOOT_ENV.harness, + ide: BARE_BOOT_ENV.ide, + runtime: BARE_BOOT_ENV.serverRuntime, }; for (let i = 0; i < argv.length; i++) { const key = argv[i]; @@ -131,9 +133,7 @@ function main(): void { ); // The shared resolution chain (R1): catalog lookup → declared identity → - // resolveMbaConfig → sanitize → buildLlamaServerFlags. The in-daemon - // resolveBootRecipe runs the same chain, so the flags the script sets and - // the flags the proxy applies are provably the same bytes. + // resolveBootRecipe runs the same chain (bare env unless --harness). // ADR-0103: if a machine profile has been persisted, apply it now. const paths: MbaStorePaths = defaultStorePaths(); const machineInfo = readMachineInfo(paths); @@ -147,6 +147,7 @@ function main(): void { harness: args.harness, ide: args.ide, serverRuntime: args.runtime, + applyEnvFolders: args.harness !== BARE_BOOT_ENV.harness, }, { machineInfo, machineOverlay }, ); diff --git a/packages/core/src/service/server-boot.ts b/packages/core/src/service/server-boot.ts index 87be274..ba5b8bd 100644 --- a/packages/core/src/service/server-boot.ts +++ b/packages/core/src/service/server-boot.ts @@ -3,10 +3,9 @@ * * The "how" of booting a model server in-daemon, replacing the retired * `llama-server-up.sh` shell-out: - * - `resolveBootRecipe` — resolve the per-model tuning recipe (the same - * 4-rung merge the proxy uses: `resolveMbaConfig` → - * `sanitizeLlamaCppServerFlags` → `buildLlamaServerFlags`), so the flags - * the daemon sets are provably the same bytes the proxy applies. + * - `resolveBootRecipe` — resolve family + model dials only (no env + * overlay). Connect attaches a client later. Same sanitize/build chain + * as the one-shot recipe CLI. * - `defaultBinaryPath` — map a fork to its llama-server binary (boot-script * parity), overridable via `MBA_LLAMA_SERVER_BIN`. * - `bootServer` — enforce the G2 port rule (refuse a busy port, allow a new @@ -17,7 +16,7 @@ * recipe read; process spawning goes through the injected `LifecycleSeams`. */ -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { homedir } from "node:os"; import { delimiter, join } from "node:path"; import { daemonLog, resolveSeams, type LifecycleSeams } from "../mba/index.js"; @@ -26,10 +25,7 @@ import type { MachineOverlayMode } from "./config-store.js"; import { resolveRecipe, type RecipeResolutionContext } from "./recipe-resolution.js"; import { listUpstreams, readRegistry, writeRegistry, type UpstreamEntry } from "./upstream-registry.js"; import { getServerTypeOps, type ServerType } from "./server-types.js"; -import { resolveEnvContext, DEFAULT_RESOLVE_ENV } from "./env-context.js"; -import { readModelCatalog } from "./model-catalog.js"; -import { readSessions } from "./sessions.js"; -import { readOperatorClients } from "./operator-clients.js"; +import { BARE_BOOT_ENV, isBareBootEnv } from "./env-context.js"; /** The two llama.cpp fork variants (boot-script parity). */ export type Fork = "upstream" | "llama.cpp"; @@ -113,15 +109,29 @@ export interface BootRecipe { readonly annotations: readonly string[]; /** Whether the recipe fits the supplied machine (true if no machine info). */ readonly fitsMachine: boolean; - /** Harness + ide + runtime used for environment-folder selection. */ + /** Always bare at boot (`none`); connect attaches a client later. */ readonly env: RecipeResolutionContext; + /** Sparse model facts for the boot TTY card. Absent keys are omitted. */ + readonly model?: BootCardModel; +} + +/** Important weights/client facts for the boot preview (not a spec dump). */ +export interface BootCardModel { + readonly sizeLabel?: string; + readonly quant?: string; + readonly architecture?: string; + readonly fileBytes?: number; + readonly vision?: boolean; + readonly toolCalling?: boolean; + /** `expertCount/expertUsedCount` when this is an MoE. */ + readonly moe?: string; } /** * Resolve the effective llama.cpp recipe for `modelFile` from the adapter - * tree. Thin wrapper over the shared `resolveRecipe` chain (R1) — the same - * chain the `resolve-server-recipe` CLI runs, so the daemon and the legacy - * external boot script set identical flags. + * tree. Family + model dials only — no pairing overlay, no Copilot default. + * Connect attaches a client later. Same sanitize/build chain as the + * `resolve-server-recipe` CLI when that CLI also omits `--harness`. * * @throws {Error} when no adapter under `adapterDir` declares `modelFile` * (the model is not in the MBA tree — the route maps this to 404). @@ -131,19 +141,8 @@ export function resolveBootRecipe( adapterDir: string, machineInfo?: MachineInfo, machineOverlay: MachineOverlayMode = "enforce", - pairing?: { readonly sessionsPath?: string; readonly clientsPath?: string }, ): BootRecipe { - const catalog = readModelCatalog(adapterDir); - const entry = catalog.find((c) => c.modelFile === modelFile); - const env = entry - ? resolveEnvContext({ - modelId: entry.id, - sessions: pairing?.sessionsPath ? readSessions(pairing.sessionsPath) : [], - operatorClients: pairing?.clientsPath - ? readOperatorClients(pairing.clientsPath) - : [], - }) - : DEFAULT_RESOLVE_ENV; + const env = BARE_BOOT_ENV; const recipe = resolveRecipe( modelFile, adapterDir, @@ -158,7 +157,38 @@ export function resolveBootRecipe( annotations: recipe.annotations, fitsMachine: recipe.fitsMachine, env, + model: bootCardModel(recipe), + }; +} + +function bootCardModel( + recipe: ReturnType, +): BootCardModel | undefined { + const p = recipe.resolved.profile; + const fileBytes = ggufFileBytes(recipe.modelFile); + const experts = p?.params?.expertCount; + const used = p?.params?.expertUsedCount; + const moe = + experts !== undefined ? (used !== undefined ? `${experts}/${used}` : String(experts)) : undefined; + const model: BootCardModel = { + ...(p?.sizeLabel ? { sizeLabel: p.sizeLabel } : {}), + ...(p?.quant ? { quant: p.quant } : {}), + ...(p?.architecture ? { architecture: p.architecture } : {}), + ...(fileBytes !== undefined ? { fileBytes } : {}), + ...(recipe.client?.vision !== undefined ? { vision: recipe.client.vision } : {}), + ...(recipe.client?.toolCalling !== undefined ? { toolCalling: recipe.client.toolCalling } : {}), + ...(moe ? { moe } : {}), }; + return Object.keys(model).length > 0 ? model : undefined; +} + +function ggufFileBytes(path: string): number | undefined { + try { + const n = statSync(path).size; + return n > 0 ? n : undefined; + } catch { + return undefined; + } } /** Input to `bootServer`. */ @@ -185,10 +215,6 @@ export interface BootServerInput { readonly machineInfo?: MachineInfo; /** How to apply the machine overlay (default `enforce`). */ readonly machineOverlay?: MachineOverlayMode; - /** Paired sessions — boot uses this model's newest pairing as resolve env. */ - readonly sessionsPath?: string; - /** Operator-defined clients (ide fallback for a paired harness). */ - readonly clientsPath?: string; /** Lifecycle seams (spawn/fetch/kill) — injectable for tests. */ readonly seams?: LifecycleSeams; } @@ -307,10 +333,12 @@ export async function bootServer(input: BootServerInput): Promise 0) { for (const annotation of recipe.annotations) { diff --git a/packages/core/src/service/server-connect.test.ts b/packages/core/src/service/server-connect.test.ts index 79fe716..9740f42 100644 --- a/packages/core/src/service/server-connect.test.ts +++ b/packages/core/src/service/server-connect.test.ts @@ -256,8 +256,16 @@ describe("POST /connect", () => { }; expect(st.pairing.sessions).toEqual( expect.arrayContaining([ - expect.objectContaining({ modelId: "qwen3-coder-30b", card: false }), - expect.objectContaining({ modelId: "other-coder", card: true }), + expect.objectContaining({ + modelId: "qwen3-coder-30b", + card: false, + envelope: ".cursor/rules/mba.mdc", + }), + expect.objectContaining({ + modelId: "other-coder", + card: true, + envelope: ".cursor/rules/mba.mdc", + }), ]), ); }); diff --git a/packages/core/src/service/server-servers.test.ts b/packages/core/src/service/server-servers.test.ts index 2748db5..158ea24 100644 --- a/packages/core/src/service/server-servers.test.ts +++ b/packages/core/src/service/server-servers.test.ts @@ -258,6 +258,7 @@ describe("mba service server plane (ADR-0097 Phase 2)", () => { // --- POST /servers/resolve ------------------------------------------------ it("POST /servers/resolve returns the effective flags without booting", async () => { + writeFileSync(modelFile, Buffer.alloc(64)); const app = createMbaServiceApp({ paths, adapterDir }); const res = await app.request("/servers/resolve", { method: "POST", @@ -280,6 +281,11 @@ describe("mba service server plane (ADR-0097 Phase 2)", () => { expect(body.cliArgs).not.toContain("-m"); expect(body.warmupTokens).toBeDefined(); expect(Array.isArray((body as { binaries?: unknown }).binaries)).toBe(true); + const facts = body as { cpuThreads?: number; model?: { fileBytes?: number } }; + expect(facts.model?.fileBytes).toBeGreaterThan(0); + expect((body as { machineOverlay?: string }).machineOverlay).toBe("enforce"); + expect((body as { envAttached?: boolean }).envAttached).toBe(false); + expect((body as { env?: { harness: string } }).env?.harness).toBe("none"); }); it("POST /servers/resolve appends extraArgs from server_setup.json (ADR-0100)", async () => { diff --git a/packages/core/src/service/server.ts b/packages/core/src/service/server.ts index 9dfe0f4..8126a6e 100644 --- a/packages/core/src/service/server.ts +++ b/packages/core/src/service/server.ts @@ -90,10 +90,10 @@ import { type SwitchExecutor, } from "./model-switch.js"; import { readModelDials, setModelDial, type ModelDialFile } from "./model-config.js"; -import { compactHarnessKey } from "../mba/envelope.js"; +import { compactHarnessKey, envelopeRelativePath } from "../mba/envelope.js"; import { readEnvelopeOwner } from "../mba/stage-instructions.js"; import { stageModelCard, restageSlotsAfterRevoke, restagePairedSlotsForModel } from "./stage-model-card.js"; -import { defaultIdeForHarness } from "./env-context.js"; +import { defaultIdeForHarness, bootEnvJson, isBareBootEnv } from "./env-context.js"; import { operatorEnvelopeBindings, readOperatorClients } from "./operator-clients.js"; import { hashToken, @@ -305,7 +305,11 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { if (!ownerBySlot.has(key)) { ownerBySlot.set(key, readEnvelopeOwner(s.projectRoot, s.harness, extras, s.ide)); } - return { ...s, card: ownerBySlot.get(key) === s.modelId }; + return { + ...s, + card: ownerBySlot.get(key) === s.modelId, + envelope: envelopeRelativePath(s.harness, s.ide, extras), + }; }); return c.json({ version: cfg.version, @@ -846,7 +850,6 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { opts.adapterDir ?? "", opts.machineInfo, machineOverlay(), - { sessionsPath: paths.sessionsPath, clientsPath: paths.clientsPath }, ); const selection = selectLlamaServer({ lastPath: readLlamaServerChoice(paths)?.path, @@ -858,7 +861,8 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { modelFile: recipe.modelFile, cliArgs: recipe.cliArgs, warmupTokens: recipe.warmupTokens, - env: recipe.env, + env: bootEnvJson(recipe.env), + envAttached: !isBareBootEnv(recipe.env), binary: selection.selected, binaries: selection.catalog, recommended: selection.recommended, @@ -868,6 +872,13 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { gpus: (opts.machineInfo?.gpus ?? []) .map((g) => g.name) .filter((n): n is string => typeof n === "string" && n.length > 0), + vramBytes: (opts.machineInfo?.gpus ?? []) + .filter((g) => typeof g.name === "string" && g.name.length > 0) + .map((g) => (typeof g.vramBytes === "number" && g.vramBytes > 0 ? g.vramBytes : null)), + ramBytes: opts.machineInfo?.totalRamBytes, + cpuThreads: opts.machineInfo?.cpuCores, + machineOverlay: machineOverlay(), + model: recipe.model, }); } catch (err) { return c.json( @@ -944,8 +955,6 @@ export function createMbaServiceApp(opts: MbaServiceAppOptions = {}): Hono { binaryPath, machineInfo: opts.machineInfo, machineOverlay: machineOverlay(), - sessionsPath: paths.sessionsPath, - clientsPath: paths.clientsPath, seams: opts.lifecycleSeams, }); if (!result.ok) { @@ -1149,8 +1158,6 @@ async function defaultSwitchExecutor( registryPath: (opts.paths ?? defaultStorePaths()).upstreamsPath, machineInfo: opts.machineInfo, machineOverlay: readGlobalConfig(opts.paths ?? defaultStorePaths()).machineOverlay, - sessionsPath: (opts.paths ?? defaultStorePaths()).sessionsPath, - clientsPath: (opts.paths ?? defaultStorePaths()).clientsPath, seams: opts.lifecycleSeams, }); if (!result.ok) {