@anna-ai/cli 0.1.4 → 0.1.11

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/README.md CHANGED
@@ -48,17 +48,62 @@ Layered fail-fast checks: JSON Schema → `ui` static → cross-file `tool_id`
48
48
  linter (with Levenshtein-1 typo detection) → `--strict` host_api ACL grep
49
49
  of bundle JS/TS.
50
50
 
51
- ### `anna-app dev [--manifest …] [--bundle …] [--port 5180] [--matrix-nexus-root <path>]`
51
+ ### `anna-app dev [--manifest …] [--bundle …] [--port 5180] [--matrix-nexus-root <path>] [--executa <spec>…]`
52
52
 
53
53
  Boots the local harness:
54
54
 
55
55
  - Spawns the Python `anna-app-bridge` (production dispatcher reused via
56
56
  `WindowStoreProtocol`).
57
57
  - Serves a mock dashboard at `http://localhost:<port>/`.
58
- - Auto-discovers `<manifest-dir>/executas/<name>/pyproject.toml` and
59
- registers them in the in-process `ExecutaPool`.
58
+ - Auto-discovers `<manifest-dir>/executas/<name>/` plugins (Python /
59
+ Node.js / Go / pre-built binary) and registers them in the in-process
60
+ `ExecutaPool`. See "Multi-language executas" below.
60
61
  - Hot-reloads the bundle on disk changes (use `--no-watch` to disable).
61
62
 
63
+ #### Multi-language executas
64
+
65
+ Each subdirectory of `<manifest-dir>/executas/` is launched according to
66
+ the first sentinel that matches:
67
+
68
+ | # | Sentinel | Type | Default launch |
69
+ | - | ----------------------- | -------- | ------------------------------------------------------------- |
70
+ | 0 | `executa.json` | (any) | `command` field, else type-specific default below |
71
+ | 1 | `pyproject.toml` | `python` | `uv run --project <dir> <tool_id>` |
72
+ | 2 | `package.json` | `node` | `node <bin[tool_id] \| bin \| main \| module>` |
73
+ | 3 | `go.mod` (alone) | `go` | requires `executa.json` declaring `type: "go"` |
74
+ | 4 | `bin/<dirname>` exec | `binary` | runs the executable directly |
75
+
76
+ `executa.json` (recommended for clarity, required for Go and pre-built
77
+ binary tools):
78
+
79
+ ```jsonc
80
+ {
81
+ "tool_id": "tool-yourhandle-foo-abcd1234",
82
+ "type": "python" | "node" | "go" | "binary",
83
+ "command": ["…"], // optional; full override of type defaults
84
+ "enabled": true // optional; default true. Set false to skip
85
+ // (useful when shipping multiple language
86
+ // flavours of the same tool_id).
87
+ }
88
+ ```
89
+
90
+ The `--executa <spec>` flag (repeatable) registers an out-of-tree
91
+ executa, or fully overrides auto-discovery for the run. Spec syntax:
92
+
93
+ ```bash
94
+ # Auto-detect from the dir (same rules as in-tree discovery):
95
+ anna-app dev --executa dir=./vendor/external-tool
96
+
97
+ # Force type when there's no executa.json:
98
+ anna-app dev --executa dir=./executas/foo,type=go
99
+
100
+ # Fully explicit:
101
+ anna-app dev --executa dir=./executas/foo,tool_id=tool-h-foo-12345678,command="node plugin.js"
102
+ ```
103
+
104
+ For the full discovery / `executa.json` reference, see
105
+ [`anna-executa-examples/docs/multi-language-anna-apps.md`](https://github.com/openclaw/anna-executa-examples/blob/main/docs/multi-language-anna-apps.md).
106
+
62
107
  Two runtime modes (auto-selected):
63
108
 
64
109
  | Mode | When | Command |
@@ -130,6 +175,22 @@ public npm package [`@anna-ai/app-runtime`](https://www.npmjs.com/package/@anna-
130
175
  which is declared as a normal dependency. No vendored copy, no sync
131
176
  step.
132
177
 
178
+ ## Persistent storage (APS)
179
+
180
+ Anna 1.2+ exposes a per-user JSON-RPC storage surface under the
181
+ `storage/*` namespace. Plugins authored with this CLI can opt in by:
182
+
183
+ 1. Declaring `storage.user` (or `.app`/`.tool`) in the manifest's
184
+ `host_capabilities` array.
185
+ 2. Negotiating `client_capabilities.storage = {}` during `initialize`.
186
+ 3. Asking the user to grant storage in the Anna admin panel.
187
+
188
+ See the protocol & best-practice guide at
189
+ [anna-executa-examples/docs/persistent-storage.md](https://github.com/openclaw/anna-executa-examples/blob/main/docs/persistent-storage.md)
190
+ for wire format, error codes, and a worked OCR-cache example. The
191
+ local `dev` harness mocks APS by default so end-to-end tests do not
192
+ need network access.
193
+
133
194
  ## Roadmap
134
195
 
135
196
  | Phase | Status | Scope |
@@ -1,3 +1,3 @@
1
- import { PINNED_RUNTIME_VERSION, PythonBridge } from "./bridge-CBcQUQGU.js";
1
+ import { PINNED_RUNTIME_VERSION, PythonBridge } from "./bridge-Cpm3D2Wk.js";
2
2
 
3
3
  export { PINNED_RUNTIME_VERSION, PythonBridge };
@@ -9,7 +9,7 @@ import { createInterface } from "node:readline";
9
9
  * `uvx <pkg>@<version>` so end users always run the dispatcher version
10
10
  * the CLI was tested against.
11
11
  */
12
- const PINNED_RUNTIME_VERSION = "0.2.0a1";
12
+ const PINNED_RUNTIME_VERSION = "0.2.0a2";
13
13
  var PythonBridge = class {
14
14
  proc = null;
15
15
  nextId = 1;
package/dist/cli.js CHANGED
@@ -1,9 +1,12 @@
1
1
  #!/usr/bin/env node
2
+ import { dirname, extname, join, relative, resolve } from "node:path";
3
+ import { createRequire } from "node:module";
2
4
  import { Command } from "commander";
3
5
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
- import { dirname, join, resolve } from "node:path";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import kleur from "kleur";
8
+ import Ajv from "ajv/dist/2020.js";
9
+ import addFormats from "ajv-formats";
7
10
 
8
11
  //#region src/commands/init.ts
9
12
  const here = dirname(fileURLToPath(import.meta.url));
@@ -13,13 +16,14 @@ function templateRoot(template) {
13
16
  }
14
17
  function substitute(content, slug) {
15
18
  const toolId = `tool-dev-${slug}`;
16
- return content.replace(/__SLUG__/g, slug).replace(/__TOOL_ID__/g, toolId);
19
+ const slugPy = slug.replace(/-/g, "_");
20
+ return content.replace(/__SLUG_PY__/g, slugPy).replace(/__SLUG__/g, slug).replace(/__TOOL_ID__/g, toolId);
17
21
  }
18
22
  function copyDirWithSubst(src, dst, slug) {
19
23
  mkdirSync(dst, { recursive: true });
20
24
  for (const entry of readdirSync(src, { withFileTypes: true })) {
21
25
  const s = join(src, entry.name);
22
- const d = join(dst, entry.name);
26
+ const d = join(dst, substitute(entry.name, slug));
23
27
  if (entry.isDirectory()) copyDirWithSubst(s, d, slug);
24
28
  else if (entry.isFile()) {
25
29
  const stat = statSync(s);
@@ -54,10 +58,371 @@ function runInit(opts) {
54
58
  return 0;
55
59
  }
56
60
 
61
+ //#endregion
62
+ //#region src/schema-bundle.ts
63
+ function resolveSchemaDir() {
64
+ if (process.env.ANNA_APP_SCHEMA_DIR) return process.env.ANNA_APP_SCHEMA_DIR;
65
+ const req = createRequire(import.meta.url);
66
+ const methodsAbs = req.resolve("@anna-ai/app-schema/methods");
67
+ return resolve(dirname(methodsAbs), "..");
68
+ }
69
+ function loadSchemaBundle() {
70
+ let dir;
71
+ try {
72
+ dir = resolveSchemaDir();
73
+ } catch (e) {
74
+ throw new Error(`Could not locate @anna-ai/app-schema: ${e.message}\nRun \`pnpm install\` (or set ANNA_APP_SCHEMA_DIR to a local checkout).`);
75
+ }
76
+ if (!existsSync(resolve(dir, "dispatcher_version.txt"))) throw new Error(`@anna-ai/app-schema bundle is missing expected files at ${dir}`);
77
+ const read = (rel) => readFileSync(resolve(dir, rel), "utf-8");
78
+ const readJson = (rel) => JSON.parse(read(rel));
79
+ const dispatcherVersion = read("dispatcher_version.txt").trim();
80
+ const appManifestSchema = readJson("manifest/AppManifest.json");
81
+ const uiSectionSchema = readJson("manifest/UiManifestSection.json");
82
+ const methodsTable = readJson("host_api/methods.json");
83
+ const eventsSchema = readJson("events/AnnaAppEvent.json");
84
+ return {
85
+ dir,
86
+ dispatcherVersion,
87
+ appManifestSchema,
88
+ uiSectionSchema,
89
+ methods: methodsTable.methods,
90
+ events: eventsSchema.properties.kind.enum
91
+ };
92
+ }
93
+
94
+ //#endregion
95
+ //#region src/validate/manifest-schema.ts
96
+ function validateManifestSchema(bundle, manifest) {
97
+ const ajv = new Ajv({
98
+ allErrors: true,
99
+ strict: false
100
+ });
101
+ addFormats(ajv);
102
+ const validate = ajv.compile(bundle.appManifestSchema);
103
+ const ok = validate(manifest);
104
+ if (ok) return [];
105
+ return (validate.errors ?? []).map((e) => ({
106
+ path: e.instancePath || "(root)",
107
+ message: `${e.message ?? "invalid"}${e.params ? " " + JSON.stringify(e.params) : ""}`
108
+ }));
109
+ }
110
+ const ANNA_CALL_RE = /\banna\.([a-z]+)\.([a-zA-Z_][a-zA-Z0-9_]*)\s*\(/g;
111
+ function scanHostApiCalls(files) {
112
+ const out = [];
113
+ for (const f of files) {
114
+ const lines = f.content.split("\n");
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const line = lines[i];
117
+ ANNA_CALL_RE.lastIndex = 0;
118
+ let m;
119
+ while ((m = ANNA_CALL_RE.exec(line)) !== null) out.push({
120
+ file: f.path,
121
+ line: i + 1,
122
+ ns: m[1],
123
+ method: m[2]
124
+ });
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+ /**
130
+ * Mirror of `anna_app_runtime_service.host_api_allows` (matrix-nexus).
131
+ * Keep these in sync — divergence here means the CLI lies about what
132
+ * production accepts.
133
+ */
134
+ function hostApiAllows(manifest, ns, method) {
135
+ if (!manifest.ui) return false;
136
+ if (ns === "window") return true;
137
+ const grants = manifest.ui.host_api ?? {};
138
+ const methods = grants[ns];
139
+ if (!methods || methods.length === 0) return false;
140
+ if (methods.includes("*")) return true;
141
+ if (methods.includes(method)) return true;
142
+ if (ns === "tools" && (method === "invoke" || method === "list")) return true;
143
+ return false;
144
+ }
145
+ function checkHostApiAllowance(usages, manifest, bundleMethods) {
146
+ const errors = [];
147
+ const noAuth = new Set(bundleMethods.filter((m) => m.no_auth).map((m) => `${m.namespace}.${m.method}`));
148
+ const known = new Set(bundleMethods.map((m) => `${m.namespace}.${m.method}`));
149
+ for (const u of usages) {
150
+ const fq = `${u.ns}.${u.method}`;
151
+ if (!known.has(fq)) continue;
152
+ if (noAuth.has(fq)) continue;
153
+ if (!hostApiAllows(manifest, u.ns, u.method)) errors.push(`${u.file}:${u.line} calls anna.${fq} but manifest.ui.host_api.${u.ns} does not grant it`);
154
+ }
155
+ return errors;
156
+ }
157
+
158
+ //#endregion
159
+ //#region src/validate/ui-section.ts
160
+ /**
161
+ * Static validation of `manifest.ui` — direct port of
162
+ * `src/services/anna_app_validator.py::validate_ui_section_static` from
163
+ * matrix-nexus. Keep these algorithms byte-equivalent: any divergence here
164
+ * means `anna-app validate` will lie about what production accepts.
165
+ */
166
+ const VIEW_NAME_PATTERN = /^[a-z0-9_-]{1,40}$/;
167
+ const BUNDLE_PATH_PATTERN = /^[A-Za-z0-9_./\-]+$/;
168
+ const HOST_API_TOOL_REF_PATTERN = /^(?:required:\*|optional:\*|required:[A-Za-z0-9_.\-]+|optional:[A-Za-z0-9_.\-]+|[A-Za-z0-9_.\-]+)$/;
169
+ const CSP_OVERRIDABLE_DIRECTIVES = new Set([
170
+ "connect-src",
171
+ "img-src",
172
+ "media-src",
173
+ "font-src",
174
+ "style-src",
175
+ "script-src"
176
+ ]);
177
+ function validateUiSectionStatic(manifest) {
178
+ const ui = manifest.ui;
179
+ if (!ui) return [];
180
+ const errors = [];
181
+ const bundle = ui.bundle;
182
+ if (bundle.format && bundle.format !== "static-spa") errors.push(`ui.bundle.format 当前仅支持 'static-spa': ${bundle.format}`);
183
+ const entry = bundle.entry ?? "";
184
+ const entryClean = entry.split("#")[0].split("?")[0];
185
+ if (!BUNDLE_PATH_PATTERN.test(entryClean)) errors.push(`ui.bundle.entry 包含非法字符: ${entry}`);
186
+ if (entry.includes("..") || entry.includes("\\") || entry.includes("//")) errors.push(`ui.bundle.entry 路径穿越禁止: ${entry}`);
187
+ for (const origin of bundle.external_origins ?? []) if (!origin.startsWith("https://") || origin.includes("*")) errors.push(`ui.bundle.external_origins 必须为 https:// 前缀且不含 '*': ${origin}`);
188
+ const views = ui.views ?? [];
189
+ if (views.length < 1 || views.length > 16) errors.push("ui.views 数量必须在 1..16 之间");
190
+ const defaults = views.filter((v) => v.default).length;
191
+ if (defaults > 1) errors.push("ui.views 中只能有一个 default=true");
192
+ const seen = new Set();
193
+ for (const v of views) {
194
+ if (!VIEW_NAME_PATTERN.test(v.name)) errors.push(`非法 view name: ${v.name}`);
195
+ if (seen.has(v.name)) errors.push(`重复 view name: ${v.name}`);
196
+ seen.add(v.name);
197
+ if (v.min_size && v.default_size) {
198
+ if (v.default_size.w < v.min_size.w || v.default_size.h < v.min_size.h) errors.push(`view '${v.name}' default_size 小于 min_size`);
199
+ }
200
+ if (v.max_size && v.default_size) {
201
+ if (v.default_size.w > v.max_size.w || v.default_size.h > v.max_size.h) errors.push(`view '${v.name}' default_size 大于 max_size`);
202
+ }
203
+ }
204
+ const requiredIds = new Set((manifest.required_executas ?? []).map((r) => r.tool_id));
205
+ const optionalIds = new Set((manifest.optional_executas ?? []).map((r) => r.tool_id));
206
+ for (const ref of ui.host_api?.tools ?? []) {
207
+ if (!HOST_API_TOOL_REF_PATTERN.test(ref)) {
208
+ errors.push(`host_api.tools 非法引用: ${ref}`);
209
+ continue;
210
+ }
211
+ if (ref === "required:*" || ref === "optional:*") continue;
212
+ const bare = ref.includes(":") ? ref.split(":", 2)[1] : ref;
213
+ if (!requiredIds.has(bare) && !optionalIds.has(bare)) errors.push(`host_api.tools 引用未在 manifest 中声明的 tool_id: ${ref}`);
214
+ }
215
+ const cspOverrides = ui.csp_overrides ?? {};
216
+ const badDirectives = Object.keys(cspOverrides).filter((d) => !CSP_OVERRIDABLE_DIRECTIVES.has(d)).sort();
217
+ if (badDirectives.length) errors.push(`csp_overrides 含不允许的 directive: ${JSON.stringify(badDirectives)}`);
218
+ for (const d of ["script-src", "style-src"]) for (const v of cspOverrides[d] ?? []) if (v !== "'self'" && !v.startsWith("'sha256-") && !v.startsWith("'nonce-")) errors.push(`csp_overrides[${d}] 仅允许 'self' / 'sha256-...' / 'nonce-...': ${v}`);
219
+ return errors;
220
+ }
221
+
222
+ //#endregion
223
+ //#region src/validate/tool-id-linter.ts
224
+ const TOOL_ID_RE = /\b(?:tool|skill)-[a-z0-9][a-z0-9-]{1,80}\b/g;
225
+ const SCAN_EXTENSIONS = new Set([
226
+ ".py",
227
+ ".toml",
228
+ ".json",
229
+ ".js",
230
+ ".ts",
231
+ ".jsx",
232
+ ".tsx",
233
+ ".mjs",
234
+ ".cjs"
235
+ ]);
236
+ const SCAN_SKIP_DIRS = new Set([
237
+ "node_modules",
238
+ "dist",
239
+ "build",
240
+ ".git",
241
+ ".venv",
242
+ "venv",
243
+ "__pycache__",
244
+ ".pytest_cache",
245
+ "coverage",
246
+ "fixtures"
247
+ ]);
248
+ function walkAndScan(rootDir, fs) {
249
+ const occurrences = [];
250
+ const stack = [rootDir];
251
+ while (stack.length) {
252
+ const current = stack.pop();
253
+ let entries;
254
+ try {
255
+ entries = fs.readdirSync(current);
256
+ } catch {
257
+ continue;
258
+ }
259
+ for (const entry of entries) {
260
+ const full = join(current, entry.name);
261
+ if (entry.isDirectory()) {
262
+ if (entry.name.startsWith(".") && entry.name !== ".github") continue;
263
+ if (SCAN_SKIP_DIRS.has(entry.name)) continue;
264
+ stack.push(full);
265
+ } else if (entry.isFile()) {
266
+ const ext = extname(entry.name);
267
+ if (!SCAN_EXTENSIONS.has(ext) && entry.name !== "pyproject.toml") continue;
268
+ let content;
269
+ try {
270
+ const st = statSync(full);
271
+ if (st.size > 1024 * 1024) continue;
272
+ content = readFileSync(full, "utf-8");
273
+ } catch {
274
+ continue;
275
+ }
276
+ const rel = relative(rootDir, full);
277
+ const lines = content.split("\n");
278
+ for (let i = 0; i < lines.length; i++) {
279
+ const line = lines[i];
280
+ TOOL_ID_RE.lastIndex = 0;
281
+ let m;
282
+ while ((m = TOOL_ID_RE.exec(line)) !== null) occurrences.push({
283
+ file: rel,
284
+ line: i + 1,
285
+ toolId: m[0]
286
+ });
287
+ }
288
+ }
289
+ }
290
+ }
291
+ return occurrences;
292
+ }
293
+ function levenshtein(a, b) {
294
+ if (a === b) return 0;
295
+ if (!a.length) return b.length;
296
+ if (!b.length) return a.length;
297
+ const prev = new Array(b.length + 1);
298
+ const curr = new Array(b.length + 1);
299
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
300
+ for (let i = 1; i <= a.length; i++) {
301
+ curr[0] = i;
302
+ for (let j = 1; j <= b.length; j++) {
303
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
304
+ curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
305
+ }
306
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
307
+ }
308
+ return prev[b.length];
309
+ }
310
+ function analyzeToolIds(input) {
311
+ const byId = new Map();
312
+ for (const o of input.occurrences) {
313
+ if (!byId.has(o.toolId)) byId.set(o.toolId, []);
314
+ byId.get(o.toolId).push(o);
315
+ }
316
+ const errors = [];
317
+ const warnings = [];
318
+ input.declaredManifestIds;
319
+ const ids = Array.from(byId.keys());
320
+ for (let i = 0; i < ids.length; i++) for (let j = i + 1; j < ids.length; j++) {
321
+ const a = ids[i];
322
+ const b = ids[j];
323
+ if (Math.abs(a.length - b.length) > 1) continue;
324
+ if (levenshtein(a, b) > 1) continue;
325
+ const aLocs = byId.get(a);
326
+ const bLocs = byId.get(b);
327
+ const aFiles = new Set(aLocs.map((o) => o.file));
328
+ const bFiles = new Set(bLocs.map((o) => o.file));
329
+ const isolated = aFiles.size === 1 || bFiles.size === 1;
330
+ if (isolated) {
331
+ const minor = aFiles.size === 1 ? aLocs : bLocs;
332
+ const major = aFiles.size === 1 ? bLocs : aLocs;
333
+ errors.push(`Likely typo: "${minor[0].toolId}" (only at ${minor.map((o) => `${o.file}:${o.line}`).join(", ")}) differs by ≤1 char from "${major[0].toolId}" (used in ${major.length} place(s))`);
334
+ }
335
+ }
336
+ return {
337
+ occurrences: input.occurrences,
338
+ byId,
339
+ errors,
340
+ warnings
341
+ };
342
+ }
343
+
344
+ //#endregion
345
+ //#region src/commands/validate.ts
346
+ function readManifest(path) {
347
+ if (!existsSync(path)) throw new Error(`manifest not found: ${path}`);
348
+ return JSON.parse(readFileSync(path, "utf-8"));
349
+ }
350
+ function readBundleSources(dir) {
351
+ if (!existsSync(dir)) return [];
352
+ const out = [];
353
+ const stack = [dir];
354
+ while (stack.length) {
355
+ const cur = stack.pop();
356
+ let entries;
357
+ try {
358
+ entries = readdirSync(cur, { withFileTypes: true });
359
+ } catch {
360
+ continue;
361
+ }
362
+ for (const e of entries) {
363
+ const full = `${cur}/${e.name}`;
364
+ if (e.isDirectory()) {
365
+ if (e.name === "node_modules" || e.name.startsWith(".")) continue;
366
+ stack.push(full);
367
+ } else if (/\.(js|ts|jsx|tsx|mjs|cjs)$/.test(e.name)) try {
368
+ out.push({
369
+ path: full,
370
+ content: readFileSync(full, "utf-8")
371
+ });
372
+ } catch {}
373
+ }
374
+ }
375
+ return out;
376
+ }
377
+ function runValidate(opts) {
378
+ const errors = [];
379
+ const warnings = [];
380
+ const bundle = loadSchemaBundle();
381
+ console.log(kleur.gray(`[validate] @anna-ai/app-schema dispatcher_version=${bundle.dispatcherVersion} (${bundle.dir})`));
382
+ const manifest = readManifest(opts.manifestPath);
383
+ const schemaIssues = validateManifestSchema(bundle, manifest);
384
+ for (const i of schemaIssues) errors.push(`schema ${i.path}: ${i.message}`);
385
+ for (const e of validateUiSectionStatic(manifest)) errors.push(`ui: ${e}`);
386
+ const occ = walkAndScan(opts.cwd, { readdirSync: (p) => readdirSync(p, { withFileTypes: true }).map((e) => ({
387
+ name: e.name,
388
+ isDirectory: () => e.isDirectory(),
389
+ isFile: () => e.isFile()
390
+ })) });
391
+ const declared = [...(manifest.required_executas ?? []).map((r) => r.tool_id), ...(manifest.optional_executas ?? []).map((r) => r.tool_id)];
392
+ const idReport = analyzeToolIds({
393
+ occurrences: occ,
394
+ declaredManifestIds: declared
395
+ });
396
+ for (const e of idReport.errors) errors.push(`tool-id: ${e}`);
397
+ for (const w of idReport.warnings) warnings.push(`tool-id: ${w}`);
398
+ if (opts.strict) {
399
+ const bundleDir = opts.bundleDir ?? resolve(opts.cwd, "bundle");
400
+ const sources = readBundleSources(bundleDir);
401
+ const usages = scanHostApiCalls(sources);
402
+ const aclErrs = checkHostApiAllowance(usages, manifest, bundle.methods);
403
+ for (const e of aclErrs) errors.push(`host-api: ${e}`);
404
+ }
405
+ return {
406
+ errors,
407
+ warnings
408
+ };
409
+ }
410
+ function printResult(result) {
411
+ for (const w of result.warnings) console.warn(kleur.yellow(`⚠ ${w}`));
412
+ for (const e of result.errors) console.error(kleur.red(`✗ ${e}`));
413
+ if (result.errors.length === 0) {
414
+ console.log(kleur.green(`✓ validate passed${result.warnings.length ? ` (${result.warnings.length} warning(s))` : ""}`));
415
+ return 0;
416
+ }
417
+ console.error(kleur.red(`✗ validate failed: ${result.errors.length} error(s)`));
418
+ return 1;
419
+ }
420
+
57
421
  //#endregion
58
422
  //#region src/cli.ts
423
+ const pkg = createRequire(import.meta.url)("../package.json");
59
424
  const program = new Command();
60
- program.name("anna-app").description("Anna App developer CLI (scaffold, validate, harness)").version("0.1.0");
425
+ program.name("anna-app").description("Anna App developer CLI (scaffold, validate, harness)").version(pkg.version);
61
426
  program.command("init <dir>").description("Scaffold a new Anna App project").option("--slug <slug>", "App slug (lowercase, hyphens)", "").option("--template <name>", "Template to use", "minimal").option("--force", "Overwrite existing dir if non-empty", false).action((dir, opts) => {
62
427
  const slug = opts.slug || dir.split(/[\\/]/).pop() || "my-anna-app";
63
428
  const code = runInit({
@@ -68,10 +433,34 @@ program.command("init <dir>").description("Scaffold a new Anna App project").opt
68
433
  });
69
434
  process.exit(code);
70
435
  });
71
- program.command("dev").description("Run a local harness (in-process dispatcher + iframe + SSE relay)").option("--manifest <path>", "manifest.json path", "manifest.json").option("--bundle <dir>", "bundle directory (default: ./bundle)").option("--slug <slug>", "App slug (overrides manifest.slug/name)").option("--view <name>", "View name to open (default: manifest default)").option("--matrix-nexus-root <path>", "matrix-nexus checkout (auto-detected if omitted; can also use $ANNA_NEXUS_ROOT)").option("--port <number>", "HTTP port", "5180").option("--user-id <id>", "Harness user_id", "1").option("--cwd <dir>", "Project root (default: cwd)").option("--no-watch", "Disable bundle file watcher (default: enabled)").action(async (opts) => {
72
- const { runDev } = await import("./dev-DImwL-ql.js");
436
+ program.command("validate").description("Run schema + ACL checks on a manifest+bundle").option("--manifest <path>", "manifest.json path", "manifest.json").option("--bundle <dir>", "bundle directory (default: ./bundle)").option("--strict", "Enable strict checks (host_api ACL grep)", false).action(async (opts) => {
437
+ const cwd = process.cwd();
438
+ const result = runValidate({
439
+ cwd,
440
+ manifestPath: resolve(cwd, opts.manifest),
441
+ bundleDir: opts.bundle ? resolve(cwd, opts.bundle) : null,
442
+ strict: opts.strict
443
+ });
444
+ const code = printResult(result);
445
+ process.exit(code);
446
+ });
447
+ program.command("dev").description("Run a local harness (in-process dispatcher + iframe + SSE relay)").option("--manifest <path>", "manifest.json path", "manifest.json").option("--bundle <dir>", "bundle directory (default: ./bundle)").option("--slug <slug>", "App slug (overrides manifest.slug/name)").option("--view <name>", "View name to open (default: manifest default)").option("--matrix-nexus-root <path>", "matrix-nexus checkout (auto-detected if omitted; can also use $ANNA_NEXUS_ROOT)").option("--port <number>", "HTTP port", "5180").option("--user-id <id>", "Harness user_id", "1").option("--cwd <dir>", "Project root (default: cwd)").option("--no-watch", "Disable bundle file watcher (default: enabled)").option("--executa <spec>", "Explicit executa registration; repeatable. Spec: comma-separated key=value (dir=<path>[,tool_id=<id>][,type=python|node|go|binary][,command=\"<argv>\"]). When only `dir=` is given, the executa is auto-detected from executa.json / pyproject.toml / package.json / go.mod. Overrides directory auto-discovery under <manifest-dir>/executas/.", (val, prev) => prev ? [...prev, val] : [val]).option("--no-llm", "Disable LLM bridge (anna.llm/agent return llm_disabled)", false).option("--mock-llm <fixture>", "Serve canned LLM responses from a JSONL fixture").option("--llm-account <host>", "Saved account host to use (default: current)").option("--llm-app-id <id>", "app_id passed to /dev/session/mint").action(async (opts) => {
448
+ const { runDev, parseExecutaSpec } = await import("./dev-BPIUX2Nh.js");
449
+ const cwd = opts.cwd ?? process.cwd();
450
+ let executas;
451
+ if (opts.executa && opts.executa.length > 0) {
452
+ executas = [];
453
+ for (const spec of opts.executa) {
454
+ const r = parseExecutaSpec(spec, cwd);
455
+ if (r instanceof Error) {
456
+ console.error(`✗ --executa: ${r.message}`);
457
+ process.exit(2);
458
+ }
459
+ executas.push(r);
460
+ }
461
+ }
73
462
  const code = await runDev({
74
- cwd: opts.cwd ?? process.cwd(),
463
+ cwd,
75
464
  manifestPath: opts.manifest,
76
465
  bundleDir: opts.bundle,
77
466
  slug: opts.slug,
@@ -79,13 +468,18 @@ program.command("dev").description("Run a local harness (in-process dispatcher +
79
468
  matrixNexusRoot: opts.matrixNexusRoot,
80
469
  port: Number.parseInt(opts.port, 10),
81
470
  userId: Number.parseInt(opts.userId, 10),
82
- noWatch: opts.watch === false
471
+ noWatch: opts.watch === false,
472
+ executas,
473
+ noLlm: opts.llm === false,
474
+ mockLlm: opts.mockLlm,
475
+ llmAccount: opts.llmAccount,
476
+ llmAppId: opts.llmAppId ? Number.parseInt(opts.llmAppId, 10) : void 0
83
477
  });
84
478
  process.exit(code);
85
479
  });
86
480
  const fixture = program.command("fixture").description("Inspect / replay harness recordings (Phase 6)");
87
481
  fixture.command("verify <file>").description("Schema + invariant checks on a harness JSONL recording").option("--json", "Emit machine-readable JSON", false).action(async (file, opts) => {
88
- const { runFixtureVerify } = await import("./fixture-BGjMtqWA.js");
482
+ const { runFixtureVerify } = await import("./fixture-RceUUd84.js");
89
483
  const code = await runFixtureVerify({
90
484
  file,
91
485
  json: opts.json
@@ -93,7 +487,7 @@ fixture.command("verify <file>").description("Schema + invariant checks on a har
93
487
  process.exit(code);
94
488
  });
95
489
  fixture.command("summarize <file>").description("Print a human-readable digest of a harness recording").option("--json", "Emit machine-readable JSON", false).action(async (file, opts) => {
96
- const { runFixtureSummarize } = await import("./fixture-BGjMtqWA.js");
490
+ const { runFixtureSummarize } = await import("./fixture-RceUUd84.js");
97
491
  const code = await runFixtureSummarize({
98
492
  file,
99
493
  json: opts.json
@@ -101,7 +495,7 @@ fixture.command("summarize <file>").description("Print a human-readable digest o
101
495
  process.exit(code);
102
496
  });
103
497
  fixture.command("replay <file>").description("Dry-run replay of a harness recording (Phase 6 MVP)").option("--manifest <path>", "manifest.json path", "manifest.json").action(async (file, opts) => {
104
- const { runFixtureReplay } = await import("./fixture-BGjMtqWA.js");
498
+ const { runFixtureReplay } = await import("./fixture-RceUUd84.js");
105
499
  const code = await runFixtureReplay({
106
500
  file,
107
501
  manifest: opts.manifest
@@ -109,10 +503,31 @@ fixture.command("replay <file>").description("Dry-run replay of a harness record
109
503
  process.exit(code);
110
504
  });
111
505
  program.command("doctor").description("Check environment for `anna-app dev` (uv, matrix-nexus, dev key)").option("--matrix-nexus-root <path>", "matrix-nexus checkout (optional)").action(async (opts) => {
112
- const { runDoctor } = await import("./doctor-yxWmMSJc.js");
506
+ const { runDoctor } = await import("./doctor-R1pjmBDG.js");
113
507
  const code = await runDoctor({ matrixNexusRoot: opts.matrixNexusRoot });
114
508
  process.exit(code);
115
509
  });
510
+ program.command("login").description("Device-flow login against a nexus host; saves a PAT to ~/.config/anna/credentials.json").requiredOption("--host <url>", "nexus base URL, e.g. https://nexus.example.com").option("--no-browser", "Do not open a browser window automatically", false).action(async (opts) => {
511
+ const { runLogin } = await import("./login-D8cmvBb6.js");
512
+ const code = await runLogin({
513
+ host: opts.host,
514
+ noBrowser: opts.browser === false
515
+ });
516
+ process.exit(code);
517
+ });
518
+ program.command("logout").description("Remove a saved PAT entry").option("--host <url>", "Account to remove (default: current)").option("--all", "Remove every saved account", false).action(async (opts) => {
519
+ const { runLogout } = await import("./logout-P6L9VU4W.js");
520
+ const code = await runLogout({
521
+ host: opts.host,
522
+ all: opts.all
523
+ });
524
+ process.exit(code);
525
+ });
526
+ program.command("whoami").description("Show the current account (and any others)").option("--json", "Emit machine-readable JSON", false).action(async (opts) => {
527
+ const { runWhoami } = await import("./whoami-jqlQwe7Z.js");
528
+ const code = await runWhoami({ json: opts.json });
529
+ process.exit(code);
530
+ });
116
531
  program.parseAsync(process.argv).catch((e) => {
117
532
  console.error(e);
118
533
  process.exit(2);