Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/adr/0091-model-folders-and-environment-overrides.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0098-model-pull-capability.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`):

Expand Down
2 changes: 1 addition & 1 deletion docs/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Expand Down
8 changes: 4 additions & 4 deletions docs/architecture/views.c4
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>"
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 <id>"
}

dynamic view connect {
Expand Down
3 changes: 3 additions & 0 deletions docs/workflows/add-interactive-cli-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
117 changes: 117 additions & 0 deletions docs/workflows/cli-development.md
Original file line number Diff line number Diff line change
@@ -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 (`<!-- mba-model: … -->`), 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 <id>` (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
147 changes: 147 additions & 0 deletions packages/core/src/cli/boot-preview.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
Loading