geneseed 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (337) hide show
  1. package/CHANGELOG.md +938 -0
  2. package/DESIGN.md +1423 -0
  3. package/LICENSE +21 -0
  4. package/QUICKSTART.md +48 -0
  5. package/README.md +336 -0
  6. package/SETUP.md +1027 -0
  7. package/SHIPPED.md +70 -0
  8. package/adapters/bob/README.md +100 -0
  9. package/adapters/claude-code/README.md +101 -0
  10. package/adapters/claude-code/settings.json +47 -0
  11. package/adapters/claude-code/skills/geneseed/SKILL.md +72 -0
  12. package/adapters/copilot/README.md +76 -0
  13. package/adapters/opencode/GLOBAL-HARNESS-SPEC.md +434 -0
  14. package/adapters/opencode/HOW-OPENCODE-LOADS.md +158 -0
  15. package/adapters/opencode/README.md +582 -0
  16. package/adapters/opencode/agents/orchestrator.md +25 -0
  17. package/adapters/opencode/opencode.json +44 -0
  18. package/adapters/opencode/plugins/geneseed-activity.js +430 -0
  19. package/adapters/opencode/plugins/geneseed-context.js +876 -0
  20. package/adapters/opencode/plugins/geneseed-guard.js +290 -0
  21. package/adapters/opencode/plugins/geneseed-learn.js +396 -0
  22. package/adapters/opencode/plugins/geneseed-notify.js +142 -0
  23. package/adapters/opencode/plugins/geneseed-ponytail.js +148 -0
  24. package/adapters/opencode/plugins/geneseed-workflow.js +183 -0
  25. package/adapters/opencode/workflows/_runtime.js +277 -0
  26. package/adapters/opencode/workflows/council.js +64 -0
  27. package/adapters/opencode/workflows/dispatch.js +89 -0
  28. package/adapters/opencode/workflows/research-plan-implement.js +43 -0
  29. package/adapters/opencode/workflows/review.js +57 -0
  30. package/bin/geneseed-cli.mjs +412 -0
  31. package/bin/geneseed-hook.mjs +129 -0
  32. package/bin/geneseed.mjs +1086 -0
  33. package/docs/declined.md +149 -0
  34. package/docs/limits.md +270 -0
  35. package/docs/opencode-plugin-setup.md +64 -0
  36. package/docs/token-footprint.md +68 -0
  37. package/docs/web/_groups.json +45 -0
  38. package/docs/web/about.md +6 -0
  39. package/docs/web/adapters-claude-code.md +8 -0
  40. package/docs/web/adapters-opencode-loads.md +8 -0
  41. package/docs/web/adapters-opencode-spec.md +8 -0
  42. package/docs/web/adapters-opencode.md +8 -0
  43. package/docs/web/agents.md +8 -0
  44. package/docs/web/authoring.md +11 -0
  45. package/docs/web/autostart.md +9 -0
  46. package/docs/web/claude-hooks.md +36 -0
  47. package/docs/web/cli.md +6 -0
  48. package/docs/web/collaboration.md +27 -0
  49. package/docs/web/configure-wiki.md +9 -0
  50. package/docs/web/design.md +7 -0
  51. package/docs/web/env-knobs.md +9 -0
  52. package/docs/web/exclusions.md +26 -0
  53. package/docs/web/first-session.md +28 -0
  54. package/docs/web/footprint.md +31 -0
  55. package/docs/web/foreman-mode.md +33 -0
  56. package/docs/web/glossary.md +6 -0
  57. package/docs/web/harnesses.md +28 -0
  58. package/docs/web/headless.md +9 -0
  59. package/docs/web/ingest-docs.md +9 -0
  60. package/docs/web/install-paths.md +9 -0
  61. package/docs/web/install-quick.md +35 -0
  62. package/docs/web/lsp-overview.md +37 -0
  63. package/docs/web/mcp-claude-code.md +10 -0
  64. package/docs/web/mcp-filesystem.md +9 -0
  65. package/docs/web/mcp-gitlab.md +9 -0
  66. package/docs/web/mcp-markitdown.md +9 -0
  67. package/docs/web/mcp-overview.md +34 -0
  68. package/docs/web/mcp-trouble.md +9 -0
  69. package/docs/web/mcp-verify.md +9 -0
  70. package/docs/web/memory.md +7 -0
  71. package/docs/web/migrate.md +39 -0
  72. package/docs/web/model.md +20 -0
  73. package/docs/web/notebook.md +7 -0
  74. package/docs/web/pipelines.md +24 -0
  75. package/docs/web/plugin-activity.md +23 -0
  76. package/docs/web/plugin-context.md +26 -0
  77. package/docs/web/plugin-guard.md +24 -0
  78. package/docs/web/plugin-learn.md +27 -0
  79. package/docs/web/plugin-notify.md +24 -0
  80. package/docs/web/plugin-ponytail.md +23 -0
  81. package/docs/web/plugin-setup.md +7 -0
  82. package/docs/web/plugin-workflow.md +24 -0
  83. package/docs/web/plugins.md +17 -0
  84. package/docs/web/project-context.md +9 -0
  85. package/docs/web/rules.md +8 -0
  86. package/docs/web/run-anywhere.md +9 -0
  87. package/docs/web/self-improve.md +8 -0
  88. package/docs/web/setup-choices.md +39 -0
  89. package/docs/web/skills.md +8 -0
  90. package/docs/web/themes-author.md +8 -0
  91. package/docs/web/themes.md +9 -0
  92. package/docs/web/token-footprint.md +7 -0
  93. package/docs/web/trouble.md +38 -0
  94. package/docs/web/upgrade.md +9 -0
  95. package/docs/web/verify.md +30 -0
  96. package/docs/web/wiki.md +7 -0
  97. package/docs/web/worktree.md +10 -0
  98. package/docs/web-ui.md +144 -0
  99. package/docs/wiki.md +43 -0
  100. package/geneseed +39 -0
  101. package/geneseed.cmd +34 -0
  102. package/harness.config.json +6 -0
  103. package/js/anim.mjs +326 -0
  104. package/js/catalog.mjs +112 -0
  105. package/js/checkout.mjs +88 -0
  106. package/js/cli-table.json +1034 -0
  107. package/js/cli.mjs +498 -0
  108. package/js/diff.mjs +384 -0
  109. package/js/doctor.mjs +1364 -0
  110. package/js/emit.mjs +1373 -0
  111. package/js/excludes.mjs +271 -0
  112. package/js/generate.mjs +379 -0
  113. package/js/hooks.mjs +1026 -0
  114. package/js/hosts.mjs +253 -0
  115. package/js/installs.mjs +378 -0
  116. package/js/inventory.mjs +175 -0
  117. package/js/lib/fs.mjs +913 -0
  118. package/js/lib/proc.mjs +28 -0
  119. package/js/lib/udiff.mjs +275 -0
  120. package/js/link.mjs +286 -0
  121. package/js/mcp.mjs +335 -0
  122. package/js/memory.mjs +108 -0
  123. package/js/menu.mjs +118 -0
  124. package/js/migrate.mjs +251 -0
  125. package/js/native.mjs +426 -0
  126. package/js/opencode.mjs +325 -0
  127. package/js/registry.mjs +110 -0
  128. package/js/render.mjs +282 -0
  129. package/js/settings.mjs +924 -0
  130. package/js/setup.mjs +492 -0
  131. package/js/status.mjs +281 -0
  132. package/js/themes.mjs +195 -0
  133. package/js/tui.mjs +444 -0
  134. package/js/uninstall.mjs +1137 -0
  135. package/js/update.mjs +910 -0
  136. package/js/web/actions.mjs +868 -0
  137. package/js/web/activity.mjs +284 -0
  138. package/js/web/api.mjs +873 -0
  139. package/js/web/docs.mjs +531 -0
  140. package/js/web/graph.mjs +0 -0
  141. package/js/web/jobs.mjs +480 -0
  142. package/js/web/server.mjs +1135 -0
  143. package/package.json +54 -0
  144. package/registry.json +474 -0
  145. package/src/AGENT.md.tmpl +390 -0
  146. package/src/agents/_template.md +76 -0
  147. package/src/agents/advocate.md +36 -0
  148. package/src/agents/architect.md +55 -0
  149. package/src/agents/developer.md +66 -0
  150. package/src/agents/docs.md +54 -0
  151. package/src/agents/empiricist.md +39 -0
  152. package/src/agents/explorer.md +54 -0
  153. package/src/agents/framer.md +36 -0
  154. package/src/agents/historian.md +51 -0
  155. package/src/agents/operator.md +37 -0
  156. package/src/agents/pragmatist.md +37 -0
  157. package/src/agents/reviewer.md +52 -0
  158. package/src/agents/security.md +57 -0
  159. package/src/agents/skeptic.md +48 -0
  160. package/src/agents/steward.md +36 -0
  161. package/src/agents/tester.md +54 -0
  162. package/src/agents/user-advocate.md +36 -0
  163. package/src/agents/visionary.md +36 -0
  164. package/src/laws/universal.md +387 -0
  165. package/src/memory/README.md +109 -0
  166. package/src/memory/gitignore +6 -0
  167. package/src/modes/README.md +26 -0
  168. package/src/modes/direct.md +2 -0
  169. package/src/modes/foreman.md +19 -0
  170. package/src/notebook/README.md +39 -0
  171. package/src/notebook/gitignore +7 -0
  172. package/src/postures/README.md +31 -0
  173. package/src/postures/artisan.md +14 -0
  174. package/src/postures/assistant.md +12 -0
  175. package/src/postures/expert.md +11 -0
  176. package/src/postures/mentor.md +11 -0
  177. package/src/postures/peer.md +12 -0
  178. package/src/skills/_template.md +47 -0
  179. package/src/skills/brainstorm.md +28 -0
  180. package/src/skills/clarify.md +28 -0
  181. package/src/skills/codebase-design.md +44 -0
  182. package/src/skills/commit.md +48 -0
  183. package/src/skills/council.md +34 -0
  184. package/src/skills/crash-course.md +28 -0
  185. package/src/skills/daydream/.claude-plugin/plugin.json +9 -0
  186. package/src/skills/daydream/SKILL.md +74 -0
  187. package/src/skills/daydream/VENDOR.md +25 -0
  188. package/src/skills/daydream/critic-prompt.md +38 -0
  189. package/src/skills/daydream/instructions.md +422 -0
  190. package/src/skills/daydream/synthesizer-prompt.md +41 -0
  191. package/src/skills/debug.md +37 -0
  192. package/src/skills/decode.md +28 -0
  193. package/src/skills/develop.md +29 -0
  194. package/src/skills/document-project.md +79 -0
  195. package/src/skills/domain-modeling.md +38 -0
  196. package/src/skills/drill.md +28 -0
  197. package/src/skills/feynman.md +26 -0
  198. package/src/skills/forge-mcp.md +48 -0
  199. package/src/skills/fresh-eyes.md +29 -0
  200. package/src/skills/frontend-design.md +58 -0
  201. package/src/skills/gap-detector.md +27 -0
  202. package/src/skills/geneseed-code-review.md +33 -0
  203. package/src/skills/geneseed.md +29 -0
  204. package/src/skills/git-archaeology.md +47 -0
  205. package/src/skills/git-rescue.md +68 -0
  206. package/src/skills/handoff.md +29 -0
  207. package/src/skills/herdr.md +164 -0
  208. package/src/skills/ingest.md +49 -0
  209. package/src/skills/learning-path.md +26 -0
  210. package/src/skills/migrate.md +45 -0
  211. package/src/skills/opencode-theme.md +52 -0
  212. package/src/skills/parallel-agents.md +28 -0
  213. package/src/skills/pipeline.md +52 -0
  214. package/src/skills/plan.md +45 -0
  215. package/src/skills/ponytail.md +29 -0
  216. package/src/skills/profile.md +58 -0
  217. package/src/skills/prose.md +42 -0
  218. package/src/skills/prototype.md +44 -0
  219. package/src/skills/react-view-transitions/AGENTS.md +955 -0
  220. package/src/skills/react-view-transitions/README.md +42 -0
  221. package/src/skills/react-view-transitions/SKILL.md +320 -0
  222. package/src/skills/react-view-transitions/VENDOR.md +16 -0
  223. package/src/skills/react-view-transitions/metadata.json +12 -0
  224. package/src/skills/react-view-transitions/references/css-recipes.md +242 -0
  225. package/src/skills/react-view-transitions/references/implementation.md +182 -0
  226. package/src/skills/react-view-transitions/references/nextjs.md +176 -0
  227. package/src/skills/react-view-transitions/references/patterns.md +262 -0
  228. package/src/skills/refactor.md +32 -0
  229. package/src/skills/release.md +43 -0
  230. package/src/skills/repo-map.md +36 -0
  231. package/src/skills/research.md +42 -0
  232. package/src/skills/review-response.md +36 -0
  233. package/src/skills/roast-me.md +28 -0
  234. package/src/skills/rule.md +100 -0
  235. package/src/skills/ship.md +49 -0
  236. package/src/skills/tdd.md +31 -0
  237. package/src/skills/tickets.md +44 -0
  238. package/src/skills/token-report/SKILL.md +76 -0
  239. package/src/skills/token-report/VENDOR.md +23 -0
  240. package/src/skills/token-report/scripts/token_report.mjs +1301 -0
  241. package/src/skills/wayfinder.md +49 -0
  242. package/src/skills/wiki.md +68 -0
  243. package/src/skills/workflow.md +38 -0
  244. package/themes/README.md +69 -0
  245. package/themes/_TEMPLATE.json +147 -0
  246. package/themes/biker.json +147 -0
  247. package/themes/commentator.json +147 -0
  248. package/themes/cyberpunk.json +147 -0
  249. package/themes/gamer.json +147 -0
  250. package/themes/imperial.json +147 -0
  251. package/themes/joker.json +147 -0
  252. package/themes/marvin.json +147 -0
  253. package/themes/mean.json +147 -0
  254. package/themes/military.json +147 -0
  255. package/themes/neutral.json +147 -0
  256. package/themes/opencode/README.md +58 -0
  257. package/themes/opencode/catppuccin.json +27 -0
  258. package/themes/opencode/dracula.json +27 -0
  259. package/themes/opencode/everforest.json +27 -0
  260. package/themes/opencode/gruvbox.json +27 -0
  261. package/themes/opencode/kanagawa.json +27 -0
  262. package/themes/opencode/neon.json +27 -0
  263. package/themes/opencode/nord.json +27 -0
  264. package/themes/opencode/rosepine.json +27 -0
  265. package/themes/opencode/tokyonight.json +27 -0
  266. package/themes/pirate.json +147 -0
  267. package/themes/sports.json +147 -0
  268. package/themes/verstappen.json +147 -0
  269. package/themes/wizard.json +147 -0
  270. package/web/dist/assets/About-DX1-VTBP.js +1 -0
  271. package/web/dist/assets/Activity-Dp9NaXOn.js +1 -0
  272. package/web/dist/assets/ActivityDetail-HpOBtrXS.js +1 -0
  273. package/web/dist/assets/Diff-Ct4G3Se8.js +5 -0
  274. package/web/dist/assets/Doctor-DGjkZFuA.js +1 -0
  275. package/web/dist/assets/Graph-b8o-S6kv.js +1 -0
  276. package/web/dist/assets/Harnesses-gr3-aHBF.js +1 -0
  277. package/web/dist/assets/Laws-DTwF5tTC.js +1 -0
  278. package/web/dist/assets/Library-BEZscyaQ.js +1 -0
  279. package/web/dist/assets/Markdown-k_LSLBGq.js +1 -0
  280. package/web/dist/assets/Profile-DydukTxP.js +1 -0
  281. package/web/dist/assets/Rules-CIRG4Ldt.js +1 -0
  282. package/web/dist/assets/Skills-Buoi1IBr.js +1 -0
  283. package/web/dist/assets/StatusBadge-CE_AJttl.js +1 -0
  284. package/web/dist/assets/Themes-CI4o3gJC.js +2 -0
  285. package/web/dist/assets/activity-BCW7e6Cy.js +1 -0
  286. package/web/dist/assets/audiowide-latin-400-normal-6GFCX7ni.woff2 +0 -0
  287. package/web/dist/assets/geist-mono-latin-400-normal-DKaoCDn5.woff2 +0 -0
  288. package/web/dist/assets/geist-mono-latin-500-normal-C3sF8Y1B.woff2 +0 -0
  289. package/web/dist/assets/geist-mono-latin-600-normal-BHzjB6_C.woff2 +0 -0
  290. package/web/dist/assets/hanken-grotesk-latin-400-italic-Bserb8X5.woff2 +0 -0
  291. package/web/dist/assets/hanken-grotesk-latin-400-normal-BG6hkEXj.woff2 +0 -0
  292. package/web/dist/assets/hanken-grotesk-latin-500-italic-BGfwwhek.woff2 +0 -0
  293. package/web/dist/assets/hanken-grotesk-latin-500-normal-DrDcrrxK.woff2 +0 -0
  294. package/web/dist/assets/hanken-grotesk-latin-600-normal-CIXX6EOa.woff2 +0 -0
  295. package/web/dist/assets/hanken-grotesk-latin-700-normal-CeQ8H3UY.woff2 +0 -0
  296. package/web/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  297. package/web/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  298. package/web/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  299. package/web/dist/assets/ibm-plex-serif-latin-400-normal-BIGslYFI.woff2 +0 -0
  300. package/web/dist/assets/ibm-plex-serif-latin-500-normal-REKAfBVG.woff2 +0 -0
  301. package/web/dist/assets/ibm-plex-serif-latin-600-normal-DAxitmSu.woff2 +0 -0
  302. package/web/dist/assets/index-B1PIx7RM.js +1 -0
  303. package/web/dist/assets/index-BnTQUSUz.js +12 -0
  304. package/web/dist/assets/index-Ck2iCdaC.js +2 -0
  305. package/web/dist/assets/index-DUCsDv7z.css +1 -0
  306. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  307. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  308. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  309. package/web/dist/assets/inter-latin-700-normal-Yt3aPRUw.woff2 +0 -0
  310. package/web/dist/assets/jetbrains-mono-latin-400-normal-V6pRDFza.woff2 +0 -0
  311. package/web/dist/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  312. package/web/dist/assets/jetbrains-mono-latin-600-normal-C8RAYTDA.woff2 +0 -0
  313. package/web/dist/assets/marked.esm-JKvyS0Pp.js +46 -0
  314. package/web/dist/assets/oswald-latin-400-normal-BAFo8sP8.woff2 +0 -0
  315. package/web/dist/assets/oswald-latin-500-normal-B-eyT0Dw.woff2 +0 -0
  316. package/web/dist/assets/oswald-latin-600-normal-plFmSr5g.woff2 +0 -0
  317. package/web/dist/assets/oswald-latin-700-normal-vy6hmKSr.woff2 +0 -0
  318. package/web/dist/assets/poppins-latin-400-normal-cpxAROuN.woff2 +0 -0
  319. package/web/dist/assets/poppins-latin-500-normal-C8OXljZJ.woff2 +0 -0
  320. package/web/dist/assets/poppins-latin-600-normal-zEkxB9Mr.woff2 +0 -0
  321. package/web/dist/assets/poppins-latin-700-normal-Qrb0O0WB.woff2 +0 -0
  322. package/web/dist/assets/react-DOpiTn5v.js +32 -0
  323. package/web/dist/assets/roman-DkYN0LX9.js +1 -0
  324. package/web/dist/assets/space-grotesk-latin-400-normal-CJ-V5oYT.woff2 +0 -0
  325. package/web/dist/assets/space-grotesk-latin-500-normal-lFbtlQH6.woff2 +0 -0
  326. package/web/dist/assets/space-grotesk-latin-600-normal-DjKNqYRj.woff2 +0 -0
  327. package/web/dist/assets/space-grotesk-latin-700-normal-RjhwGPKo.woff2 +0 -0
  328. package/web/dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 +0 -0
  329. package/web/dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 +0 -0
  330. package/web/dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 +0 -0
  331. package/web/dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 +0 -0
  332. package/web/dist/icon-maskable.svg +10 -0
  333. package/web/dist/icon.svg +8 -0
  334. package/web/dist/index.html +21 -0
  335. package/web/dist/manifest.webmanifest +14 -0
  336. package/web/dist/sw.js +40 -0
  337. package/web/src/pages/Laws.jsx +230 -0
package/js/cli.mjs ADDED
@@ -0,0 +1,498 @@
1
+ /**
2
+ * `js/cli-table.json` — the CLI as data, and the one place either implementation describes it.
3
+ *
4
+ * WHY THIS FILE EXISTS. `rituals/harness.py`'s `build_argparser()` was 25 subparsers (26
5
+ * invocable names — `update` aliases `upgrade`) and 46 `add_argument` calls. Node cannot
6
+ * introspect argparse, and P6d deferred the console's `cli` docs page for exactly that
7
+ * reason: a Node twin could only be a hand-written copy of the whole CLI surface, describing
8
+ * verbs `bin/geneseed-cli.mjs` cannot even run. This port's standing rule is that a copy of a
9
+ * value under test stops being the value under test, and that would have been the largest
10
+ * copy in it.
11
+ *
12
+ * P10c's answer was to make the metadata DATA — generated into `cli.json` at the repo root by
13
+ * `tests/gen_cli_reference.py` and read by both sides. P2 finishes the thought: the table is
14
+ * no longer GENERATED from anything, it is the OWNED document, and it lives here on a product
15
+ * path because that is where a document npm ships belongs. `package.json`'s `files[]` already
16
+ * carries `js/`, so it needs no row of its own, and nothing has to import out of `tests/`.
17
+ *
18
+ * ITS READERS, and each one takes a different view of the same rows:
19
+ *
20
+ * * `cliReference()` — `js/web/docs.mjs`'s `cli` kind, the page P6d deferred. STRIPS the
21
+ * hidden arguments and the two fields the page never carried;
22
+ * * `cliSpec()` — `bin/geneseed-cli.mjs`'s argument parser. That table used to carry its
23
+ * OWN hand-written transcription of the same `add_argument` calls, gated against the
24
+ * subparsers by name only, so `VERBS` is now `{ fn }` per row and everything
25
+ * argparse-shaped is derived here. KEEPS the hidden arguments, `--port`'s int type and
26
+ * `theme`'s mutex group, none of which the page may show;
27
+ * * `cliCommand()`/`printHelp()` — `--help` on BOTH entry points, so the table owes a row to
28
+ * every verb `bin/geneseed-hook.mjs` dispatches as well, not only to the CLI entry's.
29
+ *
30
+ * THE STORED SHAPE IS THE UNION AND MUST STAY ONE. Neither reader's shape can be the file's:
31
+ * a file filtered for the page breaks the CLI, and a file in `cliSpec`'s shape cannot render
32
+ * the docs. It stays exactly what the argparse walk produced.
33
+ *
34
+ * WHAT USED TO GUARD IT, AND WHAT DOES NOW. While the parser existed, `cliReferenceProblems()`
35
+ * hashed `rituals/harness.py` and doctor reported drift on both binaries. That digest was over
36
+ * a file this migration deletes, so it is gone; `tests/test_cli_reference.py` holds the
37
+ * argparse-vs-table comparison, and it can only ever cover the rows argparse HAS: `catalog`,
38
+ * `mcp` and `memory` never had a subparser and are outside it by construction. The successor
39
+ * that checks the WHOLE table, and outlives the parser, is `tests/unit/cli_table.test.mjs`;
40
+ * after that the table is simply the source of truth and is edited directly.
41
+ *
42
+ * THE COST, NAMED: this entry cannot parse anything without this file. `js/checkout.mjs`
43
+ * already reads `harness.config.json` at import for every render, so a tracked data document
44
+ * the code needs is not a new shape here — and this is not the hook entry, whose latency
45
+ * budget is `bin/geneseed-hook.mjs`'s and is untouched.
46
+ */
47
+ import { readFileSync } from 'node:fs';
48
+ import path from 'node:path';
49
+
50
+ import { ROOT } from './checkout.mjs';
51
+ import { pyInt, pyStripSpace } from './lib/fs.mjs';
52
+
53
+ const CLI_JSON = path.join(ROOT, 'js', 'cli-table.json');
54
+
55
+ /** The keys the docs page has always carried, per argument — `type` and `hidden` are ours. */
56
+ const PAGE_ARG_KEYS = ['names', 'dest', 'metavar', 'help', 'choices', 'default',
57
+ 'required', 'nargs', 'is_flag'];
58
+
59
+ /**
60
+ * `js/cli-table.json`, parsed. An unreadable file is the empty reference; the entry points
61
+ * turn that into a refusal that names itself rather than a parse with no rules.
62
+ *
63
+ * NOT MEMOISED, and the first draft was. `harness.load_cli_reference` re-reads the file on
64
+ * every call, so a cache here would make an edited table visible to the reference's running
65
+ * daemon and invisible to this one until it restarted — a divergence no cell can reach and no
66
+ * reviewer would look for. One re-read of a file this size is not a cost worth buying that with.
67
+ */
68
+ function load() {
69
+ try {
70
+ return JSON.parse(readFileSync(CLI_JSON, 'utf-8'));
71
+ } catch {
72
+ return { prog: 'harness', commands: [] };
73
+ }
74
+ }
75
+
76
+ const pageArgs = (args) => (args ?? [])
77
+ .filter((a) => !a.hidden)
78
+ .map((a) => Object.fromEntries(PAGE_ARG_KEYS.map((k) => [k, a[k] ?? null])));
79
+
80
+ /**
81
+ * `harness.load_cli_reference` — the file as the docs PAGE consumes it.
82
+ *
83
+ * The page is a VIEW of the file, not the other way round: `cliSpec` below needs the
84
+ * arguments argparse hides and the page must not show them, so the file carries everything
85
+ * and each reader filters. `?? null` reproduces `dict.get(k)` for a key a future generator
86
+ * stops writing — `undefined` would vanish from `JSON.stringify` and change the wire shape.
87
+ */
88
+ export function cliReference() {
89
+ const data = load();
90
+ return {
91
+ prog: data.prog ?? 'harness',
92
+ commands: (data.commands ?? []).map((c) => ({
93
+ name: c.name ?? '',
94
+ help: c.help ?? '',
95
+ description: c.description ?? '',
96
+ positionals: pageArgs(c.positionals),
97
+ options: pageArgs(c.options),
98
+ })),
99
+ };
100
+ }
101
+
102
+ /**
103
+ * `-h/--help`, which the table does NOT carry.
104
+ *
105
+ * argparse adds it at parser construction, before any `add_argument` the walk can see, so it
106
+ * is absent from every command in the file and present in every help text the reference
107
+ * prints. Reproduced here as a row rather than special-cased in the renderer, because it is
108
+ * first in the options section and first in the usage line for every verb the table carries —
109
+ * a position it only gets by being an action like the others.
110
+ */
111
+ const HELP_ACTION = {
112
+ names: ['-h', '--help'],
113
+ dest: 'help',
114
+ metavar: null,
115
+ help: 'show this help message and exit',
116
+ choices: null,
117
+ required: false,
118
+ nargs: '0',
119
+ is_flag: true,
120
+ hidden: false,
121
+ };
122
+
123
+ /** argparse's `_metavar_formatter`: an explicit metavar, else the choices, else the default. */
124
+ function metavarFor(a, positional) {
125
+ if (a.metavar !== null && a.metavar !== undefined) return a.metavar;
126
+ if (a.choices) return `{${a.choices.join(',')}}`;
127
+ return positional ? a.dest : a.dest.toUpperCase();
128
+ }
129
+
130
+ /** argparse's `_format_args`, for the three `nargs` spellings this parser uses. */
131
+ function formatArgs(a, positional) {
132
+ const mv = metavarFor(a, positional);
133
+ if (a.nargs === '0') return '';
134
+ if (a.nargs === '?') return `[${mv}]`;
135
+ if (a.nargs === '*') return `[${mv} ...]`;
136
+ return mv;
137
+ }
138
+
139
+ /** The usage-line spelling of one action, WITHOUT the `[…]` an optional gets around it. */
140
+ function usagePart(a, positional) {
141
+ if (positional) return formatArgs(a, true);
142
+ const args = formatArgs(a, false);
143
+ return args ? `${a.names[0]} ${args}` : a.names[0];
144
+ }
145
+
146
+ /** argparse's `_format_action_invocation` — the left column of the two-column body. */
147
+ function actionInvocation(a, positional) {
148
+ if (positional) return metavarFor(a, true);
149
+ const args = formatArgs(a, false);
150
+ return args ? `${a.names.join(', ')} ${args}` : a.names.join(', ');
151
+ }
152
+
153
+ // `textwrap`'s `wordsep_re`, which is what makes `pyTextWrap` below a port rather than a guess.
154
+ //
155
+ // A GREEDY SPACE-ONLY WRAP WAS THE FIRST DRAFT AND THE GATE REFUSED IT. `textwrap` splits a
156
+ // hyphenated word into two chunks, so it can break a line inside `back-compat` where a
157
+ // space-only wrap cannot. Measured before deciding: every help string then in the table disagreed
158
+ // with a greedy wrap at some width, the widest at 175 — so "the branch is dead" was simply
159
+ // false, and `test_the_ports_line_breaker_is_textwrap_at_every_width` said so on the run that
160
+ // first asserted it. The rules are transcribed instead.
161
+ //
162
+ // Python's classes, spelled for a `u`-flagged JavaScript regex: `\w` is letters, numbers and
163
+ // underscore; `[^\d\W]` is that minus the DECIMAL digits, which leaves the letter-ish half
164
+ // (`\p{Nl}`/`\p{No}` are `\w` and not `\d` in Python, so they stay). `\s` is the one class left
165
+ // spelled as itself, and the two languages differ on U+FEFF — Python excludes it, JavaScript
166
+ // does not. The gate sweeps the real help strings at every width rather than assuming that
167
+ // stays unreachable.
168
+ const WD = '[\\p{L}\\p{N}_]';
169
+ const LT = '[\\p{L}\\p{Nl}\\p{No}_]';
170
+ const WP = '[\\p{L}\\p{N}_!"\'&.,?]';
171
+ const WORDSEP = new RegExp(
172
+ '(\\s+' // any whitespace
173
+ + `|(?<=${WP})-{2,}(?=${WD})` // em-dash between words
174
+ + '|\\S+?(?:' // word, possibly hyphenated
175
+ + `-(?:(?<=${LT}{2}-)|(?<=${LT}-${LT}-))(?=${LT}-?${LT})`
176
+ + '|(?=\\s|$)'
177
+ + `|(?<=${WP})(?=-{2,}${WD})))`, 'u');
178
+
179
+ /**
180
+ * `textwrap.wrap(text, width)` for the settings argparse's `_split_lines` uses: no indent,
181
+ * `drop_whitespace`, `break_long_words` and `break_on_hyphens` all on, and the text already
182
+ * whitespace-collapsed and stripped by the caller.
183
+ *
184
+ * ponytail: lives here rather than in `js/lib/` because `formatHelp` is its only consumer.
185
+ * `rituals/_harness_tui*.py` calls `textwrap.wrap` in seven places and is declared unported;
186
+ * move this when that changes, not before.
187
+ */
188
+ export function pyTextWrap(text, width) {
189
+ const chunks = text.split(WORDSEP).filter(Boolean).reverse();
190
+ const blank = (s) => s.trim() === '';
191
+ const lines = [];
192
+ while (chunks.length) {
193
+ const line = [];
194
+ let len = 0;
195
+ if (blank(chunks[chunks.length - 1]) && lines.length) chunks.pop();
196
+ while (chunks.length && len + chunks[chunks.length - 1].length <= width) {
197
+ len += chunks[chunks.length - 1].length;
198
+ line.push(chunks.pop());
199
+ }
200
+ // `_handle_long_word`: a chunk too wide for any line is cut to what is left of this one —
201
+ // after the last hyphen inside that room, if there is one with something before it.
202
+ //
203
+ // `room > 0` IS CPython's gh-139065 fix — `if self.break_long_words and space_left > 0:`,
204
+ // landed on main as 1c598e0 and cherry-picked to 3.13 as b1bc743, first shipped in 3.13.14.
205
+ // Without it a line already exactly `width` long gets `chunk.slice(0, 0)` — an EMPTY string
206
+ // — appended, and that empty chunk then absorbs the single trailing-whitespace `pop` below,
207
+ // so the real whitespace chunk survives and the line keeps a trailing space. This machine's
208
+ // 3.13.5 predates the fix, so the sweep gate agreed with the unguarded port here and
209
+ // disagreed on CI's 3.13.14. `room <= 0` needs no `elif not cur_line` arm: an empty line has
210
+ // `len === 0`, so `room` is `width` (or 1 when `width < 1`) and is never <= 0 there.
211
+ const room = width < 1 ? 1 : width - len;
212
+ if (room > 0 && chunks.length && chunks[chunks.length - 1].length > width) {
213
+ const chunk = chunks[chunks.length - 1];
214
+ let end = room;
215
+ if (chunk.length > room) {
216
+ const hyphen = chunk.lastIndexOf('-', room - 1);
217
+ if (hyphen > 0 && [...chunk.slice(0, hyphen)].some((c) => c !== '-')) end = hyphen + 1;
218
+ }
219
+ line.push(chunk.slice(0, end));
220
+ chunks[chunks.length - 1] = chunk.slice(end);
221
+ len = line.reduce((n, s) => n + s.length, 0);
222
+ }
223
+ if (line.length && blank(line[line.length - 1])) line.pop();
224
+ if (line.length) lines.push(line.join(''));
225
+ }
226
+ return lines.length ? lines : [''];
227
+ }
228
+
229
+ /**
230
+ * argparse's `_format_usage`, including the re-flow it does when the line does not fit.
231
+ *
232
+ * `prog` is the WHOLE program word — `harness diff`, `geneseed diff` — because that is what
233
+ * the indent of a wrapped continuation line is computed from, which is why the divergence
234
+ * cannot be patched into a recorded text after the fact.
235
+ */
236
+ function formatUsage(prog, optParts, posParts, width) {
237
+ const prefix = 'usage: ';
238
+ const flat = [prog, [...optParts, ...posParts].join(' ')].filter(Boolean).join(' ');
239
+ if (prefix.length + flat.length <= width) return prefix + flat;
240
+
241
+ const getLines = (parts, indent, pre) => {
242
+ const out = [];
243
+ let line = [];
244
+ let lineLen = (pre === undefined ? indent.length : pre.length) - 1;
245
+ for (const part of parts) {
246
+ if (lineLen + 1 + part.length > width && line.length) {
247
+ out.push(indent + line.join(' '));
248
+ line = [];
249
+ lineLen = indent.length - 1;
250
+ }
251
+ line.push(part);
252
+ lineLen += 1 + part.length;
253
+ }
254
+ if (line.length) out.push(indent + line.join(' '));
255
+ if (pre !== undefined && out.length) out[0] = out[0].slice(indent.length);
256
+ return out;
257
+ };
258
+
259
+ let lines;
260
+ if (prefix.length + prog.length <= 0.75 * width) {
261
+ const indent = ' '.repeat(prefix.length + prog.length + 1);
262
+ if (optParts.length) {
263
+ lines = getLines([prog, ...optParts], indent, prefix);
264
+ lines.push(...getLines(posParts, indent));
265
+ } else if (posParts.length) {
266
+ lines = getLines([prog, ...posParts], indent, prefix);
267
+ } else {
268
+ lines = [prog];
269
+ }
270
+ } else {
271
+ const indent = ' '.repeat(prefix.length);
272
+ lines = getLines([...optParts, ...posParts], indent);
273
+ if (lines.length > 1) {
274
+ lines = [...getLines(optParts, indent), ...getLines(posParts, indent)];
275
+ }
276
+ lines = [prog, ...lines];
277
+ }
278
+ return prefix + lines.join('\n');
279
+ }
280
+
281
+ /** argparse's `_format_action` — the two-column row, and the wrapped help beside it. */
282
+ function formatAction(a, positional, helpPosition, width) {
283
+ const actionWidth = helpPosition - 2 - 2;
284
+ const header = actionInvocation(a, positional);
285
+ const help = a.help ?? '';
286
+ const parts = [];
287
+ let indentFirst = 0;
288
+ if (!help) {
289
+ parts.push(` ${header}\n`);
290
+ } else if (header.length <= actionWidth) {
291
+ parts.push(` ${header.padEnd(actionWidth)} `);
292
+ } else {
293
+ parts.push(` ${header}\n`);
294
+ indentFirst = helpPosition;
295
+ }
296
+ if (help && help.trim()) {
297
+ // `_whitespace_matcher` is `re.compile(r'\s+', re.ASCII)` — the ASCII class, so a help
298
+ // string carrying U+00A0 keeps it rather than having it collapsed to a break opportunity.
299
+ const collapsed = help.replace(/[ \t\n\v\f\r]+/g, ' ').trim();
300
+ const lines = pyTextWrap(collapsed, Math.max(width - helpPosition, 11));
301
+ parts.push(`${' '.repeat(indentFirst)}${lines[0]}\n`);
302
+ for (const line of lines.slice(1)) parts.push(`${' '.repeat(helpPosition)}${line}\n`);
303
+ } else if (!parts[0].endsWith('\n')) {
304
+ parts.push('\n');
305
+ }
306
+ return parts.join('');
307
+ }
308
+
309
+ /**
310
+ * `parser.format_help()` for one subcommand, byte for byte with argparse.
311
+ *
312
+ * WHY THE PORT RENDERS THIS AT ALL. Task 10 compared the two implementations' `--help` for the
313
+ * first time — `tests/harness_golden.py` has zero `--help` cells — and found the port answering
314
+ * `geneseed: error: unrecognized arguments: --help`, exit 2, on all 26 verbs. That is not the
315
+ * declared wording divergence `parse`'s docblock reserves for argparse's ERROR text: it is the
316
+ * absence of the surface, on the binary that survives the reference's deletion.
317
+ *
318
+ * `prog` IS AN ARGUMENT AND THAT IS THE ONE DELIBERATE DIVERGENCE. The reference names itself
319
+ * `harness`, after the Python file this migration deletes; a user who typed `geneseed status
320
+ * --help` is shown `usage: geneseed status`, which is also how every error this entry prints
321
+ * already spells itself (`die`). `tests/__snapshots__/help/` freezes the reference's answers
322
+ * and `tests/snapshot/cli_help.test.mjs` replays them through here with `prog` set to the reference's
323
+ * own — so the layout is gated byte for byte and the rename is the only thing that moves.
324
+ *
325
+ * NOT EVERY VERB HAS A RECORDED ANSWER, AND THREE NEVER CAN. `catalog`, `mcp` and `memory` had
326
+ * no subparser for the recorder to render from, so the corpus covers the verbs argparse
327
+ * answered to and those three are held ABSOLUTELY instead — `tests/snapshot/cli_help.test.mjs` names
328
+ * both populations (`RECORDED`, `NATIVE`) and fails a verb that falls into neither, which is
329
+ * what keeps "no recorded text" from meaning "no gate".
330
+ *
331
+ * The alias rides on the same argument: argparse gives `update` the parser object it built for
332
+ * `upgrade`, so `harness.py update --help` prints `usage: harness upgrade`. The recorded prog
333
+ * map reproduces that; the shipped call passes `geneseed update`, which is what the user typed.
334
+ *
335
+ * `width` is argparse's `shutil.get_terminal_size().columns - 2`, passed in rather than read,
336
+ * because a recorded help text whose wrap column came from the recorder's terminal is a corpus
337
+ * that reddens on the next machine — the rot vector this branch has now found nine ways.
338
+ */
339
+ export function formatHelp(cmd, prog, width) {
340
+ const positionals = (cmd.positionals ?? []).filter((a) => !a.hidden);
341
+ const options = [HELP_ACTION, ...(cmd.options ?? []).filter((a) => !a.hidden)];
342
+
343
+ const optParts = [];
344
+ const done = new Set();
345
+ for (const a of options) {
346
+ const group = (cmd.mutex ?? []).find((g) => g.includes(a.names[0]));
347
+ if (group === undefined) {
348
+ optParts.push(a.required ? usagePart(a, false) : `[${usagePart(a, false)}]`);
349
+ continue;
350
+ }
351
+ // `add_mutually_exclusive_group()` — never required in this parser, so `[a | b]`, and the
352
+ // members lose their own brackets to it. Emitted once, at the first member's position.
353
+ //
354
+ // ONE PART PER MEMBER, NOT ONE PER GROUP, and that distinction is the whole of a real
355
+ // divergence this port shipped. `_get_actions_usage_parts` decorates the members IN PLACE:
356
+ // the first gets the opening bracket, every part but the last gains a trailing ` |`, the
357
+ // last gains the closing bracket — and it returns them as SEPARATE parts. `_format_usage`
358
+ // then wraps on the part boundaries, so argparse can break the line INSIDE the group and
359
+ // end a line with `[--solid-only |`. A single atomic `[a | b]` part cannot be broken at
360
+ // all, so the two layouts diverge at every width where the group straddles the wrap
361
+ // column: measured on `theme`, 73 of the 181 widths in COLUMNS 20..200, and 64 of those in
362
+ // the arm the fixtures exercise. COLUMNS=80 — the one width the 26 recorded fixtures are
363
+ // rendered at — is not one of them, which is why nothing saw it.
364
+ if (done.has(group)) continue;
365
+ done.add(group);
366
+ const members = group.map((f) => usagePart(options.find((o) => o.names.includes(f)), false));
367
+ members[0] = `[${members[0]}`;
368
+ members[members.length - 1] += ']';
369
+ for (let i = 0; i < members.length - 1; i += 1) members[i] += ' |';
370
+ optParts.push(...members);
371
+ }
372
+ const posParts = positionals.map((a) => usagePart(a, true));
373
+
374
+ // `_action_max_length` is the widest invocation in the WHOLE parser plus the section indent,
375
+ // and the hidden actions contribute nothing to it — `add_argument` drops a SUPPRESSed one
376
+ // before it is measured, which is why `web`'s column is set by `{start,stop,restart,status}`
377
+ // and not by `--daemon-internal`.
378
+ const invocations = [...positionals.map((a) => actionInvocation(a, true)),
379
+ ...options.map((a) => actionInvocation(a, false))];
380
+ // `_max_help_position` IS ITSELF A FUNCTION OF THE WIDTH, which the port had baked as the
381
+ // constant 24: `HelpFormatter.__init__` is
382
+ // `min(max_help_position, max(width - 20, indent_increment * 2))`, so the two-column layout
383
+ // collapses to a 4-column indent on a narrow terminal and every action's help moves to its
384
+ // own line. Equal to 24 for every width from 44 up, which is why the fixtures at width 78
385
+ // could not see it and why only a width SWEEP could.
386
+ const maxHelpPosition = Math.min(24, Math.max(width - 20, 2 * 2));
387
+ const helpPosition = Math.min(Math.max(...invocations.map((s) => s.length)) + 2 + 2,
388
+ maxHelpPosition);
389
+
390
+ const blocks = [`${formatUsage(prog, optParts, posParts, width)}\n`];
391
+ if (positionals.length) {
392
+ blocks.push(`positional arguments:\n${positionals
393
+ .map((a) => formatAction(a, true, helpPosition, width)).join('')}`);
394
+ }
395
+ blocks.push(`options:\n${options
396
+ .map((a) => formatAction(a, false, helpPosition, width)).join('')}`);
397
+ // `HelpFormatter.format_help`'s last two steps: collapse a run of blank lines, then trim to
398
+ // exactly one trailing newline.
399
+ return `${blocks.join('\n').replace(/\n\n\n+/g, '\n\n').replace(/^\n+|\n+$/g, '')}\n`;
400
+ }
401
+
402
+ /**
403
+ * The width argparse would use, from the same inputs: `COLUMNS` first, then the terminal, then
404
+ * 80 — minus the two columns `HelpFormatter.__init__` subtracts. (`shutil.get_terminal_size`
405
+ * reads `COLUMNS` before it asks the tty, and falls back to 80 when neither answers, which is
406
+ * every pipe both implementations write help into.)
407
+ *
408
+ * `pyInt`, NOT `Number.parseInt`, and the tree already owned it. `get_terminal_size` spells
409
+ * this `int(os.environ['COLUMNS'])` inside `except (KeyError, ValueError)`, and `int` is not
410
+ * `parseInt`. Measured against the reference over a corpus of `$COLUMNS` values
411
+ * (`test_the_ports_help_width_is_shutil_get_terminal_size`): `COLUMNS=1_0` is 10 to Python and
412
+ * 1 to `parseInt`, `COLUMNS=٣` is 3 and NaN, `COLUMNS=' 80 '` is 80 and NaN. `COLUMNS=80abc`
413
+ * is a ValueError to Python and 80 to `parseInt` — the same answer down a PIPE, where the
414
+ * fallback is 80 either way, and a different one on any terminal that is not 80 wide, which is
415
+ * why the corpus is the gate rather than a cell. A non-positive value falls through on both
416
+ * sides, which is Python's `if columns <= 0` and was already right here.
417
+ *
418
+ * `pyStripSpace` because THIS is the caller `pyInt`'s docblock did not have: its other two
419
+ * strip before calling, and `$COLUMNS` arrives however the shell set it — `int(' 80 ')` is 80.
420
+ */
421
+ export function helpWidth() {
422
+ const env = pyInt(pyStripSpace(process.env.COLUMNS ?? ''));
423
+ if (env !== null && env > 0) return env - 2;
424
+ return (process.stdout.columns > 0 ? process.stdout.columns : 80) - 2;
425
+ }
426
+
427
+ /** One command's raw row — everything the table knows, unlike the page's filtered view. */
428
+ export function cliCommand(verb) {
429
+ return (load().commands ?? []).find((c) => c.name === verb) ?? null;
430
+ }
431
+
432
+ /**
433
+ * `<verb> --help` for whichever binary asked, or `null` for a verb the table does not
434
+ * describe — the caller already owns that refusal and its message.
435
+ *
436
+ * ONE OWNER FOR TWO ENTRIES. `bin/geneseed-hook.mjs` carries the four hook verbs and
437
+ * `bin/geneseed-cli.mjs` the rest of the table, and
438
+ * `test_the_two_entry_points_carry_disjoint_verb_sets` keeps them from learning each other's
439
+ * tables — so the help each prints has to come from here rather than from a copy in each.
440
+ *
441
+ * CRLF ON WINDOWS for `die`'s reason: argparse writes through a Python text stream, which
442
+ * translates, and this text lands in the same terminals and the same pipes as that one.
443
+ */
444
+ export function printHelp(prog, verb) {
445
+ const cmd = cliCommand(verb);
446
+ if (cmd === null) return null;
447
+ const text = formatHelp(cmd, `${prog} ${verb}`, helpWidth());
448
+ process.stdout.write(process.platform === 'win32' ? text.replaceAll('\n', '\r\n') : text);
449
+ return 0;
450
+ }
451
+
452
+ /** argparse's `dest` is snake_case; every JS name in this port is camelCase. */
453
+ const camel = (dest) => dest.replace(/_([a-z])/g, (_m, c) => c.toUpperCase());
454
+
455
+ /**
456
+ * One subcommand's argparse surface, in the shape `bin/geneseed-cli.mjs`'s `parse` consumes.
457
+ *
458
+ * EVERY ROW OF THIS MAPPING WAS A HAND-WRITTEN COMMENT IN THAT FILE'S `VERBS` TABLE, and each
459
+ * one names a failure it prevents:
460
+ *
461
+ * * a positional with `nargs="?"` is OPTIONAL — `link [dir]`, `upgrade [ref] [theme]`;
462
+ * * a positional with `nargs="*"` is the trailing SINK (`bootstrap extra`), and `parse`
463
+ * keeps it from absorbing options, which is what stops `bootstrap --no-setpu` from
464
+ * running a full update-and-wizard while the user believes they asked for neither;
465
+ * * `store_true` is `nargs === '0'` and takes no value, so `--global=x` is an error;
466
+ * * an OPTION with `nargs="?"` is argparse's `const=True` — bare `diff --out` means "the
467
+ * default timestamped path" and `--out FILE` means the string. A plain option would
468
+ * refuse the bare form; a flag would drop the filename;
469
+ * * `type=int` refuses `--port abc` instead of silently binding 4747;
470
+ * * a mutually exclusive group refuses `--solid-only --transparent-only`, in the order the
471
+ * group declares them, which is the order the message names them in.
472
+ *
473
+ * `null` for a verb the file does not describe — the caller turns that into a refusal rather
474
+ * than a parse with no rules, because a spec of `{}` would accept anything and bind nothing.
475
+ */
476
+ export function cliSpec(verb) {
477
+ const cmd = (load().commands ?? []).find((c) => c.name === verb);
478
+ if (cmd === undefined) return null;
479
+ const spec = { positionals: [] };
480
+ for (const a of cmd.positionals ?? []) {
481
+ if (a.nargs === '*') { spec.variadic = a.dest; continue; }
482
+ const p = { name: a.dest };
483
+ if (a.nargs === '?') p.optional = true;
484
+ if (a.choices) p.choices = a.choices;
485
+ spec.positionals.push(p);
486
+ }
487
+ for (const a of cmd.options ?? []) {
488
+ const dest = camel(a.dest);
489
+ // eslint-disable-next-line no-nested-ternary
490
+ const table = a.is_flag ? 'flags' : (a.nargs === '?' ? 'optValue' : 'options');
491
+ for (const name of a.names ?? []) {
492
+ (spec[table] ??= {})[name] = dest;
493
+ if (a.type === 'int') (spec.ints ??= []).push(name);
494
+ }
495
+ }
496
+ if ((cmd.mutex ?? []).length) spec.mutex = cmd.mutex;
497
+ return spec;
498
+ }