zelpi 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.
@@ -0,0 +1,406 @@
1
+ // zosCLI — command handlers. Each maps a verb to the API-hooks layer, the
2
+ // kernel profile router, or the hub/repo. Rendering uses cli/ui.mjs.
3
+
4
+ import { fileURLToPath } from "node:url";
5
+ import path from "node:path";
6
+ import fs from "node:fs";
7
+ import { spawn } from "node:child_process";
8
+ import * as api from "./client.mjs";
9
+ import * as cfg from "./config.mjs";
10
+ import { PROFILES, PROFILE_KEYS, resolveProfile, routingNote } from "./kernel.mjs";
11
+ import { SOCS, CHASSIS, MODELS, FAMILIES, resolveModel, chassisByName } from "./hub.mjs";
12
+ import { c, sym, table, bar, kv, heading, hexAccent } from "./ui.mjs";
13
+
14
+ const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), "../../");
15
+ const out = (s = "") => console.log(s);
16
+ const pct = (q) => `${Math.round((q || 0) * 100)}%`;
17
+
18
+ const STATUS_COLOR = {
19
+ idle: c.gray, navigating: c.cyan, scanning: c.blue, manipulating: c.yellow,
20
+ paused: c.red, charging: c.magenta,
21
+ };
22
+ const MODEL_STATUS_COLOR = {
23
+ training: c.yellow, ready: c.cyan, deployed: c.ok, archived: c.gray,
24
+ };
25
+ const KIND_LAYER = { vla: "S2 · VLA", act: "S1 · ACT", mhal: "S0 · M-HAL" };
26
+
27
+ function profileBadge(key) {
28
+ const p = PROFILES[key] ?? PROFILES.balanced;
29
+ return `${hexAccent(profileHex(key), p.glyph + " " + p.label)} ${c.dim("(" + routingNote(key) + ")")}`;
30
+ }
31
+ function profileHex(key) {
32
+ return { balanced: "#94a3b8", robustness: "#60a5fa", optimisation: "#34d399", "system-critical": "#f87171" }[key] ?? "#94a3b8";
33
+ }
34
+
35
+ // ── status ───────────────────────────────────────────────────────────────────
36
+ export async function status() {
37
+ const h = await api.health();
38
+ out(heading("ZelPi · status"));
39
+ out(kv("Backend", `${cfg.httpBase()} ${sym.ok} ${c.ok("live")}`));
40
+ out(kv("Store", h.store ?? "—"));
41
+ out(kv("Profile", profileBadge(h.profile ?? "balanced")));
42
+ out(kv("Fleet", `${h.fleet} online`));
43
+ out(kv("Active tasks", String(h.tasks ?? 0)));
44
+ const llm = h.llm || {};
45
+ const llmTxt = !llm.enabled
46
+ ? c.dim("disabled — deterministic parser")
47
+ : llm.reachable
48
+ ? c.ok(`${llm.provider} ready (${llm.model})`)
49
+ : c.warn(`${llm.provider} unreachable — deterministic fallback`);
50
+ out(kv("System 2 LLM", llmTxt));
51
+ out(kv("ROS link", h.ros?.connected ? c.ok("connected") : c.dim("disconnected")));
52
+ out(kv("Foundry", h.foundry?.enabled ? (h.foundry.reachable ? c.ok("MLflow connected") : c.warn("unreachable")) : c.dim("off (local training)")));
53
+
54
+ const snap = await api.snapshot();
55
+ const dep = snap?.metrics?.deployed ?? {};
56
+ const depTxt = ["vla", "act", "mhal"]
57
+ .map((k) => (dep[k] ? `${k.toUpperCase()} ${dep[k].name} v${dep[k].version} ${c.dim("(" + pct(dep[k].quality) + ")")}` : `${k.toUpperCase()} ${c.dim("—")}`))
58
+ .join(" " + sym.dot + " ");
59
+ out(kv("Deployed", depTxt));
60
+ out();
61
+ }
62
+
63
+ // ── fleet ──────────────────────────────────────────────────────────────────--
64
+ export async function fleet(args, flags) {
65
+ if (args[0] === "enroll") return enroll(args.slice(1), flags);
66
+ const snap = await api.snapshot();
67
+ out(heading(`ZelPi · fleet (${snap.robots.length} online)`));
68
+ const rows = snap.robots.map((r) => [
69
+ c.bold(r.name),
70
+ r.chassis,
71
+ r.bus,
72
+ (STATUS_COLOR[r.status] ?? ((x) => x))(r.status),
73
+ bar(r.battery / 100, 10, r.battery < 25 ? c.red : c.green) + " " + pct(r.battery / 100),
74
+ r.taskId ?? c.dim("—"),
75
+ c.dim(`(${r.pos.x.toFixed(0)},${r.pos.y.toFixed(0)})`),
76
+ ]);
77
+ out(table(["NAME", "CHASSIS", "BUS", "STATUS", "BATTERY", "TASK", "POS"], rows));
78
+ out();
79
+ }
80
+
81
+ async function enroll(args, flags) {
82
+ const name = flags.name || flags.chassis || args.join(" ");
83
+ const preset = chassisByName(flags.chassis || args.join(" "));
84
+ if (!preset) {
85
+ out(`${sym.err} unknown chassis. Available:`);
86
+ for (const ch of CHASSIS) out(` ${sym.dot} ${c.bold(ch.chassis)} ${c.dim(`(${ch.bus}, ${ch.joints} joints)`)}`);
87
+ out(`\n ${c.dim('e.g. npx zelpi fleet enroll --chassis "Quadruped" --name Rex --aisle 2')}`);
88
+ return;
89
+ }
90
+ const spec = {
91
+ name: flags.name || preset.chassis.split(" ")[0],
92
+ chassis: preset.chassis,
93
+ bus: preset.bus,
94
+ joints: preset.joints,
95
+ aisle: Number(flags.aisle ?? 1),
96
+ };
97
+ const before = await api.snapshot();
98
+ await api.command({ type: "enrollEmbodiment", spec }, { settle: 3 });
99
+ const after = await api.snapshot();
100
+ const added = after.robots.find((r) => !before.robots.some((b) => b.id === r.id));
101
+ out(`${sym.ok} enrolled ${c.bold(added?.name ?? spec.name)} ${c.dim(`(${spec.chassis}, ${spec.bus}, ${spec.joints} joints, aisle ${spec.aisle})`)}`);
102
+ out(` ${c.dim("M-HAL adapting hardware-abstraction layer · M2M identity issued.")}`);
103
+ }
104
+
105
+ // ── models (the VLM/WM/VLA/LLM layer) ────────────────────────────────────────
106
+ export async function models(args, flags) {
107
+ if (args[0] === "train") return trainModel(args.slice(1), flags);
108
+ if (args[0] === "deploy") return deployModel(args.slice(1), flags);
109
+ const snap = await api.snapshot();
110
+ out(heading(`ZelPi · models (${snap.models.length})`));
111
+ const rows = snap.models.map((m) => [
112
+ c.dim(m.id),
113
+ KIND_LAYER[m.kind] ?? m.kind,
114
+ c.bold(m.name),
115
+ "v" + m.version,
116
+ (MODEL_STATUS_COLOR[m.status] ?? ((x) => x))(m.status === "deployed" ? "● deployed" : m.status),
117
+ m.status === "training"
118
+ ? bar(m.trainProgress, 12, c.yellow) + " " + pct(m.trainProgress)
119
+ : bar(m.quality, 12, c.green) + " " + pct(m.quality),
120
+ m.embodiment,
121
+ ]);
122
+ out(table(["ID", "LAYER", "NAME", "VER", "STATUS", "QUALITY/TRAIN", "EMBODIMENT"], rows));
123
+ out(`\n ${c.dim("train: npx zelpi models train --kind vla|act|mhal --name <n> deploy: npx zelpi models deploy <id>")}`);
124
+ out();
125
+ }
126
+
127
+ async function trainModel(args, flags) {
128
+ const kind = (flags.kind || args[0] || "").toLowerCase();
129
+ if (!["vla", "act", "mhal"].includes(kind)) {
130
+ out(`${sym.err} --kind must be one of: vla (S2) · act (S1) · mhal (S0)`);
131
+ return;
132
+ }
133
+ const name = flags.name || args.find((a) => a !== kind) || `${kind}-edge`;
134
+ const embodiment = flags.embodiment || "universal";
135
+ const before = await api.snapshot();
136
+ await api.command({ type: "createModel", kind, name, embodiment }, { settle: 3 });
137
+ const after = await api.snapshot();
138
+ const mdl = after.models.find((m) => !before.models.some((b) => b.id === m.id)) || after.models.find((m) => m.name === name);
139
+ out(`${sym.ok} training ${c.bold(name)} ${c.dim(`(${KIND_LAYER[kind]}, embodiment: ${embodiment})`)}`);
140
+ if (mdl) out(` ${c.dim(`id ${mdl.id} · fitting on ${(mdl.datasetMB / 1024).toFixed(2)} GB edge experience → Cloud GPU. Deploy when ready: npx zelpi models deploy ${mdl.id}`)}`);
141
+ out(` ${c.dim("watch live: npx zelpi watch")}`);
142
+ }
143
+
144
+ async function deployModel(args, flags) {
145
+ const id = args[0] || flags.id;
146
+ if (!id) return out(`${sym.err} usage: npx zelpi models deploy <id>`);
147
+ const snap = await api.snapshot();
148
+ const mdl = snap.models.find((m) => m.id === id);
149
+ if (!mdl) return out(`${sym.err} no model ${id}. List: npx zelpi models`);
150
+ if (mdl.status === "training") return out(`${sym.warn} ${id} still training (${pct(mdl.trainProgress)}). Wait for 'ready'.`);
151
+ await api.command({ type: "deployModel", id }, { settle: 3 });
152
+ out(`${sym.ok} deployed ${c.bold(mdl.name)} v${mdl.version} → ${c.bold(KIND_LAYER[mdl.kind])} ${c.dim(`(quality ${pct(mdl.quality)})`)}`);
153
+ out(` ${c.dim("Global model pushed to fleet; prior version demoted.")}`);
154
+ }
155
+
156
+ // ── hub / repo (SOCs · chassis · HF model registry) ──────────────────────────
157
+ export async function hub(args, flags) {
158
+ const sub = args[0];
159
+ if (sub === "pull") return pull(args.slice(1), flags);
160
+ if (sub === "socs" || sub === "soc") return out(socsTable());
161
+ if (sub === "chassis") return out(chassisTable());
162
+ if (sub === "models" || sub === "registry") return out(modelsTable());
163
+ out(heading("ZelPi · hub — compatible SOCs, chassis & model registry"));
164
+ out(socsTable());
165
+ out(chassisTable());
166
+ out(modelsTable());
167
+ out(`\n ${c.dim("load a model: npx zelpi hub pull pi0 or any HF id: npx zelpi hub pull openvla/openvla-7b")}`);
168
+ out();
169
+ }
170
+
171
+ function socsTable() {
172
+ const rows = SOCS.map((s) => [c.bold(s.id), s.vendor, s.accel, String(s.tops) + " TOPS", s.bus, s.note]);
173
+ return heading("Compatible SOCs (edge inference targets)") + "\n" +
174
+ table(["SOC", "VENDOR", "ACCELERATOR", "COMPUTE", "BUS", "NOTE"], rows);
175
+ }
176
+ function chassisTable() {
177
+ const rows = CHASSIS.map((ch) => [c.bold(ch.chassis), ch.bus, String(ch.joints)]);
178
+ return heading("Chassis embodiments (M-HAL targets)") + "\n" +
179
+ table(["CHASSIS", "BUS", "JOINTS"], rows);
180
+ }
181
+ function modelsTable() {
182
+ const rows = MODELS.map((m) => [c.bold(m.key), m.family, (KIND_LAYER[m.kind] ?? m.kind), m.hf, m.license, m.note]);
183
+ return heading(`Model registry — families: ${FAMILIES.join(" · ")} (→ .hf)`) + "\n" +
184
+ table(["KEY", "FAMILY", "LAYER", "HUGGINGFACE", "LICENSE", "NOTE"], rows);
185
+ }
186
+
187
+ async function pull(args, flags) {
188
+ const ref = args[0] || flags.model;
189
+ if (!ref) return out(`${sym.err} usage: npx zelpi hub pull <key|hf-id> (e.g. pi0, openvla/openvla-7b)`);
190
+ const m = resolveModel(ref);
191
+ const name = flags.name || m.name;
192
+ out(`${m.known ? sym.ok : sym.warn} resolving ${c.bold(ref)} ${c.dim(`→ ${m.family} · ${KIND_LAYER[m.kind]} · ${m.license}`)}`);
193
+ out(` ${c.dim(`loading weights from Hugging Face: ${m.hf}`)}`);
194
+ const before = await api.snapshot();
195
+ await api.command({ type: "createModel", kind: m.kind, name, embodiment: flags.embodiment || "universal" }, { settle: 3 });
196
+ const after = await api.snapshot();
197
+ const mdl = after.models.find((x) => !before.models.some((b) => b.id === x.id)) || after.models.find((x) => x.name === name);
198
+ out(`${sym.ok} registered ${c.bold(name)} → fine-tuning on fleet experience (Cloud GPU).`);
199
+ if (mdl) out(` ${c.dim(`id ${mdl.id} · deploy when ready: npx zelpi models deploy ${mdl.id}`)}`);
200
+ }
201
+
202
+ // ── profile (kernel QoS router) ──────────────────────────────────────────────
203
+ export async function profile(args) {
204
+ if (!args[0]) {
205
+ const snap = await api.snapshot().catch(() => null);
206
+ const active = snap?.profile ?? cfg.load().profile;
207
+ out(heading("ZelPi · kernel profiles"));
208
+ for (const k of PROFILE_KEYS) {
209
+ const p = PROFILES[k];
210
+ const mark = k === active ? c.ok("●") : c.gray("○");
211
+ out(` ${mark} ${hexAccent(profileHex(k), p.glyph + " " + p.label.padEnd(26))} ${c.dim(p.desc)}`);
212
+ out(` ${c.dim(routingNote(k))}`);
213
+ }
214
+ out(`\n ${c.dim("switch: npx zelpi profile optimisation")}`);
215
+ return;
216
+ }
217
+ const key = resolveProfile(args[0]);
218
+ if (!key) return out(`${sym.err} unknown profile '${args[0]}'. One of: ${PROFILE_KEYS.join(", ")}`);
219
+ await api.command({ type: "setProfile", profile: key }, { settle: 2 });
220
+ cfg.set("profile", key);
221
+ out(`${sym.ok} kernel profile → ${profileBadge(key)}`);
222
+ }
223
+
224
+ // ── intent (routed through the active/selected profile) ──────────────────────
225
+ export async function intent(args, flags) {
226
+ const text = args.join(" ").trim();
227
+ if (!text) return out(`${sym.err} usage: npx zelpi intent "Inventory Aisle 4" [--profile optimisation]`);
228
+ if (flags.profile) {
229
+ const key = resolveProfile(flags.profile);
230
+ if (!key) return out(`${sym.err} unknown profile '${flags.profile}'`);
231
+ await api.command({ type: "setProfile", profile: key }, { settle: 1 });
232
+ cfg.set("profile", key);
233
+ out(`${sym.dot} routing via ${profileBadge(key)}`);
234
+ }
235
+ const before = await api.snapshot();
236
+ const beforeIds = new Set(before.tasks.map((t) => t.id));
237
+ out(`${sym.arrow} ${c.bold(text)} ${c.dim("· System 2 planning…")}`);
238
+ // The intent is planned asynchronously (LLM); wait for the new task to land.
239
+ const after = await api.command(
240
+ { type: "intent", text },
241
+ { until: (s) => s.tasks.some((t) => !beforeIds.has(t.id)), timeout: 12000 },
242
+ );
243
+ const task = after.tasks.find((t) => !beforeIds.has(t.id)) || after.tasks[after.tasks.length - 1];
244
+ if (!task) return out(` ${c.dim("(dispatched)")}`);
245
+ const robot = after.robots.find((r) => r.id === task.robotId);
246
+ out(` ${sym.ok} ${c.bold(task.id)} ${c.dim("·")} ${task.summary}`);
247
+ out(` ${c.dim("assigned →")} ${c.bold(robot?.name ?? task.robotId)} ${c.dim(`(${robot?.chassis ?? "?"})`)}`);
248
+ for (const st of task.subtasks) {
249
+ const g = { queued: c.gray("○"), active: c.cyan("◉"), done: c.ok("✓"), blocked: c.red("⚠") }[st.state] ?? "·";
250
+ out(` ${g} ${st.label} ${c.dim(`[${st.agent}/${st.kind}]`)}`);
251
+ }
252
+ if (after.safety) out(`\n ${sym.warn} ${c.red("Safety gate raised:")} ${after.safety.message}\n ${c.dim("resolve:")} npx zelpi safety ${after.safety.options.map((o) => o.toLowerCase()).join(" | ")}`);
253
+ out(` ${c.dim("watch it run: npx zelpi watch")}`);
254
+ }
255
+
256
+ // ── safety broker / HITL gate ────────────────────────────────────────────────
257
+ export async function safety(args) {
258
+ const snap = await api.snapshot();
259
+ if (!snap.safety) {
260
+ out(`${sym.ok} no pending safety gate. Fleet trajectories clear.`);
261
+ return;
262
+ }
263
+ const s = snap.safety;
264
+ if (!args[0]) {
265
+ out(heading("ZelPi · Safety Broker — HITL gate"));
266
+ out(` ${sym.warn} ${c.red(s.kind.toUpperCase())}: ${s.message}`);
267
+ const robot = snap.robots.find((r) => r.id === s.robotId);
268
+ out(` ${c.dim("robot:")} ${robot?.name ?? s.robotId} ${c.dim("task:")} ${s.taskId}`);
269
+ out(` ${c.dim("decide:")} ${s.options.map((o) => c.bold(o)).join(c.dim(" | "))}`);
270
+ out(`\n ${c.dim("e.g. npx zelpi safety " + s.options[0].toLowerCase())}`);
271
+ return;
272
+ }
273
+ const choice = s.options.find((o) => o.toLowerCase() === args[0].toLowerCase()) || args[0];
274
+ await api.command({ type: "resolveSafety", option: choice }, { settle: 3 });
275
+ out(`${sym.ok} resolved HITL gate → ${c.bold(choice)}`);
276
+ }
277
+
278
+ // ── live watch (activity feed + metrics) ─────────────────────────────────────
279
+ export function watch() {
280
+ out(c.dim("watching ZelPi — Ctrl-C to stop\n"));
281
+ let lastLogId = 0;
282
+ let firstFrame = true;
283
+ const stop = api.watch((snap) => {
284
+ if (firstFrame) {
285
+ const known = snap.logs.map((l) => l.id);
286
+ lastLogId = known.length ? Math.max(...known) : 0;
287
+ firstFrame = false;
288
+ renderHeader(snap);
289
+ return;
290
+ }
291
+ for (const log of snap.logs) {
292
+ if (log.id <= lastLogId) continue;
293
+ lastLogId = log.id;
294
+ out(formatLog(log));
295
+ }
296
+ });
297
+ process.on("SIGINT", () => { stop(); out("\n" + c.dim("stopped.")); process.exit(0); });
298
+ }
299
+
300
+ const SYS_COLOR = { S3: c.magenta, S2: c.blue, S1: c.green, S0: c.yellow, SAFETY: c.red, HITL: c.yellow, OP: c.cyan };
301
+ const LEVEL_GLYPH = { info: c.gray("·"), act: c.cyan("▸"), warn: c.warn("▲"), alert: c.red("⚠"), ok: c.ok("✓") };
302
+ function formatLog(l) {
303
+ const sys = (SYS_COLOR[l.system] ?? ((x) => x))(l.system.padEnd(6));
304
+ return ` ${c.dim(l.t.toFixed(1).padStart(6))} ${LEVEL_GLYPH[l.level] ?? "·"} ${sys} ${l.text}`;
305
+ }
306
+ function renderHeader(snap) {
307
+ const m = snap.metrics;
308
+ out(c.dim(` clock ${m.clock.toFixed(0)}s · fleet ${m.fleetOnline} · tasks ${m.activeTasks} · profile ${snap.profile} · S2 ${m.hz.S2.toFixed(1)}Hz S1 ${m.hz.S1.toFixed(0)}Hz S0 ${m.hz.S0.toFixed(0)}Hz`));
309
+ out(c.gray(" " + "─".repeat(60)));
310
+ }
311
+
312
+ // ── lifecycle: pause / resume / reset ────────────────────────────────────────
313
+ export async function pause() {
314
+ const snap = await api.command({ type: "togglePause" }, { settle: 2 });
315
+ out(`${sym.ok} fleet ${snap?.paused ? c.warn("paused") : c.ok("running")}`);
316
+ }
317
+ export const resume = pause;
318
+ export async function reset() {
319
+ await api.command({ type: "reset" }, { settle: 2 });
320
+ out(`${sym.ok} world reset to seed state.`);
321
+ }
322
+
323
+ // ── metrics (Prometheus exposition passthrough) ──────────────────────────────
324
+ export async function metrics() {
325
+ const txt = await api.metricsText();
326
+ out(typeof txt === "string" ? txt : JSON.stringify(txt, null, 2));
327
+ }
328
+
329
+ // ── login (fetch a dev JWT, persist to config) ───────────────────────────────
330
+ export async function login(args) {
331
+ const user = args[0] || "operator";
332
+ const res = await api.login(user);
333
+ if (res.token) {
334
+ cfg.set("token", res.token);
335
+ out(`${sym.ok} token stored for ${c.bold(user)} ${c.dim(res.authEnabled ? "(auth enforced)" : "(auth currently off)")}`);
336
+ } else {
337
+ out(`${sym.err} no token returned`);
338
+ }
339
+ }
340
+
341
+ // ── up: boot the backend (full repo backend if present, else embedded) ───────
342
+ export async function up(args, flags) {
343
+ // already live?
344
+ try {
345
+ const h = await api.health();
346
+ out(`${sym.ok} backend already live at ${cfg.httpBase()} ${c.dim(`(fleet ${h.fleet}, store ${h.store})`)}`);
347
+ if (!flags.force) return;
348
+ } catch { /* not up — boot it */ }
349
+
350
+ const serverEntry = path.join(REPO_ROOT, "server", "server.ts");
351
+ const useFull = !flags.embedded && fs.existsSync(serverEntry);
352
+ if (useFull) {
353
+ const ros = flags.ros ? "server:ros" : "server";
354
+ out(`${sym.arrow} starting full backend ${c.dim(`(npm run ${ros}, LLM-capable) — Ctrl-C to stop`)}\n`);
355
+ const child = spawn("npm", ["run", ros], { cwd: REPO_ROOT, stdio: "inherit", env: process.env });
356
+ child.on("exit", (code) => process.exit(code ?? 0));
357
+ await new Promise(() => {}); // keep foreground until the child exits
358
+ return;
359
+ }
360
+ // self-contained embedded backend (no repo / no tsx needed)
361
+ await serverForeground(args, flags);
362
+ }
363
+
364
+ // ── __server: run the embedded backend foreground (also spawned as a daemon) ──
365
+ export async function serverForeground(args, flags) {
366
+ const port = Number(flags.port ?? cfg.load().port ?? 8787);
367
+ const quiet = !!flags.quiet;
368
+ const { createEmbeddedServer } = await import("./embedded.mjs");
369
+ let handle;
370
+ try {
371
+ handle = await createEmbeddedServer({ port });
372
+ } catch (e) {
373
+ if (e?.code === "EADDRINUSE") { if (!quiet) out(`${sym.warn} port ${port} already in use — a backend may already be running.`); return; }
374
+ throw e;
375
+ }
376
+ if (!quiet) {
377
+ out(`${sym.ok} ${c.ok("embedded ZelPi backend live")} ${c.dim(`→ http://127.0.0.1:${port} (ws://127.0.0.1:${port})`)}`);
378
+ out(` ${c.dim("deterministic System-2 parser · state persists to ~/.zelpi· Ctrl-C to stop")}`);
379
+ }
380
+ const shutdown = () => { handle.close(); process.exit(0); };
381
+ process.on("SIGINT", shutdown);
382
+ process.on("SIGTERM", shutdown);
383
+ await new Promise(() => {}); // run until killed
384
+ }
385
+
386
+ // ── down: stop the embedded daemon ───────────────────────────────────────────
387
+ export async function down() {
388
+ const { stopDaemon } = await import("./daemon.mjs");
389
+ const info = stopDaemon();
390
+ if (info?.pid) out(`${sym.ok} stopped embedded backend ${c.dim(`(pid ${info.pid})`)}`);
391
+ else out(`${sym.dot} no embedded backend daemon was running.`);
392
+ }
393
+
394
+ // ── config ───────────────────────────────────────────────────────────────────
395
+ export function config(args) {
396
+ if (args[0] === "set" && args[1] && args[2] !== undefined) {
397
+ const value = args[1] === "port" ? Number(args[2]) : args[2];
398
+ cfg.set(args[1], value);
399
+ out(`${sym.ok} ${args[1]} = ${args[2]}`);
400
+ return;
401
+ }
402
+ const conf = cfg.load();
403
+ out(heading("ZelPi · config") + ` ${c.dim(cfg.CONFIG_FILE)}`);
404
+ for (const [k, v] of Object.entries(conf)) out(kv(k, k === "token" && v ? c.dim("(set)") : String(v || c.dim("—"))));
405
+ out(`\n ${c.dim("set: npx zelpi config set host 10.0.0.5 npx zelpi config set port 8787")}`);
406
+ }
package/cli/config.mjs ADDED
@@ -0,0 +1,58 @@
1
+ // pi-os CLI — persistent config (~/.pi-os/config.json).
2
+ // Stores the backend endpoint, auth token, and the active kernel profile so the
3
+ // CLI behaves like a stateful OS shell across invocations.
4
+
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ const DIR = path.join(os.homedir(), ".pi-os");
10
+ const FILE = path.join(DIR, "config.json");
11
+
12
+ const DEFAULTS = {
13
+ // The MCP / API-hooks endpoint the CLI drives. HTTP for /health + db views,
14
+ // ws:// for the live command + snapshot channel.
15
+ host: "127.0.0.1",
16
+ port: 8787,
17
+ token: process.env.PIOS_TOKEN ?? "",
18
+ profile: "balanced",
19
+ };
20
+
21
+ export function load() {
22
+ try {
23
+ const raw = JSON.parse(fs.readFileSync(FILE, "utf8"));
24
+ return { ...DEFAULTS, ...raw };
25
+ } catch {
26
+ return { ...DEFAULTS };
27
+ }
28
+ }
29
+
30
+ export function save(cfg) {
31
+ try {
32
+ fs.mkdirSync(DIR, { recursive: true });
33
+ fs.writeFileSync(FILE, JSON.stringify(cfg, null, 2));
34
+ } catch {
35
+ /* best-effort; CLI still works with in-memory config */
36
+ }
37
+ }
38
+
39
+ export function set(key, value) {
40
+ const cfg = load();
41
+ cfg[key] = value;
42
+ save(cfg);
43
+ return cfg;
44
+ }
45
+
46
+ export function httpBase(cfg = load()) {
47
+ const env = process.env.PIOS_HOST;
48
+ if (env) return env.replace(/\/$/, "");
49
+ return `http://${cfg.host}:${cfg.port}`;
50
+ }
51
+
52
+ export function wsUrl(cfg = load()) {
53
+ const env = process.env.PIOS_WS_URL;
54
+ const base = env || `ws://${cfg.host}:${cfg.port}`;
55
+ return cfg.token ? `${base}?token=${encodeURIComponent(cfg.token)}` : base;
56
+ }
57
+
58
+ export { FILE as CONFIG_FILE };
package/cli/daemon.mjs ADDED
@@ -0,0 +1,75 @@
1
+ // pi-os CLI — local embedded-backend lifecycle.
2
+ // `ensureBackend()` makes one-shot commands "just work" after `npm i -g zelpi`:
3
+ // if the configured backend is local and not running, it boots the embedded
4
+ // server (cli/embedded.mjs) as a detached daemon and waits for health. Remote
5
+ // backends (PIOS_HOST set to another machine) are left untouched.
6
+
7
+ import { fileURLToPath } from "node:url";
8
+ import { spawn } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { httpBase, load } from "./config.mjs";
13
+
14
+ const BIN = path.resolve(fileURLToPath(import.meta.url), "../../bin/pi-os.mjs");
15
+ const DAEMON_FILE = path.join(os.homedir(), ".pi-os", "daemon.json");
16
+
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
+
19
+ async function reachable(base, timeout = 800) {
20
+ try {
21
+ const res = await fetch(base + "/health", { signal: AbortSignal.timeout(timeout) });
22
+ return res.ok;
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function isLocal(base) {
29
+ try {
30
+ const h = new URL(base).hostname;
31
+ return h === "127.0.0.1" || h === "localhost" || h === "::1";
32
+ } catch {
33
+ return false;
34
+ }
35
+ }
36
+
37
+ /** Ensure a backend is up. Auto-starts the embedded daemon for local targets.
38
+ * Returns { mode: "remote" | "running" | "started" | "unreachable" }. */
39
+ export async function ensureBackend() {
40
+ const base = httpBase();
41
+ if (await reachable(base)) return { mode: "running", base };
42
+ if (!isLocal(base)) return { mode: "remote", base }; // user manages the remote host
43
+ if (process.env.PIOS_NO_AUTOSTART) return { mode: "unreachable", base };
44
+
45
+ const port = load().port || 8787;
46
+ const child = spawn(process.execPath, [BIN, "__server", "--port", String(port), "--quiet"], {
47
+ detached: true,
48
+ stdio: "ignore",
49
+ env: process.env,
50
+ });
51
+ child.unref();
52
+ try {
53
+ fs.mkdirSync(path.dirname(DAEMON_FILE), { recursive: true });
54
+ fs.writeFileSync(DAEMON_FILE, JSON.stringify({ pid: child.pid, port, startedAt: Date.now() }));
55
+ } catch { /* best-effort */ }
56
+
57
+ for (let i = 0; i < 40; i++) {
58
+ if (await reachable(base, 400)) return { mode: "started", base, pid: child.pid };
59
+ await sleep(150);
60
+ }
61
+ return { mode: "unreachable", base };
62
+ }
63
+
64
+ /** Stop the embedded daemon if we started one. */
65
+ export function stopDaemon() {
66
+ let info;
67
+ try { info = JSON.parse(fs.readFileSync(DAEMON_FILE, "utf8")); } catch { return null; }
68
+ if (info?.pid) {
69
+ try { process.kill(info.pid); } catch { /* already gone */ }
70
+ }
71
+ try { fs.rmSync(DAEMON_FILE); } catch {}
72
+ return info;
73
+ }
74
+
75
+ export { DAEMON_FILE };
@@ -0,0 +1,70 @@
1
+ // zosCLI — argument parsing + verb→handler dispatch. Shared by the binary
2
+ // entrypoint and the interactive REPL so both speak an identical command set.
3
+
4
+ import * as cmd from "./commands.mjs";
5
+ import { mcp } from "./mcp.mjs";
6
+ import { c, sym } from "./ui.mjs";
7
+
8
+ /** Split tokens into positional args and --flags (--k v / --k=v / --bool). */
9
+ export function parseArgs(tokens) {
10
+ const _ = [];
11
+ const flags = {};
12
+ for (let i = 0; i < tokens.length; i++) {
13
+ const t = tokens[i];
14
+ if (t.startsWith("--")) {
15
+ const body = t.slice(2);
16
+ if (body.includes("=")) {
17
+ const [k, ...v] = body.split("=");
18
+ flags[k] = v.join("=");
19
+ } else if (tokens[i + 1] != null && !tokens[i + 1].startsWith("--")) {
20
+ flags[body] = tokens[++i];
21
+ } else {
22
+ flags[body] = true;
23
+ }
24
+ } else {
25
+ _.push(t);
26
+ }
27
+ }
28
+ return { _, flags };
29
+ }
30
+
31
+ // Verb → handler. Aliases point at the same function.
32
+ export const COMMANDS = {
33
+ status: cmd.status, stat: cmd.status, st: cmd.status,
34
+ intent: cmd.intent, run: cmd.intent, do: cmd.intent,
35
+ fleet: cmd.fleet, robots: cmd.fleet,
36
+ models: cmd.models, model: cmd.models,
37
+ hub: cmd.hub, repo: cmd.hub,
38
+ profile: cmd.profile, prof: cmd.profile,
39
+ safety: cmd.safety, hitl: cmd.safety,
40
+ watch: cmd.watch, logs: cmd.watch, feed: cmd.watch,
41
+ pause: cmd.pause, resume: cmd.resume,
42
+ reset: cmd.reset,
43
+ metrics: cmd.metrics,
44
+ login: cmd.login,
45
+ up: cmd.up, serve: cmd.up, start: cmd.up,
46
+ down: cmd.down, stop: cmd.down,
47
+ config: cmd.config,
48
+ mcp: () => mcp(),
49
+ __server: cmd.serverForeground,
50
+ };
51
+
52
+ // Commands that should NOT trigger embedded-backend auto-start.
53
+ export const NO_AUTOSTART = new Set(["help", "version", "config", "up", "serve", "start", "down", "stop", "__server"]);
54
+
55
+ /** Run a parsed argv (array of tokens). Returns a promise. */
56
+ export async function run(tokens) {
57
+ const verb = tokens[0];
58
+ const handler = COMMANDS[verb];
59
+ const { _, flags } = parseArgs(tokens.slice(1));
60
+ if (!handler) {
61
+ // No verb match: in an OS shell, bare text is an intent.
62
+ if (verb && !verb.startsWith("-")) {
63
+ const parsed = parseArgs(tokens);
64
+ return cmd.intent(parsed._, parsed.flags);
65
+ }
66
+ console.log(`${sym.err} unknown command '${verb}'. Try: ${c.dim("npx zelpi help")}`);
67
+ return;
68
+ }
69
+ return handler(_, flags);
70
+ }