@anna-ai/cli 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,426 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Anna App Harness — Mock Dashboard</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --fg: #1a1a1a;
11
+ --muted: #666;
12
+ --bg: #f6f6f8;
13
+ --card: #fff;
14
+ --border: #e2e2e8;
15
+ --accent: #4f46e5;
16
+ }
17
+ @media (prefers-color-scheme: dark) {
18
+ :root {
19
+ --fg: #f0f0f0;
20
+ --muted: #999;
21
+ --bg: #111114;
22
+ --card: #1c1c20;
23
+ --border: #2a2a30;
24
+ --accent: #818cf8;
25
+ }
26
+ }
27
+ body {
28
+ margin: 0;
29
+ font-family:
30
+ -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
31
+ sans-serif;
32
+ background: var(--bg);
33
+ color: var(--fg);
34
+ height: 100vh;
35
+ display: grid;
36
+ grid-template-columns: 1fr 360px;
37
+ grid-template-rows: 56px 1fr;
38
+ grid-template-areas:
39
+ "header header"
40
+ "main side";
41
+ }
42
+ header {
43
+ grid-area: header;
44
+ display: flex;
45
+ align-items: center;
46
+ gap: 16px;
47
+ padding: 0 20px;
48
+ background: var(--card);
49
+ border-bottom: 1px solid var(--border);
50
+ }
51
+ header h1 {
52
+ font-size: 14px;
53
+ font-weight: 600;
54
+ margin: 0;
55
+ letter-spacing: 0.02em;
56
+ }
57
+ header .badge {
58
+ font-size: 11px;
59
+ padding: 2px 8px;
60
+ border-radius: 999px;
61
+ background: var(--accent);
62
+ color: white;
63
+ }
64
+ header .meta {
65
+ margin-left: auto;
66
+ font-size: 12px;
67
+ color: var(--muted);
68
+ font-family:
69
+ ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
70
+ }
71
+ main {
72
+ grid-area: main;
73
+ padding: 20px;
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 12px;
77
+ /* desktop-like backdrop so the simulated window stands out */
78
+ background:
79
+ radial-gradient(
80
+ circle at 30% 20%,
81
+ rgba(99, 102, 241, 0.08),
82
+ transparent 60%
83
+ ),
84
+ var(--bg);
85
+ }
86
+ .iframe-card {
87
+ background: var(--card);
88
+ border: 1px solid var(--border);
89
+ border-radius: 8px;
90
+ overflow: hidden;
91
+ display: flex;
92
+ flex-direction: column;
93
+ min-height: 0;
94
+ box-shadow:
95
+ 0 8px 24px rgba(0, 0, 0, 0.12),
96
+ 0 2px 6px rgba(0, 0, 0, 0.08);
97
+ /* width/height set inline from view_meta.default_size */
98
+ margin: auto;
99
+ max-width: 100%;
100
+ max-height: 100%;
101
+ }
102
+ .iframe-card .titlebar {
103
+ padding: 8px 12px;
104
+ font-size: 12px;
105
+ color: var(--muted);
106
+ border-bottom: 1px solid var(--border);
107
+ font-family:
108
+ ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
109
+ display: flex;
110
+ gap: 12px;
111
+ align-items: center;
112
+ flex-shrink: 0;
113
+ }
114
+ .iframe-card .titlebar .size {
115
+ margin-left: auto;
116
+ font-size: 11px;
117
+ opacity: 0.7;
118
+ }
119
+ .iframe-card iframe {
120
+ flex: 1;
121
+ border: 0;
122
+ background: white;
123
+ display: block;
124
+ }
125
+ aside {
126
+ grid-area: side;
127
+ border-left: 1px solid var(--border);
128
+ background: var(--card);
129
+ padding: 16px;
130
+ overflow-y: auto;
131
+ font-family:
132
+ ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
133
+ font-size: 11px;
134
+ line-height: 1.5;
135
+ }
136
+ aside h2 {
137
+ margin: 0 0 8px;
138
+ font-size: 12px;
139
+ font-weight: 600;
140
+ font-family:
141
+ -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui,
142
+ sans-serif;
143
+ color: var(--muted);
144
+ text-transform: uppercase;
145
+ letter-spacing: 0.05em;
146
+ }
147
+ .log {
148
+ white-space: pre-wrap;
149
+ word-break: break-all;
150
+ }
151
+ .log .req { color: #6366f1; }
152
+ .log .res-ok { color: #10b981; }
153
+ .log .res-err { color: #ef4444; }
154
+ .log .event { color: #f59e0b; }
155
+ .log .meta-line { color: var(--muted); }
156
+ .log .ts { color: var(--muted); margin-right: 6px; }
157
+ .pill {
158
+ display: inline-block;
159
+ padding: 1px 6px;
160
+ border-radius: 4px;
161
+ background: rgba(99, 102, 241, 0.12);
162
+ color: var(--accent);
163
+ font-size: 10px;
164
+ margin-right: 4px;
165
+ }
166
+ </style>
167
+ </head>
168
+ <body>
169
+ <header>
170
+ <h1>Anna App Harness</h1>
171
+ <span class="badge" id="status">starting…</span>
172
+ <span class="meta" id="meta"></span>
173
+ <button id="rec-btn" type="button"
174
+ style="margin-left:12px;padding:4px 10px;font-size:11px;border:1px solid var(--border);background:var(--card);color:var(--fg);border-radius:6px;cursor:pointer;">
175
+ ⏺ record
176
+ </button>
177
+ </header>
178
+ <main>
179
+ <div class="iframe-card" id="frame">
180
+ <div class="titlebar">
181
+ <span id="iframe-src">awaiting session…</span>
182
+ <span class="size" id="size"></span>
183
+ </div>
184
+ <iframe id="app" sandbox="allow-scripts allow-forms"></iframe>
185
+ </div>
186
+ </main>
187
+ <aside>
188
+ <h2>RPC log</h2>
189
+ <div class="log" id="log"></div>
190
+ </aside>
191
+
192
+ <script>
193
+ // ---------------------------------------------------------------
194
+ // Mock dashboard:
195
+ // 1. POST /api/session/create → { session_id, window_uuid, token, view }
196
+ // 2. mount iframe at /anna-apps/<slug>/dev/<entry>?wid=…&t=…
197
+ // 3. relay postMessage envelopes ⇄ POST /api/session/call
198
+ // 4. WS at /ws subscribes to drained host events → forward as
199
+ // `{kind:"event", event, payload}` postMessages to iframe
200
+ //
201
+ // The iframe runs the real production SDK (served at
202
+ // /static/anna-apps/_sdk/0.1.0/index.js by the harness server),
203
+ // so byte-identical with production.
204
+ // ---------------------------------------------------------------
205
+
206
+ const log = document.getElementById("log");
207
+ const statusEl = document.getElementById("status");
208
+ const metaEl = document.getElementById("meta");
209
+ const iframeSrc = document.getElementById("iframe-src");
210
+ const iframe = document.getElementById("app");
211
+ const frame = document.getElementById("frame");
212
+ const sizeEl = document.getElementById("size");
213
+ const recBtn = document.getElementById("rec-btn");
214
+
215
+ let sessionId = null;
216
+ let windowUuid = null;
217
+ let recState = { active: false, t0: 0, lines: [] };
218
+
219
+ function recPush(kind, body) {
220
+ if (!recState.active) return;
221
+ recState.lines.push({ t: Date.now() - recState.t0, kind, ...body });
222
+ }
223
+ recBtn.addEventListener("click", () => {
224
+ if (!recState.active) {
225
+ recState = { active: true, t0: Date.now(), lines: [] };
226
+ recState.lines.push({
227
+ t: 0,
228
+ kind: "meta",
229
+ harness_version: "0.1.0",
230
+ session_id: sessionId,
231
+ window_uuid: windowUuid,
232
+ });
233
+ recBtn.textContent = "⏹ stop";
234
+ recBtn.style.background = "#ef4444";
235
+ recBtn.style.color = "white";
236
+ logLine("meta-line", "recording started");
237
+ } else {
238
+ const blob = new Blob(
239
+ recState.lines.map((l) => JSON.stringify(l) + "\n"),
240
+ { type: "application/x-ndjson" },
241
+ );
242
+ const a = document.createElement("a");
243
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
244
+ a.href = URL.createObjectURL(blob);
245
+ a.download = `harness-${stamp}.jsonl`;
246
+ a.click();
247
+ recBtn.textContent = "⏺ record";
248
+ recBtn.style.background = "var(--card)";
249
+ recBtn.style.color = "var(--fg)";
250
+ logLine("meta-line", `recording saved (${recState.lines.length} lines)`);
251
+ recState = { active: false, t0: 0, lines: [] };
252
+ }
253
+ });
254
+
255
+ function tsNow() {
256
+ return new Date().toISOString().slice(11, 23);
257
+ }
258
+ function logLine(cls, html) {
259
+ const div = document.createElement("div");
260
+ div.className = `log-row ${cls}`;
261
+ div.innerHTML = `<span class="ts">${tsNow()}</span><span class="${cls}">${html}</span>`;
262
+ log.appendChild(div);
263
+ log.scrollTop = log.scrollHeight;
264
+ }
265
+
266
+ async function start() {
267
+ try {
268
+ const cfg = await (await fetch("/api/config")).json();
269
+ metaEl.textContent = `${cfg.app_slug} · view=${cfg.view ?? "default"}`;
270
+ const res = await fetch("/api/session/create", { method: "POST" });
271
+ if (!res.ok) throw new Error(`/api/session/create → ${res.status}`);
272
+ const out = await res.json();
273
+ // Size the simulated window from view_meta.default_size so the app
274
+ // doesn't get a wide-desktop iframe it wasn't designed for.
275
+ const vm = out.view_meta ?? {};
276
+ const ds = vm.default_size ?? { w: 480, h: 640 };
277
+ frame.style.width = `${ds.w}px`;
278
+ frame.style.height = `${ds.h + 33}px`; // +titlebar
279
+ sizeEl.textContent = `${ds.w}×${ds.h}`;
280
+ sessionId = out.session_id;
281
+ windowUuid = out.window_uuid;
282
+ const url = `${cfg.bundle_base}?wid=${encodeURIComponent(out.window_uuid)}&t=${encodeURIComponent(out.token)}`;
283
+ iframeSrc.textContent = url;
284
+ iframe.src = url;
285
+ statusEl.textContent = "connected";
286
+ statusEl.style.background = "#10b981";
287
+ logLine(
288
+ "meta-line",
289
+ `session created: <span class="pill">${out.session_id}</span> wid=${out.window_uuid}`,
290
+ );
291
+
292
+ // SSE over WS for emit_event drains.
293
+ const ws = new WebSocket(
294
+ `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws?session_id=${encodeURIComponent(sessionId)}`,
295
+ );
296
+ ws.onmessage = (e) => {
297
+ try {
298
+ const env = JSON.parse(e.data);
299
+ if (env.kind === "reload") {
300
+ logLine("meta-line", `↻ reload (${env.path}) — reloading iframe`);
301
+ if (iframe.contentWindow) iframe.contentWindow.location.reload();
302
+ return;
303
+ }
304
+ if (env.kind === "event") {
305
+ relayEventToIframe(env);
306
+ }
307
+ } catch (_) {}
308
+ };
309
+
310
+ // Periodic auth.refresh: dev tokens are short-lived (ttl ≈ 30s).
311
+ // Refresh every 20s and forward via the SDK's auth.refresh event.
312
+ setInterval(async () => {
313
+ try {
314
+ const r = await fetch("/api/session/refresh-token", {
315
+ method: "POST",
316
+ });
317
+ if (!r.ok) return;
318
+ const { token } = await r.json();
319
+ if (!token || !iframe.contentWindow) return;
320
+ iframe.contentWindow.postMessage(
321
+ {
322
+ wid: windowUuid,
323
+ kind: "event",
324
+ event: "auth.refresh",
325
+ token,
326
+ },
327
+ "*",
328
+ );
329
+ recPush("auth.refresh", { token: token.slice(0, 12) + "…" });
330
+ } catch (_) {}
331
+ }, 20000);
332
+ } catch (e) {
333
+ statusEl.textContent = "error";
334
+ statusEl.style.background = "#ef4444";
335
+ logLine("res-err", `bootstrap failed: ${escapeHtml(String(e))}`);
336
+ }
337
+ }
338
+
339
+ function relayEventToIframe(ev) {
340
+ // ev = { kind:"event", user_id, kind:..., payload, ts } from server.ts
341
+ if (!iframe.contentWindow) return;
342
+ const env = {
343
+ wid: windowUuid,
344
+ kind: "event",
345
+ event: ev.kind,
346
+ payload: ev.payload,
347
+ };
348
+ iframe.contentWindow.postMessage(env, "*");
349
+ logLine(
350
+ "event",
351
+ `← event <span class="pill">${escapeHtml(ev.kind)}</span> ${escapeHtml(JSON.stringify(ev.payload))}`,
352
+ );
353
+ recPush("event", { event: ev.kind, payload: ev.payload });
354
+ }
355
+
356
+ // postMessage RPC bridge: iframe → POST /api/session/call → result
357
+ window.addEventListener("message", async (ev) => {
358
+ const msg = ev.data;
359
+ if (!msg || typeof msg !== "object") return;
360
+ if (msg.kind !== "req") return;
361
+ if (msg.wid && msg.wid !== windowUuid) return;
362
+ const { id, ns, method, args } = msg;
363
+ logLine(
364
+ "req",
365
+ `→ req <span class="pill">${escapeHtml(ns)}.${escapeHtml(method)}</span> ${escapeHtml(JSON.stringify(args ?? {}))}`,
366
+ );
367
+ recPush("req", { id, ns, method, args: args ?? {} });
368
+ try {
369
+ const res = await fetch("/api/session/call", {
370
+ method: "POST",
371
+ headers: { "content-type": "application/json" },
372
+ body: JSON.stringify({
373
+ session_id: sessionId,
374
+ ns,
375
+ method,
376
+ args: args ?? {},
377
+ }),
378
+ });
379
+ const body = await res.json();
380
+ if (body.ok) {
381
+ iframe.contentWindow.postMessage(
382
+ { wid: windowUuid, kind: "res", id, result: body.result },
383
+ "*",
384
+ );
385
+ logLine(
386
+ "res-ok",
387
+ `← res ok <span class="pill">${escapeHtml(ns)}.${escapeHtml(method)}</span> ${escapeHtml(JSON.stringify(body.result))}`,
388
+ );
389
+ recPush("res", { id, ok: true, result: body.result });
390
+ } else {
391
+ iframe.contentWindow.postMessage(
392
+ { wid: windowUuid, kind: "res", id, error: body.error },
393
+ "*",
394
+ );
395
+ logLine(
396
+ "res-err",
397
+ `← res err <span class="pill">${escapeHtml(body.error.code)}</span> ${escapeHtml(body.error.message)}`,
398
+ );
399
+ recPush("res", { id, ok: false, error: body.error });
400
+ }
401
+ } catch (e) {
402
+ iframe.contentWindow.postMessage(
403
+ {
404
+ wid: windowUuid,
405
+ kind: "res",
406
+ id,
407
+ error: { code: "transport", message: String(e) },
408
+ },
409
+ "*",
410
+ );
411
+ logLine("res-err", `← res transport-err ${escapeHtml(String(e))}`);
412
+ }
413
+ });
414
+
415
+ function escapeHtml(s) {
416
+ return String(s)
417
+ .replaceAll("&", "&amp;")
418
+ .replaceAll("<", "&lt;")
419
+ .replaceAll(">", "&gt;")
420
+ .replaceAll('"', "&quot;");
421
+ }
422
+
423
+ start();
424
+ </script>
425
+ </body>
426
+ </html>
@@ -0,0 +1,163 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { dirname, isAbsolute, resolve } from "node:path";
3
+ import { bold, cyan, dim, green, red, yellow } from "kleur/colors";
4
+
5
+ //#region src/commands/dev.ts
6
+ async function runDev(opts) {
7
+ const cwd = resolve(opts.cwd);
8
+ const manifestPath = isAbsolute(opts.manifestPath) ? opts.manifestPath : resolve(cwd, opts.manifestPath);
9
+ if (!existsSync(manifestPath)) {
10
+ console.error(red(`✗ manifest not found: ${manifestPath}`));
11
+ return 2;
12
+ }
13
+ let manifest;
14
+ try {
15
+ manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
16
+ } catch (e) {
17
+ console.error(red(`✗ manifest is not valid json: ${e.message}`));
18
+ return 2;
19
+ }
20
+ const slug = opts.slug ?? deriveSlug(manifest, manifestPath);
21
+ const bundleDir = opts.bundleDir ? resolve(cwd, opts.bundleDir) : resolve(dirname(manifestPath), "bundle");
22
+ const bundleEntry = manifest.ui?.bundle?.entry ?? "index.html";
23
+ if (!existsSync(bundleDir)) {
24
+ console.error(red(`✗ bundle dir not found: ${bundleDir}`));
25
+ return 2;
26
+ }
27
+ if (!existsSync(resolve(bundleDir, bundleEntry))) {
28
+ console.error(red(`✗ bundle entry "${bundleEntry}" not found under ${bundleDir}`));
29
+ return 2;
30
+ }
31
+ const matrixNexusRoot = await resolveMatrixNexusRoot(opts.matrixNexusRoot, cwd);
32
+ const mode = matrixNexusRoot ? "nexus-source" : "uvx";
33
+ const { PythonBridge, PINNED_RUNTIME_VERSION } = await import("./bridge-t2Qqu3hC.js");
34
+ const { HarnessServer } = await import("./server-B6-Qdluv.js");
35
+ const bridge = new PythonBridge({
36
+ mode,
37
+ matrixNexusRoot: matrixNexusRoot ?? void 0,
38
+ onStderr: (line) => process.stderr.write(dim(`[bridge] ${line}\n`))
39
+ });
40
+ console.log(bold(cyan("anna-app dev")));
41
+ console.log(` manifest ${dim(manifestPath)}`);
42
+ console.log(` bundle ${dim(`${bundleDir}/${bundleEntry}`)}`);
43
+ if (mode === "nexus-source") {
44
+ console.log(` matrix-nexus root ${dim(matrixNexusRoot)}`);
45
+ console.log(` runtime ${dim("nexus-source (uv run)")}`);
46
+ } else console.log(` runtime ${dim(`uvx anna-app-runtime-local@${PINNED_RUNTIME_VERSION}`)}`);
47
+ console.log(` spawning python bridge…`);
48
+ try {
49
+ await bridge.start();
50
+ } catch (e) {
51
+ console.error(red(`✗ bridge failed to start: ${e.message}`));
52
+ if (mode === "uvx") console.error(dim(" (try: install uv from https://docs.astral.sh/uv/, or pass --matrix-nexus-root to use a checkout)"));
53
+ else console.error(dim(" (try: cd matrix-nexus && uv sync; ensure `uv` is on PATH; or unset --matrix-nexus-root to use uvx)"));
54
+ return 2;
55
+ }
56
+ try {
57
+ const pong = await bridge.call("ping");
58
+ if (!pong.pong) throw new Error("bridge ping returned non-pong");
59
+ } catch (e) {
60
+ console.error(red(`✗ bridge ping failed: ${e.message}`));
61
+ await bridge.stop();
62
+ return 2;
63
+ }
64
+ console.log(green(" ✓ bridge ready"));
65
+ const executas = opts.executas ?? autoDiscoverExecutas(dirname(manifestPath));
66
+ if (executas.length > 0) console.log(` executas ${dim(executas.map((e) => e.tool_id).join(", "))}`);
67
+ const server = new HarnessServer({
68
+ slug,
69
+ manifest,
70
+ bundleDir,
71
+ bundleEntry,
72
+ view: opts.view,
73
+ matrixNexusRoot,
74
+ userId: opts.userId,
75
+ port: opts.port,
76
+ executas,
77
+ watch: !opts.noWatch
78
+ }, bridge);
79
+ try {
80
+ await server.listen();
81
+ } catch (e) {
82
+ const msg = e.code === "EADDRINUSE" ? `port ${opts.port} already in use` : e.message;
83
+ console.error(red(`✗ harness failed to listen: ${msg}`));
84
+ await bridge.stop();
85
+ return 2;
86
+ }
87
+ console.log(` ${green("✓")} dashboard ${cyan(`http://localhost:${opts.port}/`)}`);
88
+ console.log(yellow(" press Ctrl+C to stop"));
89
+ const shutdown = async () => {
90
+ console.log(dim("\n shutting down…"));
91
+ try {
92
+ await server.close();
93
+ await bridge.stop();
94
+ } finally {
95
+ process.exit(0);
96
+ }
97
+ };
98
+ process.once("SIGINT", shutdown);
99
+ process.once("SIGTERM", shutdown);
100
+ await new Promise(() => {});
101
+ return 0;
102
+ }
103
+ function deriveSlug(manifest, path) {
104
+ const fromMan = manifest.slug ?? manifest.name;
105
+ if (typeof fromMan === "string" && fromMan) return fromMan;
106
+ return dirname(path).split(/[\\/]/).pop() || "anna-app-dev";
107
+ }
108
+ /**
109
+ * Look in `<manifest-dir>/executas/<name>/pyproject.toml` and infer
110
+ * `tool_id` from the first `[project.scripts]` entry. We grep with a tiny
111
+ * regex instead of pulling in a TOML parser — the pyproject script line
112
+ * always looks like `"tool-anna-foo-xxxx" = "foo_plugin:main"`.
113
+ */
114
+ function autoDiscoverExecutas(manifestDir) {
115
+ const root = resolve(manifestDir, "executas");
116
+ if (!existsSync(root)) return [];
117
+ const out = [];
118
+ for (const name of readdirSync(root)) {
119
+ const dir = resolve(root, name);
120
+ let st;
121
+ try {
122
+ st = statSync(dir);
123
+ } catch {
124
+ continue;
125
+ }
126
+ if (!st.isDirectory()) continue;
127
+ const py = resolve(dir, "pyproject.toml");
128
+ if (!existsSync(py)) continue;
129
+ let body;
130
+ try {
131
+ body = readFileSync(py, "utf-8");
132
+ } catch {
133
+ continue;
134
+ }
135
+ const m = body.match(/\[project\.scripts\][\s\S]*?"([^"]+)"\s*=/);
136
+ if (!m || !m[1]) continue;
137
+ out.push({
138
+ tool_id: m[1],
139
+ project_dir: dir
140
+ });
141
+ }
142
+ return out;
143
+ }
144
+ async function resolveMatrixNexusRoot(explicit, cwd) {
145
+ const candidates = [explicit, process.env.ANNA_NEXUS_ROOT];
146
+ let dir = cwd;
147
+ while (true) {
148
+ candidates.push(dir);
149
+ const parent = dirname(dir);
150
+ if (parent === dir) break;
151
+ dir = parent;
152
+ }
153
+ candidates.push(resolve(cwd, "..", "matrix-nexus"));
154
+ for (const c of candidates) {
155
+ if (!c) continue;
156
+ const abs = isAbsolute(c) ? c : resolve(cwd, c);
157
+ if (existsSync(resolve(abs, "packages/anna-app-runtime-local/pyproject.toml"))) return abs;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ //#endregion
163
+ export { runDev };
@@ -0,0 +1,69 @@
1
+ import { PINNED_RUNTIME_VERSION } from "./bridge-CzEs7jaN.js";
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
+ import { bold, dim, green, red, yellow } from "kleur/colors";
5
+ import { spawnSync } from "node:child_process";
6
+ import { homedir } from "node:os";
7
+
8
+ //#region src/commands/doctor.ts
9
+ async function runDoctor(opts) {
10
+ console.log(bold("anna-app doctor"));
11
+ let failed = 0;
12
+ const uv = spawnSync("uv", ["--version"], { encoding: "utf-8" });
13
+ if (uv.status === 0 && uv.stdout) console.log(` ${green("✓")} uv ${dim(uv.stdout.trim())}`);
14
+ else {
15
+ failed++;
16
+ console.log(` ${red("✗")} uv ${red("not found on PATH")}`);
17
+ console.log(dim(" install: curl -LsSf https://astral.sh/uv/install.sh | sh"));
18
+ }
19
+ const toolDir = spawnSync("uv", ["tool", "dir"], { encoding: "utf-8" });
20
+ if (toolDir.status === 0 && toolDir.stdout) {
21
+ const root = toolDir.stdout.trim();
22
+ const candidate = resolve(root, "anna-app-runtime-local");
23
+ if (existsSync(candidate)) console.log(` ${green("✓")} uvx ${dim(`cache hit: ${candidate}`)}`);
24
+ else {
25
+ console.log(` ${yellow("·")} uvx ${yellow(`no cache for anna-app-runtime-local@${PINNED_RUNTIME_VERSION}`)}`);
26
+ console.log(dim(` first 'anna-app dev' will install via 'uvx anna-app-runtime-local@${PINNED_RUNTIME_VERSION}' (one-time)`));
27
+ }
28
+ }
29
+ const nexus = await resolveNexusRoot(opts.matrixNexusRoot, process.cwd());
30
+ if (nexus) console.log(` ${green("✓")} nexus ${dim(nexus)} ${dim("(contributor mode)")}`);
31
+ else console.log(` ${yellow("·")} nexus ${dim("not found")} ${dim("(uvx mode will be used)")}`);
32
+ const keyPath = resolve(homedir(), ".anna-app", "dev.key");
33
+ if (!existsSync(keyPath)) console.log(` ${yellow("·")} key ${dim(keyPath)} ${yellow("(missing — will be auto-created on first run)")}`);
34
+ else {
35
+ const mode = statSync(keyPath).mode & 511;
36
+ if (mode === 384) console.log(` ${green("✓")} key ${dim(keyPath)} ${dim("0600")}`);
37
+ else {
38
+ failed++;
39
+ console.log(` ${red("✗")} key ${dim(keyPath)} ${red(`mode ${mode.toString(8)} (expected 0600)`)}`);
40
+ console.log(dim(` fix: chmod 600 ${keyPath}`));
41
+ }
42
+ }
43
+ if (failed === 0) {
44
+ console.log(green("\n all required checks passed"));
45
+ return 0;
46
+ }
47
+ console.log(red(`\n ${failed} required check(s) failed`));
48
+ return 1;
49
+ }
50
+ async function resolveNexusRoot(explicit, cwd) {
51
+ const candidates = [explicit, process.env.ANNA_NEXUS_ROOT];
52
+ let dir = cwd;
53
+ while (true) {
54
+ candidates.push(dir);
55
+ const parent = dirname(dir);
56
+ if (parent === dir) break;
57
+ dir = parent;
58
+ }
59
+ candidates.push(resolve(cwd, "..", "matrix-nexus"));
60
+ for (const c of candidates) {
61
+ if (!c) continue;
62
+ const abs = isAbsolute(c) ? c : resolve(cwd, c);
63
+ if (existsSync(resolve(abs, "packages/anna-app-runtime-local/pyproject.toml"))) return abs;
64
+ }
65
+ return null;
66
+ }
67
+
68
+ //#endregion
69
+ export { runDoctor };