@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.
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # @anna-ai/cli
2
+
3
+ Anna App developer CLI — scaffold, validate, run a local dev harness, drive
4
+ fixture-based regression tests, and program against the harness from
5
+ `vitest`/`pytest`. Phases 2–9 (MVP) are done.
6
+
7
+ ## Install
8
+
9
+ End-user install (after first npm publish):
10
+
11
+ ```bash
12
+ npm i -g @anna-ai/cli
13
+ anna-app --help
14
+ ```
15
+
16
+ The `dev` and `fixture replay` commands transparently fetch the pinned Python
17
+ runtime via `uvx` (no matrix-nexus checkout required). Install
18
+ [uv](https://docs.astral.sh/uv/) once:
19
+
20
+ ```bash
21
+ curl -LsSf https://astral.sh/uv/install.sh | sh
22
+ ```
23
+
24
+ Local dev (this repo):
25
+
26
+ ```bash
27
+ pnpm install
28
+ pnpm sync:schema # vendors @anna-ai/app-schema from sibling matrix-nexus checkout
29
+ pnpm build
30
+ node dist/cli.js --help
31
+ ```
32
+
33
+ ## Commands
34
+
35
+ ### `anna-app init <dir> [--slug <slug>] [--template minimal] [--force]`
36
+
37
+ Scaffolds a new Anna App project (manifest + bundle + a stdio plugin
38
+ template). `__TOOL_ID__` becomes `tool-dev-<slug>`.
39
+
40
+ ```bash
41
+ anna-app init my-app --slug my-app
42
+ cd my-app
43
+ anna-app validate
44
+ ```
45
+
46
+ ### `anna-app validate [--manifest manifest.json] [--bundle bundle] [--strict]`
47
+
48
+ Layered fail-fast checks: JSON Schema → `ui` static → cross-file `tool_id`
49
+ linter (with Levenshtein-1 typo detection) → `--strict` host_api ACL grep
50
+ of bundle JS/TS.
51
+
52
+ ### `anna-app dev [--manifest …] [--bundle …] [--port 5180] [--matrix-nexus-root <path>]`
53
+
54
+ Boots the local harness:
55
+
56
+ - Spawns the Python `anna-app-bridge` (production dispatcher reused via
57
+ `WindowStoreProtocol`).
58
+ - Serves a mock dashboard at `http://localhost:<port>/`.
59
+ - Auto-discovers `<manifest-dir>/executas/<name>/pyproject.toml` and
60
+ registers them in the in-process `ExecutaPool`.
61
+ - Hot-reloads the bundle on disk changes (use `--no-watch` to disable).
62
+
63
+ Two runtime modes (auto-selected):
64
+
65
+ | Mode | When | Command |
66
+ | --- | --- | --- |
67
+ | `uvx` (default) | No matrix-nexus checkout nearby | `uvx anna-app-runtime-local@<pinned> anna-app-bridge` |
68
+ | `nexus-source` | `--matrix-nexus-root` set, `$ANNA_NEXUS_ROOT` set, or running from inside a checkout | `uv run --project <root> python -m anna_app_runtime_local.bridge` |
69
+
70
+ The pinned wheel version is exported as `PINNED_RUNTIME_VERSION` from
71
+ [src/harness/bridge.ts](src/harness/bridge.ts) — bump in lock-step with
72
+ `packages/VERSIONS.md` in matrix-nexus.
73
+
74
+ ### `anna-app doctor [--matrix-nexus-root <path>]`
75
+
76
+ Environment sanity check: `uv` on PATH, `uvx` cache state for the pinned
77
+ runtime, optional matrix-nexus checkout, dev signing key permissions.
78
+ Exit `0` if all required checks pass.
79
+
80
+ ### `anna-app fixture verify <file.jsonl>`
81
+
82
+ Strict syntactic + semantic validation of a recorded fixture (envelope
83
+ ordering, response⇄request pairing, host_api allowlist).
84
+
85
+ ### `anna-app fixture summarize <file.jsonl>`
86
+
87
+ Pretty stats: counts per `(ns, method)`, error breakdown, event totals.
88
+
89
+ ### `anna-app fixture replay <file.jsonl> [--manifest …]`
90
+
91
+ Dry-run replay (MVP): re-executes the recorded request sequence against
92
+ a fresh `LocalDispatcherSession` and diffs each response. Live executa
93
+ re-invocation is deferred to a future minor.
94
+
95
+ ## Programmatic harness (vitest)
96
+
97
+ ```ts
98
+ import { mountBundle } from "@anna-ai/cli/test";
99
+
100
+ const h = await mountBundle({
101
+ manifest: "./examples/hello/manifest.json",
102
+ bundle: "./examples/hello/bundle",
103
+ });
104
+ await h.call("storage", "set", { key: "k", value: 1 });
105
+ const events = h.drainEvents();
106
+ await h.close();
107
+ ```
108
+
109
+ See [src/test/index.ts](src/test/index.ts) for the full surface
110
+ (`call`, `expectAcl`, `drainEvents`, `dispose`).
111
+
112
+ ## Pytest plugin (`anna-executa-test`)
113
+
114
+ For executa authors. Lives in matrix-nexus at
115
+ `packages/anna-executa-test/`. Provides `executa_session` /
116
+ `executa_invoke` fixtures — see that package's README.
117
+
118
+ ## Schema bundle sync
119
+
120
+ The CLI vendors `@anna-ai/app-schema` until the npm bundle ships:
121
+
122
+ ```bash
123
+ pnpm sync:schema # default: ../matrix-nexus
124
+ pnpm sync:schema -- --from /path/to/... # override
125
+ ```
126
+
127
+ ## Roadmap
128
+
129
+ | Phase | Status | Scope |
130
+ | --- | --- | --- |
131
+ | 2 — `init` + `validate` | ✅ MVP | Schema/ACL checks |
132
+ | 3 — `dev` harness | ✅ MVP | Stdio bridge, dashboard, hot-reload |
133
+ | 4 — `@anna-ai/cli/test` | ✅ MVP | Programmatic `mountBundle` |
134
+ | 5 — `anna-executa-test` (pytest) | ✅ MVP | Plugin shipped from matrix-nexus |
135
+ | 6 — `fixture {verify,summarize,replay}` | ✅ MVP (dry-run replay) | Live re-invoke deferred |
136
+ | 7 — Publish & distribution | ✅ done | uvx default + full `anna-app-core@0.2.0a1` extraction; end-users no longer need a matrix-nexus checkout |
137
+ | 8 — `manifest.dev` block + init template + 4 docs | ✅ done | See [matrix-nexus/docs/developers/apps/](https://github.com/openclaw/matrix-nexus/tree/main/docs/developers/apps): `local-dev.md`, `testing-bundle.md`, `testing-plugin.md`, `recording-replay.md` |
138
+ | 9 — Reference example upgraded | ✅ done | [`anna-executa-examples/examples/anna-app-focus-flow`](https://github.com/openclaw/anna-executa-examples/tree/main/examples/anna-app-focus-flow) ships `tests/{bundle,plugin}/`, fixtures, and `.github/workflows/anna-app.yml` |
139
+
140
+ ## Pinned versions
141
+
142
+ - `PINNED_RUNTIME_VERSION = "0.2.0a1"` ([src/harness/bridge.ts](src/harness/bridge.ts))
143
+ - See `matrix-nexus/packages/VERSIONS.md` for the full toolchain matrix.
144
+
145
+ ## Tests
146
+
147
+ ```bash
148
+ pnpm test
149
+ ```
@@ -0,0 +1,145 @@
1
+ import { delimiter, resolve } from "node:path";
2
+ import { spawn } from "node:child_process";
3
+ import { createInterface } from "node:readline";
4
+
5
+ //#region src/harness/bridge.ts
6
+ /**
7
+ * Pinned `anna-app-runtime-local` version for uvx mode. Bump in lock-step
8
+ * with `packages/VERSIONS.md` (matrix-nexus). The bridge passes this to
9
+ * `uvx <pkg>@<version>` so end users always run the dispatcher version
10
+ * the CLI was tested against.
11
+ */
12
+ const PINNED_RUNTIME_VERSION = "0.2.0a1";
13
+ var PythonBridge = class {
14
+ proc = null;
15
+ nextId = 1;
16
+ pending = new Map();
17
+ readyPromise = null;
18
+ closed = false;
19
+ constructor(opts) {
20
+ this.opts = opts;
21
+ }
22
+ /**
23
+ * Build the default launch command from `opts.mode`. uvx by default; for
24
+ * `nexus-source` mode, `matrixNexusRoot` must be set.
25
+ */
26
+ buildDefaultCommand() {
27
+ const mode = this.opts.mode ?? "uvx";
28
+ if (mode === "nexus-source") {
29
+ if (!this.opts.matrixNexusRoot) throw new Error("BridgeOptions.matrixNexusRoot is required when mode=\"nexus-source\"");
30
+ return [
31
+ "uv",
32
+ "run",
33
+ "--project",
34
+ this.opts.matrixNexusRoot,
35
+ "python",
36
+ "-m",
37
+ "anna_app_runtime_local.bridge"
38
+ ];
39
+ }
40
+ const version = this.opts.runtimeVersion ?? PINNED_RUNTIME_VERSION;
41
+ return [
42
+ "uvx",
43
+ `anna-app-runtime-local@${version}`,
44
+ "anna-app-bridge"
45
+ ];
46
+ }
47
+ /** Spawn the Python process and wait for the `_ready` notification. */
48
+ async start() {
49
+ if (this.proc) return;
50
+ const cmd = this.opts.runtimeCommand ?? this.buildDefaultCommand();
51
+ const [bin, ...args] = cmd;
52
+ const useNexusPathInjection = !this.opts.runtimeCommand && (this.opts.mode ?? "uvx") === "nexus-source" && !!this.opts.matrixNexusRoot;
53
+ const env = { ...process.env };
54
+ if (useNexusPathInjection) {
55
+ const wheelSrc = resolve(this.opts.matrixNexusRoot, "packages/anna-app-runtime-local/src");
56
+ const existingPath = process.env.PYTHONPATH ?? "";
57
+ env.PYTHONPATH = existingPath ? `${wheelSrc}${delimiter}${existingPath}` : wheelSrc;
58
+ }
59
+ const cwd = (this.opts.mode ?? "uvx") === "nexus-source" ? this.opts.matrixNexusRoot : process.cwd();
60
+ if (!bin) throw new Error("PythonBridge: empty launch command (no executable)");
61
+ this.proc = spawn(bin, args, {
62
+ cwd,
63
+ stdio: [
64
+ "pipe",
65
+ "pipe",
66
+ "pipe"
67
+ ],
68
+ env
69
+ });
70
+ this.proc.on("exit", (code, signal) => {
71
+ this.closed = true;
72
+ const err = new Error(`python bridge exited (code=${code} signal=${signal ?? "-"})`);
73
+ for (const p of this.pending.values()) p.reject(err);
74
+ this.pending.clear();
75
+ });
76
+ const stderrSink = this.opts.onStderr ?? ((l) => process.stderr.write(`[bridge] ${l}\n`));
77
+ createInterface({ input: this.proc.stderr }).on("line", stderrSink);
78
+ this.readyPromise = new Promise((resolve$1, reject) => {
79
+ const rl = createInterface({ input: this.proc.stdout });
80
+ let ready = false;
81
+ rl.on("line", (line) => {
82
+ if (!line.trim()) return;
83
+ let env$1;
84
+ try {
85
+ env$1 = JSON.parse(line);
86
+ } catch (e) {
87
+ stderrSink(`non-json from bridge stdout: ${line}`);
88
+ return;
89
+ }
90
+ if (!ready && env$1.method === "_ready") {
91
+ ready = true;
92
+ resolve$1();
93
+ return;
94
+ }
95
+ this.handleResponse(env$1);
96
+ });
97
+ this.proc.on("error", (e) => reject(e));
98
+ setTimeout(() => {
99
+ if (!ready) reject(new Error("python bridge did not signal ready in 8s"));
100
+ }, 8e3);
101
+ });
102
+ await this.readyPromise;
103
+ }
104
+ handleResponse(env) {
105
+ const id = env.id;
106
+ if (id == null) return;
107
+ const pending = this.pending.get(id);
108
+ if (!pending) return;
109
+ this.pending.delete(id);
110
+ if (env.error) {
111
+ const e = env.error;
112
+ const err = new Error(`[${e.code}] ${e.message}`);
113
+ err.rpc = e;
114
+ pending.reject(err);
115
+ } else pending.resolve(env.result);
116
+ }
117
+ /** Send one JSON-RPC request and await its response. */
118
+ call(method, params = {}) {
119
+ if (this.closed || !this.proc) return Promise.reject(new Error("python bridge not running"));
120
+ const id = this.nextId++;
121
+ const env = {
122
+ jsonrpc: "2.0",
123
+ id,
124
+ method,
125
+ params
126
+ };
127
+ return new Promise((resolve$1, reject) => {
128
+ this.pending.set(id, {
129
+ resolve: (v) => resolve$1(v),
130
+ reject
131
+ });
132
+ this.proc.stdin.write(`${JSON.stringify(env)}\n`);
133
+ });
134
+ }
135
+ async stop() {
136
+ if (!this.proc) return;
137
+ this.closed = true;
138
+ this.proc.stdin.end();
139
+ this.proc.kill("SIGTERM");
140
+ this.proc = null;
141
+ }
142
+ };
143
+
144
+ //#endregion
145
+ export { PINNED_RUNTIME_VERSION, PythonBridge };
@@ -0,0 +1,3 @@
1
+ import { PINNED_RUNTIME_VERSION, PythonBridge } from "./bridge-CzEs7jaN.js";
2
+
3
+ export { PINNED_RUNTIME_VERSION, PythonBridge };
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { };
package/dist/cli.js ADDED
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import kleur from "kleur";
7
+
8
+ //#region src/commands/init.ts
9
+ const here = dirname(fileURLToPath(import.meta.url));
10
+ function templateRoot(template) {
11
+ for (const cand of [resolve(here, "..", "..", "templates", template), resolve(here, "..", "templates", template)]) if (existsSync(cand)) return cand;
12
+ throw new Error(`template not found: ${template}`);
13
+ }
14
+ function substitute(content, slug) {
15
+ const toolId = `tool-dev-${slug}`;
16
+ return content.replace(/__SLUG__/g, slug).replace(/__TOOL_ID__/g, toolId);
17
+ }
18
+ function copyDirWithSubst(src, dst, slug) {
19
+ mkdirSync(dst, { recursive: true });
20
+ for (const entry of readdirSync(src, { withFileTypes: true })) {
21
+ const s = join(src, entry.name);
22
+ const d = join(dst, entry.name);
23
+ if (entry.isDirectory()) copyDirWithSubst(s, d, slug);
24
+ else if (entry.isFile()) {
25
+ const stat = statSync(s);
26
+ if (stat.size < 256 * 1024) {
27
+ const buf = readFileSync(s);
28
+ const looksText = !buf.includes(0);
29
+ if (looksText) {
30
+ writeFileSync(d, substitute(buf.toString("utf-8"), slug), "utf-8");
31
+ continue;
32
+ }
33
+ }
34
+ cpSync(s, d);
35
+ }
36
+ }
37
+ }
38
+ function runInit(opts) {
39
+ const target = resolve(process.cwd(), opts.targetDir);
40
+ if (existsSync(target) && !opts.force) {
41
+ if (readdirSync(target).filter((n) => !n.startsWith(".")).length > 0) {
42
+ console.error(kleur.red(`✗ target dir not empty: ${target} (use --force to override)`));
43
+ return 1;
44
+ }
45
+ }
46
+ if (!/^[a-z][a-z0-9-]{1,40}$/.test(opts.slug)) {
47
+ console.error(kleur.red(`✗ invalid slug "${opts.slug}": must match /^[a-z][a-z0-9-]{1,40}$/`));
48
+ return 1;
49
+ }
50
+ const tplRoot = templateRoot(opts.template);
51
+ copyDirWithSubst(tplRoot, target, opts.slug);
52
+ console.log(kleur.green(`✓ scaffolded "${opts.slug}" at ${target}`));
53
+ console.log(kleur.gray(` next: cd ${opts.targetDir} && anna-app validate`));
54
+ return 0;
55
+ }
56
+
57
+ //#endregion
58
+ //#region src/cli.ts
59
+ const program = new Command();
60
+ program.name("anna-app").description("Anna App developer CLI (scaffold, validate, harness)").version("0.1.0");
61
+ 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
+ const slug = opts.slug || dir.split(/[\\/]/).pop() || "my-anna-app";
63
+ const code = runInit({
64
+ targetDir: dir,
65
+ slug,
66
+ template: opts.template,
67
+ force: opts.force
68
+ });
69
+ process.exit(code);
70
+ });
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-Bgku5ngL.js");
73
+ const code = await runDev({
74
+ cwd: opts.cwd ?? process.cwd(),
75
+ manifestPath: opts.manifest,
76
+ bundleDir: opts.bundle,
77
+ slug: opts.slug,
78
+ view: opts.view,
79
+ matrixNexusRoot: opts.matrixNexusRoot,
80
+ port: Number.parseInt(opts.port, 10),
81
+ userId: Number.parseInt(opts.userId, 10),
82
+ noWatch: opts.watch === false
83
+ });
84
+ process.exit(code);
85
+ });
86
+ const fixture = program.command("fixture").description("Inspect / replay harness recordings (Phase 6)");
87
+ 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");
89
+ const code = await runFixtureVerify({
90
+ file,
91
+ json: opts.json
92
+ });
93
+ process.exit(code);
94
+ });
95
+ 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");
97
+ const code = await runFixtureSummarize({
98
+ file,
99
+ json: opts.json
100
+ });
101
+ process.exit(code);
102
+ });
103
+ 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");
105
+ const code = await runFixtureReplay({
106
+ file,
107
+ manifest: opts.manifest
108
+ });
109
+ process.exit(code);
110
+ });
111
+ 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-D3z4YslL.js");
113
+ const code = await runDoctor({ matrixNexusRoot: opts.matrixNexusRoot });
114
+ process.exit(code);
115
+ });
116
+ program.parseAsync(process.argv).catch((e) => {
117
+ console.error(e);
118
+ process.exit(2);
119
+ });
120
+
121
+ //#endregion