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
3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ mba models stage qwen --harness cursor
mba connect qwen --harness cursor
mba servers # list / boot / stop (TTY)
mba s logs <id>
mba migrate models ~/models # local GGUFs → hub (copy/hardlink)
mba machine # enforce | warn | off
mba estimate-memory <gguf>
eval "$(mba completion)" # bash; or: mba completion zsh
Expand All @@ -101,8 +102,6 @@ eval "$(mba completion)" # bash; or: mba completion zsh

Defaults are OS-aware: XDG on Linux, `%APPDATA%` / `%LOCALAPPDATA%` on Windows, `~/Library/Application Support` on macOS.

Upgrading from a pre-0.1.1 install: `mba migrate-paths` once (local, never overwrites).

## MCP

The service must already be running.
Expand Down
6 changes: 3 additions & 3 deletions docs/workflows/cli-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ Industry notes (Claude Code architecture review + git/gh/kubectl): steal TTY-vs-

## 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`.
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): `estimate-memory`, `completion`, `--help`. Scan of operator GGUFs for `mba migrate` is local; the hub write is `POST /models/adopt`.

Nouns: `models` (`m`), `servers` (`s`), `clients` (`c`), `machine`, `status`. Old flat verbs stay as aliases.
Nouns: `models` (`m`), `servers` (`s`), `clients` (`c`), `migrate`, `machine`, `status`. Old flat verbs stay as aliases. `migrate` has no shortcut.

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.

Expand All @@ -32,7 +32,7 @@ TTY and `--json` are two skins of the same route. JSON field names are the contr
| `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 |
| `status.ts` / `clients.ts` / `models.ts` / `servers.ts` / `machine.ts` / `migrate.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 |
Expand Down
4 changes: 2 additions & 2 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ configure adapter → BCB (system watch) → AMPI (system live response)
- **BCB** — behavioral circuit breakers. You name known failure modes on this model and configure an escalation ladder plus a programmatic response.
- **AMPI** — automated multi-process intervention. A deterministic named recipe that runs when a breaker fires (Sanitize, Assist, Sanction, Recover).
- **Service** — binds `127.0.0.1` on an OS-assigned port and writes `<state dir>/mba/service.json`.
- **CLI** — `mba` (`models`, `servers`, `machine`, `status`). The published bin is the CLI, not the service.
- **CLI** — `mba` (`models`, `servers`, `clients`, `migrate`, `machine`, `status`). The published bin is the CLI, not the service.

## Install (library)

Expand Down Expand Up @@ -58,7 +58,7 @@ Node ≥ 22. llama.cpp on `PATH` if you boot with `mba servers boot`.
| `MBA_SWITCH_PORT` | Default boot port (8080) |
| `MBA_UPSTREAM_URL` | Fallback upstream when the registry is empty |

Defaults are OS-aware (see `src/service/paths.ts`). Upgrading from a pre-0.1.1 install: `mba migrate-paths` once (local, never overwrites).
Defaults are OS-aware (see `src/service/paths.ts`).

## Develop

Expand Down
18 changes: 13 additions & 5 deletions packages/core/src/cli/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
* Install: eval "$(mba completion)" or mba completion zsh
*/

const GROUPS = "models m servers server s clients client c machine status help completion migrate-paths estimate-memory connect";
const GROUPS = "models m servers server s clients client c migrate machine status help completion estimate-memory connect";
const MODEL_SUB = "list show set open path pull search edit stage connect";
const SERVER_SUB = "list boot stop logs slots binaries builds";
const CLIENT_SUB = "list add connect revoke remove";
const MIGRATE_SUB = "models find";
const MACHINE_SUB = "enforce warn off";

function bashScript(): string {
Expand Down Expand Up @@ -49,14 +50,19 @@ _mba() {
COMPREPLY=( $(compgen -W "${CLIENT_SUB}" -- "\$cur") )
fi
;;
migrate)
if [[ \${COMP_CWORD} -eq 2 ]]; then
COMPREPLY=( $(compgen -W "${MIGRATE_SUB}" -- "\$cur") )
fi
;;
machine|machine-overlay)
COMPREPLY=( $(compgen -W "${MACHINE_SUB}" -- "\$cur") )
;;
completion)
COMPREPLY=( $(compgen -W "bash zsh" -- "\$cur") )
;;
help)
COMPREPLY=( $(compgen -W "models servers clients machine status" -- "\$cur") )
COMPREPLY=( $(compgen -W "models servers clients migrate machine status" -- "\$cur") )
;;
esac
}
Expand All @@ -67,11 +73,12 @@ complete -F _mba mba
function zshScript(): string {
return `#compdef mba
_mba() {
local -a groups modelsubs serversubs clientsubs
groups=(models m servers server s clients client c machine status help completion migrate-paths estimate-memory connect)
local -a groups modelsubs serversubs clientsubs migratesubs
groups=(models m servers server s clients client c migrate machine status help completion estimate-memory connect)
modelsubs=(list show set open path pull search edit stage connect)
serversubs=(list boot stop logs slots binaries builds)
clientsubs=(list add connect revoke remove)
migratesubs=(models find)
case $CURRENT in
2) _describe 'command' groups ;;
*)
Expand All @@ -83,9 +90,10 @@ _mba() {
;;
servers|server|s) _describe 'servers' serversubs ;;
clients|client|c) _describe 'clients' clientsubs ;;
migrate) _describe 'migrate' migratesubs ;;
machine) _describe 'mode' '(enforce warn off)' ;;
completion) _describe 'shell' '(bash zsh)' ;;
help) _describe 'topic' '(models servers clients machine status)' ;;
help) _describe 'topic' '(models servers clients migrate machine status)' ;;
esac
;;
esac
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/cli/help.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it } from "vitest";
import { usageServers } from "./help.js";
import { usageMigrate, usageServers } from "./help.js";

describe("help", () => {
const prevNoColor = process.env.NO_COLOR;
Expand All @@ -18,4 +18,14 @@ describe("help", () => {
expect(text).toContain("MBA_SWITCH_PORT");
expect(text).toContain("--json");
});

it("names migrate models, find, and --from", () => {
process.env.NO_COLOR = "1";
const text = usageMigrate();
expect(text).toContain("mba migrate models");
expect(text).toContain("mba migrate find");
expect(text).toContain("--from");
expect(text).toContain("--move");
expect(text).toContain("--yes");
});
});
33 changes: 30 additions & 3 deletions packages/core/src/cli/help.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { brand, dim, heading, paint, BOLD } from "./style.js";

export type HelpTopic = "overview" | "models" | "servers" | "clients" | "machine" | "status";
export type HelpTopic =
| "overview"
| "models"
| "servers"
| "clients"
| "machine"
| "status"
| "migrate";

function cmd(line: string, note: string): string {
return ` ${paint(line.padEnd(36), BOLD)} ${dim(note)}`;
Expand All @@ -14,19 +21,19 @@ export function usageOverview(): string {
cmd("mba models", "edit, search, pull (m)"),
cmd("mba servers", "list, boot, stop, logs, slots, builds (s)"),
cmd("mba clients", "list, add, connect, revoke (c)"),
cmd("mba migrate", "local GGUFs → hub"),
cmd("mba machine", "hardware clamp mode"),
cmd("mba status", "service, loaded model, pairing slots"),
"",
heading("Local"),
cmd("mba migrate-paths", "move legacy state + store"),
cmd("mba estimate-memory <gguf>", "RAM/VRAM estimate"),
cmd("mba completion [bash|zsh]", "print shell completion"),
"",
dim(" mba home menu on a TTY"),
dim(" mba <group> --help details for that group"),
dim(" shortcuts m → models s → servers c → clients"),
dim(" --yes skip confirm (restart / boot preview)"),
dim(" --json machine-readable list/show/status/stage/connect"),
dim(" --json machine-readable list/show/status/stage/connect/migrate"),
].join("\n");
}

Expand Down Expand Up @@ -110,6 +117,24 @@ export function usageStatus(): string {
].join("\n");
}

export function usageMigrate(): string {
return [
`${brand("migrate")}`,
"",
cmd("mba migrate", "models / find (TTY menu)"),
cmd("mba migrate models [dir]", "GGUFs in that folder → hub"),
cmd("mba migrate find [query]", "fuzzy-find GGUFs → hub"),
"",
dim(" copies into the hub (hardlink when possible)"),
dim(" find ~/.cache/huggingface/hub and ~/models"),
dim(" --from limit find to one directory"),
dim(" TTY asks whether to remove the source (enter = keep)"),
dim(" --move remove source without asking"),
dim(" --yes skip asks; keeps source unless --move"),
dim(" --json same as --yes, machine-readable results"),
].join("\n");
}

export function usageFor(topic: HelpTopic): string {
switch (topic) {
case "models":
Expand All @@ -122,6 +147,8 @@ export function usageFor(topic: HelpTopic): string {
return usageMachine();
case "status":
return usageStatus();
case "migrate":
return usageMigrate();
default:
return usageOverview();
}
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/cli/interactive.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
pickLabeledInteractive,
pickModelInteractive,
pickPreviewInteractive,
pickManyInteractive,
pickServerInteractive,
searchHfInteractive,
type ModelEntry,
Expand Down Expand Up @@ -574,3 +575,43 @@ describe("askYesNoInteractive", () => {
await expect(p).resolves.toBeNull();
});
});

describe("pickManyInteractive", () => {
let stdin: ReturnType<typeof fakeStdin>;
const items = [
{ label: "a.gguf", value: "/tmp/a.gguf", preview: [["file", "a.gguf"]] as const },
{ label: "b.gguf", value: "/tmp/b.gguf", preview: [["file", "b.gguf"]] as const },
];
beforeEach(() => {
stdin = fakeStdin();
vi.spyOn(process, "stdin", "get").mockReturnValue(stdin as unknown as NodeJS.ReadStream & { fd: 0 });
vi.spyOn(process.stdout, "write").mockReturnValue(true as unknown as ReturnType<typeof process.stdout.write>);
});
afterEach(() => {
vi.restoreAllMocks();
});

it("adopts the highlighted row on enter with nothing marked", async () => {
const p = pickManyInteractive("adopt", items);
await tick();
stdin.emit("\r");
await expect(p).resolves.toEqual(["/tmp/a.gguf"]);
});

it("toggles with space and confirms the marked set", async () => {
const p = pickManyInteractive("adopt", items);
await tick();
stdin.emit(" ");
stdin.emit("\x1b[B");
stdin.emit(" ");
stdin.emit("\r");
await expect(p).resolves.toEqual(["/tmp/a.gguf", "/tmp/b.gguf"]);
});

it("resolves null on Esc", async () => {
const p = pickManyInteractive("adopt", items);
await tick();
stdin.emit("\x1b");
await expect(p).resolves.toBeNull();
});
});
102 changes: 99 additions & 3 deletions packages/core/src/cli/interactive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,17 +102,26 @@ function previewPickLines(
allCount: number,
list: readonly PreviewPickItem[],
cursor: number,
marked?: ReadonlySet<string>,
): string[] {
const win = sliceWindow(list, cursor, PREVIEW_LIST_WINDOW);
const left =
win.items.length === 0
? [dim(" (no matches)")]
: win.items.map((it, i) => option(win.start + i === cursor, it.label));
: win.items.map((it, i) =>
option(win.start + i === cursor, it.label, "", marked?.has(it.value)),
);
const current = list[cursor];
const count =
marked !== undefined ? `${marked.size} sel · ${list.length}/${allCount}` : `${list.length}/${allCount}`;
return previewBox({
title,
detail: filter ? `filter: ${filter}` : undefined,
count: `${list.length}/${allCount}`,
detail: filter
? `filter: ${filter}`
: marked !== undefined
? "space toggle · enter adopt"
: undefined,
count,
left,
preview: current?.preview ?? [],
});
Expand Down Expand Up @@ -737,6 +746,93 @@ export function pickPreviewInteractive(
});
}

/**
* Multi-select sibling of `pickPreviewInteractive`. Space toggles the
* current row (does not add to the filter). Enter confirms the marked
* set; if nothing is marked, the highlighted row is adopted. Esc clears
* the filter first, then cancels.
*/
export function pickManyInteractive(
title: string,
items: readonly PreviewPickItem[],
): Promise<string[] | null> {
return new Promise<string[] | null>((resolve, reject) => {
const stdin = process.stdin;
const frame = createMenuFrame();
let query = "";
let cursor = 0;
const marked = new Set<string>();

const filtered = () => (query ? items.filter((it) => matchesQuery(it, query)) : items);

const render = () => {
frame.draw(previewPickLines(brand(title), query, items.length, filtered(), cursor, marked));
};

const finish = (ok: () => void) => {
endInteractive(stdin, onData);
frame.close({ erase: true });
ok();
};

const onData = (buf: Buffer) => {
for (const key of tokenizeKeys(buf.toString("utf8"))) {
const list = filtered();
if (key === "\x1b[A") {
if (list.length === 0) continue;
cursor = (cursor - 1 + list.length) % list.length;
render();
} else if (key === "\x1b[B") {
if (list.length === 0) continue;
cursor = (cursor + 1) % list.length;
render();
} else if (key === " ") {
const row = list[cursor];
if (!row) continue;
if (marked.has(row.value)) marked.delete(row.value);
else marked.add(row.value);
render();
} else if (key === "\r" || key === "\n") {
const chosen =
marked.size > 0
? items.filter((it) => marked.has(it.value)).map((it) => it.value)
: list[cursor]
? [list[cursor]!.value]
: [];
if (chosen.length === 0) continue;
finish(() => resolve(chosen));
return;
} else if (key === "\x1b") {
if (query.length > 0) {
query = "";
cursor = 0;
render();
continue;
}
finish(() => resolve(null));
return;
} else if (key === "\x7f" || key === "\b") {
query = query.slice(0, -1);
cursor = Math.min(cursor, Math.max(0, filtered().length - 1));
render();
} else if (key === "\x03") {
finish(() => reject(new Error("cancelled")));
return;
} else if (key.length === 1 && !key.startsWith("\x1b")) {
query += key;
cursor = 0;
render();
}
}
};

stdin.setRawMode(true);
stdin.resume();
stdin.on("data", onData);
render();
});
}

/**
* Raw-mode text prompt. Enter with an empty input returns `defaultValue`;
* otherwise returns the typed text. Esc returns null (cancel).
Expand Down
Loading