@melra/cli 0.3.0-alpha.11 → 0.3.0-alpha.13

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.
Files changed (48) hide show
  1. package/README.md +14 -7
  2. package/dist/activity.d.ts +26 -0
  3. package/dist/activity.d.ts.map +1 -0
  4. package/dist/activity.js +404 -0
  5. package/dist/activity.js.map +1 -0
  6. package/dist/approval-screen.d.ts +35 -0
  7. package/dist/approval-screen.d.ts.map +1 -0
  8. package/dist/approval-screen.js +130 -0
  9. package/dist/approval-screen.js.map +1 -0
  10. package/dist/client-status.d.ts +24 -0
  11. package/dist/client-status.d.ts.map +1 -0
  12. package/dist/client-status.js +273 -0
  13. package/dist/client-status.js.map +1 -0
  14. package/dist/clients.d.ts +32 -0
  15. package/dist/clients.d.ts.map +1 -0
  16. package/dist/clients.js +588 -0
  17. package/dist/clients.js.map +1 -0
  18. package/dist/conformance.d.ts +7 -0
  19. package/dist/conformance.d.ts.map +1 -1
  20. package/dist/conformance.js +134 -30
  21. package/dist/conformance.js.map +1 -1
  22. package/dist/demo-workflow.d.ts +46 -0
  23. package/dist/demo-workflow.d.ts.map +1 -0
  24. package/dist/demo-workflow.js +58 -0
  25. package/dist/demo-workflow.js.map +1 -0
  26. package/dist/environment.d.ts +15 -1
  27. package/dist/environment.d.ts.map +1 -1
  28. package/dist/environment.js +30 -4
  29. package/dist/environment.js.map +1 -1
  30. package/dist/human.d.ts +67 -0
  31. package/dist/human.d.ts.map +1 -0
  32. package/dist/human.js +266 -0
  33. package/dist/human.js.map +1 -0
  34. package/dist/index.js +432 -77
  35. package/dist/index.js.map +1 -1
  36. package/dist/isolation.d.ts +35 -0
  37. package/dist/isolation.d.ts.map +1 -0
  38. package/dist/isolation.js +66 -0
  39. package/dist/isolation.js.map +1 -0
  40. package/dist/jsonc.d.ts +24 -0
  41. package/dist/jsonc.d.ts.map +1 -0
  42. package/dist/jsonc.js +238 -0
  43. package/dist/jsonc.js.map +1 -0
  44. package/dist/output.d.ts +28 -0
  45. package/dist/output.d.ts.map +1 -0
  46. package/dist/output.js +84 -0
  47. package/dist/output.js.map +1 -0
  48. package/package.json +11 -10
package/dist/index.js CHANGED
@@ -1,27 +1,73 @@
1
1
  #!/usr/bin/env node
2
2
  // Copyright 2026 XAGI Labs Private Limited
3
3
  // SPDX-License-Identifier: Apache-2.0
4
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { access, lstat, mkdir, readFile, stat, writeFile } from "node:fs/promises";
5
5
  import { constants } from "node:fs";
6
6
  import { spawn } from "node:child_process";
7
- import { basename, join, resolve } from "node:path";
7
+ import { basename, dirname, join, resolve } from "node:path";
8
8
  import { createInterface } from "node:readline/promises";
9
9
  import { DatabaseSync } from "node:sqlite";
10
- import { ApprovalResponseSchema, TaskRequestSchema, WorkflowDefinitionSchema, WorkflowInputSchema, PRODUCT_VERSION, deploymentMode, principalRef, } from "@melra/protocol";
11
- import { assertEnforceable, createMelraRuntime, serveHttp, OAuthProvider, clientPrincipal, serveStdio, unconfinedRoot, } from "@melra/server";
10
+ import { ApprovalResponseSchema, TaskRequestSchema, TaskStatusSchema, WorkflowDefinitionSchema, WorkflowInputSchema, PRODUCT_VERSION, deploymentMode, principalRef, } from "@melra/protocol";
11
+ import { assertEnforceable, confinedRoot, createMelraRuntime, serveHttp, OAuthProvider, clientPrincipal, serveStdio, unconfinedRoot, workspaceHoldsData, } from "@melra/server";
12
12
  import { detectBrowserExecutable } from "@melra/browser-runtime";
13
13
  import { createSystemComputerAdapter } from "@melra/computer-runtime";
14
14
  import { capabilityUsageReader } from "@melra/runtime-core";
15
15
  import { SqliteStore } from "@melra/storage-sqlite";
16
16
  import { classifyOperation, createDefaultPolicy, evaluatePolicy, loadPolicy, } from "@melra/policy-core";
17
- import { parseCliEnvironment, serverLaunch, } from "./environment.js";
17
+ import { approverLabel, parseCliEnvironment, serverLaunch, } from "./environment.js";
18
+ import { approvalScreen, printable } from "./approval-screen.js";
18
19
  import { runConformance } from "./conformance.js";
20
+ import { statusCommand, tasksCommand, watchCommand } from "./activity.js";
21
+ import { clientStatuses, clientsCheck, detectClients } from "./client-status.js";
22
+ import { RESTART_SAFE_WORKFLOW } from "./demo-workflow.js";
23
+ import { detectIsolation, isolationCheck } from "./isolation.js";
24
+ import { renderConformance, renderDoctor, renderInit, renderSetup, setupWizard, } from "./human.js";
25
+ import { isHuman, paint, printJsonOrHuman } from "./output.js";
26
+ import { CLIENTS, clientConfig, clientConfigPath, resolveClient, writeClientConfig, } from "./clients.js";
27
+ /**
28
+ * Fold in what the policy file says, so every command describes the server it
29
+ * would actually start: `unhinged` if either asks for it, and the stricter
30
+ * `mode`, exactly as the server combines them.
31
+ *
32
+ * The workspace is MELRA_WORKSPACE when it is set, which every config `setup`
33
+ * writes does, and the working directory otherwise. A file the operator names
34
+ * in MELRA_POLICY may pin it when MELRA_WORKSPACE is not set; any policy file
35
+ * may narrow it to a directory inside, never move it outside. Older `setup`
36
+ * runs wrote their own workspace into the shared ~/.melra/policy.json, and a
37
+ * server for any later project was then confined to the first one instead.
38
+ *
39
+ * A file that does not parse changes nothing here; `doctor` reports it and the
40
+ * server refuses to start on it.
41
+ */
42
+ async function settle(env) {
43
+ const path = await existingPolicyPath(env);
44
+ if (path === undefined)
45
+ return env;
46
+ let file;
47
+ try {
48
+ file = await loadPolicy(path, env.workspaceRoot);
49
+ }
50
+ catch {
51
+ return env;
52
+ }
53
+ const shared = resolve(path) === resolve(env.dataDirectory, "policy.json");
54
+ return {
55
+ ...env,
56
+ workspaceRoot: env.workspaceSet !== true && !shared
57
+ ? file.workspaceRoot
58
+ : confinedRoot(env.workspaceRoot, file.workspaceRoot),
59
+ unhinged: env.unhinged || file.unhinged,
60
+ mode: env.mode === "enforced" || file.mode === "enforced" ? "enforced" : "developer",
61
+ };
62
+ }
19
63
  async function existingPolicyPath(env) {
20
64
  if (env.policyPath !== undefined)
21
65
  return env.policyPath;
22
66
  const candidate = join(env.dataDirectory, "policy.json");
23
67
  try {
24
- await access(candidate, constants.R_OK);
68
+ // Exists, not readable: an unreadable file has to fail loudly, not be
69
+ // skipped for the built-in defaults along with its enforced mode.
70
+ await access(candidate, constants.F_OK);
25
71
  return candidate;
26
72
  }
27
73
  catch {
@@ -89,7 +135,10 @@ async function runtime(env) {
89
135
  });
90
136
  }
91
137
  function output(value) {
92
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
138
+ // JSON.stringify escapes C0 controls but leaves C1, DEL and the invisible
139
+ // and reordering characters raw, and much of what is printed came from an
140
+ // agent. Spelled out, it is still valid JSON for the same values.
141
+ process.stdout.write(`${printable(JSON.stringify(value, null, 2), true)}\n`);
93
142
  }
94
143
  function argument(name, args) {
95
144
  const index = args.indexOf(name);
@@ -111,7 +160,8 @@ function rejectUnknownFlags(args, known) {
111
160
  ]);
112
161
  // A flag's own value can look like a flag (`--input node=--x`), so skip the
113
162
  // slot after any known flag that takes one.
114
- const valued = new Set(known.filter((flag) => flag !== "--http"));
163
+ const boolean = new Set(["--http", "--json", "--yes", "--deny"]);
164
+ const valued = new Set(known.filter((flag) => !boolean.has(flag)));
115
165
  for (let index = 0; index < args.length; index += 1) {
116
166
  const argument_ = args[index];
117
167
  if (argument_ === undefined || !argument_.startsWith("--"))
@@ -283,14 +333,13 @@ async function workflowCommand(args, env) {
283
333
  }
284
334
  }
285
335
  async function durableCoreDemo(env) {
286
- const examplePath = resolve(import.meta.dirname, "../../../examples/workflows/restart-safe.json");
287
336
  const melra = await runtime(env);
288
337
  try {
289
- const definition = WorkflowDefinitionSchema.parse(JSON.parse(await readFile(examplePath, "utf8")));
338
+ const definition = WorkflowDefinitionSchema.parse(RESTART_SAFE_WORKFLOW);
290
339
  const planned = melra.workflows.plan(definition);
291
340
  const advanced = await melra.workflows.advance(planned.id);
292
341
  output({
293
- examplePath,
342
+ example: definition.name,
294
343
  workflow: advanced.run,
295
344
  next: advanced.run.status === "verified_complete"
296
345
  ? "complete"
@@ -302,6 +351,54 @@ async function durableCoreDemo(env) {
302
351
  await melra.close();
303
352
  }
304
353
  }
354
+ /**
355
+ * Whether `MELRA_SOCKET` names somewhere `serve` can listen, and whether a
356
+ * socket already there is one only this user can open. The mode is the whole
357
+ * point of the transport, so a wider one is a `fail`, not a `warn`.
358
+ */
359
+ async function socketCheck(path) {
360
+ const name = "socket";
361
+ if (process.platform === "win32") {
362
+ return {
363
+ name,
364
+ status: "fail",
365
+ detail: "MELRA_SOCKET is not supported on Windows; use stdio or loopback HTTP",
366
+ };
367
+ }
368
+ try {
369
+ if (!(await stat(dirname(path))).isDirectory())
370
+ throw new Error("not a directory");
371
+ }
372
+ catch {
373
+ return { name, status: "fail", detail: `parent directory missing: ${dirname(path)}` };
374
+ }
375
+ let stats;
376
+ try {
377
+ stats = await lstat(path);
378
+ }
379
+ catch {
380
+ return {
381
+ name,
382
+ status: "pass",
383
+ detail: `${path}: not listening; 'melra serve' creates it mode 0600`,
384
+ };
385
+ }
386
+ if (!stats.isSocket()) {
387
+ return {
388
+ name,
389
+ status: "fail",
390
+ detail: `${path} exists and is not a socket; 'melra serve' will refuse it`,
391
+ };
392
+ }
393
+ const mode = stats.mode & 0o777;
394
+ return (mode & 0o077) === 0
395
+ ? { name, status: "pass", detail: `${path}: socket, mode 0${mode.toString(8)}` }
396
+ : {
397
+ name,
398
+ status: "fail",
399
+ detail: `${path}: socket, mode 0${mode.toString(8)}; other users can connect`,
400
+ };
401
+ }
305
402
  async function doctor(env) {
306
403
  const checks = [];
307
404
  const major = Number(process.versions.node.split(".")[0]);
@@ -319,17 +416,33 @@ async function doctor(env) {
319
416
  status: "warn",
320
417
  detail: "UNSAFE-LOCAL: no policy, approval, evidence, confinement, or destination check is applied",
321
418
  }
322
- : { name: "guardrails", status: "pass", detail: "enforced" });
323
- // Not a `fail` either way. Developer mode is a legitimate choice and the
324
- // default; what is not legitimate is a machine that cannot say which one it is.
419
+ // Not "enforced": the next check uses that word for the deployment mode,
420
+ // and one JSON blob saying `guardrails: enforced` beside `mode: developer`
421
+ // reads as a contradiction that is not there.
422
+ : { name: "guardrails", status: "pass", detail: "all applied" });
423
+ // Neither mode is a `fail` on its own. Developer mode is a legitimate choice and the
424
+ // default; what is not legitimate is a machine that cannot say which one it is,
425
+ // or one asking for enforced mode and unsafe-local at once, which a policy file
426
+ // can do past the flag check and which the server then refuses to start.
427
+ let refusal;
428
+ try {
429
+ assertEnforceable(env.mode, env.unhinged);
430
+ }
431
+ catch (error) {
432
+ refusal = error instanceof Error ? error.message : String(error);
433
+ }
325
434
  checks.push({
326
435
  name: "mode",
327
- status: "pass",
328
- detail: env.mode === "enforced"
329
- ? "enforced: no unsafe-local, loopback only, no client self-registration"
330
- : "developer: MELRA governs what it is asked for; the harness may have another path",
436
+ status: refusal === undefined ? "pass" : "fail",
437
+ detail: refusal ??
438
+ (env.mode === "enforced"
439
+ ? "enforced: no unsafe-local, loopback or Unix socket only, no client self-registration"
440
+ : "developer: MELRA governs what it is asked for; the harness may have another path"),
331
441
  });
332
442
  try {
443
+ // A file passes access() too, and the runtime then fails to start on it.
444
+ if (!(await stat(env.workspaceRoot)).isDirectory())
445
+ throw new Error("not a directory");
333
446
  await access(env.workspaceRoot, constants.R_OK | constants.W_OK);
334
447
  checks.push({
335
448
  name: "workspace",
@@ -360,6 +473,30 @@ async function doctor(env) {
360
473
  detail: env.dataDirectory,
361
474
  });
362
475
  }
476
+ // Only once both exist as directories: the check would create a missing
477
+ // workspace. Not in unsafe-local, which reaches every file on the machine and
478
+ // whose guardrails warning already says so.
479
+ if (!env.unhinged &&
480
+ checks.every((check) => !["workspace", "data-directory"].includes(check.name) || check.status === "pass")) {
481
+ try {
482
+ const inside = await workspaceHoldsData(env.workspaceRoot, env.dataDirectory);
483
+ checks.push(inside
484
+ ? {
485
+ name: "data-location",
486
+ // The server refuses to start this way in enforced mode.
487
+ status: env.mode === "enforced" ? "fail" : "warn",
488
+ detail: `${env.dataDirectory} is inside the workspace, where a recursive read reaches payload.key without naming it`,
489
+ }
490
+ : { name: "data-location", status: "pass", detail: "outside the workspace" });
491
+ }
492
+ catch (error) {
493
+ checks.push({
494
+ name: "data-location",
495
+ status: "fail",
496
+ detail: `could not resolve the workspace: ${error instanceof Error ? error.message : String(error)}`,
497
+ });
498
+ }
499
+ }
363
500
  try {
364
501
  const database = new DatabaseSync(":memory:");
365
502
  database.exec("CREATE TABLE readiness(value TEXT)");
@@ -390,8 +527,9 @@ async function doctor(env) {
390
527
  });
391
528
  try {
392
529
  const path = await existingPolicyPath(env);
530
+ // The server's own parser, so a bad `mode` or grant fails here too.
393
531
  if (path !== undefined)
394
- JSON.parse(await readFile(path, "utf8"));
532
+ await loadPolicy(path, env.workspaceRoot);
395
533
  checks.push({
396
534
  name: "policy",
397
535
  status: "pass",
@@ -405,6 +543,11 @@ async function doctor(env) {
405
543
  detail: error instanceof Error ? error.message : String(error),
406
544
  });
407
545
  }
546
+ if (env.socketPath !== undefined)
547
+ checks.push(await socketCheck(env.socketPath));
548
+ checks.push(isolationCheck(await detectIsolation(), env.mode));
549
+ const clients = await clientStatuses(env.workspaceRoot);
550
+ checks.push(clientsCheck(clients));
408
551
  const failed = checks.some((check) => check.status === "fail");
409
552
  return {
410
553
  report: {
@@ -416,46 +559,92 @@ async function doctor(env) {
416
559
  unhinged: env.unhinged,
417
560
  mode: env.mode,
418
561
  checks,
562
+ // Per client, so a script can find a stale pin without parsing `detail`.
563
+ clients,
419
564
  },
420
565
  failed,
421
566
  };
422
567
  }
423
568
  async function init(args, env) {
424
569
  await mkdir(env.dataDirectory, { recursive: true });
425
- const policyPath = join(env.dataDirectory, "policy.json");
570
+ // The operator's MELRA_POLICY when there is one: writing the default path
571
+ // into the client config instead quietly swapped their policy for ours.
572
+ const policyPath = env.policyPath ?? join(env.dataDirectory, "policy.json");
426
573
  try {
427
574
  await access(policyPath, constants.F_OK);
428
575
  }
429
576
  catch {
430
- await writeFile(policyPath, `${JSON.stringify(createDefaultPolicy(env.workspaceRoot), null, 2)}\n`, { flag: "wx" });
431
- }
432
- const client = argument("--client", args) ?? "generic";
433
- const config = {
434
- mcpServers: {
435
- melra: {
436
- ...serverLaunch(import.meta.dirname, PRODUCT_VERSION),
437
- env: {
438
- MELRA_WORKSPACE: env.workspaceRoot,
439
- MELRA_HOME: env.dataDirectory,
440
- MELRA_POLICY: policyPath,
441
- },
442
- },
577
+ if (env.policyPath !== undefined) {
578
+ throw new Error(`MELRA_POLICY names ${policyPath}, which does not exist`);
579
+ }
580
+ // No `workspaceRoot`: this file is shared by every project's server, and
581
+ // each config `setup` writes names its own workspace in MELRA_WORKSPACE.
582
+ const { workspaceRoot: _shared, ...policy } = createDefaultPolicy(env.workspaceRoot);
583
+ await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`, { flag: "wx" });
584
+ }
585
+ const client = resolveClient(argument("--client", args) ?? "generic");
586
+ const label = CLIENTS[client]?.label ?? client;
587
+ const config = clientConfig(client, {
588
+ ...serverLaunch(import.meta.dirname, PRODUCT_VERSION),
589
+ env: {
590
+ MELRA_WORKSPACE: env.workspaceRoot,
591
+ MELRA_HOME: env.dataDirectory,
592
+ MELRA_POLICY: policyPath,
443
593
  },
444
- };
594
+ });
595
+ const configPath = clientConfigPath(client, env.workspaceRoot);
596
+ if (args.includes("--write")) {
597
+ if (configPath === undefined) {
598
+ throw new Error("--write needs a named --client; see 'melra help' for the list");
599
+ }
600
+ const { backup, unchanged } = await writeClientConfig(configPath, config, client);
601
+ const next = CLIENTS[client]?.next ?? `Restart ${label} to load it.`;
602
+ return {
603
+ initialized: true,
604
+ client,
605
+ policyPath,
606
+ configPath,
607
+ written: true,
608
+ ...(backup === undefined ? {} : { backup }),
609
+ ...(unchanged === undefined ? {} : { unchanged }),
610
+ config,
611
+ note: unchanged
612
+ ? `${configPath} already has this melra entry; nothing changed. ${next}`
613
+ : `Added melra to ${configPath}. ${next}`,
614
+ };
615
+ }
445
616
  return {
446
617
  initialized: true,
447
618
  client,
448
619
  policyPath,
620
+ ...(configPath === undefined ? {} : { configPath }),
449
621
  config,
450
- note: `Add the mcpServers.melra entry to ${client}'s MCP configuration.`,
622
+ note: configPath === undefined
623
+ ? "Add the mcpServers.melra entry to your client's MCP configuration, " +
624
+ "or rerun with --client <name> for that client's own format."
625
+ : `Add this to ${configPath}, or rerun with --write to merge it there for you.`,
451
626
  };
452
627
  }
453
628
  // One command for the whole local setup: write the policy, emit a working
454
629
  // client config, and verify the machine can actually run it.
455
630
  async function setup(args, env) {
456
- const initialized = await init(args, env);
631
+ let chosen = args;
632
+ // Only a person at a terminal on both ends is asked. Anything else — a pipe,
633
+ // a test, `--json` — gets today's non-interactive generic setup unchanged.
634
+ if (argument("--client", args) === undefined && isHuman(args) && process.stdin.isTTY) {
635
+ const answer = await setupWizard({ input: process.stdin, output: process.stdout }, detectClients(env.workspaceRoot));
636
+ if (answer === undefined) {
637
+ process.stdout.write("\nCancelled; nothing was written.\n");
638
+ return 130;
639
+ }
640
+ process.stdout.write("\n");
641
+ // The same arguments the person could have typed, so the wizard cannot
642
+ // drift from what `--client X --write` does.
643
+ chosen = ["--client", answer.client, ...(answer.write ? ["--write"] : [])];
644
+ }
645
+ const initialized = await init(chosen, env);
457
646
  const { report, failed } = await doctor(env);
458
- output({ ...initialized, ...report });
647
+ printJsonOrHuman({ ...initialized, ...report }, () => renderSetup(initialized, report), args);
459
648
  return failed ? 1 : 0;
460
649
  }
461
650
  async function runTask(args, env) {
@@ -471,13 +660,14 @@ async function runTask(args, env) {
471
660
  output({ task, next: "rerun interactively to approve this scoped action" });
472
661
  return 3;
473
662
  }
474
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
475
- const phrase = await prompt.question(`Type the exact approval phrase '${task.approval.phrase}' to continue: `);
476
- prompt.close();
477
- const execution = await melra.controller.execute(task.id, {
478
- approvalId: task.approval.approvalId,
479
- phrase,
480
- });
663
+ // Shown the effect and asked, rather than made to retype a phrase that
664
+ // proves only that someone typed it.
665
+ const approver = approverLabel(env);
666
+ const view = melra.controller.approvalView(task.id);
667
+ if (!(await confirmInTerminal(view)))
668
+ return 1;
669
+ melra.controller.decide(task.id, view.renderDigest, approver);
670
+ const execution = await melra.controller.execute(task.id);
481
671
  output(execution);
482
672
  return execution.task.status === "verified_success" ? 0 : 2;
483
673
  }
@@ -489,6 +679,68 @@ async function runTask(args, env) {
489
679
  await melra.close();
490
680
  }
491
681
  }
682
+ /** The effect as an approver needs to see it, on stderr, then y/N. */
683
+ async function confirmInTerminal(view) {
684
+ process.stderr.write(approvalScreen(view));
685
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
686
+ const answer = (await prompt.question("Approve this effect? [y/N] ")).trim().toLowerCase();
687
+ prompt.close();
688
+ return answer === "y" || answer === "yes";
689
+ }
690
+ /**
691
+ * Records an approval for a task another process planned, without running it:
692
+ * the agent that planned it runs it, and the decision lets it.
693
+ */
694
+ async function approveCommand(args, env) {
695
+ // `--mode` is the one flag here that takes a value.
696
+ const given = args.find((value, index) => !value.startsWith("--") && args[index - 1] !== "--mode");
697
+ if (given === undefined)
698
+ throw new Error("approve requires a task ID");
699
+ const approver = approverLabel(env);
700
+ const melra = await runtime(env);
701
+ try {
702
+ // `melra tasks` shows the first eight characters, so a prefix naming one
703
+ // waiting task is enough.
704
+ const waiting = melra.store
705
+ .listTasks(500, "awaiting_approval")
706
+ .filter((task) => task.id.startsWith(given));
707
+ if (given.length < 36 && waiting.length > 1)
708
+ throw new Error(`task_id_ambiguous:${given}`);
709
+ const taskId = given.length < 36 && waiting.length === 1 ? waiting[0].id : given;
710
+ const view = melra.controller.approvalView(taskId);
711
+ if (args.includes("--deny")) {
712
+ melra.controller.cancel(taskId);
713
+ // Read back: another process may have started it in the meantime.
714
+ const task = melra.controller.status(taskId);
715
+ output({ task });
716
+ return task.status === "cancelled" ? 0 : 1;
717
+ }
718
+ if (!args.includes("--yes")) {
719
+ if (!process.stdin.isTTY || args.includes("--json")) {
720
+ output({
721
+ taskId,
722
+ renderDigest: view.renderDigest,
723
+ contract: view.contract,
724
+ next: "rerun with --yes to approve, or in a terminal to be asked",
725
+ });
726
+ return 3;
727
+ }
728
+ if (!(await confirmInTerminal(view))) {
729
+ process.stderr.write("Not approved.\n");
730
+ return 1;
731
+ }
732
+ }
733
+ const decision = melra.controller.decide(taskId, view.renderDigest, approver);
734
+ if (decision.channel === "in_band") {
735
+ process.stderr.write("Recorded in band: this process is in developer mode. Set \"mode\": \"enforced\" in the policy file (or MELRA_MODE=enforced) for the server and for this command. An enforced server will not run on this decision: a workflow plans the step again once the challenge expires; a single task needs --deny and planning again.\n");
736
+ }
737
+ output({ decision });
738
+ return 0;
739
+ }
740
+ finally {
741
+ await melra.close();
742
+ }
743
+ }
492
744
  async function inspectTask(args, env) {
493
745
  const taskId = args[0];
494
746
  if (taskId === undefined)
@@ -513,6 +765,7 @@ async function policyTest(args, env) {
513
765
  // answer for a policy nobody is running.
514
766
  const policy = {
515
767
  ...(await loadPolicy(await existingPolicyPath(env), env.workspaceRoot)),
768
+ workspaceRoot: env.workspaceRoot,
516
769
  unhinged: env.unhinged,
517
770
  mode: env.mode,
518
771
  };
@@ -556,7 +809,7 @@ function clientsCommand(args, env) {
556
809
  }
557
810
  for (const client of clients) {
558
811
  process.stdout.write(`${client.approvedAt === undefined ? "pending " : "approved"} ` +
559
- `${client.id} ${client.name}\n` +
812
+ `${client.id} ${printable(client.name)}\n` +
560
813
  ` ${principalRef(clientPrincipal(client))}\n`);
561
814
  }
562
815
  process.stdout.write(`\nRevoke one with 'melra clients --revoke <client-id>', or all of them\n` +
@@ -588,25 +841,45 @@ function help() {
588
841
  process.stdout.write(`MELRA ${PRODUCT_VERSION}
589
842
 
590
843
  Usage:
591
- melra setup [--client <claude|cursor|vscode|codex|generic>]
592
- melra doctor
844
+ melra setup [--client <name>] [--write] [--json]
845
+ melra doctor [--json]
593
846
  melra conformance [--url <mcp-url> --token <token>] [--level <1|2|3>]
594
- melra init [--client <claude|cursor|vscode|codex|generic>]
847
+ melra init [--client <name>] [--write] [--json]
595
848
  melra serve [--http] [--port <port>] [--open]
849
+ melra serve --socket <path>
596
850
  melra run --request <task.json>
851
+ melra approve <task-id> [--yes] [--deny] [--json]
597
852
  melra inspect <task-id>
853
+ melra tasks [--limit <n>] [--status <status>] [--json]
854
+ melra watch [--json]
855
+ melra status [--json]
598
856
  melra workflow plan --definition <workflow.json>
599
857
  melra workflow advance <workflow-id> [--approval <id>:<exact-phrase>]
600
858
  [--input <node-id>=<value>]
859
+ (--approval: developer mode; enforced:
860
+ melra approve <task-id>, then advance)
601
861
  melra workflow inspect <workflow-id>
602
862
  melra workflow cancel <workflow-id>
603
863
  melra workflow pause|resume|suspend <workflow-id>
604
864
  melra demo durable-core
605
865
  melra clients [--revoke <client-id|all>]
606
866
  melra policy test --request <task.json>
607
- melra version
867
+ melra version [--json]
868
+
869
+ In a terminal, doctor, setup, init, and version print a readable report, and
870
+ 'melra setup' with no --client asks which detected client to set up. Piped or
871
+ captured, they print JSON (version: the bare number), exactly as before.
608
872
 
609
873
  Flags:
874
+ --json Print JSON even in a terminal, and never ask a question.
875
+ --client <name> Emit the MCP config in this client's own format and say
876
+ where it goes: claude-code (or claude), claude-desktop,
877
+ cursor, vscode, windsurf, codex, gemini, zed, cline,
878
+ opencode, kiro, goose, continue, amp, openclaw, hermes,
879
+ copilot, qwen, auggie, warp, junie, droid, kimi, kilo,
880
+ lmstudio, or generic (default).
881
+ --write With --client, merge the config into that client's file,
882
+ keeping everything else and saving the old file as .bak.
610
883
  --unsafe-local Run with no policy and no guardrails. Everything the OS user
611
884
  can do, any caller can now do. Same as MELRA_UNHINGED=1.
612
885
  Refused outright when --mode enforced. Was --unhinged, which
@@ -619,20 +892,30 @@ Flags:
619
892
  read-only REST API, a workflow event stream, and the console.
620
893
  Prints a bearer token; every request must carry it.
621
894
  --port <port> HTTP port (default: 7457, or MELRA_HTTP_PORT).
895
+ --socket <path> Serve what --http serves on a Unix socket created mode 0600,
896
+ so only this OS user can connect; the token is still
897
+ required. Not on Windows. Same as MELRA_SOCKET.
622
898
  --open Open the console in your browser once it is listening.
623
899
  --url <mcp-url> Endpoint for 'conformance'. Default: spawn 'melra serve'.
624
900
  --level <1|2|3> Conformance level being claimed (default: 3). The run exits
625
901
  non-zero if the endpoint falls short of it.
902
+ --json For tasks, watch, and status: JSON (NDJSON for watch) even
903
+ in a terminal. Output that is not a terminal is JSON anyway.
904
+ --limit <n> How many recent tasks 'tasks' lists (default: 20, max 500).
905
+ --status <s> Only tasks in this status, e.g. awaiting_approval.
626
906
 
627
907
  Environment:
628
- MELRA_WORKSPACE Workspace boundary (default: current directory)
908
+ MELRA_WORKSPACE Workspace boundary (default: a MELRA_POLICY file's
909
+ workspaceRoot, else the current directory)
629
910
  MELRA_HOME Local database and artifact directory
630
911
  MELRA_POLICY Optional local policy JSON
631
912
  MELRA_UNHINGED Set to 1 to disable every guardrail (see --unsafe-local)
632
913
  MELRA_MODE developer (default) or enforced (see --mode)
914
+ MELRA_APPROVER Name 'melra approve' records as the approver (default: operator)
633
915
  MELRA_HTTP_PORT Port for 'serve --http' (default: 7457)
634
916
  MELRA_HTTP_TOKEN Fixed bearer token for 'serve --http' (default: random)
635
917
  MELRA_HTTP_OAUTH Set to 0 so only that token gets in; no client can register
918
+ MELRA_SOCKET Unix socket path for 'serve' instead of stdio (see --socket)
636
919
  MELRA_BROWSER Optional Chrome/Chromium/Edge executable
637
920
  MELRA_BROWSER_CDP_ENDPOINT Optional HTTP(S) CDP endpoint
638
921
  MELRA_BROWSER_CDP_CONTEXT_INDEX External context index (-1 is last)
@@ -654,15 +937,21 @@ async function main() {
654
937
  "it is an opt-out of the entire safety model.\n");
655
938
  }
656
939
  const modeFlag = argument("--mode", args);
657
- const env = {
940
+ const requested = {
658
941
  ...parsed,
659
942
  unhinged: parsed.unhinged || deprecated || args.includes("--unsafe-local"),
660
- ...(modeFlag === undefined ? {} : { mode: deploymentMode(modeFlag) }),
943
+ // The strictest of the flag and the variable, as for the policy file below:
944
+ // `--mode developer` does not undo MELRA_MODE=enforced.
945
+ ...(modeFlag === undefined || deploymentMode(modeFlag) === "developer"
946
+ ? {}
947
+ : { mode: "enforced" }),
661
948
  };
662
949
  // Before the banner: a process that refuses to start has no guardrails to warn
663
950
  // about, and printing the unsafe-local warning first would read as if it had
664
- // taken effect.
665
- assertEnforceable(env.mode, env.unhinged);
951
+ // taken effect. On the flags and variables only: a policy file that asks for
952
+ // both is the server's to refuse, naming the file, and `doctor` still runs.
953
+ assertEnforceable(requested.mode, requested.unhinged);
954
+ const env = await settle(requested);
666
955
  // Before the command runs, and for every command including `help` — the mode
667
956
  // is a property of the process, so there is no invocation where staying quiet
668
957
  // about it is right.
@@ -671,14 +960,14 @@ async function main() {
671
960
  }
672
961
  switch (command) {
673
962
  case "doctor": {
674
- rejectUnknownFlags(args, []);
963
+ rejectUnknownFlags(args, ["--json"]);
675
964
  const { report, failed } = await doctor(env);
676
- output(report);
965
+ printJsonOrHuman(report, renderDoctor, args);
677
966
  process.exitCode = failed ? 1 : 0;
678
967
  return;
679
968
  }
680
969
  case "conformance": {
681
- rejectUnknownFlags(args, ["--url", "--token", "--level"]);
970
+ rejectUnknownFlags(args, ["--url", "--token", "--level", "--json"]);
682
971
  const level = argument("--level", args);
683
972
  if (level !== undefined && !["1", "2", "3"].includes(level)) {
684
973
  throw new Error("--level must be 1, 2, or 3");
@@ -690,33 +979,63 @@ async function main() {
690
979
  ...(token === undefined ? {} : { token }),
691
980
  ...(level === undefined ? {} : { claim: Number(level) }),
692
981
  });
693
- output(report);
982
+ printJsonOrHuman(report, renderConformance, args);
694
983
  process.exitCode = failed ? 1 : 0;
695
984
  return;
696
985
  }
697
986
  case "init":
698
- rejectUnknownFlags(args, ["--client"]);
699
- output(await init(args, env));
987
+ rejectUnknownFlags(args, ["--client", "--write", "--json"]);
988
+ printJsonOrHuman(await init(args, env), renderInit, args);
700
989
  return;
701
990
  case "setup":
702
- rejectUnknownFlags(args, ["--client"]);
991
+ rejectUnknownFlags(args, ["--client", "--write", "--json"]);
703
992
  process.exitCode = await setup(args, env);
704
993
  return;
705
994
  case "serve": {
706
- rejectUnknownFlags(args, ["--http", "--port", "--open"]);
707
- const melra = await runtime(env);
995
+ rejectUnknownFlags(args, ["--http", "--port", "--open", "--socket"]);
996
+ if (args.includes("--socket") && argument("--socket", args) === undefined) {
997
+ throw new Error("--socket requires a path");
998
+ }
999
+ const socketPath = argument("--socket", args) ?? env.socketPath;
708
1000
  const http = args.includes("--http");
709
1001
  const port = argument("--port", args);
710
- const server = http
711
- ? undefined
712
- : await serveStdio(melra);
713
- const endpoint = http
714
- ? await serveHttp({
715
- runtime: melra,
716
- ...(port === undefined ? {} : { port: Number(port) }),
717
- })
718
- : undefined;
719
- if (endpoint !== undefined) {
1002
+ // One transport per process. Quietly preferring one would leave an
1003
+ // operator believing they had the other — and the difference between a
1004
+ // socket only they can open and a port anyone local can is the point.
1005
+ if (socketPath !== undefined &&
1006
+ (http || port !== undefined || args.includes("--open"))) {
1007
+ throw new Error("--socket (or MELRA_SOCKET) replaces --http, --port, and --open; pass one transport");
1008
+ }
1009
+ const melra = await runtime(env);
1010
+ const server = http || socketPath !== undefined ? undefined : await serveStdio(melra);
1011
+ const endpoint = socketPath !== undefined
1012
+ ? await serveHttp({ runtime: melra, socketPath })
1013
+ : http
1014
+ ? await serveHttp({
1015
+ runtime: melra,
1016
+ ...(port === undefined ? {} : { port: Number(port) }),
1017
+ })
1018
+ : undefined;
1019
+ if (endpoint?.socketPath !== undefined) {
1020
+ // An exported MELRA_SOCKET also reaches every stdio client spawned from
1021
+ // that shell, which then waits forever for JSON-RPC on stdout. Name the
1022
+ // variable where those clients log stderr, so the hang has a cause.
1023
+ if (argument("--socket", args) === undefined) {
1024
+ process.stderr.write(`melra: MELRA_SOCKET is set, so 'serve' is on the socket below, not stdio.\n` +
1025
+ ` An MCP client that started this process will get no reply; unset\n` +
1026
+ ` MELRA_SOCKET for it, or pass --socket explicitly when you mean it.\n`);
1027
+ }
1028
+ process.stderr.write(`MELRA server listening on Unix socket ${endpoint.socketPath} (mode 0600)\n` +
1029
+ ` MCP: ${endpoint.mcpUrl}, connecting to that socket\n` +
1030
+ ` Token: ${endpoint.token}\n` +
1031
+ ` Try: curl --unix-socket ${endpoint.socketPath} -H 'Authorization: Bearer <token>' http://localhost/api/capabilities\n` +
1032
+ `Only this OS user can connect; the token is still required on every\n` +
1033
+ `request. OAuth is off: no browser can reach a socket to approve a client.\n` +
1034
+ (env.mode === "enforced"
1035
+ ? `Enforced mode: --unsafe-local is refused. Every receipt says 'enforced'.\n`
1036
+ : ""));
1037
+ }
1038
+ else if (endpoint !== undefined) {
720
1039
  // stdout stays clean for the stdio transport's sake even here, so the
721
1040
  // one command an operator has to copy goes where they can read it.
722
1041
  process.stderr.write(`MELRA HTTP server listening on http://${endpoint.host}:${endpoint.port}\n` +
@@ -725,8 +1044,8 @@ async function main() {
725
1044
  ` Token: ${endpoint.token}\n` +
726
1045
  (endpoint.oauth
727
1046
  ? `A client that cannot be given the token can register itself and ask\n` +
728
- `you to approve it in a browser; approved clients are named on every\n` +
729
- `receipt. Set MELRA_HTTP_OAUTH=0 to allow only the token above.\n`
1047
+ `you to approve it in a browser, with the token above; approved clients\n` +
1048
+ `are named on every receipt. Set MELRA_HTTP_OAUTH=0 to allow only the token.\n`
730
1049
  : `OAuth is off, so the token above is the only way in.\n`) +
731
1050
  (env.mode === "enforced"
732
1051
  ? `Enforced mode: loopback only, no client can register itself, and\n` +
@@ -749,18 +1068,50 @@ async function main() {
749
1068
  rejectUnknownFlags(args, ["--request"]);
750
1069
  process.exitCode = await runTask(args, env);
751
1070
  return;
1071
+ case "approve":
1072
+ rejectUnknownFlags(args, ["--yes", "--deny", "--json"]);
1073
+ process.exitCode = await approveCommand(args, env);
1074
+ return;
752
1075
  case "inspect":
753
1076
  case "export":
754
1077
  rejectUnknownFlags(args, []);
755
1078
  await inspectTask(args, env);
756
1079
  return;
1080
+ case "tasks": {
1081
+ rejectUnknownFlags(args, ["--limit", "--status", "--json"]);
1082
+ const limit = Number(argument("--limit", args) ?? "20");
1083
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
1084
+ throw new Error("--limit must be a whole number from 1 to 500");
1085
+ }
1086
+ const status = argument("--status", args);
1087
+ process.exitCode = tasksCommand(env, {
1088
+ limit,
1089
+ json: args.includes("--json"),
1090
+ ...(status === undefined ? {} : { status: TaskStatusSchema.parse(status) }),
1091
+ });
1092
+ return;
1093
+ }
1094
+ case "watch":
1095
+ rejectUnknownFlags(args, ["--json"]);
1096
+ process.exitCode = await watchCommand(env, { json: args.includes("--json") });
1097
+ return;
1098
+ case "status": {
1099
+ rejectUnknownFlags(args, ["--json"]);
1100
+ const policyPath = await existingPolicyPath(env);
1101
+ process.exitCode = await statusCommand(env, {
1102
+ json: args.includes("--json"),
1103
+ ...(policyPath === undefined ? {} : { policyPath }),
1104
+ });
1105
+ return;
1106
+ }
757
1107
  case "workflow":
758
1108
  rejectUnknownFlags(args, ["--definition", "--approval", "--input"]);
759
1109
  process.exitCode = await workflowCommand(args, env);
760
1110
  return;
761
1111
  case "demo":
762
1112
  rejectUnknownFlags(args, []);
763
- if (args[0] !== "durable-core") {
1113
+ // One demo today, so naming it is optional.
1114
+ if ((args[0] ?? "durable-core") !== "durable-core") {
764
1115
  throw new Error("demo supports only 'durable-core'");
765
1116
  }
766
1117
  process.exitCode = await durableCoreDemo(env);
@@ -778,7 +1129,11 @@ async function main() {
778
1129
  case "version":
779
1130
  case "--version":
780
1131
  case "-v":
781
- process.stdout.write(`${PRODUCT_VERSION}\n`);
1132
+ // `--json` keeps the bare version scripts already parse; a JSON object
1133
+ // here would break every `$(melra version)` in the wild.
1134
+ process.stdout.write(isHuman(args)
1135
+ ? `MELRA ${paint.bold(PRODUCT_VERSION)} ${paint.dim(`(Node ${process.version}, ${process.platform}-${process.arch})`)}\n`
1136
+ : `${PRODUCT_VERSION}\n`);
782
1137
  return;
783
1138
  case "help":
784
1139
  case "--help":