@melra/cli 0.3.0-alpha.10

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/dist/index.js ADDED
@@ -0,0 +1,770 @@
1
+ #!/usr/bin/env node
2
+ // Copyright 2026 XAGI Labs Private Limited
3
+ // SPDX-License-Identifier: Apache-2.0
4
+ import { access, mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { constants } from "node:fs";
6
+ import { spawn } from "node:child_process";
7
+ import { basename, join, resolve } from "node:path";
8
+ import { createInterface } from "node:readline/promises";
9
+ import { DatabaseSync } from "node:sqlite";
10
+ import { ApprovalResponseSchema, TaskRequestSchema, WorkflowDefinitionSchema, WorkflowInputSchema, PRODUCT_VERSION, principalRef, } from "@melra/protocol";
11
+ import { createMelraRuntime, serveHttp, OAuthProvider, clientPrincipal, serveStdio, unconfinedRoot, } from "@melra/server";
12
+ import { detectBrowserExecutable } from "@melra/browser-runtime";
13
+ import { createSystemComputerAdapter } from "@melra/computer-runtime";
14
+ import { classifyOperation, createDefaultPolicy, evaluatePolicy, loadPolicy, } from "@melra/policy-core";
15
+ import { parseCliEnvironment, serverLaunch, } from "./environment.js";
16
+ import { runConformance } from "./conformance.js";
17
+ async function existingPolicyPath(env) {
18
+ if (env.policyPath !== undefined)
19
+ return env.policyPath;
20
+ const candidate = join(env.dataDirectory, "policy.json");
21
+ try {
22
+ await access(candidate, constants.R_OK);
23
+ return candidate;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ /**
30
+ * The banner unhinged mode prints before it does anything.
31
+ *
32
+ * It goes to stderr, which is where it has to go: in `serve` the stdout stream
33
+ * is the MCP transport and any prose written there corrupts the protocol. stderr
34
+ * is what MCP clients surface in their server logs, so the operator still sees
35
+ * it. Ordinary runs get it too — the mode is per-process, not per-command, and a
36
+ * developer who exported the variable in one shell and forgot deserves the
37
+ * reminder on every invocation rather than only at startup.
38
+ */
39
+ function unhingedBanner(root) {
40
+ return [
41
+ "",
42
+ " ############################################################",
43
+ " # MELRA IS RUNNING UNHINGED. NO GUARDRAILS ARE APPLIED. #",
44
+ " ############################################################",
45
+ "",
46
+ " Disabled for every task this process runs:",
47
+ " - policy decisions: nothing is denied, no approval is ever asked for",
48
+ " - the command allowlist, including shells and sudo",
49
+ " - the evidence requirement on mutations and destructive operations",
50
+ " - the browser's block on private, loopback, and cloud-metadata hosts",
51
+ ` - workspace confinement: files and commands can reach all of ${root}`,
52
+ "",
53
+ " A caller can now delete, overwrite, exfiltrate, or execute anything this",
54
+ " OS user can. Receipts are still written, so you will be able to read what",
55
+ " happened — after it has happened.",
56
+ "",
57
+ " Turn it off by unsetting MELRA_UNHINGED and dropping --unhinged.",
58
+ "",
59
+ ].join("\n");
60
+ }
61
+ async function runtime(env) {
62
+ const policyPath = await existingPolicyPath(env);
63
+ return await createMelraRuntime({
64
+ workspaceRoot: env.workspaceRoot,
65
+ dataDirectory: env.dataDirectory,
66
+ unhinged: env.unhinged,
67
+ ...(policyPath === undefined ? {} : { policyPath }),
68
+ ...(env.browserExecutablePath === undefined
69
+ ? {}
70
+ : { browserExecutablePath: env.browserExecutablePath }),
71
+ ...(env.browserCdpEndpoint === undefined
72
+ ? {}
73
+ : { browserCdpEndpoint: env.browserCdpEndpoint }),
74
+ ...(env.browserCdpContextIndex === undefined
75
+ ? {}
76
+ : { browserCdpContextIndex: env.browserCdpContextIndex }),
77
+ ...(env.browserHarPath === undefined
78
+ ? {}
79
+ : { browserHarPath: env.browserHarPath }),
80
+ ...(env.browserHarReplayPath === undefined
81
+ ? {}
82
+ : { browserHarReplayPath: env.browserHarReplayPath }),
83
+ ...(env.browserProfileDir === undefined
84
+ ? {}
85
+ : { browserProfileDir: env.browserProfileDir }),
86
+ });
87
+ }
88
+ function output(value) {
89
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
90
+ }
91
+ function argument(name, args) {
92
+ const index = args.indexOf(name);
93
+ return index < 0 ? undefined : args[index + 1];
94
+ }
95
+ /**
96
+ * A flag this command does not know is a typo, and a typo must not be silently
97
+ * ignored: `melra run --requst t.json` fell through to reading stdin, which in
98
+ * a terminal prints nothing and in a pipeline that never closes hangs forever.
99
+ */
100
+ function rejectUnknownFlags(args, known) {
101
+ const allowed = new Set([...known, "--unhinged"]);
102
+ // A flag's own value can look like a flag (`--input node=--x`), so skip the
103
+ // slot after any known flag that takes one.
104
+ const valued = new Set(known.filter((flag) => flag !== "--http"));
105
+ for (let index = 0; index < args.length; index += 1) {
106
+ const argument_ = args[index];
107
+ if (argument_ === undefined || !argument_.startsWith("--"))
108
+ continue;
109
+ if (!allowed.has(argument_)) {
110
+ throw new Error(`unknown flag ${argument_}. Known here: ${[...allowed].sort().join(", ")}`);
111
+ }
112
+ if (valued.has(argument_))
113
+ index += 1;
114
+ }
115
+ }
116
+ // `JSON.parse` reports a character offset and nothing about where it was
117
+ // reading, so a typo in a workflow definition surfaced as a bare
118
+ // "Expected property name or '}' in JSON at position 1". Name the source.
119
+ function parseJson(text, source) {
120
+ try {
121
+ return JSON.parse(text);
122
+ }
123
+ catch (error) {
124
+ const detail = error instanceof Error ? error.message : String(error);
125
+ throw new Error(`${source} is not valid JSON: ${detail}`);
126
+ }
127
+ }
128
+ async function readTaskRequest(args) {
129
+ const requestPath = argument("--request", args);
130
+ if (requestPath !== undefined) {
131
+ const absolute = resolve(requestPath);
132
+ return TaskRequestSchema.parse(parseJson(await readFile(absolute, "utf8"), absolute));
133
+ }
134
+ if (!process.stdin.isTTY) {
135
+ let input = "";
136
+ for await (const chunk of process.stdin)
137
+ input += String(chunk);
138
+ // Redirected-but-empty stdin is what a script gets wrong, and `JSON.parse`
139
+ // answers it with "Unexpected end of JSON input" — true, and no help at
140
+ // all. Say the same thing an interactive shell would.
141
+ if (input.trim() === "") {
142
+ throw new Error("provide --request <file> or pipe a JSON task request");
143
+ }
144
+ return TaskRequestSchema.parse(parseJson(input, "the piped task request"));
145
+ }
146
+ throw new Error("provide --request <file> or pipe a JSON task request");
147
+ }
148
+ async function readWorkflowDefinition(args) {
149
+ const definitionPath = argument("--definition", args);
150
+ if (definitionPath === undefined) {
151
+ throw new Error("workflow plan requires --definition <file>");
152
+ }
153
+ const absolute = resolve(definitionPath);
154
+ return WorkflowDefinitionSchema.parse(parseJson(await readFile(absolute, "utf8"), absolute));
155
+ }
156
+ function workflowExitCode(status) {
157
+ if (status === "awaiting_approval")
158
+ return 3;
159
+ // A run waiting on a person is not a failure — it is a prompt. Its own exit
160
+ // code lets a script tell "answer me" apart from "approve me" and "broke".
161
+ if (status === "awaiting_input")
162
+ return 4;
163
+ if ([
164
+ "failed",
165
+ "partially_complete",
166
+ "cancelled",
167
+ "recovery_required",
168
+ ].includes(status)) {
169
+ return 2;
170
+ }
171
+ return 0;
172
+ }
173
+ // `--input <node-id>=<value>`. `=` rather than `:` because a node ID never
174
+ // contains one but an answer very often does (URLs, times, ratios).
175
+ function workflowInputs(args) {
176
+ const inputs = [];
177
+ for (let index = 0; index < args.length; index += 1) {
178
+ if (args[index] !== "--input")
179
+ continue;
180
+ const value = args[index + 1];
181
+ if (value === undefined)
182
+ throw new Error("--input requires a value");
183
+ const separator = value.indexOf("=");
184
+ if (separator < 1 || separator === value.length - 1) {
185
+ throw new Error("--input must be <node-id>=<value>");
186
+ }
187
+ inputs.push(WorkflowInputSchema.parse({
188
+ nodeId: value.slice(0, separator),
189
+ value: value.slice(separator + 1),
190
+ }));
191
+ index += 1;
192
+ }
193
+ return inputs;
194
+ }
195
+ function workflowApprovals(args) {
196
+ const approvals = [];
197
+ for (let index = 0; index < args.length; index += 1) {
198
+ if (args[index] !== "--approval")
199
+ continue;
200
+ const value = args[index + 1];
201
+ if (value === undefined)
202
+ throw new Error("--approval requires a value");
203
+ const separator = value.indexOf(":");
204
+ if (separator < 1 || separator === value.length - 1) {
205
+ throw new Error("--approval must be <approval-id>:<exact-phrase>");
206
+ }
207
+ approvals.push(ApprovalResponseSchema.parse({
208
+ approvalId: value.slice(0, separator),
209
+ phrase: value.slice(separator + 1),
210
+ }));
211
+ index += 1;
212
+ }
213
+ return approvals;
214
+ }
215
+ async function workflowCommand(args, env) {
216
+ const [action, ...actionArgs] = args;
217
+ const melra = await runtime(env);
218
+ try {
219
+ switch (action) {
220
+ case "plan": {
221
+ const run = melra.workflows.plan(await readWorkflowDefinition(actionArgs));
222
+ output(run);
223
+ return workflowExitCode(run.status);
224
+ }
225
+ case "advance": {
226
+ const workflowId = actionArgs[0];
227
+ if (workflowId === undefined) {
228
+ throw new Error("workflow advance requires a workflow ID");
229
+ }
230
+ const rest = actionArgs.slice(1);
231
+ const result = await melra.workflows.advance(workflowId, workflowApprovals(rest), workflowInputs(rest));
232
+ output(result);
233
+ return workflowExitCode(result.run.status);
234
+ }
235
+ case "inspect": {
236
+ const workflowId = actionArgs[0];
237
+ if (workflowId === undefined) {
238
+ throw new Error("workflow inspect requires a workflow ID");
239
+ }
240
+ output(melra.workflows.status(workflowId));
241
+ return 0;
242
+ }
243
+ case "cancel": {
244
+ const workflowId = actionArgs[0];
245
+ if (workflowId === undefined) {
246
+ throw new Error("workflow cancel requires a workflow ID");
247
+ }
248
+ const run = melra.workflows.cancel(workflowId);
249
+ output(run);
250
+ return workflowExitCode(run.status);
251
+ }
252
+ case "pause":
253
+ case "resume":
254
+ case "suspend": {
255
+ const workflowId = actionArgs[0];
256
+ if (workflowId === undefined) {
257
+ throw new Error(`workflow ${action} requires a workflow ID`);
258
+ }
259
+ const run = action === "pause"
260
+ ? melra.workflows.pause(workflowId)
261
+ : action === "suspend"
262
+ ? melra.workflows.suspend(workflowId)
263
+ : melra.workflows.resume(workflowId);
264
+ output(run);
265
+ return workflowExitCode(run.status);
266
+ }
267
+ default:
268
+ throw new Error("workflow supports plan, advance, inspect, cancel, pause, resume, and suspend");
269
+ }
270
+ }
271
+ finally {
272
+ await melra.close();
273
+ }
274
+ }
275
+ async function durableCoreDemo(env) {
276
+ const examplePath = resolve(import.meta.dirname, "../../../examples/workflows/restart-safe.json");
277
+ const melra = await runtime(env);
278
+ try {
279
+ const definition = WorkflowDefinitionSchema.parse(JSON.parse(await readFile(examplePath, "utf8")));
280
+ const planned = melra.workflows.plan(definition);
281
+ const advanced = await melra.workflows.advance(planned.id);
282
+ output({
283
+ examplePath,
284
+ workflow: advanced.run,
285
+ next: advanced.run.status === "verified_complete"
286
+ ? "complete"
287
+ : `melra workflow advance ${planned.id}`,
288
+ });
289
+ return workflowExitCode(advanced.run.status);
290
+ }
291
+ finally {
292
+ await melra.close();
293
+ }
294
+ }
295
+ async function doctor(env) {
296
+ const checks = [];
297
+ const major = Number(process.versions.node.split(".")[0]);
298
+ checks.push({
299
+ name: "node",
300
+ status: major >= 22 ? "pass" : "fail",
301
+ detail: process.version,
302
+ });
303
+ // A `warn`, not a `fail`: the operator asked for this, so `doctor` must still
304
+ // exit zero. But a machine running with no guardrails should never be able to
305
+ // report a clean bill of health without saying so.
306
+ checks.push(env.unhinged
307
+ ? {
308
+ name: "guardrails",
309
+ status: "warn",
310
+ detail: "UNHINGED: no policy, approval, evidence, confinement, or destination check is applied",
311
+ }
312
+ : { name: "guardrails", status: "pass", detail: "enforced" });
313
+ try {
314
+ await access(env.workspaceRoot, constants.R_OK | constants.W_OK);
315
+ checks.push({
316
+ name: "workspace",
317
+ status: "pass",
318
+ detail: env.workspaceRoot,
319
+ });
320
+ }
321
+ catch {
322
+ checks.push({
323
+ name: "workspace",
324
+ status: "fail",
325
+ detail: `not readable and writable: ${env.workspaceRoot}`,
326
+ });
327
+ }
328
+ try {
329
+ await mkdir(env.dataDirectory, { recursive: true });
330
+ await access(env.dataDirectory, constants.R_OK | constants.W_OK);
331
+ checks.push({
332
+ name: "data-directory",
333
+ status: "pass",
334
+ detail: env.dataDirectory,
335
+ });
336
+ }
337
+ catch {
338
+ checks.push({
339
+ name: "data-directory",
340
+ status: "fail",
341
+ detail: env.dataDirectory,
342
+ });
343
+ }
344
+ try {
345
+ const database = new DatabaseSync(":memory:");
346
+ database.exec("CREATE TABLE readiness(value TEXT)");
347
+ database.close();
348
+ checks.push({ name: "sqlite", status: "pass", detail: "node:sqlite available" });
349
+ }
350
+ catch (error) {
351
+ checks.push({
352
+ name: "sqlite",
353
+ status: "fail",
354
+ detail: error instanceof Error ? error.message : String(error),
355
+ });
356
+ }
357
+ const browser = env.browserExecutablePath ?? (await detectBrowserExecutable());
358
+ checks.push({
359
+ name: "browser",
360
+ status: browser === undefined ? "warn" : "pass",
361
+ detail: browser ??
362
+ "Chrome, Chromium, or Edge not found; non-browser capabilities remain available",
363
+ });
364
+ const computer = await createSystemComputerAdapter().capabilities();
365
+ checks.push({
366
+ name: "computer",
367
+ status: computer.available ? "pass" : "warn",
368
+ detail: computer.available
369
+ ? `${computer.adapter}: screenshot=${computer.screenshot}, pointer=${computer.pointer}, keyboard=${computer.keyboard}, scroll=${computer.scroll}`
370
+ : computer.limitations.join("; "),
371
+ });
372
+ try {
373
+ const path = await existingPolicyPath(env);
374
+ if (path !== undefined)
375
+ JSON.parse(await readFile(path, "utf8"));
376
+ checks.push({
377
+ name: "policy",
378
+ status: "pass",
379
+ detail: path ?? "using safe built-in defaults",
380
+ });
381
+ }
382
+ catch (error) {
383
+ checks.push({
384
+ name: "policy",
385
+ status: "fail",
386
+ detail: error instanceof Error ? error.message : String(error),
387
+ });
388
+ }
389
+ const failed = checks.some((check) => check.status === "fail");
390
+ return {
391
+ report: {
392
+ product: "MELRA",
393
+ version: PRODUCT_VERSION,
394
+ ready: !failed,
395
+ // Alongside the `guardrails` check so a script does not have to read a
396
+ // detail string to find out whether this machine has any.
397
+ unhinged: env.unhinged,
398
+ checks,
399
+ },
400
+ failed,
401
+ };
402
+ }
403
+ async function init(args, env) {
404
+ await mkdir(env.dataDirectory, { recursive: true });
405
+ const policyPath = join(env.dataDirectory, "policy.json");
406
+ try {
407
+ await access(policyPath, constants.F_OK);
408
+ }
409
+ catch {
410
+ await writeFile(policyPath, `${JSON.stringify(createDefaultPolicy(env.workspaceRoot), null, 2)}\n`, { flag: "wx" });
411
+ }
412
+ const client = argument("--client", args) ?? "generic";
413
+ const config = {
414
+ mcpServers: {
415
+ melra: {
416
+ ...serverLaunch(import.meta.dirname, PRODUCT_VERSION),
417
+ env: {
418
+ MELRA_WORKSPACE: env.workspaceRoot,
419
+ MELRA_HOME: env.dataDirectory,
420
+ MELRA_POLICY: policyPath,
421
+ },
422
+ },
423
+ },
424
+ };
425
+ return {
426
+ initialized: true,
427
+ client,
428
+ policyPath,
429
+ config,
430
+ note: `Add the mcpServers.melra entry to ${client}'s MCP configuration.`,
431
+ };
432
+ }
433
+ // One command for the whole local setup: write the policy, emit a working
434
+ // client config, and verify the machine can actually run it.
435
+ async function setup(args, env) {
436
+ const initialized = await init(args, env);
437
+ const { report, failed } = await doctor(env);
438
+ output({ ...initialized, ...report });
439
+ return failed ? 1 : 0;
440
+ }
441
+ async function runTask(args, env) {
442
+ const melra = await runtime(env);
443
+ try {
444
+ const task = melra.controller.plan(await readTaskRequest(args));
445
+ if (task.status === "policy_blocked") {
446
+ output({ task });
447
+ return 4;
448
+ }
449
+ if (task.status === "awaiting_approval") {
450
+ if (!process.stdin.isTTY) {
451
+ output({ task, next: "rerun interactively to approve this scoped action" });
452
+ return 3;
453
+ }
454
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
455
+ const phrase = await prompt.question(`Type the exact approval phrase '${task.approval.phrase}' to continue: `);
456
+ prompt.close();
457
+ const execution = await melra.controller.execute(task.id, {
458
+ approvalId: task.approval.approvalId,
459
+ phrase,
460
+ });
461
+ output(execution);
462
+ return execution.task.status === "verified_success" ? 0 : 2;
463
+ }
464
+ const execution = await melra.controller.execute(task.id);
465
+ output(execution);
466
+ return execution.task.status === "verified_success" ? 0 : 2;
467
+ }
468
+ finally {
469
+ await melra.close();
470
+ }
471
+ }
472
+ async function inspectTask(args, env) {
473
+ const taskId = args[0];
474
+ if (taskId === undefined)
475
+ throw new Error("inspect requires a task ID");
476
+ const melra = await runtime(env);
477
+ try {
478
+ output({
479
+ task: melra.controller.status(taskId),
480
+ ...melra.controller.receipts({ taskId }),
481
+ });
482
+ }
483
+ finally {
484
+ await melra.close();
485
+ }
486
+ }
487
+ async function policyTest(args, env) {
488
+ const request = await readTaskRequest(args);
489
+ // Reports what this process would actually decide, unhinged mode included —
490
+ // a dry run that disagreed with the server it is previewing is worse than
491
+ // none. That is also why the operator's own `policy.json` is loaded rather
492
+ // than the defaults: `serve` reads it, so a preview that ignored it would
493
+ // answer for a policy nobody is running.
494
+ const policy = {
495
+ ...(await loadPolicy(await existingPolicyPath(env), env.workspaceRoot)),
496
+ unhinged: env.unhinged,
497
+ };
498
+ const taskId = "00000000-0000-4000-8000-000000000000";
499
+ // The decision carries effect, risk, and traits; the classification adds the
500
+ // capability and target it was made against, so a surprising verdict can be
501
+ // traced to what MELRA thought the request was touching.
502
+ const { capability, target } = classifyOperation(request.operation);
503
+ output({
504
+ ...evaluatePolicy(taskId, request, policy),
505
+ classified: { capability, target },
506
+ });
507
+ }
508
+ /**
509
+ * The clients an operator has approved over OAuth, and the way to take that
510
+ * back. Reads the same file the server writes, so it works while the server is
511
+ * down — which is when someone most wants to withdraw an approval.
512
+ */
513
+ function clientsCommand(args, env) {
514
+ const oauth = new OAuthProvider(env.dataDirectory);
515
+ const revoke = argument("--revoke", args);
516
+ if (revoke !== undefined) {
517
+ const dropped = oauth.revoke(revoke === "all" ? undefined : revoke);
518
+ process.stdout.write(`Revoked ${dropped} token${dropped === 1 ? "" : "s"}. ` +
519
+ `The client must be approved again before it can act.\n`);
520
+ return 0;
521
+ }
522
+ const clients = oauth.clients();
523
+ if (clients.length === 0) {
524
+ process.stdout.write("No client has registered against this data directory.\n");
525
+ return 0;
526
+ }
527
+ for (const client of clients) {
528
+ process.stdout.write(`${client.approvedAt === undefined ? "pending " : "approved"} ` +
529
+ `${client.id} ${client.name}\n` +
530
+ ` ${principalRef(clientPrincipal(client))}\n`);
531
+ }
532
+ process.stdout.write(`\nRevoke one with 'melra clients --revoke <client-id>', or all of them\n` +
533
+ `with 'melra clients --revoke all'.\n`);
534
+ return 0;
535
+ }
536
+ /**
537
+ * Hands the console URL to whatever opens links here.
538
+ *
539
+ * The URL carries the token, so this is the difference between copying a secret
540
+ * out of a terminal and the console simply being open. Failure is silent on
541
+ * purpose: the URL is already on screen, and a server that refused to start
542
+ * because a desktop helper is missing would be worse than one you click into.
543
+ */
544
+ function openInBrowser(url) {
545
+ const [command, ...prefix] = process.platform === "darwin"
546
+ ? ["open"]
547
+ : process.platform === "win32"
548
+ ? ["cmd", "/c", "start", ""]
549
+ : ["xdg-open"];
550
+ const child = spawn(command, [...prefix, url], {
551
+ stdio: "ignore",
552
+ detached: true,
553
+ });
554
+ child.on("error", () => { });
555
+ child.unref();
556
+ }
557
+ function help() {
558
+ process.stdout.write(`MELRA ${PRODUCT_VERSION}
559
+
560
+ Usage:
561
+ melra setup [--client <claude|cursor|vscode|codex|generic>]
562
+ melra doctor
563
+ melra conformance [--url <mcp-url> --token <token>] [--level <1|2|3>]
564
+ melra init --client <claude|cursor|vscode|codex|generic>
565
+ melra serve [--http] [--port <port>] [--open]
566
+ melra run --request <task.json>
567
+ melra inspect <task-id>
568
+ melra workflow plan --definition <workflow.json>
569
+ melra workflow advance <workflow-id> [--approval <id>:<exact-phrase>]
570
+ [--input <node-id>=<value>]
571
+ melra workflow inspect <workflow-id>
572
+ melra workflow cancel <workflow-id>
573
+ melra workflow pause|resume|suspend <workflow-id>
574
+ melra demo durable-core
575
+ melra clients [--revoke <client-id|all>]
576
+ melra policy test --request <task.json>
577
+ melra version
578
+
579
+ Flags:
580
+ --unhinged Run with no policy and no guardrails. Everything the OS user
581
+ can do, any caller can now do. Same as MELRA_UNHINGED=1.
582
+ --http Serve MCP over loopback HTTP instead of stdio, alongside a
583
+ read-only REST API, a workflow event stream, and the console.
584
+ Prints a bearer token; every request must carry it.
585
+ --port <port> HTTP port (default: 7457, or MELRA_HTTP_PORT).
586
+ --open Open the console in your browser once it is listening.
587
+ --url <mcp-url> Endpoint for 'conformance'. Default: spawn 'melra serve'.
588
+ --level <1|2|3> Conformance level being claimed (default: 3). The run exits
589
+ non-zero if the endpoint falls short of it.
590
+
591
+ Environment:
592
+ MELRA_WORKSPACE Workspace boundary (default: current directory)
593
+ MELRA_HOME Local database and artifact directory
594
+ MELRA_POLICY Optional local policy JSON
595
+ MELRA_UNHINGED Set to 1 to disable every guardrail (see --unhinged)
596
+ MELRA_HTTP_PORT Port for 'serve --http' (default: 7457)
597
+ MELRA_HTTP_TOKEN Fixed bearer token for 'serve --http' (default: random)
598
+ MELRA_HTTP_OAUTH Set to 0 so only that token gets in; no client can register
599
+ MELRA_BROWSER Optional Chrome/Chromium/Edge executable
600
+ MELRA_BROWSER_CDP_ENDPOINT Optional HTTP(S) CDP endpoint
601
+ MELRA_BROWSER_CDP_CONTEXT_INDEX External context index (-1 is last)
602
+ MELRA_BROWSER_HAR_PATH Absolute HAR output path
603
+ MELRA_BROWSER_HAR_REPLAY Absolute HAR to replay; nothing hits network
604
+ MELRA_BROWSER_PROFILE Absolute dir keeping cookies between runs
605
+ `);
606
+ }
607
+ async function main() {
608
+ const [command = "help", ...args] = process.argv.slice(2);
609
+ const parsed = parseCliEnvironment(process.env);
610
+ // `--unhinged` is an alias for the variable, not a second setting: one field
611
+ // carries the answer so no code path can consult the weaker of the two.
612
+ const env = args.includes("--unhinged")
613
+ ? { ...parsed, unhinged: true }
614
+ : parsed;
615
+ // Before the command runs, and for every command including `help` — the mode
616
+ // is a property of the process, so there is no invocation where staying quiet
617
+ // about it is right.
618
+ if (env.unhinged) {
619
+ process.stderr.write(`${unhingedBanner(unconfinedRoot(env.workspaceRoot))}\n`);
620
+ }
621
+ switch (command) {
622
+ case "doctor": {
623
+ rejectUnknownFlags(args, []);
624
+ const { report, failed } = await doctor(env);
625
+ output(report);
626
+ process.exitCode = failed ? 1 : 0;
627
+ return;
628
+ }
629
+ case "conformance": {
630
+ rejectUnknownFlags(args, ["--url", "--token", "--level"]);
631
+ const level = argument("--level", args);
632
+ if (level !== undefined && !["1", "2", "3"].includes(level)) {
633
+ throw new Error("--level must be 1, 2, or 3");
634
+ }
635
+ const url = argument("--url", args);
636
+ const token = argument("--token", args);
637
+ const { report, failed } = await runConformance({
638
+ ...(url === undefined ? {} : { url }),
639
+ ...(token === undefined ? {} : { token }),
640
+ ...(level === undefined ? {} : { claim: Number(level) }),
641
+ });
642
+ output(report);
643
+ process.exitCode = failed ? 1 : 0;
644
+ return;
645
+ }
646
+ case "init":
647
+ rejectUnknownFlags(args, ["--client"]);
648
+ output(await init(args, env));
649
+ return;
650
+ case "setup":
651
+ rejectUnknownFlags(args, ["--client"]);
652
+ process.exitCode = await setup(args, env);
653
+ return;
654
+ case "serve": {
655
+ rejectUnknownFlags(args, ["--http", "--port", "--open"]);
656
+ const melra = await runtime(env);
657
+ const http = args.includes("--http");
658
+ const port = argument("--port", args);
659
+ const server = http
660
+ ? undefined
661
+ : await serveStdio(melra);
662
+ const endpoint = http
663
+ ? await serveHttp({
664
+ runtime: melra,
665
+ ...(port === undefined ? {} : { port: Number(port) }),
666
+ })
667
+ : undefined;
668
+ if (endpoint !== undefined) {
669
+ // stdout stays clean for the stdio transport's sake even here, so the
670
+ // one command an operator has to copy goes where they can read it.
671
+ process.stderr.write(`MELRA HTTP server listening on http://${endpoint.host}:${endpoint.port}\n` +
672
+ ` Console: ${endpoint.url}\n` +
673
+ ` MCP: ${endpoint.mcpUrl}\n` +
674
+ ` Token: ${endpoint.token}\n` +
675
+ (endpoint.oauth
676
+ ? `A client that cannot be given the token can register itself and ask\n` +
677
+ `you to approve it in a browser; approved clients are named on every\n` +
678
+ `receipt. Set MELRA_HTTP_OAUTH=0 to allow only the token above.\n`
679
+ : `OAuth is off, so the token above is the only way in.\n`) +
680
+ `Loopback only. Anyone who can read this token can drive this machine.\n`);
681
+ if (args.includes("--open"))
682
+ openInBrowser(endpoint.url);
683
+ }
684
+ const close = async () => {
685
+ await server?.close();
686
+ await endpoint?.close();
687
+ await melra.close();
688
+ };
689
+ process.once("SIGINT", () => void close().finally(() => process.exit(0)));
690
+ process.once("SIGTERM", () => void close().finally(() => process.exit(0)));
691
+ return;
692
+ }
693
+ case "run":
694
+ rejectUnknownFlags(args, ["--request"]);
695
+ process.exitCode = await runTask(args, env);
696
+ return;
697
+ case "inspect":
698
+ case "export":
699
+ rejectUnknownFlags(args, []);
700
+ await inspectTask(args, env);
701
+ return;
702
+ case "workflow":
703
+ rejectUnknownFlags(args, ["--definition", "--approval", "--input"]);
704
+ process.exitCode = await workflowCommand(args, env);
705
+ return;
706
+ case "demo":
707
+ rejectUnknownFlags(args, []);
708
+ if (args[0] !== "durable-core") {
709
+ throw new Error("demo supports only 'durable-core'");
710
+ }
711
+ process.exitCode = await durableCoreDemo(env);
712
+ return;
713
+ case "clients":
714
+ rejectUnknownFlags(args, ["--revoke"]);
715
+ process.exitCode = clientsCommand(args, env);
716
+ return;
717
+ case "policy":
718
+ if (args[0] !== "test")
719
+ throw new Error("policy supports only 'test'");
720
+ rejectUnknownFlags(args.slice(1), ["--request"]);
721
+ await policyTest(args.slice(1), env);
722
+ return;
723
+ case "version":
724
+ case "--version":
725
+ case "-v":
726
+ process.stdout.write(`${PRODUCT_VERSION}\n`);
727
+ return;
728
+ case "help":
729
+ case "--help":
730
+ case "-h":
731
+ help();
732
+ return;
733
+ default:
734
+ throw new Error(`unknown command: ${basename(command)}`);
735
+ }
736
+ }
737
+ /**
738
+ * A schema rejection, rendered as one line per problem.
739
+ *
740
+ * Zod's `message` is the JSON dump of its issue array, so an input missing one
741
+ * field used to fill the terminal with brackets and offsets. The useful part of
742
+ * each issue is where it is and what is wrong with it.
743
+ *
744
+ * Detected by shape rather than `instanceof ZodError` so the CLI does not take
745
+ * a direct zod dependency to print an error — every schema it parses comes from
746
+ * `@melra/protocol`, and matching on `issues` also survives a major zod bump.
747
+ */
748
+ function schemaIssues(error) {
749
+ if (typeof error !== "object" || error === null)
750
+ return undefined;
751
+ const { issues } = error;
752
+ if (!Array.isArray(issues) || issues.length === 0)
753
+ return undefined;
754
+ const lines = issues.map((issue) => {
755
+ const path = (issue.path ?? [])
756
+ .map((segment) => typeof segment === "number" ? `[${segment}]` : `.${segment}`)
757
+ .join("")
758
+ .replace(/^\./, "");
759
+ const message = issue.message ?? "invalid value";
760
+ return ` ${path === "" ? "(root)" : path}: ${message}`;
761
+ });
762
+ return ["input does not match the schema:", ...lines].join("\n");
763
+ }
764
+ main().catch((error) => {
765
+ const message = schemaIssues(error) ??
766
+ (error instanceof Error ? error.message : String(error));
767
+ process.stderr.write(`melra: ${message}\n`);
768
+ process.exitCode = 1;
769
+ });
770
+ //# sourceMappingURL=index.js.map