@melra/cli 0.3.0-alpha.10 → 0.3.0-alpha.12

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 (43) hide show
  1. package/README.md +11 -6
  2. package/dist/activity.d.ts +26 -0
  3. package/dist/activity.d.ts.map +1 -0
  4. package/dist/activity.js +401 -0
  5. package/dist/activity.js.map +1 -0
  6. package/dist/client-status.d.ts +24 -0
  7. package/dist/client-status.d.ts.map +1 -0
  8. package/dist/client-status.js +273 -0
  9. package/dist/client-status.js.map +1 -0
  10. package/dist/clients.d.ts +32 -0
  11. package/dist/clients.d.ts.map +1 -0
  12. package/dist/clients.js +584 -0
  13. package/dist/clients.js.map +1 -0
  14. package/dist/conformance.d.ts.map +1 -1
  15. package/dist/conformance.js +47 -7
  16. package/dist/conformance.js.map +1 -1
  17. package/dist/demo-workflow.d.ts +46 -0
  18. package/dist/demo-workflow.d.ts.map +1 -0
  19. package/dist/demo-workflow.js +58 -0
  20. package/dist/demo-workflow.js.map +1 -0
  21. package/dist/environment.d.ts +5 -1
  22. package/dist/environment.d.ts.map +1 -1
  23. package/dist/environment.js +15 -1
  24. package/dist/environment.js.map +1 -1
  25. package/dist/human.d.ts +67 -0
  26. package/dist/human.d.ts.map +1 -0
  27. package/dist/human.js +257 -0
  28. package/dist/human.js.map +1 -0
  29. package/dist/index.js +316 -72
  30. package/dist/index.js.map +1 -1
  31. package/dist/isolation.d.ts +35 -0
  32. package/dist/isolation.d.ts.map +1 -0
  33. package/dist/isolation.js +66 -0
  34. package/dist/isolation.js.map +1 -0
  35. package/dist/jsonc.d.ts +24 -0
  36. package/dist/jsonc.d.ts.map +1 -0
  37. package/dist/jsonc.js +238 -0
  38. package/dist/jsonc.js.map +1 -0
  39. package/dist/output.d.ts +28 -0
  40. package/dist/output.d.ts.map +1 -0
  41. package/dist/output.js +77 -0
  42. package/dist/output.js.map +1 -0
  43. package/package.json +10 -9
package/dist/index.js CHANGED
@@ -1,19 +1,28 @@
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, principalRef, } from "@melra/protocol";
11
- import { 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, createMelraRuntime, serveHttp, OAuthProvider, clientPrincipal, serveStdio, unconfinedRoot, } from "@melra/server";
12
12
  import { detectBrowserExecutable } from "@melra/browser-runtime";
13
13
  import { createSystemComputerAdapter } from "@melra/computer-runtime";
14
+ import { capabilityUsageReader } from "@melra/runtime-core";
15
+ import { SqliteStore } from "@melra/storage-sqlite";
14
16
  import { classifyOperation, createDefaultPolicy, evaluatePolicy, loadPolicy, } from "@melra/policy-core";
15
17
  import { parseCliEnvironment, serverLaunch, } from "./environment.js";
16
18
  import { runConformance } from "./conformance.js";
19
+ import { statusCommand, tasksCommand, watchCommand } from "./activity.js";
20
+ import { clientStatuses, clientsCheck, detectClients } from "./client-status.js";
21
+ import { RESTART_SAFE_WORKFLOW } from "./demo-workflow.js";
22
+ import { detectIsolation, isolationCheck } from "./isolation.js";
23
+ import { renderConformance, renderDoctor, renderInit, renderSetup, setupWizard, } from "./human.js";
24
+ import { isHuman, paint, printJsonOrHuman } from "./output.js";
25
+ import { CLIENTS, clientConfig, clientConfigPath, resolveClient, writeClientConfig, } from "./clients.js";
17
26
  async function existingPolicyPath(env) {
18
27
  if (env.policyPath !== undefined)
19
28
  return env.policyPath;
@@ -27,7 +36,7 @@ async function existingPolicyPath(env) {
27
36
  }
28
37
  }
29
38
  /**
30
- * The banner unhinged mode prints before it does anything.
39
+ * The banner unsafe-local mode prints before it does anything.
31
40
  *
32
41
  * It goes to stderr, which is where it has to go: in `serve` the stdout stream
33
42
  * is the MCP transport and any prose written there corrupts the protocol. stderr
@@ -36,11 +45,11 @@ async function existingPolicyPath(env) {
36
45
  * developer who exported the variable in one shell and forgot deserves the
37
46
  * reminder on every invocation rather than only at startup.
38
47
  */
39
- function unhingedBanner(root) {
48
+ function unsafeLocalBanner(root) {
40
49
  return [
41
50
  "",
42
51
  " ############################################################",
43
- " # MELRA IS RUNNING UNHINGED. NO GUARDRAILS ARE APPLIED. #",
52
+ " # MELRA IS RUNNING UNSAFE-LOCAL. NO GUARDRAILS APPLY. #",
44
53
  " ############################################################",
45
54
  "",
46
55
  " Disabled for every task this process runs:",
@@ -54,7 +63,7 @@ function unhingedBanner(root) {
54
63
  " OS user can. Receipts are still written, so you will be able to read what",
55
64
  " happened — after it has happened.",
56
65
  "",
57
- " Turn it off by unsetting MELRA_UNHINGED and dropping --unhinged.",
66
+ " Turn it off by unsetting MELRA_UNHINGED and dropping --unsafe-local.",
58
67
  "",
59
68
  ].join("\n");
60
69
  }
@@ -64,6 +73,7 @@ async function runtime(env) {
64
73
  workspaceRoot: env.workspaceRoot,
65
74
  dataDirectory: env.dataDirectory,
66
75
  unhinged: env.unhinged,
76
+ mode: env.mode,
67
77
  ...(policyPath === undefined ? {} : { policyPath }),
68
78
  ...(env.browserExecutablePath === undefined
69
79
  ? {}
@@ -98,10 +108,17 @@ function argument(name, args) {
98
108
  * a terminal prints nothing and in a pipeline that never closes hangs forever.
99
109
  */
100
110
  function rejectUnknownFlags(args, known) {
101
- const allowed = new Set([...known, "--unhinged"]);
111
+ // Process-wide rather than per-command: these three describe the process, not
112
+ // what a command does, so every command accepts them.
113
+ const allowed = new Set([
114
+ ...known,
115
+ "--unsafe-local",
116
+ "--unhinged",
117
+ "--mode",
118
+ ]);
102
119
  // A flag's own value can look like a flag (`--input node=--x`), so skip the
103
120
  // slot after any known flag that takes one.
104
- const valued = new Set(known.filter((flag) => flag !== "--http"));
121
+ const valued = new Set(known.filter((flag) => flag !== "--http" && flag !== "--json"));
105
122
  for (let index = 0; index < args.length; index += 1) {
106
123
  const argument_ = args[index];
107
124
  if (argument_ === undefined || !argument_.startsWith("--"))
@@ -273,14 +290,13 @@ async function workflowCommand(args, env) {
273
290
  }
274
291
  }
275
292
  async function durableCoreDemo(env) {
276
- const examplePath = resolve(import.meta.dirname, "../../../examples/workflows/restart-safe.json");
277
293
  const melra = await runtime(env);
278
294
  try {
279
- const definition = WorkflowDefinitionSchema.parse(JSON.parse(await readFile(examplePath, "utf8")));
295
+ const definition = WorkflowDefinitionSchema.parse(RESTART_SAFE_WORKFLOW);
280
296
  const planned = melra.workflows.plan(definition);
281
297
  const advanced = await melra.workflows.advance(planned.id);
282
298
  output({
283
- examplePath,
299
+ example: definition.name,
284
300
  workflow: advanced.run,
285
301
  next: advanced.run.status === "verified_complete"
286
302
  ? "complete"
@@ -292,6 +308,54 @@ async function durableCoreDemo(env) {
292
308
  await melra.close();
293
309
  }
294
310
  }
311
+ /**
312
+ * Whether `MELRA_SOCKET` names somewhere `serve` can listen, and whether a
313
+ * socket already there is one only this user can open. The mode is the whole
314
+ * point of the transport, so a wider one is a `fail`, not a `warn`.
315
+ */
316
+ async function socketCheck(path) {
317
+ const name = "socket";
318
+ if (process.platform === "win32") {
319
+ return {
320
+ name,
321
+ status: "fail",
322
+ detail: "MELRA_SOCKET is not supported on Windows; use stdio or loopback HTTP",
323
+ };
324
+ }
325
+ try {
326
+ if (!(await stat(dirname(path))).isDirectory())
327
+ throw new Error("not a directory");
328
+ }
329
+ catch {
330
+ return { name, status: "fail", detail: `parent directory missing: ${dirname(path)}` };
331
+ }
332
+ let stats;
333
+ try {
334
+ stats = await lstat(path);
335
+ }
336
+ catch {
337
+ return {
338
+ name,
339
+ status: "pass",
340
+ detail: `${path}: not listening; 'melra serve' creates it mode 0600`,
341
+ };
342
+ }
343
+ if (!stats.isSocket()) {
344
+ return {
345
+ name,
346
+ status: "fail",
347
+ detail: `${path} exists and is not a socket; 'melra serve' will refuse it`,
348
+ };
349
+ }
350
+ const mode = stats.mode & 0o777;
351
+ return (mode & 0o077) === 0
352
+ ? { name, status: "pass", detail: `${path}: socket, mode 0${mode.toString(8)}` }
353
+ : {
354
+ name,
355
+ status: "fail",
356
+ detail: `${path}: socket, mode 0${mode.toString(8)}; other users can connect`,
357
+ };
358
+ }
295
359
  async function doctor(env) {
296
360
  const checks = [];
297
361
  const major = Number(process.versions.node.split(".")[0]);
@@ -307,9 +371,21 @@ async function doctor(env) {
307
371
  ? {
308
372
  name: "guardrails",
309
373
  status: "warn",
310
- detail: "UNHINGED: no policy, approval, evidence, confinement, or destination check is applied",
374
+ detail: "UNSAFE-LOCAL: no policy, approval, evidence, confinement, or destination check is applied",
311
375
  }
312
- : { name: "guardrails", status: "pass", detail: "enforced" });
376
+ // Not "enforced": the next check uses that word for the deployment mode,
377
+ // and one JSON blob saying `guardrails: enforced` beside `mode: developer`
378
+ // reads as a contradiction that is not there.
379
+ : { name: "guardrails", status: "pass", detail: "all applied" });
380
+ // Not a `fail` either way. Developer mode is a legitimate choice and the
381
+ // default; what is not legitimate is a machine that cannot say which one it is.
382
+ checks.push({
383
+ name: "mode",
384
+ status: "pass",
385
+ detail: env.mode === "enforced"
386
+ ? "enforced: no unsafe-local, loopback or Unix socket only, no client self-registration"
387
+ : "developer: MELRA governs what it is asked for; the harness may have another path",
388
+ });
313
389
  try {
314
390
  await access(env.workspaceRoot, constants.R_OK | constants.W_OK);
315
391
  checks.push({
@@ -386,6 +462,11 @@ async function doctor(env) {
386
462
  detail: error instanceof Error ? error.message : String(error),
387
463
  });
388
464
  }
465
+ if (env.socketPath !== undefined)
466
+ checks.push(await socketCheck(env.socketPath));
467
+ checks.push(isolationCheck(await detectIsolation(), env.mode));
468
+ const clients = await clientStatuses(env.workspaceRoot);
469
+ checks.push(clientsCheck(clients));
389
470
  const failed = checks.some((check) => check.status === "fail");
390
471
  return {
391
472
  report: {
@@ -395,7 +476,10 @@ async function doctor(env) {
395
476
  // Alongside the `guardrails` check so a script does not have to read a
396
477
  // detail string to find out whether this machine has any.
397
478
  unhinged: env.unhinged,
479
+ mode: env.mode,
398
480
  checks,
481
+ // Per client, so a script can find a stale pin without parsing `detail`.
482
+ clients,
399
483
  },
400
484
  failed,
401
485
  };
@@ -409,33 +493,69 @@ async function init(args, env) {
409
493
  catch {
410
494
  await writeFile(policyPath, `${JSON.stringify(createDefaultPolicy(env.workspaceRoot), null, 2)}\n`, { flag: "wx" });
411
495
  }
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
- },
496
+ const client = resolveClient(argument("--client", args) ?? "generic");
497
+ const label = CLIENTS[client]?.label ?? client;
498
+ const config = clientConfig(client, {
499
+ ...serverLaunch(import.meta.dirname, PRODUCT_VERSION),
500
+ env: {
501
+ MELRA_WORKSPACE: env.workspaceRoot,
502
+ MELRA_HOME: env.dataDirectory,
503
+ MELRA_POLICY: policyPath,
423
504
  },
424
- };
505
+ });
506
+ const configPath = clientConfigPath(client, env.workspaceRoot);
507
+ if (args.includes("--write")) {
508
+ if (configPath === undefined) {
509
+ throw new Error("--write needs a named --client; see 'melra help' for the list");
510
+ }
511
+ const { backup, unchanged } = await writeClientConfig(configPath, config, client);
512
+ const next = CLIENTS[client]?.next ?? `Restart ${label} to load it.`;
513
+ return {
514
+ initialized: true,
515
+ client,
516
+ policyPath,
517
+ configPath,
518
+ written: true,
519
+ ...(backup === undefined ? {} : { backup }),
520
+ ...(unchanged === undefined ? {} : { unchanged }),
521
+ config,
522
+ note: unchanged
523
+ ? `${configPath} already has this melra entry; nothing changed. ${next}`
524
+ : `Added melra to ${configPath}. ${next}`,
525
+ };
526
+ }
425
527
  return {
426
528
  initialized: true,
427
529
  client,
428
530
  policyPath,
531
+ ...(configPath === undefined ? {} : { configPath }),
429
532
  config,
430
- note: `Add the mcpServers.melra entry to ${client}'s MCP configuration.`,
533
+ note: configPath === undefined
534
+ ? "Add the mcpServers.melra entry to your client's MCP configuration, " +
535
+ "or rerun with --client <name> for that client's own format."
536
+ : `Add this to ${configPath}, or rerun with --write to merge it there for you.`,
431
537
  };
432
538
  }
433
539
  // One command for the whole local setup: write the policy, emit a working
434
540
  // client config, and verify the machine can actually run it.
435
541
  async function setup(args, env) {
436
- const initialized = await init(args, env);
542
+ let chosen = args;
543
+ // Only a person at a terminal on both ends is asked. Anything else — a pipe,
544
+ // a test, `--json` — gets today's non-interactive generic setup unchanged.
545
+ if (argument("--client", args) === undefined && isHuman(args) && process.stdin.isTTY) {
546
+ const answer = await setupWizard({ input: process.stdin, output: process.stdout }, detectClients(env.workspaceRoot));
547
+ if (answer === undefined) {
548
+ process.stdout.write("\nCancelled; nothing was written.\n");
549
+ return 130;
550
+ }
551
+ process.stdout.write("\n");
552
+ // The same arguments the person could have typed, so the wizard cannot
553
+ // drift from what `--client X --write` does.
554
+ chosen = ["--client", answer.client, ...(answer.write ? ["--write"] : [])];
555
+ }
556
+ const initialized = await init(chosen, env);
437
557
  const { report, failed } = await doctor(env);
438
- output({ ...initialized, ...report });
558
+ printJsonOrHuman({ ...initialized, ...report }, () => renderSetup(initialized, report), args);
439
559
  return failed ? 1 : 0;
440
560
  }
441
561
  async function runTask(args, env) {
@@ -494,16 +614,26 @@ async function policyTest(args, env) {
494
614
  const policy = {
495
615
  ...(await loadPolicy(await existingPolicyPath(env), env.workspaceRoot)),
496
616
  unhinged: env.unhinged,
617
+ mode: env.mode,
497
618
  };
498
619
  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
- });
620
+ // Metered grants are counted from the same database the server draws them
621
+ // down in, so a preview cannot report a budget as full when it is spent — and
622
+ // policy refuses a metered grant outright when nothing can count it.
623
+ const store = new SqliteStore(join(env.dataDirectory, "melra.sqlite"));
624
+ try {
625
+ // The decision carries effect, risk, and traits; the classification adds the
626
+ // capability and target it was made against, so a surprising verdict can be
627
+ // traced to what MELRA thought the request was touching.
628
+ const { capability, target } = classifyOperation(request.operation);
629
+ output({
630
+ ...evaluatePolicy(taskId, request, policy, capabilityUsageReader(store)),
631
+ classified: { capability, target },
632
+ });
633
+ }
634
+ finally {
635
+ store.close();
636
+ }
507
637
  }
508
638
  /**
509
639
  * The clients an operator has approved over OAuth, and the way to take that
@@ -558,13 +688,17 @@ function help() {
558
688
  process.stdout.write(`MELRA ${PRODUCT_VERSION}
559
689
 
560
690
  Usage:
561
- melra setup [--client <claude|cursor|vscode|codex|generic>]
562
- melra doctor
691
+ melra setup [--client <name>] [--write] [--json]
692
+ melra doctor [--json]
563
693
  melra conformance [--url <mcp-url> --token <token>] [--level <1|2|3>]
564
- melra init --client <claude|cursor|vscode|codex|generic>
694
+ melra init [--client <name>] [--write] [--json]
565
695
  melra serve [--http] [--port <port>] [--open]
696
+ melra serve --socket <path>
566
697
  melra run --request <task.json>
567
698
  melra inspect <task-id>
699
+ melra tasks [--limit <n>] [--status <status>] [--json]
700
+ melra watch [--json]
701
+ melra status [--json]
568
702
  melra workflow plan --definition <workflow.json>
569
703
  melra workflow advance <workflow-id> [--approval <id>:<exact-phrase>]
570
704
  [--input <node-id>=<value>]
@@ -574,28 +708,56 @@ Usage:
574
708
  melra demo durable-core
575
709
  melra clients [--revoke <client-id|all>]
576
710
  melra policy test --request <task.json>
577
- melra version
711
+ melra version [--json]
712
+
713
+ In a terminal, doctor, setup, init, and version print a readable report, and
714
+ 'melra setup' with no --client asks which detected client to set up. Piped or
715
+ captured, they print JSON (version: the bare number), exactly as before.
578
716
 
579
717
  Flags:
580
- --unhinged Run with no policy and no guardrails. Everything the OS user
718
+ --json Print JSON even in a terminal, and never ask a question.
719
+ --client <name> Emit the MCP config in this client's own format and say
720
+ where it goes: claude-code (or claude), claude-desktop,
721
+ cursor, vscode, windsurf, codex, gemini, zed, cline,
722
+ opencode, kiro, goose, continue, amp, openclaw, hermes,
723
+ copilot, qwen, auggie, warp, junie, droid, kimi, kilo,
724
+ lmstudio, or generic (default).
725
+ --write With --client, merge the config into that client's file,
726
+ keeping everything else and saving the old file as .bak.
727
+ --unsafe-local Run with no policy and no guardrails. Everything the OS user
581
728
  can do, any caller can now do. Same as MELRA_UNHINGED=1.
729
+ Refused outright when --mode enforced. Was --unhinged, which
730
+ still works and warns.
731
+ --mode <m> developer (default) or enforced. Enforced is you asserting
732
+ the harness has no other path to these systems: MELRA then
733
+ refuses --unsafe-local, refuses any bind off loopback, and
734
+ admits no self-registering client. Stamped on every receipt.
582
735
  --http Serve MCP over loopback HTTP instead of stdio, alongside a
583
736
  read-only REST API, a workflow event stream, and the console.
584
737
  Prints a bearer token; every request must carry it.
585
738
  --port <port> HTTP port (default: 7457, or MELRA_HTTP_PORT).
739
+ --socket <path> Serve what --http serves on a Unix socket created mode 0600,
740
+ so only this OS user can connect; the token is still
741
+ required. Not on Windows. Same as MELRA_SOCKET.
586
742
  --open Open the console in your browser once it is listening.
587
743
  --url <mcp-url> Endpoint for 'conformance'. Default: spawn 'melra serve'.
588
744
  --level <1|2|3> Conformance level being claimed (default: 3). The run exits
589
745
  non-zero if the endpoint falls short of it.
746
+ --json For tasks, watch, and status: JSON (NDJSON for watch) even
747
+ in a terminal. Output that is not a terminal is JSON anyway.
748
+ --limit <n> How many recent tasks 'tasks' lists (default: 20, max 500).
749
+ --status <s> Only tasks in this status, e.g. awaiting_approval.
590
750
 
591
751
  Environment:
592
752
  MELRA_WORKSPACE Workspace boundary (default: current directory)
593
753
  MELRA_HOME Local database and artifact directory
594
754
  MELRA_POLICY Optional local policy JSON
595
- MELRA_UNHINGED Set to 1 to disable every guardrail (see --unhinged)
755
+ MELRA_UNHINGED Set to 1 to disable every guardrail (see --unsafe-local)
756
+ MELRA_MODE developer (default) or enforced (see --mode)
596
757
  MELRA_HTTP_PORT Port for 'serve --http' (default: 7457)
597
758
  MELRA_HTTP_TOKEN Fixed bearer token for 'serve --http' (default: random)
598
759
  MELRA_HTTP_OAUTH Set to 0 so only that token gets in; no client can register
760
+ MELRA_SOCKET Unix socket path for 'serve' instead of stdio (see --socket)
599
761
  MELRA_BROWSER Optional Chrome/Chromium/Edge executable
600
762
  MELRA_BROWSER_CDP_ENDPOINT Optional HTTP(S) CDP endpoint
601
763
  MELRA_BROWSER_CDP_CONTEXT_INDEX External context index (-1 is last)
@@ -607,27 +769,41 @@ Environment:
607
769
  async function main() {
608
770
  const [command = "help", ...args] = process.argv.slice(2);
609
771
  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;
772
+ // `--unsafe-local` is an alias for the variable, not a second setting: one
773
+ // field carries the answer so no code path can consult the weaker of the two.
774
+ // `--unhinged` was the original name and still works — it undersold what it
775
+ // does, being a mood rather than a description, so it says so and moves on.
776
+ const deprecated = args.includes("--unhinged");
777
+ if (deprecated) {
778
+ process.stderr.write("melra: --unhinged is deprecated; use --unsafe-local. It is not a mood, " +
779
+ "it is an opt-out of the entire safety model.\n");
780
+ }
781
+ const modeFlag = argument("--mode", args);
782
+ const env = {
783
+ ...parsed,
784
+ unhinged: parsed.unhinged || deprecated || args.includes("--unsafe-local"),
785
+ ...(modeFlag === undefined ? {} : { mode: deploymentMode(modeFlag) }),
786
+ };
787
+ // Before the banner: a process that refuses to start has no guardrails to warn
788
+ // about, and printing the unsafe-local warning first would read as if it had
789
+ // taken effect.
790
+ assertEnforceable(env.mode, env.unhinged);
615
791
  // Before the command runs, and for every command including `help` — the mode
616
792
  // is a property of the process, so there is no invocation where staying quiet
617
793
  // about it is right.
618
794
  if (env.unhinged) {
619
- process.stderr.write(`${unhingedBanner(unconfinedRoot(env.workspaceRoot))}\n`);
795
+ process.stderr.write(`${unsafeLocalBanner(unconfinedRoot(env.workspaceRoot))}\n`);
620
796
  }
621
797
  switch (command) {
622
798
  case "doctor": {
623
- rejectUnknownFlags(args, []);
799
+ rejectUnknownFlags(args, ["--json"]);
624
800
  const { report, failed } = await doctor(env);
625
- output(report);
801
+ printJsonOrHuman(report, renderDoctor, args);
626
802
  process.exitCode = failed ? 1 : 0;
627
803
  return;
628
804
  }
629
805
  case "conformance": {
630
- rejectUnknownFlags(args, ["--url", "--token", "--level"]);
806
+ rejectUnknownFlags(args, ["--url", "--token", "--level", "--json"]);
631
807
  const level = argument("--level", args);
632
808
  if (level !== undefined && !["1", "2", "3"].includes(level)) {
633
809
  throw new Error("--level must be 1, 2, or 3");
@@ -639,33 +815,63 @@ async function main() {
639
815
  ...(token === undefined ? {} : { token }),
640
816
  ...(level === undefined ? {} : { claim: Number(level) }),
641
817
  });
642
- output(report);
818
+ printJsonOrHuman(report, renderConformance, args);
643
819
  process.exitCode = failed ? 1 : 0;
644
820
  return;
645
821
  }
646
822
  case "init":
647
- rejectUnknownFlags(args, ["--client"]);
648
- output(await init(args, env));
823
+ rejectUnknownFlags(args, ["--client", "--write", "--json"]);
824
+ printJsonOrHuman(await init(args, env), renderInit, args);
649
825
  return;
650
826
  case "setup":
651
- rejectUnknownFlags(args, ["--client"]);
827
+ rejectUnknownFlags(args, ["--client", "--write", "--json"]);
652
828
  process.exitCode = await setup(args, env);
653
829
  return;
654
830
  case "serve": {
655
- rejectUnknownFlags(args, ["--http", "--port", "--open"]);
656
- const melra = await runtime(env);
831
+ rejectUnknownFlags(args, ["--http", "--port", "--open", "--socket"]);
832
+ if (args.includes("--socket") && argument("--socket", args) === undefined) {
833
+ throw new Error("--socket requires a path");
834
+ }
835
+ const socketPath = argument("--socket", args) ?? env.socketPath;
657
836
  const http = args.includes("--http");
658
837
  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) {
838
+ // One transport per process. Quietly preferring one would leave an
839
+ // operator believing they had the other — and the difference between a
840
+ // socket only they can open and a port anyone local can is the point.
841
+ if (socketPath !== undefined &&
842
+ (http || port !== undefined || args.includes("--open"))) {
843
+ throw new Error("--socket (or MELRA_SOCKET) replaces --http, --port, and --open; pass one transport");
844
+ }
845
+ const melra = await runtime(env);
846
+ const server = http || socketPath !== undefined ? undefined : await serveStdio(melra);
847
+ const endpoint = socketPath !== undefined
848
+ ? await serveHttp({ runtime: melra, socketPath })
849
+ : http
850
+ ? await serveHttp({
851
+ runtime: melra,
852
+ ...(port === undefined ? {} : { port: Number(port) }),
853
+ })
854
+ : undefined;
855
+ if (endpoint?.socketPath !== undefined) {
856
+ // An exported MELRA_SOCKET also reaches every stdio client spawned from
857
+ // that shell, which then waits forever for JSON-RPC on stdout. Name the
858
+ // variable where those clients log stderr, so the hang has a cause.
859
+ if (argument("--socket", args) === undefined) {
860
+ process.stderr.write(`melra: MELRA_SOCKET is set, so 'serve' is on the socket below, not stdio.\n` +
861
+ ` An MCP client that started this process will get no reply; unset\n` +
862
+ ` MELRA_SOCKET for it, or pass --socket explicitly when you mean it.\n`);
863
+ }
864
+ process.stderr.write(`MELRA server listening on Unix socket ${endpoint.socketPath} (mode 0600)\n` +
865
+ ` MCP: ${endpoint.mcpUrl}, connecting to that socket\n` +
866
+ ` Token: ${endpoint.token}\n` +
867
+ ` Try: curl --unix-socket ${endpoint.socketPath} -H 'Authorization: Bearer <token>' http://localhost/api/capabilities\n` +
868
+ `Only this OS user can connect; the token is still required on every\n` +
869
+ `request. OAuth is off: no browser can reach a socket to approve a client.\n` +
870
+ (env.mode === "enforced"
871
+ ? `Enforced mode: --unsafe-local is refused. Every receipt says 'enforced'.\n`
872
+ : ""));
873
+ }
874
+ else if (endpoint !== undefined) {
669
875
  // stdout stays clean for the stdio transport's sake even here, so the
670
876
  // one command an operator has to copy goes where they can read it.
671
877
  process.stderr.write(`MELRA HTTP server listening on http://${endpoint.host}:${endpoint.port}\n` +
@@ -677,6 +883,10 @@ async function main() {
677
883
  `you to approve it in a browser; approved clients are named on every\n` +
678
884
  `receipt. Set MELRA_HTTP_OAUTH=0 to allow only the token above.\n`
679
885
  : `OAuth is off, so the token above is the only way in.\n`) +
886
+ (env.mode === "enforced"
887
+ ? `Enforced mode: loopback only, no client can register itself, and\n` +
888
+ `--unsafe-local is refused. Every receipt says 'enforced'.\n`
889
+ : "") +
680
890
  `Loopback only. Anyone who can read this token can drive this machine.\n`);
681
891
  if (args.includes("--open"))
682
892
  openInBrowser(endpoint.url);
@@ -699,13 +909,41 @@ async function main() {
699
909
  rejectUnknownFlags(args, []);
700
910
  await inspectTask(args, env);
701
911
  return;
912
+ case "tasks": {
913
+ rejectUnknownFlags(args, ["--limit", "--status", "--json"]);
914
+ const limit = Number(argument("--limit", args) ?? "20");
915
+ if (!Number.isInteger(limit) || limit < 1 || limit > 500) {
916
+ throw new Error("--limit must be a whole number from 1 to 500");
917
+ }
918
+ const status = argument("--status", args);
919
+ process.exitCode = tasksCommand(env, {
920
+ limit,
921
+ json: args.includes("--json"),
922
+ ...(status === undefined ? {} : { status: TaskStatusSchema.parse(status) }),
923
+ });
924
+ return;
925
+ }
926
+ case "watch":
927
+ rejectUnknownFlags(args, ["--json"]);
928
+ process.exitCode = await watchCommand(env, { json: args.includes("--json") });
929
+ return;
930
+ case "status": {
931
+ rejectUnknownFlags(args, ["--json"]);
932
+ const policyPath = await existingPolicyPath(env);
933
+ process.exitCode = await statusCommand(env, {
934
+ json: args.includes("--json"),
935
+ ...(policyPath === undefined ? {} : { policyPath }),
936
+ });
937
+ return;
938
+ }
702
939
  case "workflow":
703
940
  rejectUnknownFlags(args, ["--definition", "--approval", "--input"]);
704
941
  process.exitCode = await workflowCommand(args, env);
705
942
  return;
706
943
  case "demo":
707
944
  rejectUnknownFlags(args, []);
708
- if (args[0] !== "durable-core") {
945
+ // One demo today, so naming it is optional.
946
+ if ((args[0] ?? "durable-core") !== "durable-core") {
709
947
  throw new Error("demo supports only 'durable-core'");
710
948
  }
711
949
  process.exitCode = await durableCoreDemo(env);
@@ -723,7 +961,11 @@ async function main() {
723
961
  case "version":
724
962
  case "--version":
725
963
  case "-v":
726
- process.stdout.write(`${PRODUCT_VERSION}\n`);
964
+ // `--json` keeps the bare version scripts already parse; a JSON object
965
+ // here would break every `$(melra version)` in the wild.
966
+ process.stdout.write(isHuman(args)
967
+ ? `MELRA ${paint.bold(PRODUCT_VERSION)} ${paint.dim(`(Node ${process.version}, ${process.platform}-${process.arch})`)}\n`
968
+ : `${PRODUCT_VERSION}\n`);
727
969
  return;
728
970
  case "help":
729
971
  case "--help":
@@ -731,7 +973,9 @@ async function main() {
731
973
  help();
732
974
  return;
733
975
  default:
734
- throw new Error(`unknown command: ${basename(command)}`);
976
+ // No inline command list: the switch above is the only truth about what
977
+ // exists, and a second copy here would rot the first time one is added.
978
+ throw new Error(`unknown command: ${basename(command)}. Run 'melra --help' for the command list.`);
735
979
  }
736
980
  }
737
981
  /**