@b3labs/b3iq-cli 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +319 -0
- package/ERROR_PATHS.md +63 -0
- package/LICENSE +202 -0
- package/README.md +207 -2
- package/dist/commands/claude-desktop.js +323 -0
- package/dist/commands/codex-desktop.js +118 -0
- package/dist/commands/commands.js +39 -0
- package/dist/commands/logs.js +41 -0
- package/dist/commands/machines.js +104 -0
- package/dist/commands/media-mcp.js +19 -0
- package/dist/commands/misc.js +257 -0
- package/dist/commands/models.js +141 -0
- package/dist/commands/native-agents.js +291 -0
- package/dist/commands/opencode.js +161 -0
- package/dist/commands/plugins.js +67 -0
- package/dist/commands/runtimes.js +76 -0
- package/dist/commands/ssh.js +156 -0
- package/dist/index.js +194 -0
- package/dist/lib/api.js +149 -0
- package/dist/lib/args.js +35 -0
- package/dist/lib/commands-wait.js +41 -0
- package/dist/lib/config.js +85 -0
- package/dist/lib/desktop-server-lifecycle.js +19 -0
- package/dist/lib/diagnosis.js +678 -0
- package/dist/lib/inference.js +272 -0
- package/dist/lib/machines.js +120 -0
- package/dist/lib/management-probe.js +36 -0
- package/dist/lib/media-download.js +50 -0
- package/dist/lib/output.js +151 -0
- package/dist/lib/ssh-session.js +280 -0
- package/dist/lib/ssh-target.js +28 -0
- package/dist/lib/stdin.js +16 -0
- package/dist/lib/version.js +1 -0
- package/package.json +58 -4
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { api } from "../lib/api.js";
|
|
2
|
+
import { resolveMachine, machineLabel } from "../lib/machines.js";
|
|
3
|
+
import { CliError, emit, emitIds, isQuiet, kv, table } from "../lib/output.js";
|
|
4
|
+
const DEFAULT_LIMIT = 20;
|
|
5
|
+
const MAX_LIMIT = 50;
|
|
6
|
+
const statusLabel = (c) => (c.error ? `${c.status} (${c.error})` : c.status);
|
|
7
|
+
export async function commandsList(machine, limitFlag) {
|
|
8
|
+
const node = await resolveMachine(machine);
|
|
9
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, Number(limitFlag ?? DEFAULT_LIMIT) || DEFAULT_LIMIT));
|
|
10
|
+
const { commands } = await api.nodeCommands(node.node_id, limit);
|
|
11
|
+
if (isQuiet()) {
|
|
12
|
+
emitIds(commands.map((c) => c.command_id));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
emit(commands, () => {
|
|
16
|
+
if (commands.length === 0) {
|
|
17
|
+
process.stderr.write(`${machineLabel(node)} has no recent commands.\n`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
table(["COMMAND", "TYPE", "STATUS", "CREATED"], commands.map((c) => [c.command_id, c.type, statusLabel(c), c.created_at ?? "—"]));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export async function commandsGet(commandId, machine) {
|
|
24
|
+
if (!commandId)
|
|
25
|
+
throw new CliError("Usage: b3iq commands get <command-id> --machine <m>", "usage", 1);
|
|
26
|
+
const node = await resolveMachine(machine);
|
|
27
|
+
const { command } = await api.nodeCommand(node.node_id, commandId);
|
|
28
|
+
emit(command, () => {
|
|
29
|
+
const rows = [
|
|
30
|
+
["Command", command.command_id],
|
|
31
|
+
["Machine", machineLabel(node)],
|
|
32
|
+
["Type", command.type],
|
|
33
|
+
["Status", statusLabel(command)],
|
|
34
|
+
["Created", command.created_at ?? "—"],
|
|
35
|
+
["Completed", command.completed_at ?? "—"],
|
|
36
|
+
];
|
|
37
|
+
kv(rows);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { api } from "../lib/api.js";
|
|
2
|
+
import { emit, isJSON, log, table } from "../lib/output.js";
|
|
3
|
+
const FOLLOW_POLL_MS = 5_000;
|
|
4
|
+
function renderRows(rows) {
|
|
5
|
+
table(["TIME", "MODEL", "STATUS", "MS", "TOKENS", "NODE"], rows.map((r) => [
|
|
6
|
+
new Date(r.created_at).toLocaleTimeString(),
|
|
7
|
+
r.model_id ?? "—",
|
|
8
|
+
r.error_code ? `${r.status} (${r.error_code})` : r.status,
|
|
9
|
+
String(r.duration_ms ?? ""),
|
|
10
|
+
String(r.total_tokens ?? ""),
|
|
11
|
+
(r.selected_node_id ?? "").slice(0, 8),
|
|
12
|
+
]));
|
|
13
|
+
}
|
|
14
|
+
export async function logsCmd(limitFlag, follow) {
|
|
15
|
+
const limit = Math.min(200, Math.max(1, Number(limitFlag ?? 50) || 50));
|
|
16
|
+
const { entries } = await api.logs(limit);
|
|
17
|
+
const oldestFirst = [...entries].reverse();
|
|
18
|
+
if (!follow) {
|
|
19
|
+
emit(oldestFirst, () => renderRows(oldestFirst));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const seenEventIds = new Set(oldestFirst.map((r) => r.event_id));
|
|
23
|
+
if (isJSON())
|
|
24
|
+
for (const r of oldestFirst)
|
|
25
|
+
process.stdout.write(JSON.stringify(r) + "\n");
|
|
26
|
+
else
|
|
27
|
+
renderRows(oldestFirst);
|
|
28
|
+
log("following (ctrl-c to stop)…");
|
|
29
|
+
for (;;) {
|
|
30
|
+
await new Promise((r) => setTimeout(r, FOLLOW_POLL_MS));
|
|
31
|
+
const { entries: next } = await api.logs(limit);
|
|
32
|
+
const unseenEvents = [...next].reverse().filter((r) => !seenEventIds.has(r.event_id));
|
|
33
|
+
for (const r of unseenEvents) {
|
|
34
|
+
seenEventIds.add(r.event_id);
|
|
35
|
+
if (isJSON())
|
|
36
|
+
process.stdout.write(JSON.stringify(r) + "\n");
|
|
37
|
+
}
|
|
38
|
+
if (!isJSON() && unseenEvents.length)
|
|
39
|
+
renderRows(unseenEvents);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { api } from "../lib/api.js";
|
|
2
|
+
import { machineLabel, resolveMachine, resolveMachineOrRental } from "../lib/machines.js";
|
|
3
|
+
import { queuedThenWait } from "../lib/commands-wait.js";
|
|
4
|
+
import { emit, emitIds, isQuiet, kv, log, success, table } from "../lib/output.js";
|
|
5
|
+
import { listedOrgLabel } from "../lib/config.js";
|
|
6
|
+
export function provisioningLabel(p) {
|
|
7
|
+
if (!p)
|
|
8
|
+
return "—";
|
|
9
|
+
if (p.failed)
|
|
10
|
+
return `failed at ${p.stage ?? "unknown stage"}${p.reason ? `: ${p.reason}` : ""}`;
|
|
11
|
+
return p.stage ? `ok (${p.stage})` : "—";
|
|
12
|
+
}
|
|
13
|
+
export async function machinesList() {
|
|
14
|
+
const { nodes, org_context } = await api.nodes();
|
|
15
|
+
if (isQuiet()) {
|
|
16
|
+
emitIds(nodes.map((n) => n.node_id));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
emit(nodes, () => {
|
|
20
|
+
if (org_context)
|
|
21
|
+
log(`machines of organization ${listedOrgLabel(org_context)}`);
|
|
22
|
+
if (nodes.length === 0) {
|
|
23
|
+
process.stderr.write(org_context ? "No machines in this organization.\n" : "No machines on this account.\n");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
table(["MACHINE", "STATUS", "STATE", "MODELS", "MODE", "NODE ID"], nodes.map((n) => [
|
|
27
|
+
machineLabel(n),
|
|
28
|
+
n.liveness ?? "—",
|
|
29
|
+
n.state ?? "—",
|
|
30
|
+
`${n.models_ready ?? 0}/${n.models_total ?? 0}`,
|
|
31
|
+
n.mode ?? "—",
|
|
32
|
+
n.node_id,
|
|
33
|
+
]));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export async function machinesGet(selector) {
|
|
37
|
+
const node = await resolveMachine(selector);
|
|
38
|
+
const detail = await api.node(node.node_id);
|
|
39
|
+
emit(detail, () => {
|
|
40
|
+
const rows = [
|
|
41
|
+
["Machine", machineLabel(node)],
|
|
42
|
+
["Node ID", node.node_id],
|
|
43
|
+
["Status", node.liveness ?? "—"],
|
|
44
|
+
["State", detail.state ?? "—"],
|
|
45
|
+
["Provisioning", provisioningLabel(detail.provisioning)],
|
|
46
|
+
["Mode", node.mode ?? "—"],
|
|
47
|
+
["Models", `${node.models_ready ?? 0}/${node.models_total ?? 0}`],
|
|
48
|
+
["Last seen", node.last_seen_at ?? "—"],
|
|
49
|
+
];
|
|
50
|
+
kv(rows);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
const QUALIFY_TIMEOUT_MS = 10 * 60 * 1000;
|
|
54
|
+
export async function machinesQualify(selector, wait) {
|
|
55
|
+
const node = await resolveMachine(selector);
|
|
56
|
+
const row = await queuedThenWait(node.node_id, await api.qualify(node.node_id), {
|
|
57
|
+
wait,
|
|
58
|
+
timeoutMs: QUALIFY_TIMEOUT_MS,
|
|
59
|
+
describe: `qualify ${machineLabel(node)}`,
|
|
60
|
+
});
|
|
61
|
+
emit(row, () => {
|
|
62
|
+
if (row.status === "completed")
|
|
63
|
+
success(`${machineLabel(node)} re-qualified. Read the verdict: \`b3iq machines get ${node.node_id}\`.`);
|
|
64
|
+
else
|
|
65
|
+
process.stdout.write(`${row.command_id}\n`);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function cardsLabel(ids, cards) {
|
|
69
|
+
if (ids.length === 0)
|
|
70
|
+
return "—";
|
|
71
|
+
const indexOf = new Map(cards.map((card) => [card.pci_bus_id, card.index]));
|
|
72
|
+
return ids.map((id) => (indexOf.has(id) ? String(indexOf.get(id)) : id)).join(", ");
|
|
73
|
+
}
|
|
74
|
+
export async function machinesGuests(selector) {
|
|
75
|
+
const target = await resolveMachineOrRental(selector);
|
|
76
|
+
const view = await api.nodeGuests(target.node.node_id);
|
|
77
|
+
if (isQuiet()) {
|
|
78
|
+
emitIds(view.guests.map((g) => g.guest_id));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const label = machineLabel(target.node);
|
|
82
|
+
emit(view, () => {
|
|
83
|
+
if (view.host_role !== "hypervisor") {
|
|
84
|
+
process.stderr.write(`${label} is not a KVM host, so it runs no guests.\n`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (view.lease) {
|
|
88
|
+
kv([["Rental", `${view.lease.phase}, ${view.lease.starts_at} to ${view.lease.ends_at}`]]);
|
|
89
|
+
}
|
|
90
|
+
if (view.guests.length === 0) {
|
|
91
|
+
process.stderr.write(view.access === "guest" ? `Your guest on ${label} is not created yet.\n` : `No guests on ${label} right now.\n`);
|
|
92
|
+
}
|
|
93
|
+
else if (view.access === "guest") {
|
|
94
|
+
table(["GUEST", "STATE", "SSH", "GPUS", "IMAGE"], view.guests.map((g) => [g.guest_id, g.state, g.ssh_hostname && g.ssh_user ? `${g.ssh_user}@${g.ssh_hostname}` : "—", cardsLabel(g.gpu_pci_ids, view.gpus), g.image_version ?? "—"]));
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
table(["GUEST", "KIND", "STATE", "GPUS", "IMAGE", "CREATED"], view.guests.map((g) => [g.guest_id, g.kind, g.state, cardsLabel(g.gpu_pci_ids, view.gpus), g.image_version ?? "—", g.created_at]));
|
|
98
|
+
}
|
|
99
|
+
if (view.gpus.length > 0) {
|
|
100
|
+
process.stdout.write("\n");
|
|
101
|
+
table(["GPU", "PCI", "STATE", "REASONS", "IN USE"], view.gpus.map((c) => [String(c.index), c.pci_bus_id, c.qualification_state ?? "—", c.qualification_reasons.join(", ") || "—", c.in_use ? "yes" : "no"]));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { InferenceGateway } from "../lib/inference.js";
|
|
5
|
+
import { VERSION } from "../lib/version.js";
|
|
6
|
+
export function mediaMcpServer(options = {}) {
|
|
7
|
+
const server = new McpServer({ name: "b3iq-media", version: VERSION });
|
|
8
|
+
const gateway = new InferenceGateway(options);
|
|
9
|
+
const result = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }] });
|
|
10
|
+
server.registerTool("b3iq_models", { description: "List models and serving metadata on machines permitted by the dashboard API key. Use exact returned model IDs and b3iq.modality to choose image/video models. Do not substitute a text or other media model.", inputSchema: {} }, async (_args, extra) => result(await gateway.models(extra.signal)));
|
|
11
|
+
server.registerTool("b3iq_generate_image", { description: "Generate an image or edit a user-provided reference on a permitted owned machine. For editing, pass image_path as the absolute path to the user's PNG/JPEG/WebP attachment (at most 5 MiB) and choose a model advertising image input. Only send an attachment the user asked to use; never substitute text-to-image when editing is unavailable. Returns a job ID; call b3iq_image_status until completed. Older hosts may return a completed PNG path directly. Never resubmit while it is running.", inputSchema: { model: z.string().min(1), prompt: z.string().min(1).max(32000), image_path: z.string().min(1).optional() } }, async (args, extra) => result(await gateway.image(args.model, args.prompt, extra.signal, args.image_path)));
|
|
12
|
+
server.registerTool("b3iq_image_status", { description: "Check an image job. When completed, save its PNG in the configured B3IQ media directory. Stopping a status read does not stop generation.", inputSchema: { id: z.string().regex(/^med_[a-f0-9-]{36}$/) } }, async (args, extra) => result(await gateway.imageStatus(args.id, extra.signal)));
|
|
13
|
+
server.registerTool("b3iq_generate_video", { description: "Submit one video generation job to a permitted owned machine. Returns a job ID; call b3iq_video_status until completed. Do not resubmit while it is running.", inputSchema: { model: z.string().min(1), prompt: z.string().min(1).max(32000), seconds: z.number().int().min(1).max(30).default(1) } }, async (args, extra) => result(await gateway.video(args.model, args.prompt, args.seconds, extra.signal)));
|
|
14
|
+
server.registerTool("b3iq_video_status", { description: "Check a video job. When completed, download its MP4 to the configured media directory. Cancelling this read does not cancel the GPU job.", inputSchema: { id: z.string().regex(/^vid_[a-f0-9-]{36}$/) } }, async (args, extra) => result(await gateway.videoStatus(args.id, extra.signal)));
|
|
15
|
+
return server;
|
|
16
|
+
}
|
|
17
|
+
export async function runMediaMcp(options) {
|
|
18
|
+
await mediaMcpServer(options).connect(new StdioServerTransport());
|
|
19
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { api, deviceAuth, whoAmI } from "../lib/api.js";
|
|
4
|
+
import { machineLabel } from "../lib/machines.js";
|
|
5
|
+
import { readConfig, writeConfig, clearToken, resolveSettings, configPath, orgLabel, loginApprovalUrl, isLoopbackHost } from "../lib/config.js";
|
|
6
|
+
import { CliError, EXIT_CODE, emit, kv, log, plain, success, table, warn } from "../lib/output.js";
|
|
7
|
+
const MIN_DEVICE_POLL_SECONDS = 2;
|
|
8
|
+
const RATE_LIMIT_BACKOFF_SECONDS = 5;
|
|
9
|
+
const BROWSER_OPEN_TIMEOUT_MS = 5_000;
|
|
10
|
+
const RETIRED_TOKEN_PREFIX = "b3iq_pat_";
|
|
11
|
+
async function verifySavedTokenAndAnnounce(saved) {
|
|
12
|
+
const { user, organization, expiresAt } = await whoAmI(saved);
|
|
13
|
+
writeConfig({ org_id: organization?.id, org_name: organization?.name ?? undefined, expires_at: expiresAt ?? undefined });
|
|
14
|
+
const scope = organization ? ` for organization ${orgLabel(organization.id, organization.name)}` : "";
|
|
15
|
+
success(`Logged in as ${user.email ?? user.id}${scope}. Token saved to ${configPath()} (0600).`);
|
|
16
|
+
}
|
|
17
|
+
export async function login(token) {
|
|
18
|
+
if (!token) {
|
|
19
|
+
if (!process.stdin.isTTY) {
|
|
20
|
+
throw new CliError("No API key and no TTY. Pipe one with `b3iq login --token-stdin`, or set B3IQ_TOKEN.", "usage", 1);
|
|
21
|
+
}
|
|
22
|
+
return loginViaDeviceFlow();
|
|
23
|
+
}
|
|
24
|
+
const key = token.trim();
|
|
25
|
+
if (!key || /[\r\n]/.test(key))
|
|
26
|
+
throw new CliError("Supply one API key.", "usage", 1);
|
|
27
|
+
writeConfig({ token: key, expires_at: undefined, pat_id: undefined, org_id: undefined, org_name: undefined });
|
|
28
|
+
try {
|
|
29
|
+
await verifySavedTokenAndAnnounce({ token: key });
|
|
30
|
+
}
|
|
31
|
+
catch (e) {
|
|
32
|
+
clearToken();
|
|
33
|
+
throw e;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async function loginViaDeviceFlow() {
|
|
37
|
+
const pairing = await deviceAuth.start(`b3iq CLI on ${hostname()}`);
|
|
38
|
+
log("in the browser, choose your personal account or one of your organizations");
|
|
39
|
+
log(`confirm this code in your browser: ${pairing.user_code}`);
|
|
40
|
+
const approvalUrl = loginApprovalUrl(pairing.verification_uri_complete, pairing.user_code);
|
|
41
|
+
log(approvalUrl);
|
|
42
|
+
openBrowserBestEffort(approvalUrl);
|
|
43
|
+
const deadline = Date.now() + pairing.expires_in * 1000;
|
|
44
|
+
let pollSeconds = Math.max(MIN_DEVICE_POLL_SECONDS, pairing.interval);
|
|
45
|
+
while (Date.now() < deadline) {
|
|
46
|
+
await new Promise((r) => setTimeout(r, pollSeconds * 1000));
|
|
47
|
+
let result;
|
|
48
|
+
try {
|
|
49
|
+
result = await deviceAuth.poll(pairing.device_code);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
if (e instanceof CliError && e.code === "ratelimited") {
|
|
53
|
+
pollSeconds += RATE_LIMIT_BACKOFF_SECONDS;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
if (result.status === "authorization_pending")
|
|
59
|
+
continue;
|
|
60
|
+
if (result.status === "complete" && result.token) {
|
|
61
|
+
const orgId = result.api_key?.organization_id ?? undefined;
|
|
62
|
+
writeConfig({ token: result.token, expires_at: result.api_key?.expires_at ?? undefined, pat_id: undefined, org_id: orgId, org_name: undefined });
|
|
63
|
+
await verifySavedTokenAndAnnounce({ token: result.token, orgId });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
throw new CliError(`Unexpected login state "${result.status}" — run \`b3iq login\` again.`, "api");
|
|
67
|
+
}
|
|
68
|
+
throw new CliError("Login expired before it was approved — run `b3iq login` again.", "auth", 3);
|
|
69
|
+
}
|
|
70
|
+
export function browserTarget(url, platform = process.platform) {
|
|
71
|
+
let parsed;
|
|
72
|
+
try {
|
|
73
|
+
parsed = new URL(url);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
if (parsed.username || parsed.password)
|
|
79
|
+
return undefined;
|
|
80
|
+
if (!(parsed.protocol === "https:" || (parsed.protocol === "http:" && isLoopbackHost(parsed.hostname))))
|
|
81
|
+
return undefined;
|
|
82
|
+
if (/%(?![0-9A-Fa-f]{2})/.test(parsed.href))
|
|
83
|
+
return undefined;
|
|
84
|
+
if (platform !== "win32")
|
|
85
|
+
return parsed.href;
|
|
86
|
+
return parsed.href.replace(/[&|<>^()]/g, "^$&").replace(/%/g, "%^");
|
|
87
|
+
}
|
|
88
|
+
function openBrowserBestEffort(url) {
|
|
89
|
+
const target = browserTarget(url);
|
|
90
|
+
if (!target)
|
|
91
|
+
return;
|
|
92
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
93
|
+
const args = process.platform === "win32" ? ["/c", "start", "", target] : [target];
|
|
94
|
+
try {
|
|
95
|
+
spawnSync(cmd, args, { stdio: "ignore", timeout: BROWSER_OPEN_TIMEOUT_MS });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function logout(revoke) {
|
|
101
|
+
const cfg = readConfig();
|
|
102
|
+
if (revoke && cfg.token && (cfg.pat_id || cfg.token.startsWith(RETIRED_TOKEN_PREFIX))) {
|
|
103
|
+
log("this CLI token was already retired server-side");
|
|
104
|
+
}
|
|
105
|
+
else if (revoke && cfg.token) {
|
|
106
|
+
const saved = { token: cfg.token, orgId: cfg.org_id };
|
|
107
|
+
try {
|
|
108
|
+
await api.revokeCurrentCredential(saved);
|
|
109
|
+
log("token revoked server-side");
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
warn(`could not revoke server-side (${e instanceof Error ? e.message : e}) — removing locally anyway`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
clearToken();
|
|
116
|
+
success("Logged out.");
|
|
117
|
+
}
|
|
118
|
+
export async function whoami() {
|
|
119
|
+
const { user, organization } = await whoAmI();
|
|
120
|
+
const shown = { id: user.id, email: user.email ?? null, displayName: user.displayName ?? null, avatarUrl: user.avatarUrl ?? null };
|
|
121
|
+
emit({ ...shown, organization }, () => {
|
|
122
|
+
process.stdout.write(` ${plain(user.email ?? user.displayName ?? user.id)}\n`);
|
|
123
|
+
process.stdout.write(` ${organization ? `organization ${plain(orgLabel(organization.id, organization.name))}` : "personal account"}\n`);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function accountDetail({ user, organization }) {
|
|
127
|
+
const who = user.email ?? user.id;
|
|
128
|
+
return organization ? `${who} · organization ${orgLabel(organization.id, organization.name)}` : who;
|
|
129
|
+
}
|
|
130
|
+
function refusedForOrgKey(error) {
|
|
131
|
+
return error instanceof CliError && error.diagnosis?.observed?.http_status === 403;
|
|
132
|
+
}
|
|
133
|
+
export async function status() {
|
|
134
|
+
const [{ nodes, org_context }, servable] = await Promise.all([
|
|
135
|
+
api.nodes(),
|
|
136
|
+
api.models().then((r) => r.models, (e) => e),
|
|
137
|
+
]);
|
|
138
|
+
if (!Array.isArray(servable) && !(org_context && refusedForOrgKey(servable)))
|
|
139
|
+
throw servable;
|
|
140
|
+
const models = Array.isArray(servable) ? servable : null;
|
|
141
|
+
const online = nodes.filter((n) => n.liveness === "online").length;
|
|
142
|
+
emit({ machines_total: nodes.length, machines_online: online, models_servable: models?.length ?? null, nodes, models }, () => {
|
|
143
|
+
const modelSummary = models ? `${models.length} servable models` : "servable models need a personal login";
|
|
144
|
+
process.stdout.write(` ${online}/${nodes.length} machines online · ${modelSummary}\n\n`);
|
|
145
|
+
if (nodes.length) {
|
|
146
|
+
table(["MACHINE", "STATUS", "MODELS"], nodes.map((n) => [machineLabel(n), n.liveness ?? "—", `${n.models_ready ?? 0}/${n.models_total ?? 0}`]));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
export function configCmd(positionals) {
|
|
151
|
+
const [key, value] = positionals;
|
|
152
|
+
if (!key) {
|
|
153
|
+
const { token, ...rest } = readConfig();
|
|
154
|
+
emit({ ...rest, token_present: Boolean(token) }, () => {
|
|
155
|
+
kv([...Object.entries(rest).map(([k, v]) => [k, String(v)]), ["token", token ? "***" : "(none)"]], 16);
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (key === "default-machine") {
|
|
160
|
+
if (!value)
|
|
161
|
+
throw new CliError("Usage: b3iq config default-machine <machine-id>", "usage", 1);
|
|
162
|
+
writeConfig({ default_machine: value });
|
|
163
|
+
success(`Default machine set to ${value}.`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
throw new CliError(`Unknown config key "${key}". Supported: default-machine.`, "usage", 1);
|
|
167
|
+
}
|
|
168
|
+
export async function doctor() {
|
|
169
|
+
const checks = [];
|
|
170
|
+
const settings = resolveSettings();
|
|
171
|
+
checks.push({ name: "config", ok: true, detail: `${configPath()} · api_url=${settings.apiUrl}` });
|
|
172
|
+
checks.push({
|
|
173
|
+
name: "auth",
|
|
174
|
+
ok: !!settings.token,
|
|
175
|
+
detail: settings.token ? "token present" : "no token — run `b3iq login` or set B3IQ_TOKEN",
|
|
176
|
+
});
|
|
177
|
+
const cloudflared = spawnSync("cloudflared", ["--version"], { stdio: "pipe" });
|
|
178
|
+
const cloudflaredInstalled = cloudflared.status === 0;
|
|
179
|
+
checks.push({
|
|
180
|
+
name: "cloudflared",
|
|
181
|
+
ok: cloudflaredInstalled,
|
|
182
|
+
detail: cloudflaredInstalled
|
|
183
|
+
? String(cloudflared.stdout).trim().split("\n")[0]
|
|
184
|
+
: "not installed (needed for `b3iq ssh`) — brew install cloudflared",
|
|
185
|
+
});
|
|
186
|
+
if (settings.token) {
|
|
187
|
+
try {
|
|
188
|
+
checks.push({ name: "api", ok: true, detail: `reachable as ${accountDetail(await whoAmI())}` });
|
|
189
|
+
}
|
|
190
|
+
catch (e) {
|
|
191
|
+
checks.push({ name: "api", ok: false, detail: e instanceof Error ? e.message : String(e) });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
emit({ checks }, () => {
|
|
195
|
+
for (const c of checks)
|
|
196
|
+
(c.ok ? success : warn)(`${c.name}: ${c.detail}`);
|
|
197
|
+
});
|
|
198
|
+
if (checks.some((c) => !c.ok))
|
|
199
|
+
process.exit(EXIT_CODE.api);
|
|
200
|
+
}
|
|
201
|
+
export function help() {
|
|
202
|
+
process.stderr.write([
|
|
203
|
+
"b3iq — manage your b3iq machines from the terminal or an agent",
|
|
204
|
+
"",
|
|
205
|
+
"USAGE b3iq <command> [args] [--json|--pretty] [--quiet]",
|
|
206
|
+
"",
|
|
207
|
+
" login sign in via your browser; pick personal or an organization there",
|
|
208
|
+
" [--token-stdin saves an API key read from a pipe]",
|
|
209
|
+
" logout [--revoke] forget the saved token (--revoke kills it server-side)",
|
|
210
|
+
" whoami show the logged-in account and organization",
|
|
211
|
+
" status fleet overview (machines + servable models; models need a personal login)",
|
|
212
|
+
" machines list list your machines [--quiet ⇒ node ids]",
|
|
213
|
+
" machines get <machine> one machine's detail",
|
|
214
|
+
" machines qualify <machine> re-run the hardware qualification [--no-wait]",
|
|
215
|
+
" machines guests <machine> a KVM host's guests and cards [--quiet ⇒ guest ids]",
|
|
216
|
+
" plugins list [--machine <m>] installable plugins (per-machine: installed, with state)",
|
|
217
|
+
" plugins install <plugin> --machine <m> install onto a machine [--no-wait]",
|
|
218
|
+
" plugins remove <plugin> --machine <m> remove it, with what it installed",
|
|
219
|
+
" models list [--machine <m>] servable models (per-machine: full catalog; fleet-wide needs a personal login)",
|
|
220
|
+
" models install <model> --machine <m> install onto a machine [--no-wait]",
|
|
221
|
+
" models context <model> --machine <m> verify/save coding context [--tokens N|--reset] [--no-wait]",
|
|
222
|
+
" models remove <model> --machine <m> uninstall from a machine",
|
|
223
|
+
" runtimes check --machine <m> installed and pinned update versions",
|
|
224
|
+
" runtimes upgrade <runtime> --machine <m> --target-version <v> upgrade when idle",
|
|
225
|
+
" runtimes policy <runtime> --machine <m> --mode manual|scheduled [--hour-utc <0..23>]",
|
|
226
|
+
" runtimes list --machine <m> engines a machine runs",
|
|
227
|
+
" runtimes restart <runtime> --machine <m> bounce an engine [--no-wait]",
|
|
228
|
+
" commands list --machine <m> [--limit N] a machine's recent commands",
|
|
229
|
+
" commands get <id> --machine <m> one command's status and result",
|
|
230
|
+
" logs [--limit N] [--follow] recent inference requests (personal login)",
|
|
231
|
+
" ssh [<machine>] [-- cmd] open an SSH session; asks which machine when none is given.",
|
|
232
|
+
" A rented KVM host opens your guest, as its user. With direct SSH",
|
|
233
|
+
" on for the account: the machine's own address first, then the",
|
|
234
|
+
" Cloudflare Tunnel; reconnects a dropped shell.",
|
|
235
|
+
" [--direct|--tunnel] [--no-reconnect]",
|
|
236
|
+
" config [default-machine <id>] view / set config",
|
|
237
|
+
" doctor diagnose auth, API reachability, cloudflared",
|
|
238
|
+
" inference models [--machine <id>] discover exact served model IDs",
|
|
239
|
+
" inference setup <codex|claude|opencode> --model <id> generate opt-in native config",
|
|
240
|
+
" inference <codex|claude|opencode> --model <id> [-- args] launch terminal client",
|
|
241
|
+
" inference setup codex --desktop [--install] [--activate] --model <id> separate desktop; preserve normal defaults",
|
|
242
|
+
" --setup-name <name> keep an existing Codex setup and create a separate one",
|
|
243
|
+
" inference setup claude --desktop [--install] [--activate] --model <id> save; activation is explicit",
|
|
244
|
+
" inference restore claude --desktop restore the provider recorded before activation",
|
|
245
|
+
" inference setup opencode --desktop --install --model <id> add provider; preserve defaults",
|
|
246
|
+
" --client-version 1|2 (OpenCode launch auto-detects; setup defaults to 2)",
|
|
247
|
+
" inference key-save | key-clear save a key from stdin / remove it",
|
|
248
|
+
" mcp media run the owned-model media MCP server (stdio)",
|
|
249
|
+
"",
|
|
250
|
+
"Output: stdout is data, stderr is logs. JSON is automatic when piped;",
|
|
251
|
+
" --json forces it, --quiet prints only ids. Exit: 0 ok / 1 usage /",
|
|
252
|
+
" 2 api / 3 auth / 4 rate-limited.",
|
|
253
|
+
"Failures name the check that failed and the next command. JSON errors add",
|
|
254
|
+
"diagnostic, cause, observed, recovery, and limitation. Exit codes stay the same.",
|
|
255
|
+
"",
|
|
256
|
+
].join("\n"));
|
|
257
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { api } from "../lib/api.js";
|
|
2
|
+
import { contextSavedLines, explainProfileRefusal } from "../lib/diagnosis.js";
|
|
3
|
+
import { resolveMachine, machineLabel, noteReportedOffline } from "../lib/machines.js";
|
|
4
|
+
import { enqueueAndWait } from "../lib/commands-wait.js";
|
|
5
|
+
import { CliError, emit, emitIds, isQuiet, log, success, table } from "../lib/output.js";
|
|
6
|
+
export async function modelsList(machine) {
|
|
7
|
+
if (machine)
|
|
8
|
+
return machineCatalog(machine);
|
|
9
|
+
const { models } = await api.models();
|
|
10
|
+
const id = (m) => m.id ?? m.model ?? "";
|
|
11
|
+
if (isQuiet()) {
|
|
12
|
+
emitIds(models.map(id));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
emit(models, () => {
|
|
16
|
+
if (models.length === 0) {
|
|
17
|
+
process.stderr.write("No servable models yet. See what a machine can run: `b3iq models list --machine <m>`.\n");
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
table(["MODEL", "MODALITY", "MACHINES"], models.map((m) => [id(m), m.modality ?? "text", String(m.nodes_online ?? 0)]));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
async function machineCatalog(machine) {
|
|
24
|
+
const node = await resolveMachine(machine);
|
|
25
|
+
const profiles = (await api.node(node.node_id)).model_profiles ?? [];
|
|
26
|
+
const pid = (p) => p.id ?? p.model ?? "";
|
|
27
|
+
if (isQuiet()) {
|
|
28
|
+
emitIds(profiles.filter((p) => p.status === "installed").map(pid));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
emit(profiles, () => {
|
|
32
|
+
if (profiles.length === 0) {
|
|
33
|
+
process.stderr.write(`${machineLabel(node)} advertises no model catalog yet (agent offline or still enrolling?).\n`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
table(["MODEL", "RUNTIME", "STATUS"], profiles.map((p) => [p.name ?? pid(p), p.runtime_kind ?? "—", p.status ?? "—"]));
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function matchProfiles(profiles, selector) {
|
|
40
|
+
const lower = selector.trim().toLowerCase();
|
|
41
|
+
const exact = profiles.filter((p) => p.id?.toLowerCase() === lower || p.model?.toLowerCase() === lower || p.name?.toLowerCase() === lower);
|
|
42
|
+
if (exact.length)
|
|
43
|
+
return exact;
|
|
44
|
+
return profiles.filter((p) => (p.id ?? "").toLowerCase().includes(lower) ||
|
|
45
|
+
(p.model ?? "").toLowerCase().includes(lower) ||
|
|
46
|
+
(p.name ?? "").toLowerCase().includes(lower));
|
|
47
|
+
}
|
|
48
|
+
function requireOneProfile(profiles, selector, wantStatus, verb, node) {
|
|
49
|
+
const matches = matchProfiles(profiles, selector);
|
|
50
|
+
const eligible = matches.filter((p) => p.status === wantStatus);
|
|
51
|
+
if (matches.length === 0 || eligible.length !== 1) {
|
|
52
|
+
throw explainProfileRefusal({
|
|
53
|
+
selector,
|
|
54
|
+
wantStatus,
|
|
55
|
+
verb,
|
|
56
|
+
matches,
|
|
57
|
+
catalogSize: profiles.length,
|
|
58
|
+
nodeId: node.node_id,
|
|
59
|
+
liveness: node.liveness,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return eligible[0];
|
|
63
|
+
}
|
|
64
|
+
const FALLBACK_RUNTIME_KIND = "ollama";
|
|
65
|
+
function commandParams(profile) {
|
|
66
|
+
return {
|
|
67
|
+
profile_id: profile.id,
|
|
68
|
+
model: profile.model,
|
|
69
|
+
runtime_id: profile.runtime_kind ?? FALLBACK_RUNTIME_KIND,
|
|
70
|
+
runtime_kind: profile.runtime_kind,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const INSTALL_TIMEOUT_MS = 45 * 60 * 1000;
|
|
74
|
+
const UNINSTALL_TIMEOUT_MS = 5 * 60 * 1000;
|
|
75
|
+
export async function modelsInstall(selector, machine, wait) {
|
|
76
|
+
if (!selector)
|
|
77
|
+
throw new CliError("Usage: b3iq models install <model> --machine <m>", "usage", 1);
|
|
78
|
+
const node = await resolveMachine(machine);
|
|
79
|
+
noteReportedOffline(node);
|
|
80
|
+
const profiles = (await api.node(node.node_id)).model_profiles ?? [];
|
|
81
|
+
const profile = requireOneProfile(profiles, selector, "ready_to_install", "install", node);
|
|
82
|
+
const name = profile.name ?? profile.model ?? profile.id ?? selector;
|
|
83
|
+
const row = await enqueueAndWait(node.node_id, "model.install", commandParams(profile), {
|
|
84
|
+
wait,
|
|
85
|
+
timeoutMs: INSTALL_TIMEOUT_MS,
|
|
86
|
+
describe: `install ${name} on ${machineLabel(node)}`,
|
|
87
|
+
});
|
|
88
|
+
emit(row, () => {
|
|
89
|
+
if (row.status === "completed")
|
|
90
|
+
success(`${name} installed on ${machineLabel(node)}.`);
|
|
91
|
+
else
|
|
92
|
+
process.stdout.write(`${row.command_id}\n`);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
export async function modelsRemove(selector, machine, wait) {
|
|
96
|
+
if (!selector)
|
|
97
|
+
throw new CliError("Usage: b3iq models remove <model> --machine <m>", "usage", 1);
|
|
98
|
+
const node = await resolveMachine(machine);
|
|
99
|
+
noteReportedOffline(node);
|
|
100
|
+
const profiles = (await api.node(node.node_id)).model_profiles ?? [];
|
|
101
|
+
const profile = requireOneProfile(profiles, selector, "installed", "remove", node);
|
|
102
|
+
const name = profile.name ?? profile.model ?? profile.id ?? selector;
|
|
103
|
+
const row = await enqueueAndWait(node.node_id, "model.uninstall", commandParams(profile), {
|
|
104
|
+
wait,
|
|
105
|
+
timeoutMs: UNINSTALL_TIMEOUT_MS,
|
|
106
|
+
describe: `remove ${name} from ${machineLabel(node)}`,
|
|
107
|
+
});
|
|
108
|
+
emit(row, () => {
|
|
109
|
+
if (row.status === "completed")
|
|
110
|
+
success(`${name} removed from ${machineLabel(node)}.`);
|
|
111
|
+
else
|
|
112
|
+
process.stdout.write(`${row.command_id}\n`);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
export async function modelsContext(selector, machine, tokens, reset, wait) {
|
|
116
|
+
if (!selector)
|
|
117
|
+
throw new CliError("Usage: b3iq models context <model> --machine <m> [--tokens <n>|--reset] [--no-wait]", "usage", 1);
|
|
118
|
+
if (tokens !== undefined && (typeof tokens !== "string" || !/^[0-9]+$/.test(tokens) || Number(tokens) < 1 || Number(tokens) > 16_777_216)) {
|
|
119
|
+
throw new CliError("--tokens requires an integer between 1 and 16777216.", "usage", 1);
|
|
120
|
+
}
|
|
121
|
+
if (reset && tokens !== undefined)
|
|
122
|
+
throw new CliError("Use --tokens or --reset, not both.", "usage", 1);
|
|
123
|
+
const node = await resolveMachine(machine);
|
|
124
|
+
noteReportedOffline(node);
|
|
125
|
+
const profile = requireOneProfile((await api.node(node.node_id)).model_profiles ?? [], selector, "installed", "configure context", node);
|
|
126
|
+
const row = await enqueueAndWait(node.node_id, "model.context.configure", {
|
|
127
|
+
profile_id: profile.id, model: profile.model,
|
|
128
|
+
...(tokens === undefined ? {} : { context_window_tokens: Number(tokens) }),
|
|
129
|
+
...(reset ? { reset: true } : {}),
|
|
130
|
+
}, { wait, timeoutMs: 6 * 60 * 1000, describe: `configure context for ${profile.model} on ${machineLabel(node)}` });
|
|
131
|
+
if (row.status === "completed") {
|
|
132
|
+
for (const line of contextSavedLines(profile.model ?? selector, node.node_id, machineLabel(node), row.result))
|
|
133
|
+
log(line);
|
|
134
|
+
}
|
|
135
|
+
emit(row, () => {
|
|
136
|
+
if (row.status === "completed")
|
|
137
|
+
process.stdout.write(JSON.stringify(row.result, null, 2) + "\n");
|
|
138
|
+
else
|
|
139
|
+
process.stdout.write(`${row.command_id}\n`);
|
|
140
|
+
});
|
|
141
|
+
}
|