mrplex 0.0.1

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 (319) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +284 -0
  3. package/dist/cli/config.d.ts +28 -0
  4. package/dist/cli/config.js +48 -0
  5. package/dist/cli/config.js.map +1 -0
  6. package/dist/cli/exit-codes.d.ts +13 -0
  7. package/dist/cli/exit-codes.js +39 -0
  8. package/dist/cli/exit-codes.js.map +1 -0
  9. package/dist/cli/format.d.ts +22 -0
  10. package/dist/cli/format.js +148 -0
  11. package/dist/cli/format.js.map +1 -0
  12. package/dist/cli/main.d.ts +16 -0
  13. package/dist/cli/main.js +1518 -0
  14. package/dist/cli/main.js.map +1 -0
  15. package/dist/client/kernel-client.d.ts +97 -0
  16. package/dist/client/kernel-client.js +14 -0
  17. package/dist/client/kernel-client.js.map +1 -0
  18. package/dist/client/local.d.ts +26 -0
  19. package/dist/client/local.js +103 -0
  20. package/dist/client/local.js.map +1 -0
  21. package/dist/client/remote-mcp.d.ts +35 -0
  22. package/dist/client/remote-mcp.js +174 -0
  23. package/dist/client/remote-mcp.js.map +1 -0
  24. package/dist/embed/backfill.d.ts +24 -0
  25. package/dist/embed/backfill.js +33 -0
  26. package/dist/embed/backfill.js.map +1 -0
  27. package/dist/embed/chunker.d.ts +31 -0
  28. package/dist/embed/chunker.js +121 -0
  29. package/dist/embed/chunker.js.map +1 -0
  30. package/dist/embed/cmd-hook.d.ts +21 -0
  31. package/dist/embed/cmd-hook.js +100 -0
  32. package/dist/embed/cmd-hook.js.map +1 -0
  33. package/dist/embed/config.d.ts +61 -0
  34. package/dist/embed/config.js +109 -0
  35. package/dist/embed/config.js.map +1 -0
  36. package/dist/embed/hook.d.ts +38 -0
  37. package/dist/embed/hook.js +56 -0
  38. package/dist/embed/hook.js.map +1 -0
  39. package/dist/embed/http-hook.d.ts +13 -0
  40. package/dist/embed/http-hook.js +39 -0
  41. package/dist/embed/http-hook.js.map +1 -0
  42. package/dist/embed/worker.d.ts +62 -0
  43. package/dist/embed/worker.js +228 -0
  44. package/dist/embed/worker.js.map +1 -0
  45. package/dist/fixtures/hwe/characters/albion-ever.md +12 -0
  46. package/dist/fixtures/hwe/characters/cent.md +11 -0
  47. package/dist/fixtures/hwe/concepts/cent-demiurge-resonance.md +10 -0
  48. package/dist/fixtures/hwe/concepts/phase-looper.md +10 -0
  49. package/dist/fixtures/hwe/entities/demiurge.md +11 -0
  50. package/dist/fixtures/hwe/entities/monad.md +11 -0
  51. package/dist/fixtures/hwe/moc/globocorp-network.md +19 -0
  52. package/dist/fixtures/hwe/organizations/globocorp.md +11 -0
  53. package/dist/fixtures/hwe/readme.md +24 -0
  54. package/dist/fixtures/starship/crew/aria-okonkwo.md +14 -0
  55. package/dist/fixtures/starship/crew/bexley-orr.md +14 -0
  56. package/dist/fixtures/starship/crew/dax-thorne.md +14 -0
  57. package/dist/fixtures/starship/crew/isolde-marsh.md +16 -0
  58. package/dist/fixtures/starship/crew/kestrel-vance.md +15 -0
  59. package/dist/fixtures/starship/crew/quill-vasquez.md +14 -0
  60. package/dist/fixtures/starship/crew/soren-halloway.md +16 -0
  61. package/dist/fixtures/starship/encounters/the-drift-choir.md +14 -0
  62. package/dist/fixtures/starship/encounters/the-hollow-signal.md +16 -0
  63. package/dist/fixtures/starship/encounters/the-wandering-buoy.md +14 -0
  64. package/dist/fixtures/starship/equipment/coolant-loop-b.md +13 -0
  65. package/dist/fixtures/starship/equipment/emergency-ration-bars.md +12 -0
  66. package/dist/fixtures/starship/equipment/long-range-sensors.md +13 -0
  67. package/dist/fixtures/starship/equipment/plasma-manifold-3.md +15 -0
  68. package/dist/fixtures/starship/equipment/shuttle-corvid.md +13 -0
  69. package/dist/fixtures/starship/logs/marsh-4419-1.md +13 -0
  70. package/dist/fixtures/starship/logs/okonkwo-4415-1.md +13 -0
  71. package/dist/fixtures/starship/logs/orr-4413-1.md +13 -0
  72. package/dist/fixtures/starship/logs/orr-4420-1.md +12 -0
  73. package/dist/fixtures/starship/logs/thorne-4413-2.md +13 -0
  74. package/dist/fixtures/starship/logs/vance-4419-2.md +13 -0
  75. package/dist/fixtures/starship/misc/the-galley-ficus.md +11 -0
  76. package/dist/fixtures/starship/missions/the-cinder-run.md +17 -0
  77. package/dist/fixtures/starship/missions/the-drift-choir.md +17 -0
  78. package/dist/fixtures/starship/missions/the-hollow-signal.md +17 -0
  79. package/dist/fixtures/starship/missions/the-silent-beacon.md +17 -0
  80. package/dist/fixtures/starship/moc/crew.md +19 -0
  81. package/dist/fixtures/starship/moc/encounters.md +14 -0
  82. package/dist/fixtures/starship/moc/missions.md +15 -0
  83. package/dist/fixtures/starship/readme.md +60 -0
  84. package/dist/index.d.ts +1 -0
  85. package/dist/index.js +3 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/kernel/auth/glob.d.ts +46 -0
  88. package/dist/kernel/auth/glob.js +112 -0
  89. package/dist/kernel/auth/glob.js.map +1 -0
  90. package/dist/kernel/auth/scope.d.ts +53 -0
  91. package/dist/kernel/auth/scope.js +83 -0
  92. package/dist/kernel/auth/scope.js.map +1 -0
  93. package/dist/kernel/casefold.d.ts +37 -0
  94. package/dist/kernel/casefold.js +44 -0
  95. package/dist/kernel/casefold.js.map +1 -0
  96. package/dist/kernel/constants.d.ts +21 -0
  97. package/dist/kernel/constants.js +22 -0
  98. package/dist/kernel/constants.js.map +1 -0
  99. package/dist/kernel/context.d.ts +70 -0
  100. package/dist/kernel/context.js +114 -0
  101. package/dist/kernel/context.js.map +1 -0
  102. package/dist/kernel/deletion.d.ts +50 -0
  103. package/dist/kernel/deletion.js +83 -0
  104. package/dist/kernel/deletion.js.map +1 -0
  105. package/dist/kernel/diff.d.ts +36 -0
  106. package/dist/kernel/diff.js +61 -0
  107. package/dist/kernel/diff.js.map +1 -0
  108. package/dist/kernel/errors.d.ts +37 -0
  109. package/dist/kernel/errors.js +55 -0
  110. package/dist/kernel/errors.js.map +1 -0
  111. package/dist/kernel/frontmatter-input.d.ts +32 -0
  112. package/dist/kernel/frontmatter-input.js +85 -0
  113. package/dist/kernel/frontmatter-input.js.map +1 -0
  114. package/dist/kernel/graph.d.ts +34 -0
  115. package/dist/kernel/graph.js +559 -0
  116. package/dist/kernel/graph.js.map +1 -0
  117. package/dist/kernel/history.d.ts +74 -0
  118. package/dist/kernel/history.js +143 -0
  119. package/dist/kernel/history.js.map +1 -0
  120. package/dist/kernel/kernel.d.ts +162 -0
  121. package/dist/kernel/kernel.js +675 -0
  122. package/dist/kernel/kernel.js.map +1 -0
  123. package/dist/kernel/path-config.d.ts +101 -0
  124. package/dist/kernel/path-config.js +171 -0
  125. package/dist/kernel/path-config.js.map +1 -0
  126. package/dist/kernel/query/ast.d.ts +24 -0
  127. package/dist/kernel/query/ast.js +52 -0
  128. package/dist/kernel/query/ast.js.map +1 -0
  129. package/dist/kernel/query/cel-parse.d.ts +37 -0
  130. package/dist/kernel/query/cel-parse.js +88 -0
  131. package/dist/kernel/query/cel-parse.js.map +1 -0
  132. package/dist/kernel/query/degrees.d.ts +31 -0
  133. package/dist/kernel/query/degrees.js +135 -0
  134. package/dist/kernel/query/degrees.js.map +1 -0
  135. package/dist/kernel/query/graph-ast.d.ts +51 -0
  136. package/dist/kernel/query/graph-ast.js +139 -0
  137. package/dist/kernel/query/graph-ast.js.map +1 -0
  138. package/dist/kernel/query/query.d.ts +56 -0
  139. package/dist/kernel/query/query.js +276 -0
  140. package/dist/kernel/query/query.js.map +1 -0
  141. package/dist/kernel/validation.d.ts +64 -0
  142. package/dist/kernel/validation.js +183 -0
  143. package/dist/kernel/validation.js.map +1 -0
  144. package/dist/kernel/version-id.d.ts +10 -0
  145. package/dist/kernel/version-id.js +21 -0
  146. package/dist/kernel/version-id.js.map +1 -0
  147. package/dist/kernel/wire.d.ts +148 -0
  148. package/dist/kernel/wire.js +6 -0
  149. package/dist/kernel/wire.js.map +1 -0
  150. package/dist/links/backfill.d.ts +28 -0
  151. package/dist/links/backfill.js +33 -0
  152. package/dist/links/backfill.js.map +1 -0
  153. package/dist/links/extract.d.ts +29 -0
  154. package/dist/links/extract.js +233 -0
  155. package/dist/links/extract.js.map +1 -0
  156. package/dist/links/link-config.d.ts +70 -0
  157. package/dist/links/link-config.js +107 -0
  158. package/dist/links/link-config.js.map +1 -0
  159. package/dist/links/maintain.d.ts +37 -0
  160. package/dist/links/maintain.js +91 -0
  161. package/dist/links/maintain.js.map +1 -0
  162. package/dist/links/repair.d.ts +46 -0
  163. package/dist/links/repair.js +96 -0
  164. package/dist/links/repair.js.map +1 -0
  165. package/dist/links/resolve.d.ts +41 -0
  166. package/dist/links/resolve.js +121 -0
  167. package/dist/links/resolve.js.map +1 -0
  168. package/dist/links/stale.d.ts +32 -0
  169. package/dist/links/stale.js +73 -0
  170. package/dist/links/stale.js.map +1 -0
  171. package/dist/markdown/content-hash.d.ts +34 -0
  172. package/dist/markdown/content-hash.js +46 -0
  173. package/dist/markdown/content-hash.js.map +1 -0
  174. package/dist/markdown/frontmatter.d.ts +69 -0
  175. package/dist/markdown/frontmatter.js +147 -0
  176. package/dist/markdown/frontmatter.js.map +1 -0
  177. package/dist/markdown/hash-backfill.d.ts +22 -0
  178. package/dist/markdown/hash-backfill.js +43 -0
  179. package/dist/markdown/hash-backfill.js.map +1 -0
  180. package/dist/mcp/query-syntax.d.ts +20 -0
  181. package/dist/mcp/query-syntax.js +194 -0
  182. package/dist/mcp/query-syntax.js.map +1 -0
  183. package/dist/mcp/render.d.ts +31 -0
  184. package/dist/mcp/render.js +110 -0
  185. package/dist/mcp/render.js.map +1 -0
  186. package/dist/mcp/server.d.ts +60 -0
  187. package/dist/mcp/server.js +170 -0
  188. package/dist/mcp/server.js.map +1 -0
  189. package/dist/mcp/tools.d.ts +93 -0
  190. package/dist/mcp/tools.js +1218 -0
  191. package/dist/mcp/tools.js.map +1 -0
  192. package/dist/rest/conditional.d.ts +37 -0
  193. package/dist/rest/conditional.js +58 -0
  194. package/dist/rest/conditional.js.map +1 -0
  195. package/dist/rest/negotiate.d.ts +33 -0
  196. package/dist/rest/negotiate.js +54 -0
  197. package/dist/rest/negotiate.js.map +1 -0
  198. package/dist/rest/routes.d.ts +37 -0
  199. package/dist/rest/routes.js +744 -0
  200. package/dist/rest/routes.js.map +1 -0
  201. package/dist/seed/fixture-seed.d.ts +35 -0
  202. package/dist/seed/fixture-seed.js +75 -0
  203. package/dist/seed/fixture-seed.js.map +1 -0
  204. package/dist/seed/starship-config.d.ts +3 -0
  205. package/dist/seed/starship-config.js +5 -0
  206. package/dist/seed/starship-config.js.map +1 -0
  207. package/dist/server/headers.d.ts +36 -0
  208. package/dist/server/headers.js +30 -0
  209. package/dist/server/headers.js.map +1 -0
  210. package/dist/server/http-error.d.ts +53 -0
  211. package/dist/server/http-error.js +104 -0
  212. package/dist/server/http-error.js.map +1 -0
  213. package/dist/server/serve.d.ts +56 -0
  214. package/dist/server/serve.js +135 -0
  215. package/dist/server/serve.js.map +1 -0
  216. package/dist/shell/audit.d.ts +32 -0
  217. package/dist/shell/audit.js +28 -0
  218. package/dist/shell/audit.js.map +1 -0
  219. package/dist/shell/guard.d.ts +50 -0
  220. package/dist/shell/guard.js +176 -0
  221. package/dist/shell/guard.js.map +1 -0
  222. package/dist/shell/keys.d.ts +36 -0
  223. package/dist/shell/keys.js +64 -0
  224. package/dist/shell/keys.js.map +1 -0
  225. package/dist/shell/login.d.ts +56 -0
  226. package/dist/shell/login.js +130 -0
  227. package/dist/shell/login.js.map +1 -0
  228. package/dist/shell/oidc.d.ts +62 -0
  229. package/dist/shell/oidc.js +102 -0
  230. package/dist/shell/oidc.js.map +1 -0
  231. package/dist/shell/policy.d.ts +100 -0
  232. package/dist/shell/policy.js +232 -0
  233. package/dist/shell/policy.js.map +1 -0
  234. package/dist/shell/proxy-policy.d.ts +34 -0
  235. package/dist/shell/proxy-policy.js +120 -0
  236. package/dist/shell/proxy-policy.js.map +1 -0
  237. package/dist/shell/proxy.d.ts +47 -0
  238. package/dist/shell/proxy.js +253 -0
  239. package/dist/shell/proxy.js.map +1 -0
  240. package/dist/shell/serve.d.ts +86 -0
  241. package/dist/shell/serve.js +0 -0
  242. package/dist/shell/serve.js.map +1 -0
  243. package/dist/shell/stdio.d.ts +55 -0
  244. package/dist/shell/stdio.js +63 -0
  245. package/dist/shell/stdio.js.map +1 -0
  246. package/dist/storage/registry.d.ts +20 -0
  247. package/dist/storage/registry.js +38 -0
  248. package/dist/storage/registry.js.map +1 -0
  249. package/dist/storage/search-plan.d.ts +93 -0
  250. package/dist/storage/search-plan.js +15 -0
  251. package/dist/storage/search-plan.js.map +1 -0
  252. package/dist/storage/types.d.ts +448 -0
  253. package/dist/storage/types.js +14 -0
  254. package/dist/storage/types.js.map +1 -0
  255. package/dist/storage/versions-since.d.ts +72 -0
  256. package/dist/storage/versions-since.js +92 -0
  257. package/dist/storage/versions-since.js.map +1 -0
  258. package/dist/storage-postgres/adapter.d.ts +17 -0
  259. package/dist/storage-postgres/adapter.js +782 -0
  260. package/dist/storage-postgres/adapter.js.map +1 -0
  261. package/dist/storage-postgres/compile-postgres.d.ts +22 -0
  262. package/dist/storage-postgres/compile-postgres.js +830 -0
  263. package/dist/storage-postgres/compile-postgres.js.map +1 -0
  264. package/dist/storage-postgres/errors.d.ts +34 -0
  265. package/dist/storage-postgres/errors.js +50 -0
  266. package/dist/storage-postgres/errors.js.map +1 -0
  267. package/dist/storage-postgres/migrations/0001_init.sql +113 -0
  268. package/dist/storage-postgres/migrations/0002_content_hash.sql +7 -0
  269. package/dist/storage-postgres/migrations/index.d.ts +22 -0
  270. package/dist/storage-postgres/migrations/index.js +70 -0
  271. package/dist/storage-postgres/migrations/index.js.map +1 -0
  272. package/dist/storage-sqlite/adapter.d.ts +2 -0
  273. package/dist/storage-sqlite/adapter.js +688 -0
  274. package/dist/storage-sqlite/adapter.js.map +1 -0
  275. package/dist/storage-sqlite/compile-filter.d.ts +41 -0
  276. package/dist/storage-sqlite/compile-filter.js +881 -0
  277. package/dist/storage-sqlite/compile-filter.js.map +1 -0
  278. package/dist/storage-sqlite/compile-sqlite.d.ts +19 -0
  279. package/dist/storage-sqlite/compile-sqlite.js +176 -0
  280. package/dist/storage-sqlite/compile-sqlite.js.map +1 -0
  281. package/dist/storage-sqlite/migrations/0001_init.sql +123 -0
  282. package/dist/storage-sqlite/migrations/0002_content_hash.sql +7 -0
  283. package/dist/storage-sqlite/migrations/index.d.ts +6 -0
  284. package/dist/storage-sqlite/migrations/index.js +41 -0
  285. package/dist/storage-sqlite/migrations/index.js.map +1 -0
  286. package/dist/storage-sqlite/vec.d.ts +40 -0
  287. package/dist/storage-sqlite/vec.js +62 -0
  288. package/dist/storage-sqlite/vec.js.map +1 -0
  289. package/dist/sync/cursor.d.ts +36 -0
  290. package/dist/sync/cursor.js +66 -0
  291. package/dist/sync/cursor.js.map +1 -0
  292. package/dist/sync/daemon.d.ts +40 -0
  293. package/dist/sync/daemon.js +202 -0
  294. package/dist/sync/daemon.js.map +1 -0
  295. package/dist/sync/feed.d.ts +38 -0
  296. package/dist/sync/feed.js +125 -0
  297. package/dist/sync/feed.js.map +1 -0
  298. package/dist/sync/fs-store.d.ts +8 -0
  299. package/dist/sync/fs-store.js +76 -0
  300. package/dist/sync/fs-store.js.map +1 -0
  301. package/dist/sync/intrinsics.d.ts +52 -0
  302. package/dist/sync/intrinsics.js +76 -0
  303. package/dist/sync/intrinsics.js.map +1 -0
  304. package/dist/sync/paths.d.ts +38 -0
  305. package/dist/sync/paths.js +56 -0
  306. package/dist/sync/paths.js.map +1 -0
  307. package/dist/sync/push.d.ts +48 -0
  308. package/dist/sync/push.js +328 -0
  309. package/dist/sync/push.js.map +1 -0
  310. package/dist/sync/reconcile.d.ts +54 -0
  311. package/dist/sync/reconcile.js +252 -0
  312. package/dist/sync/reconcile.js.map +1 -0
  313. package/dist/sync/run.d.ts +27 -0
  314. package/dist/sync/run.js +46 -0
  315. package/dist/sync/run.js.map +1 -0
  316. package/dist/version.d.ts +2 -0
  317. package/dist/version.js +5 -0
  318. package/dist/version.js.map +1 -0
  319. package/package.json +80 -0
@@ -0,0 +1,1518 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mrplex CLI — reads + writes + config + serve + remote mode.
4
+ *
5
+ * The CLI is a thin client over the MCP surface (§7.3). When `--server` is
6
+ * set, commands drive `tools/call` against `<server>/mcp`; otherwise the CLI
7
+ * opens the local SQLite file and calls the kernel in-process. The
8
+ * transport seam is `KernelClient` (`src/client/*`).
9
+ *
10
+ * No-auth (noauth plan): there is no bootstrap / tokens / users. Identity is
11
+ * one opaque `author` string (--author → MRPLEX_AUTHOR → config → "mrplex");
12
+ * `--scope <json>` narrows read visibility. In remote mode an optional --token
13
+ * is forwarded verbatim as a bearer for a shell fronting the server; mrplex
14
+ * itself ignores it. serve deliberately bypasses the seam — it IS the server.
15
+ */
16
+ import { readFileSync, renameSync, writeFileSync } from "node:fs";
17
+ import { Command, InvalidArgumentError, Option } from "commander";
18
+ import { parseDocument as parseYamlDocument } from "yaml";
19
+ import { openLocalClient } from "../client/local.js";
20
+ import { openRemoteClient } from "../client/remote-mcp.js";
21
+ import { VERSION } from "../version.js";
22
+ import { backfillRepo } from "../embed/backfill.js";
23
+ import { addEmbedCliOptions, createHookFromConfig, embedFlagInputsFromCli, resolveEmbedConfig, } from "../embed/config.js";
24
+ import { createWorker } from "../embed/worker.js";
25
+ import { globToRegexSource } from "../kernel/auth/glob.js";
26
+ import { parseScopeClaims } from "../kernel/context.js";
27
+ import { KernelError } from "../kernel/errors.js";
28
+ import { createKernel } from "../kernel/kernel.js";
29
+ import { extractSystemProperties, split as splitFrontmatter } from "../markdown/frontmatter.js";
30
+ import { backfillContentHashes } from "../markdown/hash-backfill.js";
31
+ import { renderDocGetManyText, renderGraphSummary } from "../mcp/render.js";
32
+ import { startMcpStdio } from "../mcp/server.js";
33
+ import { startServer } from "../server/serve.js";
34
+ import { fileAuditSink } from "../shell/audit.js";
35
+ import { mintKey } from "../shell/keys.js";
36
+ import { deviceFlowLogin, loadTokenSet, saveTokenSet, } from "../shell/login.js";
37
+ import { createOidcVerifier } from "../shell/oidc.js";
38
+ import { PolicyError, compile, loadPolicyFile } from "../shell/policy.js";
39
+ import { startProxyServer } from "../shell/proxy.js";
40
+ import { startShellServer } from "../shell/serve.js";
41
+ import { startShellStdio } from "../shell/stdio.js";
42
+ import { normalizeDatabaseUrl, openStorage } from "../storage/registry.js";
43
+ import { startDaemon } from "../sync/daemon.js";
44
+ import { syncOnce } from "../sync/run.js";
45
+ import { loadConfig, saveConfig } from "./config.js";
46
+ import { exitCodeForKernelError } from "./exit-codes.js";
47
+ import { renderGraphMermaid, renderGraphYaml, renderHistoryTable, renderQueryTable, renderReposTable, renderVersionAsMarkdown, } from "./format.js";
48
+ // -----------------------------------------------------------------------------
49
+ // Utility parsers + helpers
50
+ // -----------------------------------------------------------------------------
51
+ // Emit `s` as a double-quoted CEL string literal. globToRegexSource output is
52
+ // a regex source, so the characters we can encounter that need escaping in a
53
+ // CEL "..." literal are `\` (regex escape marker) and `"` (literal terminator);
54
+ // glob syntax has no way to produce a raw newline. If globToRegexSource ever
55
+ // starts emitting new escapables, extend this set.
56
+ function celStringLiteral(s) {
57
+ const escaped = s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
58
+ return `"${escaped}"`;
59
+ }
60
+ // Compile a --path glob arg into a CEL $path.matches() expression and AND
61
+ // it with --filter (if any). Glob semantics match design §8.2.
62
+ function combinePathAndFilter(pathGlob, filter) {
63
+ if (!pathGlob)
64
+ return filter;
65
+ const rx = `^${globToRegexSource(pathGlob)}$`;
66
+ const pathExpr = `$path.matches(${celStringLiteral(rx)})`;
67
+ return filter ? `(${pathExpr}) && (${filter})` : pathExpr;
68
+ }
69
+ function parsePositiveInt(value, _prev) {
70
+ if (!/^\d+$/.test(value)) {
71
+ throw new InvalidArgumentError(`expected a positive integer, got "${value}"`);
72
+ }
73
+ const n = Number.parseInt(value, 10);
74
+ if (!Number.isSafeInteger(n) || n <= 0) {
75
+ throw new InvalidArgumentError(`expected a positive integer, got "${value}"`);
76
+ }
77
+ return n;
78
+ }
79
+ /** Like parsePositiveInt but allows 0 (e.g. `graph --degrees 0` = roots only). */
80
+ function parsePositiveIntOrZero(value, _prev) {
81
+ if (!/^\d+$/.test(value)) {
82
+ throw new InvalidArgumentError(`expected a non-negative integer, got "${value}"`);
83
+ }
84
+ const n = Number.parseInt(value, 10);
85
+ if (!Number.isSafeInteger(n) || n < 0) {
86
+ throw new InvalidArgumentError(`expected a non-negative integer, got "${value}"`);
87
+ }
88
+ return n;
89
+ }
90
+ /**
91
+ * Parse a --scope value (JSON ScopeClaim[]) into claims. The whole flag is one
92
+ * JSON array — the read-scope grammar is just the wire ScopeClaim shape.
93
+ */
94
+ function parseScopeArg(value) {
95
+ try {
96
+ return parseScopeClaims(value);
97
+ }
98
+ catch (err) {
99
+ // parseScopeClaims throws KernelError("filter_invalid") with the human
100
+ // reason in .data.reason (its .message is just the code).
101
+ const reason = err instanceof KernelError
102
+ ? String(err.data.reason)
103
+ : err instanceof Error
104
+ ? err.message
105
+ : String(err);
106
+ throw new InvalidArgumentError(`--scope must be a JSON ScopeClaim array: ${reason}`);
107
+ }
108
+ }
109
+ /**
110
+ * Resolve the write author, git-style precedence: --author → MRPLEX_AUTHOR →
111
+ * config `author` → engine default (undefined = kernel stamps "mrplex").
112
+ */
113
+ function resolveAuthor(opts) {
114
+ const cfg = loadConfig();
115
+ return opts.author ?? process.env.MRPLEX_AUTHOR ?? cfg.author;
116
+ }
117
+ /** Build the CallContext (author + scope) the client forwards on every call. */
118
+ function resolveContext(opts) {
119
+ const ctx = {};
120
+ const author = resolveAuthor(opts);
121
+ if (author !== undefined)
122
+ ctx.author = author;
123
+ if (opts.scope !== undefined)
124
+ ctx.scope = opts.scope;
125
+ return ctx;
126
+ }
127
+ function resolveDatabase(opts) {
128
+ const cfg = loadConfig();
129
+ const value = opts.database ?? process.env.MRPLEX_DATABASE ?? cfg.database ?? "sqlite:./mrplex.db";
130
+ return normalizeDatabaseUrl(value);
131
+ }
132
+ function resolveServer(opts) {
133
+ const cfg = loadConfig();
134
+ const value = opts.server ?? process.env.MRPLEX_SERVER ?? cfg.server;
135
+ // An empty value (e.g. `--server ""` to override an env/config default) means
136
+ // "no server" — the same falsy treatment openClient gives it. Normalize to
137
+ // undefined so downstream (source resolution, the mutex gate) sees it as unset.
138
+ return value ? value : undefined;
139
+ }
140
+ /**
141
+ * The sync cursor's source: exactly one of `server` (remote) or `database`
142
+ * (local), mirroring the --server/--database gate. A remote sync records the
143
+ * server URL; a local sync records the resolved database URL so the marker
144
+ * says where it came from.
145
+ */
146
+ function resolveSyncSource(opts) {
147
+ const server = resolveServer(opts);
148
+ if (server !== undefined)
149
+ return { server };
150
+ return { database: resolveDatabase(opts) };
151
+ }
152
+ /**
153
+ * The policy|unsafe gate (auth-shell plan decision 11). A server-starting
154
+ * command demands EXACTLY ONE of --policy or --unsafe. Throws a cli_usage
155
+ * error otherwise so the CLI exits non-zero with a clear message — the raw
156
+ * kernel is a choice you spell out, never a default you fall into.
157
+ */
158
+ function assertServeGate(policy, unsafe) {
159
+ const hasPolicy = policy !== undefined;
160
+ if (hasPolicy === unsafe) {
161
+ const err = new Error(hasPolicy
162
+ ? "--policy and --unsafe are mutually exclusive; pick one"
163
+ : "refusing to start: pass --policy <file> (authenticated) or --unsafe (full-trust, no auth)");
164
+ err.code = "cli_usage";
165
+ throw err;
166
+ }
167
+ }
168
+ /**
169
+ * Build an OIDC verifier from --oidc-issuer/--oidc-audience (both required
170
+ * together), or undefined when neither is given. Throws cli_usage if only one
171
+ * is present — a half-configured verifier would silently reject every token.
172
+ */
173
+ function buildOidcVerifier(opts) {
174
+ const { oidcIssuer, oidcAudience, oidcJwksUri } = opts;
175
+ if (oidcIssuer === undefined && oidcAudience === undefined)
176
+ return undefined;
177
+ if (oidcIssuer === undefined || oidcAudience === undefined) {
178
+ const err = new Error("--oidc-issuer and --oidc-audience must be given together");
179
+ err.code = "cli_usage";
180
+ throw err;
181
+ }
182
+ return createOidcVerifier({
183
+ issuer: oidcIssuer,
184
+ audience: oidcAudience,
185
+ jwksUri: oidcJwksUri,
186
+ });
187
+ }
188
+ /**
189
+ * Resolve a stdio credential from the launcher flags: --principal (trust-by-
190
+ * spawn) or --key / MRPLEX_SHELL_KEY (an API key). Exactly one must be present.
191
+ */
192
+ function resolveStdioCredential(principal, key, token) {
193
+ const given = [principal, key, token].filter((v) => v !== undefined).length;
194
+ if (given > 1) {
195
+ const err = new Error("--principal, --key, and --token are mutually exclusive");
196
+ err.code = "cli_conflict";
197
+ throw err;
198
+ }
199
+ if (principal !== undefined)
200
+ return { kind: "principal", id: principal };
201
+ if (key !== undefined)
202
+ return { kind: "key", key };
203
+ if (token !== undefined)
204
+ return { kind: "token", token };
205
+ const err = new Error("mcp-stdio --policy needs a credential: --principal <id>, --key <key>, or --token <jwt>");
206
+ err.code = "cli_usage";
207
+ throw err;
208
+ }
209
+ /** Wire SIGINT/SIGTERM to close a stdio mount + storage, then exit. */
210
+ function wireStdioShutdown(closeMount, closeStorage) {
211
+ const shutdown = async () => {
212
+ await closeMount().catch(() => { });
213
+ await closeStorage().catch(() => { });
214
+ process.exit(0);
215
+ };
216
+ process.on("SIGINT", () => void shutdown());
217
+ process.on("SIGTERM", () => void shutdown());
218
+ }
219
+ /**
220
+ * Resolve the target repo slug for `docs *` commands — flag → env → config.
221
+ * Throws a friendly cli-usage error if none is set; the CLI turns that into
222
+ * a non-zero exit.
223
+ */
224
+ function resolveRepoSlug(opts) {
225
+ const cfg = loadConfig();
226
+ const value = opts.repo ?? process.env.MRPLEX_REPO ?? cfg.repo;
227
+ if (!value) {
228
+ const err = new Error("no repo — set MRPLEX_REPO, use -r/--repo, or `mrplex config set-repo <slug>`");
229
+ err.code = "cli_usage";
230
+ throw err;
231
+ }
232
+ return value;
233
+ }
234
+ /**
235
+ * Open the right KernelClient — remote if `--server` (or MRPLEX_SERVER / config
236
+ * `server`) is set, otherwise the local in-process client. Enforces the
237
+ * m3-plan decision that --database and --server are mutually exclusive.
238
+ *
239
+ * In local mode we also resolve an embed hook (from --embedder or legacy
240
+ * --embed-url/--embed-cmd, or env/config) — needed for CLI-local semantic
241
+ * enqueue on writes done through the CLI.
242
+ */
243
+ async function openClient(opts, embedFlags = {}) {
244
+ const server = resolveServer(opts);
245
+ const hasExplicitDatabase = opts.database !== undefined || process.env.MRPLEX_DATABASE !== undefined;
246
+ if (server && hasExplicitDatabase) {
247
+ const err = new Error("--database and --server are mutually exclusive; pick one");
248
+ err.code = "cli_conflict";
249
+ throw err;
250
+ }
251
+ const context = resolveContext(opts);
252
+ if (server) {
253
+ // --token is an optional bearer forwarded verbatim for a shell fronting the
254
+ // remote server (noauth plan §1); mrplex itself ignores it.
255
+ return openRemoteClient({
256
+ server,
257
+ context,
258
+ token: resolveTokenString(opts.token) ?? undefined,
259
+ });
260
+ }
261
+ const embedCfg = resolveEmbedConfig(embedFlags);
262
+ const embed = createHookFromConfig(embedCfg);
263
+ return openLocalClient({ database: resolveDatabase(opts), context, embed });
264
+ }
265
+ /** Optional bearer for remote pass-through: --token → MRPLEX_TOKEN → config. */
266
+ function resolveTokenString(cliFlag) {
267
+ if (cliFlag)
268
+ return cliFlag;
269
+ if (process.env.MRPLEX_TOKEN)
270
+ return process.env.MRPLEX_TOKEN;
271
+ return loadConfig().token ?? null;
272
+ }
273
+ async function withClient(cmd, fn, embedFlags = {}) {
274
+ const opts = cmd.optsWithGlobals();
275
+ const client = await openClient(opts, embedFlags);
276
+ try {
277
+ return await fn(client, opts);
278
+ }
279
+ finally {
280
+ await client.close();
281
+ }
282
+ }
283
+ function sleep(ms) {
284
+ return new Promise((resolve) => setTimeout(resolve, ms));
285
+ }
286
+ function emit(result, opts, prettyText) {
287
+ if (opts.json) {
288
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
289
+ return;
290
+ }
291
+ const text = prettyText.endsWith("\n") ? prettyText : `${prettyText}\n`;
292
+ process.stdout.write(text);
293
+ }
294
+ function reportError(err) {
295
+ if (err instanceof KernelError) {
296
+ const payload = { code: err.code, data: err.data };
297
+ process.stderr.write(`${JSON.stringify(payload)}\n`);
298
+ process.exit(exitCodeForKernelError(err.code));
299
+ }
300
+ const code = err.code;
301
+ if (code === "unauthorized") {
302
+ process.stderr.write(`${JSON.stringify({ code, data: {} })}\n`);
303
+ process.exit(3);
304
+ }
305
+ if (code === "network") {
306
+ process.stderr.write(`${err.message}\n`);
307
+ process.exit(10);
308
+ }
309
+ if (code === "cli_conflict" || code === "cli_usage") {
310
+ process.stderr.write(`${err.message}\n`);
311
+ process.exit(1);
312
+ }
313
+ process.stderr.write(`${err.message}\n`);
314
+ process.exit(1);
315
+ }
316
+ /**
317
+ * Append a minted key's hash under `principals.<id>.keys` in a policy file,
318
+ * preserving comments and formatting via the yaml Document API (issuance is a
319
+ * diff, auth-shell decision 3). Throws if the principal isn't present — mint
320
+ * doesn't invent principals.
321
+ */
322
+ function appendKeyToPolicy(policyPath, principalId, hash) {
323
+ const text = readFileSync(policyPath, "utf8");
324
+ const doc = parseYamlDocument(text);
325
+ const principals = doc.getIn(["principals"]);
326
+ if (!principals || !doc.hasIn(["principals", principalId])) {
327
+ const err = new Error(`policy: principal "${principalId}" not found in ${policyPath}`);
328
+ err.code = "cli_usage";
329
+ throw err;
330
+ }
331
+ if (!doc.hasIn(["principals", principalId, "keys"])) {
332
+ doc.setIn(["principals", principalId, "keys"], doc.createNode([hash]));
333
+ }
334
+ else {
335
+ const keys = doc.getIn(["principals", principalId, "keys"]);
336
+ if (typeof keys.add !== "function") {
337
+ const err = new Error(`policy: principals.${principalId}.keys is not a list`);
338
+ err.code = "cli_usage";
339
+ throw err;
340
+ }
341
+ keys.add(hash);
342
+ }
343
+ // Atomic write: a tmp file on the same volume + rename, so an interleaved
344
+ // reader (a SIGHUP reload) or a racing `key mint` sees either the whole old
345
+ // file or the whole new one, never a torn intermediate. Doesn't prevent two
346
+ // concurrent mints from losing one key (last rename wins), but eliminates
347
+ // partial reads/writes.
348
+ const tmp = `${policyPath}.tmp.${process.pid}`;
349
+ writeFileSync(tmp, String(doc));
350
+ renameSync(tmp, policyPath);
351
+ }
352
+ /** Human-readable dump of an effective entitlement — the operator's "why can't
353
+ * X read Y" answer. */
354
+ function renderEntitlement(principalId, e) {
355
+ const claims = (list) => list.length === 0
356
+ ? " (none)\n"
357
+ : list
358
+ .map((c) => ` repo=${JSON.stringify(c.repo)} paths=${JSON.stringify(c.paths)}\n`)
359
+ .join("");
360
+ return (`principal: ${principalId}\n` +
361
+ `author: ${e.author}\n` +
362
+ `destructive: ${e.destructive} impersonate: ${e.impersonate}\n` +
363
+ `read:\n${claims(e.read)}` +
364
+ `write:\n${claims(e.write)}`);
365
+ }
366
+ function readFromFile(pathOrDash) {
367
+ if (pathOrDash === "-") {
368
+ return readFileSync(0, "utf8"); // fd 0 = stdin
369
+ }
370
+ return readFileSync(pathOrDash, "utf8");
371
+ }
372
+ function readDocumentInput(fromFile) {
373
+ if (!fromFile) {
374
+ return { frontmatter_raw: "", body: "" };
375
+ }
376
+ return splitFrontmatter(readFromFile(fromFile));
377
+ }
378
+ function emitVersionWrite(result, opts) {
379
+ if (opts.json) {
380
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
381
+ return;
382
+ }
383
+ process.stdout.write(`${result.version_id}\n`);
384
+ process.stderr.write(`wrote ${result.repo}/${result.path} @ ${result.version_id} (author: ${result.author})\n`);
385
+ }
386
+ // -----------------------------------------------------------------------------
387
+ // Program construction
388
+ // -----------------------------------------------------------------------------
389
+ function buildProgram() {
390
+ const program = new Command()
391
+ .name("mrplex")
392
+ .description("Markdown Repos, plexed — CLI (M3)")
393
+ .version(VERSION)
394
+ .addOption(new Option("--database <url>", "sqlite:./path.db or postgres://…").env("MRPLEX_DATABASE"))
395
+ .addOption(new Option("--token <token>", "optional bearer forwarded verbatim in remote mode (mrplex ignores it; for a fronting shell)").env("MRPLEX_TOKEN"))
396
+ .addOption(new Option("--server <url>", "talk to a remote mrplex server (mutex with --database)").env("MRPLEX_SERVER"))
397
+ .addOption(new Option("-r, --repo <slug>", "target repo for `docs *` commands").env("MRPLEX_REPO"))
398
+ .addOption(new Option("--author <s>", "opaque author string stamped on writes").env("MRPLEX_AUTHOR"))
399
+ .option("--scope <json>", "read-visibility claims as a JSON ScopeClaim array (narrows what reads see)", parseScopeArg)
400
+ .option("--json", "emit raw JSON instead of pretty output", false)
401
+ .exitOverride((err) => {
402
+ if (err.exitCode === 0)
403
+ process.exit(0);
404
+ process.exit(err.exitCode || 1);
405
+ });
406
+ // -------- serve --------
407
+ // The policy|unsafe gate (auth-shell plan decision 11): a command that starts
408
+ // a server over a local database must spell out exactly one of --policy (run
409
+ // the authenticating shell) or --unsafe (raw full-trust kernel). Neither →
410
+ // refuse; both → refuse. Full trust is never the result of a forgotten flag.
411
+ const serveCmd = program
412
+ .command("serve")
413
+ .description("start HTTP surfaces (REST + MCP Streamable HTTP) — §7.3")
414
+ .option("--policy <file>", "YAML policy file — run the authenticating shell (auth-shell plan)")
415
+ .option("--unsafe", "serve the raw full-trust kernel with NO auth (say what it is)", false)
416
+ .option("--audit <file>", "append a JSONL audit line per authenticated call (--policy only)")
417
+ .option("--oidc-issuer <url>", "OIDC issuer to accept JWT bearers from (--policy only)")
418
+ .option("--oidc-audience <aud>", "OIDC audience the JWT must carry (--policy only)")
419
+ .option("--oidc-jwks-uri <url>", "JWKS endpoint (default: <issuer>/.well-known/jwks.json)")
420
+ .option("--port <n>", "TCP port (default 8321)", parsePositiveInt)
421
+ .option("--host <h>", "bind host (default 127.0.0.1)")
422
+ .option("--mcp-stdio", "also expose MCP over STDIO for the launch token (--unsafe only)", false);
423
+ addEmbedCliOptions(serveCmd);
424
+ serveCmd.action(function () {
425
+ const gopts = this.optsWithGlobals();
426
+ const localOpts = this.opts();
427
+ (async () => {
428
+ try {
429
+ assertServeGate(localOpts.policy, localOpts.unsafe);
430
+ const database = resolveDatabase(gopts);
431
+ const embedCfg = resolveEmbedConfig(embedFlagInputsFromCli(localOpts));
432
+ // Authenticated shell mode.
433
+ if (localOpts.policy !== undefined) {
434
+ if (localOpts.mcpStdio) {
435
+ const err = new Error("--mcp-stdio is unsafe-mode only; use `mrplex mcp-stdio` instead");
436
+ err.code = "cli_conflict";
437
+ throw err;
438
+ }
439
+ const shellHandle = await startShellServer({
440
+ database,
441
+ policyPath: localOpts.policy,
442
+ host: localOpts.host,
443
+ port: localOpts.port,
444
+ embed: embedCfg,
445
+ auditPath: localOpts.audit,
446
+ auditSinkFor: localOpts.audit
447
+ ? (principal) => fileAuditSink(localOpts.audit, principal)
448
+ : undefined,
449
+ oidc: buildOidcVerifier(localOpts),
450
+ });
451
+ process.on("SIGHUP", () => shellHandle.reloadPolicy());
452
+ const shutdown = async () => {
453
+ await shellHandle.close();
454
+ process.exit(0);
455
+ };
456
+ process.on("SIGINT", () => void shutdown());
457
+ process.on("SIGTERM", () => void shutdown());
458
+ return;
459
+ }
460
+ // Unsafe raw-kernel mode.
461
+ if (localOpts.audit !== undefined) {
462
+ const err = new Error("--audit requires --policy (nothing to attribute without auth)");
463
+ err.code = "cli_conflict";
464
+ throw err;
465
+ }
466
+ const handle = await startServer({
467
+ database,
468
+ host: localOpts.host,
469
+ port: localOpts.port,
470
+ embed: embedCfg,
471
+ });
472
+ // Optional STDIO — the launch process IS the shell (noauth plan §2),
473
+ // so --author / --scope pin the session's CallContext.
474
+ if (localOpts.mcpStdio) {
475
+ try {
476
+ await startMcpStdio({
477
+ kernel: handle.kernel,
478
+ context: resolveContext(gopts),
479
+ });
480
+ }
481
+ catch (err) {
482
+ process.stderr.write(`mrplex: --mcp-stdio failed: ${err.message}\n`);
483
+ await handle.close();
484
+ process.exit(3);
485
+ }
486
+ }
487
+ const shutdown = async () => {
488
+ await handle.close();
489
+ process.exit(0);
490
+ };
491
+ process.on("SIGINT", () => void shutdown());
492
+ process.on("SIGTERM", () => void shutdown());
493
+ }
494
+ catch (err) {
495
+ reportError(err);
496
+ }
497
+ })();
498
+ });
499
+ // -------- mcp-stdio --------
500
+ // Guarded stdio MCP over a local database (auth-shell plan §1 launcher mode).
501
+ // Under the same policy|unsafe gate as serve: a server over a local database
502
+ // spells out its trust posture. The credential arrives via --principal,
503
+ // MRPLEX_SHELL_KEY / --key, or MRPLEX_SHELL_TOKEN / --token (an OAuth JWT).
504
+ const mcpStdioCmd = program
505
+ .command("mcp-stdio")
506
+ .description("run an MCP session over STDIO against a local database")
507
+ .option("--policy <file>", "YAML policy file — resolve a guarded principal")
508
+ .option("--unsafe", "raw full-trust kernel over stdio, NO auth", false)
509
+ .option("--principal <id>", "trust-by-spawn: run as this policy principal (no credential)")
510
+ .addOption(new Option("--key <key>", "API key to resolve a principal").env("MRPLEX_SHELL_KEY"))
511
+ .addOption(new Option("--token <jwt>", "OAuth access token to resolve a principal").env("MRPLEX_SHELL_TOKEN"))
512
+ .option("--oidc-issuer <url>", "OIDC issuer for --token verification")
513
+ .option("--oidc-audience <aud>", "OIDC audience for --token verification")
514
+ .option("--oidc-jwks-uri <url>", "JWKS endpoint (default: <issuer>/.well-known/jwks.json)")
515
+ .option("--audit <file>", "append a JSONL audit line per call (--policy only)");
516
+ addEmbedCliOptions(mcpStdioCmd);
517
+ mcpStdioCmd.action(function () {
518
+ const gopts = this.optsWithGlobals();
519
+ const localOpts = this.opts();
520
+ (async () => {
521
+ try {
522
+ assertServeGate(localOpts.policy, localOpts.unsafe);
523
+ const database = resolveDatabase(gopts);
524
+ const embedCfg = resolveEmbedConfig(embedFlagInputsFromCli(localOpts));
525
+ const hook = createHookFromConfig(embedCfg);
526
+ if (localOpts.policy === undefined) {
527
+ // Unsafe: raw kernel, launch-time --author/--scope pin the context.
528
+ const storage = await openStorage(database);
529
+ const kernel = createKernel({
530
+ storage,
531
+ onVersionCommitted: async (versionId) => {
532
+ await storage.backlog_enqueue(versionId);
533
+ },
534
+ queryEmbed: hook
535
+ ? async (semantic) => {
536
+ const resp = await hook.embed([semantic]);
537
+ const vector = resp.vectors[0];
538
+ if (!vector)
539
+ throw new Error("embed hook returned no vector for query string");
540
+ return { vector, model: resp.model, dim: resp.dim };
541
+ }
542
+ : undefined,
543
+ });
544
+ const worker = hook ? createWorker({ storage, hook }) : null;
545
+ worker?.start();
546
+ const mount = await startMcpStdio({ kernel, context: resolveContext(gopts) });
547
+ wireStdioShutdown(async () => {
548
+ await mount.close();
549
+ if (worker)
550
+ await worker.stop();
551
+ }, storage.close.bind(storage));
552
+ return;
553
+ }
554
+ // Resolve everything fallible (policy parse, credential shape, OIDC
555
+ // config) BEFORE opening storage, so a malformed policy — a real
556
+ // operator failure mode — fails without leaking a db connection.
557
+ const policy = loadPolicyFile(localOpts.policy);
558
+ const credential = resolveStdioCredential(localOpts.principal, localOpts.key, localOpts.token);
559
+ const oidc = buildOidcVerifier(localOpts);
560
+ const storage = await openStorage(database);
561
+ const kernel = createKernel({
562
+ storage,
563
+ onVersionCommitted: async (versionId) => {
564
+ await storage.backlog_enqueue(versionId);
565
+ },
566
+ queryEmbed: hook
567
+ ? async (semantic) => {
568
+ const resp = await hook.embed([semantic]);
569
+ const vector = resp.vectors[0];
570
+ if (!vector)
571
+ throw new Error("embed hook returned no vector for query string");
572
+ return { vector, model: resp.model, dim: resp.dim };
573
+ }
574
+ : undefined,
575
+ });
576
+ const worker = hook ? createWorker({ storage, hook }) : null;
577
+ worker?.start();
578
+ const mount = await startShellStdio({
579
+ kernel,
580
+ policy,
581
+ credential,
582
+ oidc,
583
+ auditSinkFor: localOpts.audit
584
+ ? (principal) => fileAuditSink(localOpts.audit, principal)
585
+ : undefined,
586
+ });
587
+ wireStdioShutdown(async () => {
588
+ await mount.close();
589
+ if (worker)
590
+ await worker.stop();
591
+ }, storage.close.bind(storage));
592
+ }
593
+ catch (err) {
594
+ reportError(err);
595
+ }
596
+ })();
597
+ });
598
+ // -------- proxy --------
599
+ // Fronting proxy: authenticate + enforce REST route policy, strip inbound
600
+ // X-Mrplex-* and inject the entitlement's, forward to a raw engine upstream.
601
+ // --policy is always required (an unsafe proxy is meaningless).
602
+ program
603
+ .command("proxy")
604
+ .description("authenticating reverse proxy in front of a raw engine upstream")
605
+ .requiredOption("--policy <file>", "YAML policy file")
606
+ .requiredOption("--upstream <target>", "unix:<socket-path> or http://<loopback:port>")
607
+ .option("--audit <file>", "append a JSONL audit line per authenticated request")
608
+ .option("--port <n>", "TCP port (default 8321)", parsePositiveInt)
609
+ .option("--host <h>", "bind host (default 127.0.0.1)")
610
+ .action(function () {
611
+ const localOpts = this.opts();
612
+ (async () => {
613
+ try {
614
+ const handle = await startProxyServer({
615
+ policyPath: localOpts.policy,
616
+ upstream: localOpts.upstream,
617
+ host: localOpts.host,
618
+ port: localOpts.port,
619
+ auditSinkFor: localOpts.audit
620
+ ? (principal) => fileAuditSink(localOpts.audit, principal)
621
+ : undefined,
622
+ });
623
+ process.on("SIGHUP", () => handle.reloadPolicy());
624
+ const shutdown = async () => {
625
+ await handle.close();
626
+ process.exit(0);
627
+ };
628
+ process.on("SIGINT", () => void shutdown());
629
+ process.on("SIGTERM", () => void shutdown());
630
+ }
631
+ catch (err) {
632
+ reportError(err);
633
+ }
634
+ })();
635
+ });
636
+ // -------- login --------
637
+ // OAuth device-authorization flow (auth-shell plan §1 login). Needs only the
638
+ // IdP's client config, not policy — mrplex is the resource server, the IdP is
639
+ // the authorization server. Caches access + refresh tokens (mode 600) for
640
+ // `mcp-stdio --token` to pick up.
641
+ program
642
+ .command("login")
643
+ .description("sign in via the OAuth device flow; cache the token for mcp-stdio")
644
+ .requiredOption("--device-authorization-endpoint <url>", "OAuth device authorization endpoint")
645
+ .requiredOption("--token-endpoint <url>", "OAuth token endpoint")
646
+ .requiredOption("--client-id <id>", "OAuth client id")
647
+ .option("--scope <scopes>", "space-delimited scopes", "openid email profile offline_access")
648
+ .option("--audience <aud>", "OAuth audience — required by Auth0 (and some IdPs) to issue a JWT access token rather than an opaque one; use the same value as the server's --oidc-audience")
649
+ .action(function () {
650
+ const localOpts = this.opts();
651
+ (async () => {
652
+ try {
653
+ const cfg = {
654
+ deviceAuthorizationEndpoint: localOpts.deviceAuthorizationEndpoint,
655
+ tokenEndpoint: localOpts.tokenEndpoint,
656
+ clientId: localOpts.clientId,
657
+ scope: localOpts.scope,
658
+ audience: localOpts.audience,
659
+ };
660
+ const tokens = await deviceFlowLogin(cfg);
661
+ saveTokenSet(tokens);
662
+ process.stderr.write("login: token cached (chmod 600)\n");
663
+ }
664
+ catch (err) {
665
+ process.stderr.write(`login failed: ${err.message}\n`);
666
+ process.exit(1);
667
+ }
668
+ })();
669
+ });
670
+ program
671
+ .command("logout")
672
+ .description("clear the cached OAuth token")
673
+ .action(() => {
674
+ // Overwrite with an empty token set rather than deleting — keeps the
675
+ // mode-600 file in place and makes the intent explicit.
676
+ if (loadTokenSet() !== null) {
677
+ saveTokenSet({ access_token: "" });
678
+ }
679
+ process.stderr.write("logout: cached token cleared\n");
680
+ });
681
+ // -------- config --------
682
+ const cfg = program.command("config").description("local CLI config");
683
+ cfg
684
+ .command("set-database <url>")
685
+ .description("write the default --database URL to the CLI config")
686
+ .action((url) => {
687
+ const c = { ...loadConfig(), database: url };
688
+ saveConfig(c);
689
+ process.stderr.write("config: database set\n");
690
+ });
691
+ cfg
692
+ .command("set-token <token>")
693
+ .description("write the default --token to the CLI config (mode 600)")
694
+ .action((token) => {
695
+ const c = { ...loadConfig(), token };
696
+ saveConfig(c);
697
+ process.stderr.write("config: token set (chmod 600)\n");
698
+ });
699
+ cfg
700
+ .command("set-server <url>")
701
+ .description("write the default --server URL to the CLI config")
702
+ .action((url) => {
703
+ const c = { ...loadConfig(), server: url };
704
+ saveConfig(c);
705
+ process.stderr.write("config: server set\n");
706
+ });
707
+ cfg
708
+ .command("set-repo <slug>")
709
+ .description("write the default -r/--repo slug to the CLI config")
710
+ .action((slug) => {
711
+ const c = { ...loadConfig(), repo: slug };
712
+ saveConfig(c);
713
+ process.stderr.write("config: repo set\n");
714
+ });
715
+ cfg
716
+ .command("set-author <author>")
717
+ .description('write the default --author to the CLI config (e.g. "Full Name <email@addr>")')
718
+ .action((author) => {
719
+ const c = { ...loadConfig(), author };
720
+ saveConfig(c);
721
+ process.stderr.write("config: author set\n");
722
+ });
723
+ cfg
724
+ .command("set-embedder <spec>")
725
+ .description("write the default --embedder to the CLI config (command or http(s):// URL)")
726
+ .action((spec) => {
727
+ const prev = loadConfig();
728
+ const c = { ...prev, embedder: spec };
729
+ delete c.embed_url;
730
+ delete c.embed_cmd;
731
+ saveConfig(c);
732
+ process.stderr.write("config: embedder set\n");
733
+ });
734
+ cfg
735
+ .command("show")
736
+ .description("print the current CLI config")
737
+ .action(function () {
738
+ const opts = this.optsWithGlobals();
739
+ const c = loadConfig();
740
+ if (opts.json) {
741
+ process.stdout.write(`${JSON.stringify(c, null, 2)}\n`);
742
+ }
743
+ else {
744
+ process.stdout.write(`database: ${c.database ?? "(unset)"}\nserver: ${c.server ?? "(unset)"}\nrepo: ${c.repo ?? "(unset)"}\nauthor: ${c.author ?? "(unset)"}\nembedder: ${c.embedder ?? "(unset)"}\ntoken: ${c.token ? "(set)" : "(unset)"}\n`);
745
+ }
746
+ });
747
+ // -------- key (policy tooling) --------
748
+ // `key mint` and `policy check` read/edit the policy file by definition, so
749
+ // they take --policy directly and never touch the serve gate (auth-shell §1
750
+ // "Policy tooling").
751
+ const key = program.command("key").description("API-key tooling for the auth shell");
752
+ key
753
+ .command("mint <principal>")
754
+ .description("generate a new API key; print the plaintext ONCE and the sha256 hash to store")
755
+ .option("--policy <file>", "append the hash under the principal's `keys:` in this policy file")
756
+ .action(function (principal) {
757
+ const localOpts = this.opts();
758
+ try {
759
+ const { plaintext, hash } = mintKey();
760
+ if (localOpts.policy !== undefined) {
761
+ appendKeyToPolicy(localOpts.policy, principal, hash);
762
+ process.stderr.write(`key: appended hash under principals.${principal}.keys\n`);
763
+ }
764
+ else {
765
+ process.stderr.write(`key: add this line under principals.${principal}.keys in your policy file:\n - ${hash}\n`);
766
+ }
767
+ // The plaintext is shown ONCE, on stdout, so it can be piped/captured;
768
+ // it is never stored — only the hash lives in the policy file.
769
+ process.stdout.write(`${plaintext}\n`);
770
+ }
771
+ catch (err) {
772
+ reportError(err);
773
+ }
774
+ });
775
+ // -------- policy --------
776
+ const policy = program.command("policy").description("policy-file tooling for the auth shell");
777
+ policy
778
+ .command("check [principal]")
779
+ .description("validate a policy file; with a principal, print its effective entitlement")
780
+ .requiredOption("--policy <file>", "policy file to load")
781
+ .action(function (principal) {
782
+ const localOpts = this.opts();
783
+ const gopts = this.optsWithGlobals();
784
+ try {
785
+ const loaded = loadPolicyFile(localOpts.policy);
786
+ if (principal === undefined) {
787
+ const nP = Object.keys(loaded.principals).length;
788
+ const nR = Object.keys(loaded.roles).length;
789
+ process.stderr.write(`policy OK: ${nR} role(s), ${nP} principal(s)\n`);
790
+ return;
791
+ }
792
+ const entitlement = compile(loaded, principal);
793
+ if (gopts.json) {
794
+ process.stdout.write(`${JSON.stringify(entitlement, null, 2)}\n`);
795
+ }
796
+ else {
797
+ process.stdout.write(renderEntitlement(principal, entitlement));
798
+ }
799
+ }
800
+ catch (err) {
801
+ if (err instanceof PolicyError) {
802
+ process.stderr.write(`policy: ${err.message}\n`);
803
+ process.exit(1);
804
+ }
805
+ reportError(err);
806
+ }
807
+ });
808
+ // -------- repos --------
809
+ const repos = program.command("repos").description("repo management");
810
+ repos
811
+ .command("list")
812
+ .description("list repos")
813
+ .option("--include-system", "include system-namespaced repos (§3.4)", false)
814
+ .action(function () {
815
+ const localOpts = this.opts();
816
+ withClient(this, async (client, opts) => {
817
+ const result = await client.repos.list({ include_system: localOpts.includeSystem });
818
+ emit(result, opts, renderReposTable(result));
819
+ }).catch(reportError);
820
+ });
821
+ repos
822
+ .command("get <slug>")
823
+ .description("show a repo")
824
+ .action(function (slug) {
825
+ withClient(this, async (client, opts) => {
826
+ const result = await client.repos.get(slug);
827
+ emit(result, opts, `${result.repo} ${result.path_config ? "(custom path_config)" : "(default path_config)"}`);
828
+ }).catch(reportError);
829
+ });
830
+ repos
831
+ .command("create <slug>")
832
+ .description("create a new repo (admin)")
833
+ .action(function (slug) {
834
+ withClient(this, async (client, opts) => {
835
+ const result = await client.repos.create(slug);
836
+ emit(result, opts, `created repo ${result.repo}`);
837
+ }).catch(reportError);
838
+ });
839
+ repos
840
+ .command("rename <slug> <new-slug>")
841
+ .description("rename a repo (admin)")
842
+ .action(function (slug, newSlug) {
843
+ withClient(this, async (client, opts) => {
844
+ const result = await client.repos.rename(slug, newSlug);
845
+ emit(result, opts, `renamed ${slug} → ${result.repo}`);
846
+ }).catch(reportError);
847
+ });
848
+ repos
849
+ .command("delete <slug>")
850
+ .description("delete a repo — renames slug into the system namespace (admin)")
851
+ .action(function (slug) {
852
+ withClient(this, async (client, opts) => {
853
+ const result = await client.repos.delete(slug);
854
+ emit(result, opts, `deleted (now ${result.repo})`);
855
+ }).catch(reportError);
856
+ });
857
+ repos
858
+ .command("set-path-config <slug>")
859
+ .description("set the per-repo path config override (§3.5)")
860
+ .option("--from-file <file>", "read JSON override from file (- for stdin)")
861
+ .option("--clear", "clear the override — inherit from server config", false)
862
+ .action(function (slug) {
863
+ const localOpts = this.opts();
864
+ withClient(this, async (client, opts) => {
865
+ const config = localOpts.clear
866
+ ? null
867
+ : JSON.parse(readFromFile(localOpts.fromFile ?? "-"));
868
+ const result = await client.repos.set_path_config(slug, config);
869
+ emit(result, opts, `updated ${slug}\nwarnings: ${result.warnings.length}`);
870
+ }).catch(reportError);
871
+ });
872
+ repos
873
+ .command("set-link-config <slug>")
874
+ .description("set the per-repo link-extraction config override (§11.2); re-extracts the repo")
875
+ .option("--from-file <file>", "read JSON override from file (- for stdin)")
876
+ .option("--clear", "clear the override — inherit from server config", false)
877
+ .action(function (slug) {
878
+ const localOpts = this.opts();
879
+ withClient(this, async (client, opts) => {
880
+ const config = localOpts.clear
881
+ ? null
882
+ : JSON.parse(readFromFile(localOpts.fromFile ?? "-"));
883
+ const result = await client.repos.set_link_config(slug, config);
884
+ emit(result, opts, `updated ${slug}\nreindexed ${result.reindexed.documents} doc(s), ${result.reindexed.edges} edge(s)`);
885
+ }).catch(reportError);
886
+ });
887
+ // -------- docs --------
888
+ // The target repo is a global (-r/--repo, MRPLEX_REPO, or config `repo`);
889
+ // `docs *` commands take only <path> because most sessions live inside one repo.
890
+ // Each action resolves the repo BEFORE `withClient` opens a connection — in
891
+ // remote mode that avoids a wasted network round-trip when the slug is unset.
892
+ const docs = program.command("docs").description("document ops");
893
+ docs
894
+ .command("get <path>")
895
+ .description("read the current version at <path>")
896
+ .option("--raw", "suppress server-injected $version (and other $* system properties) in the output", false)
897
+ .action(function (path) {
898
+ const localOpts = this.opts();
899
+ const globals = this.optsWithGlobals();
900
+ let repo;
901
+ try {
902
+ repo = resolveRepoSlug(globals);
903
+ }
904
+ catch (err) {
905
+ reportError(err);
906
+ }
907
+ withClient(this, async (client, opts) => {
908
+ const result = await client.docs.get(repo, path, { raw: localOpts.raw === true });
909
+ emit(result, opts, renderVersionAsMarkdown(result));
910
+ }).catch(reportError);
911
+ });
912
+ docs
913
+ .command("get-many <path...>")
914
+ .description("read the current versions of several paths at once")
915
+ .option("--raw", "suppress server-injected $version (and other $* system properties) in the output", false)
916
+ .action(function (paths) {
917
+ const localOpts = this.opts();
918
+ const globals = this.optsWithGlobals();
919
+ let repo;
920
+ try {
921
+ repo = resolveRepoSlug(globals);
922
+ }
923
+ catch (err) {
924
+ reportError(err);
925
+ }
926
+ if (paths.length === 0) {
927
+ reportError(new KernelError("filter_invalid", { reason: "at least one path is required" }));
928
+ }
929
+ withClient(this, async (client, opts) => {
930
+ const result = await client.docs.get_many(repo, paths, { raw: localOpts.raw === true });
931
+ emit(result, opts, renderDocGetManyText(result.items, result.errors));
932
+ }).catch(reportError);
933
+ });
934
+ docs
935
+ .command("get-version <version-id>")
936
+ .description("read a specific version by id")
937
+ .option("--raw", "suppress server-injected $version (and other $* system properties) in the output", false)
938
+ .action(function (versionId) {
939
+ const localOpts = this.opts();
940
+ const globals = this.optsWithGlobals();
941
+ let repo;
942
+ try {
943
+ repo = resolveRepoSlug(globals);
944
+ }
945
+ catch (err) {
946
+ reportError(err);
947
+ }
948
+ withClient(this, async (client, opts) => {
949
+ const result = await client.docs.get_version(repo, versionId, {
950
+ raw: localOpts.raw === true,
951
+ });
952
+ emit(result, opts, renderVersionAsMarkdown(result));
953
+ }).catch(reportError);
954
+ });
955
+ docs
956
+ .command("history <path>")
957
+ .description("list versions of a document newest-first (subsumed by `mrplex history`)")
958
+ .option("--limit <n>", "limit to N most-recent (positive integer)", parsePositiveInt)
959
+ .action(function (path) {
960
+ const localOpts = this.opts();
961
+ const globals = this.optsWithGlobals();
962
+ let repo;
963
+ try {
964
+ repo = resolveRepoSlug(globals);
965
+ }
966
+ catch (err) {
967
+ reportError(err);
968
+ }
969
+ withClient(this, async (client, opts) => {
970
+ // A single literal path is the old docs.history; route through the
971
+ // unified scoped walk (§3.5).
972
+ const result = await client.history.list({ repo, path, limit: localOpts.limit });
973
+ emit(result, opts, renderHistoryTable(result));
974
+ }).catch(reportError);
975
+ });
976
+ docs
977
+ .command("diff <path>")
978
+ .description("unified diff between two versions of a document (§4.3)")
979
+ .requiredOption("--from <version-id>", "source version id")
980
+ .requiredOption("--to <version-id>", "target version id")
981
+ .action(function (path) {
982
+ const localOpts = this.opts();
983
+ const globals = this.optsWithGlobals();
984
+ let repo;
985
+ try {
986
+ repo = resolveRepoSlug(globals);
987
+ }
988
+ catch (err) {
989
+ reportError(err);
990
+ }
991
+ withClient(this, async (client, opts) => {
992
+ const result = await client.docs.diff(repo, path, localOpts.from, localOpts.to);
993
+ if (opts.json) {
994
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
995
+ }
996
+ else {
997
+ // Raw patch on stdout for `patch -p0`-friendly piping.
998
+ process.stdout.write(result.patch.endsWith("\n") ? result.patch : `${result.patch}\n`);
999
+ }
1000
+ }).catch(reportError);
1001
+ });
1002
+ docs
1003
+ .command("create <path>")
1004
+ .description("create a new document (fails if the path is taken)")
1005
+ .option("--from-file <file>", "read the markdown from a file or '-' for stdin")
1006
+ .action(function (path) {
1007
+ const localOpts = this.opts();
1008
+ const globals = this.optsWithGlobals();
1009
+ let repo;
1010
+ try {
1011
+ repo = resolveRepoSlug(globals);
1012
+ }
1013
+ catch (err) {
1014
+ reportError(err);
1015
+ }
1016
+ withClient(this, async (client, opts) => {
1017
+ const { frontmatter_raw, body } = readDocumentInput(localOpts.fromFile);
1018
+ const result = await client.docs.create(repo, path, { frontmatter_raw, body });
1019
+ emitVersionWrite(result, opts);
1020
+ }).catch(reportError);
1021
+ });
1022
+ docs
1023
+ .command("put <path>")
1024
+ .description("update or move a document — path may differ from prev's path")
1025
+ .option("--prev <version-id>", "current version id (from get / history) — optional if the input's frontmatter carries `$version: <id>`")
1026
+ .option("--from-file <file>", "read the markdown from a file or '-' for stdin")
1027
+ .action(function (path) {
1028
+ const localOpts = this.opts();
1029
+ const globals = this.optsWithGlobals();
1030
+ let repo;
1031
+ try {
1032
+ repo = resolveRepoSlug(globals);
1033
+ }
1034
+ catch (err) {
1035
+ reportError(err);
1036
+ }
1037
+ withClient(this, async (client, opts) => {
1038
+ const input = {};
1039
+ let embeddedVersion;
1040
+ if (localOpts.fromFile) {
1041
+ const parsed = readDocumentInput(localOpts.fromFile);
1042
+ const { raw: cleaned, props } = extractSystemProperties(parsed.frontmatter_raw);
1043
+ input.frontmatter_raw = cleaned;
1044
+ input.body = parsed.body;
1045
+ if (typeof props.version === "string" && props.version.length > 0) {
1046
+ embeddedVersion = props.version;
1047
+ }
1048
+ }
1049
+ const prev = localOpts.prev ?? embeddedVersion;
1050
+ if (prev === undefined) {
1051
+ const err = new Error("no prev version — pass --prev, or provide `$version: <id>` in the input frontmatter");
1052
+ err.code = "cli_usage";
1053
+ throw err;
1054
+ }
1055
+ const result = await client.docs.put(repo, prev, path, input);
1056
+ emitVersionWrite(result, opts);
1057
+ }).catch(reportError);
1058
+ });
1059
+ docs
1060
+ .command("delete")
1061
+ .description("delete a document — moves to :deleted/… (idempotent)")
1062
+ .requiredOption("--prev <version-id>", "current version id (from get / history)")
1063
+ .action(function () {
1064
+ const localOpts = this.opts();
1065
+ const globals = this.optsWithGlobals();
1066
+ let repo;
1067
+ try {
1068
+ repo = resolveRepoSlug(globals);
1069
+ }
1070
+ catch (err) {
1071
+ reportError(err);
1072
+ }
1073
+ withClient(this, async (client, opts) => {
1074
+ const result = await client.docs.delete(repo, localOpts.prev);
1075
+ emitVersionWrite(result, opts);
1076
+ }).catch(reportError);
1077
+ });
1078
+ docs
1079
+ .command("mv <to-path>")
1080
+ .description("move a document — sugar for put at <to-path> with body unchanged")
1081
+ .requiredOption("--prev <version-id>", "current version id (from get / history)")
1082
+ .action(function (toPath) {
1083
+ const localOpts = this.opts();
1084
+ const globals = this.optsWithGlobals();
1085
+ let repo;
1086
+ try {
1087
+ repo = resolveRepoSlug(globals);
1088
+ }
1089
+ catch (err) {
1090
+ reportError(err);
1091
+ }
1092
+ withClient(this, async (client, opts) => {
1093
+ const result = await client.docs.put(repo, localOpts.prev, toPath, {});
1094
+ emitVersionWrite(result, opts);
1095
+ }).catch(reportError);
1096
+ });
1097
+ // -------- query --------
1098
+ program
1099
+ .command("query")
1100
+ .description("search documents — CEL filter + FTS text + semantic (design §5)")
1101
+ .option("-r, --repo <slug-or-glob>", "repo slug or glob; repeat the flag to query multiple (default: all in scope)", (value, prev) => [...(prev ?? []), value])
1102
+ .option("--filter <expr>", "CEL filter expression")
1103
+ .option("--path <glob>", "gitignore-style path glob (bare name → any depth, leading `/` → root)")
1104
+ .option("--text <query>", "FTS5 query over body")
1105
+ .option("--semantic <query>", "semantic search via embeddings (§5.1); requires an embed hook")
1106
+ .option("--limit <n>", "max results (positive integer; default 50)", parsePositiveInt)
1107
+ .option("-s, --select <field>", "field to project onto each hit ($path/$repo/$body/… or a bare frontmatter key; " +
1108
+ "default $path); repeat to add more", (value, prev) => [...(prev ?? []), value])
1109
+ .option("--include-hidden", "surface .-prefixed paths", false)
1110
+ .option("--include-system", "surface :-prefixed (deleted, etc.) paths", false)
1111
+ .action(function () {
1112
+ const localOpts = this.opts();
1113
+ withClient(this, async (client, opts) => {
1114
+ const result = await client.query({
1115
+ repo: localOpts.repo,
1116
+ filter: combinePathAndFilter(localOpts.path, localOpts.filter),
1117
+ text: localOpts.text,
1118
+ semantic: localOpts.semantic,
1119
+ limit: localOpts.limit,
1120
+ select: localOpts.select,
1121
+ include_hidden: localOpts.includeHidden,
1122
+ include_system: localOpts.includeSystem,
1123
+ });
1124
+ emit(result, opts, renderQueryTable(result));
1125
+ }).catch(reportError);
1126
+ });
1127
+ // -------- graph --------
1128
+ program
1129
+ .command("graph")
1130
+ .description("explore how documents connect — BFS over the link graph (docs/graph-plan.md)")
1131
+ .option("-r, --repo <slug>", "repo slug (exactly one; links are repo-local; default: global -r/MRPLEX_REPO)")
1132
+ .requiredOption("--roots <path-or-glob>", "root path/glob; repeat to add more", (value, prev) => [...(prev ?? []), value])
1133
+ .option("--direction <lens>", "out | in | both (default both)")
1134
+ .option("--degrees <n>", "max hops from the nearest root (default 1; 0 = roots only)", parsePositiveIntOrZero)
1135
+ .option("--fields <field>", "restrict traversal + output links to this field ($body valid); repeat to add more", (value, prev) => [...(prev ?? []), value])
1136
+ .option("--filter <expr>", "CEL visibility filter; supports the graph-only $degrees intrinsic")
1137
+ .option("--select <key>", "frontmatter key to project onto documents (default title); repeat to add more", (value, prev) => [...(prev ?? []), value])
1138
+ .option("--max-documents <n>", "soft budget on documents (default 100)", parsePositiveInt)
1139
+ .option("--render <fmt>", "summary | yaml | mermaid | json (default summary)", "summary")
1140
+ .action(function () {
1141
+ const localOpts = this.opts();
1142
+ const globals = this.optsWithGlobals();
1143
+ const repo = localOpts.repo ?? resolveRepoSlug(globals);
1144
+ withClient(this, async (client, opts) => {
1145
+ const result = await client.graph({
1146
+ repo,
1147
+ roots: localOpts.roots,
1148
+ direction: localOpts.direction,
1149
+ degrees: localOpts.degrees,
1150
+ fields: localOpts.fields,
1151
+ filter: localOpts.filter,
1152
+ select: localOpts.select,
1153
+ max_documents: localOpts.maxDocuments,
1154
+ });
1155
+ // `--render` selects the presentation (its native idiom); `--json` (or
1156
+ // `--render json`) still yields the raw structured payload via emit.
1157
+ if (opts.json || localOpts.render === "json") {
1158
+ emit(result, { ...opts, json: true }, "");
1159
+ return;
1160
+ }
1161
+ const text = localOpts.render === "yaml"
1162
+ ? renderGraphYaml(result)
1163
+ : localOpts.render === "mermaid"
1164
+ ? renderGraphMermaid(result)
1165
+ : renderGraphSummary(result);
1166
+ emit(result, opts, text);
1167
+ }).catch(reportError);
1168
+ });
1169
+ // -------- tail (sync/history plan §3.6) --------
1170
+ // The reference change-feed consumer: one VersionRef per line as NDJSON.
1171
+ // Crash-resume is `tail --since <last-line-version_id>`. `--follow N` sits at
1172
+ // the live tip, re-polling every N seconds on a short/empty page — the proving
1173
+ // ground for the leading-gap heal path.
1174
+ program
1175
+ .command("tail")
1176
+ .description("stream the global change feed as NDJSON (sync/history plan §3.6)")
1177
+ .option("--since <version-id>", 'resume cursor; omitted or "" starts from the beginning')
1178
+ .option("--follow <seconds>", "poll interval in seconds; stay at the live tip re-polling on empty pages", parsePositiveInt)
1179
+ .option("--limit <n>", "max refs per page (positive integer)", parsePositiveInt)
1180
+ .action(function () {
1181
+ const localOpts = this.opts();
1182
+ // The optional repo filter rides the global -r/--repo (or MRPLEX_REPO /
1183
+ // config); an unset repo tails every repo in scope.
1184
+ const globals = this.optsWithGlobals();
1185
+ withClient(this, async (client) => {
1186
+ let cursor = localOpts.since ?? "";
1187
+ const pollOnce = async () => {
1188
+ const page = await client.history.since({
1189
+ after_version: cursor,
1190
+ repo: globals.repo,
1191
+ limit: localOpts.limit,
1192
+ });
1193
+ for (const ref of page.refs) {
1194
+ process.stdout.write(`${JSON.stringify(ref)}\n`);
1195
+ }
1196
+ cursor = page.next_since;
1197
+ return page.refs.length;
1198
+ };
1199
+ if (localOpts.follow === undefined) {
1200
+ // One-shot: drain the currently-safe feed in pages, then stop.
1201
+ let n = await pollOnce();
1202
+ while (n > 0)
1203
+ n = await pollOnce();
1204
+ return;
1205
+ }
1206
+ // Follow: never terminate; sleep between polls (including empty ones).
1207
+ const intervalMs = localOpts.follow * 1000;
1208
+ for (;;) {
1209
+ const n = await pollOnce();
1210
+ if (n === 0)
1211
+ await sleep(intervalMs);
1212
+ }
1213
+ }).catch(reportError);
1214
+ });
1215
+ // -------- history (sync/history plan §3.5) --------
1216
+ program
1217
+ .command("history [path-glob]")
1218
+ .description("scoped, document-spanning version history — glob + --ever (§3.5)")
1219
+ .option("--ever", "include documents that moved away or were deleted", false)
1220
+ .option("--since <version-id>", "exclusive lower version-id bound")
1221
+ .option("--until <version-id>", "inclusive upper version-id bound")
1222
+ .option("--order <dir>", "asc (oldest-first) | desc (newest-first, default)")
1223
+ .option("--limit <n>", "max versions (positive integer)", parsePositiveInt)
1224
+ .action(function (pathGlob) {
1225
+ const localOpts = this.opts();
1226
+ const globals = this.optsWithGlobals();
1227
+ let repo;
1228
+ try {
1229
+ repo = resolveRepoSlug(globals);
1230
+ }
1231
+ catch (err) {
1232
+ reportError(err);
1233
+ }
1234
+ const order = localOpts.order === "asc" ? "asc" : localOpts.order === "desc" ? "desc" : undefined;
1235
+ withClient(this, async (client, opts) => {
1236
+ const result = await client.history.list({
1237
+ repo,
1238
+ path: pathGlob,
1239
+ ever: localOpts.ever,
1240
+ since: localOpts.since,
1241
+ until: localOpts.until,
1242
+ order,
1243
+ limit: localOpts.limit,
1244
+ });
1245
+ emit(result, opts, renderHistoryTable(result));
1246
+ }).catch(reportError);
1247
+ });
1248
+ // -------- links (§11.2) --------
1249
+ const links = program.command("links").description("link index — backfill / stale / repair");
1250
+ // Repo comes from the global -r/--repo (or MRPLEX_REPO / config), the same
1251
+ // source `docs *` commands use — so `mrplex -r notes links stale`.
1252
+ links
1253
+ .command("backfill")
1254
+ .description("rebuild the link index for a repo (backfill / after a link-config change)")
1255
+ .action(function () {
1256
+ const globals = this.optsWithGlobals();
1257
+ const repo = resolveRepoSlug(globals);
1258
+ withClient(this, async (client, opts) => {
1259
+ const result = await client.links.backfill(repo);
1260
+ emit(result, opts, `backfill ${repo}: documents=${result.documents} edges=${result.edges}`);
1261
+ }).catch(reportError);
1262
+ });
1263
+ links
1264
+ .command("stale")
1265
+ .description("list live docs whose written link text no longer matches the target's path")
1266
+ .action(function () {
1267
+ const globals = this.optsWithGlobals();
1268
+ const repo = resolveRepoSlug(globals);
1269
+ withClient(this, async (client, opts) => {
1270
+ const result = await client.links.stale(repo);
1271
+ const text = result.length
1272
+ ? result.map((r) => `${r.source_path}: "${r.written}" → "${r.current}"`).join("\n")
1273
+ : "no stale links";
1274
+ emit(result, opts, text);
1275
+ }).catch(reportError);
1276
+ });
1277
+ links
1278
+ .command("repair")
1279
+ .description("rewrite stale link text as optimistic docs.put (conflicts skipped)")
1280
+ .option("--dry-run", "report what would change without writing", false)
1281
+ .action(function () {
1282
+ const localOpts = this.opts();
1283
+ const globals = this.optsWithGlobals();
1284
+ const repo = resolveRepoSlug(globals);
1285
+ withClient(this, async (client, opts) => {
1286
+ const result = await client.links.repair(repo, { dry_run: localOpts.dryRun });
1287
+ const prefix = result.dry_run ? "[dry-run] " : "";
1288
+ const lines = [
1289
+ `${prefix}repaired ${result.repaired.length} doc(s), skipped ${result.skipped.length}`,
1290
+ ...result.repaired.map((r) => ` ~ ${r.path} (${r.edges} link(s))`),
1291
+ ...result.skipped.map((s) => ` ! ${s.path}: ${s.reason}`),
1292
+ ];
1293
+ emit(result, opts, lines.join("\n"));
1294
+ }).catch(reportError);
1295
+ });
1296
+ // -------- embed --------
1297
+ // Embed commands are LOCAL-mode only (bypass the client seam like
1298
+ // `bootstrap` and `serve`): backfill drives the worker directly
1299
+ // against storage, and status reads backlog+chunks tables. Running
1300
+ // against a remote server means running these commands on that host
1301
+ // — same as bootstrap.
1302
+ const embed = program.command("embed").description("embedding worker + backlog");
1303
+ const embedBackfillCmd = embed
1304
+ .command("backfill")
1305
+ .description("re-chunk + re-embed current versions missing chunks (§5.3)");
1306
+ addEmbedCliOptions(embedBackfillCmd);
1307
+ embedBackfillCmd.action(function () {
1308
+ const localOpts = this.opts();
1309
+ const gopts = this.optsWithGlobals();
1310
+ (async () => {
1311
+ try {
1312
+ const repo = resolveRepoSlug(gopts);
1313
+ const embedCfg = resolveEmbedConfig(embedFlagInputsFromCli(localOpts));
1314
+ if (embedCfg.kind === "none") {
1315
+ process.stderr.write("embed backfill: no hook configured — set --embedder (or MRPLEX_EMBEDDER)\n");
1316
+ process.exit(1);
1317
+ }
1318
+ const hook = createHookFromConfig(embedCfg);
1319
+ if (!hook) {
1320
+ process.stderr.write("embed backfill: unreachable — missing hook\n");
1321
+ process.exit(1);
1322
+ return;
1323
+ }
1324
+ const storage = await openStorage(resolveDatabase(gopts));
1325
+ const worker = createWorker({ storage, hook });
1326
+ try {
1327
+ const report = await backfillRepo(storage, repo, worker, (m) => process.stderr.write(`${m}\n`));
1328
+ if (gopts.json) {
1329
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
1330
+ }
1331
+ else {
1332
+ process.stdout.write(`backfill ${repo}: enqueued=${report.enqueued} processed=${report.processed} failed=${report.failed} skipped=${report.skipped}\n`);
1333
+ }
1334
+ if (report.failed > 0)
1335
+ process.exit(1);
1336
+ }
1337
+ finally {
1338
+ await worker.stop();
1339
+ await storage.close();
1340
+ }
1341
+ }
1342
+ catch (err) {
1343
+ reportError(err);
1344
+ }
1345
+ })();
1346
+ });
1347
+ embed
1348
+ .command("status")
1349
+ .description("inspect the embedding backlog (m4-plan §5 decision 6)")
1350
+ .action(function () {
1351
+ const gopts = this.optsWithGlobals();
1352
+ (async () => {
1353
+ try {
1354
+ const storage = await openStorage(resolveDatabase(gopts));
1355
+ try {
1356
+ const now = new Date().toISOString();
1357
+ const status = await storage.backlog_status(now);
1358
+ if (gopts.json) {
1359
+ process.stdout.write(`${JSON.stringify(status, null, 2)}\n`);
1360
+ }
1361
+ else {
1362
+ process.stdout.write(`pending: ${status.pending}\ndue: ${status.due}\nfailing: ${status.failing}\n`);
1363
+ if (status.oldest_next_retry_at) {
1364
+ process.stdout.write(`oldest retry: ${status.oldest_next_retry_at}\n`);
1365
+ }
1366
+ if (status.models.length > 0) {
1367
+ process.stdout.write("models:\n");
1368
+ for (const m of status.models) {
1369
+ process.stdout.write(` ${m.model} chunks=${m.chunk_count}\n`);
1370
+ }
1371
+ }
1372
+ if (status.recent_errors.length > 0) {
1373
+ process.stdout.write("recent errors:\n");
1374
+ for (const e of status.recent_errors) {
1375
+ process.stdout.write(` v${e.version_id}: ${e.last_error}\n`);
1376
+ }
1377
+ }
1378
+ }
1379
+ }
1380
+ finally {
1381
+ await storage.close();
1382
+ }
1383
+ }
1384
+ catch (err) {
1385
+ reportError(err);
1386
+ }
1387
+ })();
1388
+ });
1389
+ // -------- sync (sync/history plan §4) --------
1390
+ const syncCmd = program
1391
+ .command("sync <root>")
1392
+ .description("two-way sync between a local vault and a mrplex repo (§4)")
1393
+ .addHelpText("after", "\nNote: on a repo created before migration 0002, run `mrplex hash backfill` first.\n" +
1394
+ "Until every version has a stored $content_hash, a clean local copy that lacks\n" +
1395
+ "sync intrinsics can be parked as a conflict instead of adopted (§2.6).\n\n" +
1396
+ "Note: --embedder only applies in local (--database) mode. When\n" +
1397
+ "syncing against a --server, embeddings are the server's responsibility and\n" +
1398
+ "this flag is ignored.")
1399
+ .option("--once", "run startup reconciliation once, then exit (no watcher)", false)
1400
+ .option("--interval <ms>", "feed poll interval in ms (daemon; default 5000)", parsePositiveInt)
1401
+ .option("--debounce <ms>", "burst debounce in ms (daemon; default 5000)", parsePositiveInt)
1402
+ .option("--settle <ms>", "skip files younger than this many ms (partial saves)", parsePositiveInt)
1403
+ .option("--include <glob>", "include glob (default **/*.md); repeat to add more", (value, prev) => [...(prev ?? []), value])
1404
+ .option("--exclude <glob>", "exclude glob (wins over include); repeat to add more", (value, prev) => [...(prev ?? []), value])
1405
+ .option("--dry-run", "report the actions a reconciliation would take, changing nothing", false)
1406
+ .option("-v, --verbose", "log each action to stderr", false);
1407
+ addEmbedCliOptions(syncCmd);
1408
+ syncCmd.action(function (root) {
1409
+ const localOpts = this.opts();
1410
+ const embedFlags = embedFlagInputsFromCli(localOpts);
1411
+ const globals = this.optsWithGlobals();
1412
+ let repo;
1413
+ try {
1414
+ repo = resolveRepoSlug(globals);
1415
+ }
1416
+ catch (err) {
1417
+ reportError(err);
1418
+ }
1419
+ const verboseLog = localOpts.verbose
1420
+ ? (m) => process.stderr.write(`${m}\n`)
1421
+ : undefined;
1422
+ if (localOpts.once) {
1423
+ withClient(this, async (client, opts) => {
1424
+ const source = resolveSyncSource(globals);
1425
+ const report = await syncOnce(client, {
1426
+ root,
1427
+ repo,
1428
+ ...source,
1429
+ include: localOpts.include,
1430
+ exclude: localOpts.exclude,
1431
+ dryRun: localOpts.dryRun,
1432
+ log: verboseLog,
1433
+ });
1434
+ const changed = report.actions.filter((a) => a.verdict !== "clean" && a.verdict !== "skip").length;
1435
+ emit(report, opts, `sync ${repo} @ ${root}: through=${report.through_version} ` +
1436
+ `actions=${changed} feed=${report.feed_applied}${localOpts.dryRun ? " (dry-run)" : ""}`);
1437
+ }, embedFlags).catch(reportError);
1438
+ return;
1439
+ }
1440
+ // Daemon: run until interrupted (Ctrl-C). The client stays open; SIGINT/
1441
+ // SIGTERM stop the daemon and close the transport cleanly.
1442
+ (async () => {
1443
+ const client = await openClient(globals, embedFlags);
1444
+ const daemon = startDaemon(client, {
1445
+ root,
1446
+ repo,
1447
+ ...resolveSyncSource(globals),
1448
+ include: localOpts.include,
1449
+ exclude: localOpts.exclude,
1450
+ intervalMs: localOpts.interval,
1451
+ debounceMs: localOpts.debounce,
1452
+ settleMs: localOpts.settle,
1453
+ log: verboseLog,
1454
+ });
1455
+ const shutdown = async () => {
1456
+ await daemon.stop();
1457
+ await client.close();
1458
+ process.exit(0);
1459
+ };
1460
+ process.on("SIGINT", shutdown);
1461
+ process.on("SIGTERM", shutdown);
1462
+ try {
1463
+ await daemon.ready;
1464
+ process.stderr.write(`sync ${repo} @ ${root}: watching (Ctrl-C to stop)\n`);
1465
+ }
1466
+ catch (err) {
1467
+ await daemon.stop();
1468
+ await client.close();
1469
+ reportError(err);
1470
+ }
1471
+ })();
1472
+ });
1473
+ // -------- hash (sync/history plan §2.6) --------
1474
+ const hash = program.command("hash").description("content-hash maintenance");
1475
+ hash
1476
+ .command("backfill")
1477
+ .description("compute $content_hash for versions written before migration 0002")
1478
+ .action(function () {
1479
+ // The optional repo filter rides the global -r/--repo (or MRPLEX_REPO /
1480
+ // config); an unset repo backfills every repo.
1481
+ const gopts = this.optsWithGlobals();
1482
+ (async () => {
1483
+ const storage = await openStorage(resolveDatabase(gopts));
1484
+ try {
1485
+ let repoId;
1486
+ if (gopts.repo !== undefined) {
1487
+ const repo = await storage.repos_by_slug(gopts.repo);
1488
+ if (!repo)
1489
+ throw new KernelError("repo_not_found", { repo: gopts.repo });
1490
+ repoId = repo.id;
1491
+ }
1492
+ const report = await backfillContentHashes(storage, { repo_id: repoId });
1493
+ if (gopts.json) {
1494
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
1495
+ }
1496
+ else {
1497
+ const scope = gopts.repo ?? "all repos";
1498
+ process.stdout.write(`hash backfill ${scope}: hashed=${report.hashed}\n`);
1499
+ }
1500
+ }
1501
+ catch (err) {
1502
+ reportError(err);
1503
+ }
1504
+ finally {
1505
+ await storage.close();
1506
+ }
1507
+ })();
1508
+ });
1509
+ return program;
1510
+ }
1511
+ const program = buildProgram();
1512
+ try {
1513
+ program.parse(process.argv);
1514
+ }
1515
+ catch (err) {
1516
+ reportError(err);
1517
+ }
1518
+ //# sourceMappingURL=main.js.map