@canonical/pragma-cli 0.35.0 → 0.37.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 (295) hide show
  1. package/README.md +2 -2
  2. package/bundled-skills/add-standard/SKILL.md +338 -0
  3. package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
  4. package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
  5. package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
  6. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
  7. package/bundled-skills/anatomy-author/SKILL.md +1226 -0
  8. package/bundled-skills/design-auditor/SKILL.md +455 -0
  9. package/bundled-skills/implement-component/SKILL.md +333 -0
  10. package/bundled-skills/specify-component/SKILL.md +331 -0
  11. package/bundled-skills/specify-pattern/SKILL.md +292 -0
  12. package/dist/package.json +24 -20
  13. package/dist/pragma.conf.js +266 -34
  14. package/dist/pragma.conf.js.map +1 -1
  15. package/dist/src/bin.js +1 -1
  16. package/dist/src/bin.js.map +1 -1
  17. package/dist/src/capabilities/capabilities/hints.js +1 -1
  18. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  19. package/dist/src/capabilities/colophon/colophon.render.js +1 -1
  20. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  21. package/dist/src/capabilities/create/create.verb.js +2 -1
  22. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  23. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  24. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  25. package/dist/src/capabilities/create/mount.js +4 -3
  26. package/dist/src/capabilities/create/mount.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  28. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  29. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
  30. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  31. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
  32. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
  33. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  34. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  35. package/dist/src/capabilities/doctor/checks/index.js +6 -6
  36. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  37. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  38. package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
  39. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  40. package/dist/src/capabilities/doctor/doctor.render.js +43 -64
  41. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
  42. package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
  43. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  44. package/dist/src/capabilities/doctor/runChecks.js +14 -11
  45. package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
  46. package/dist/src/capabilities/graph/query.render.js +2 -2
  47. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  48. package/dist/src/capabilities/info/collectInfo.js +8 -2
  49. package/dist/src/capabilities/info/collectInfo.js.map +1 -1
  50. package/dist/src/capabilities/info/info.render.js +6 -2
  51. package/dist/src/capabilities/info/info.render.js.map +1 -1
  52. package/dist/src/capabilities/prompt/prompt.render.js +2 -2
  53. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
  54. package/dist/src/capabilities/setup/buildPlan.js +43 -39
  55. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  56. package/dist/src/capabilities/setup/mode.js +2 -2
  57. package/dist/src/capabilities/setup/operations/index.js +4 -4
  58. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  60. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/setupGenerator.js +156 -58
  64. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  65. package/dist/src/capabilities/setup/operations/setupLsp.js +136 -38
  66. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  67. package/dist/src/capabilities/setup/operations/setupMcp.js +101 -52
  68. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  69. package/dist/src/capabilities/setup/operations/setupSkills.js +329 -49
  70. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  71. package/dist/src/capabilities/setup/plan.js.map +1 -1
  72. package/dist/src/capabilities/setup/plan.render.js +144 -66
  73. package/dist/src/capabilities/setup/plan.render.js.map +1 -1
  74. package/dist/src/capabilities/setup/setup.render.js +1 -1
  75. package/dist/src/capabilities/setup/setup.verb.js +165 -63
  76. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  77. package/dist/src/capabilities/setup/targets.js +122 -51
  78. package/dist/src/capabilities/setup/targets.js.map +1 -1
  79. package/dist/src/capabilities/shared/index.js +7 -6
  80. package/dist/src/capabilities/shared/index.js.map +1 -1
  81. package/dist/src/capabilities/shared/packageManager.js +300 -54
  82. package/dist/src/capabilities/shared/packageManager.js.map +1 -1
  83. package/dist/src/capabilities/skill/discover.js +97 -11
  84. package/dist/src/capabilities/skill/discover.js.map +1 -1
  85. package/dist/src/capabilities/skill/render.js +2 -2
  86. package/dist/src/capabilities/skill/render.js.map +1 -1
  87. package/dist/src/capabilities/sources/installSkills.js +145 -3
  88. package/dist/src/capabilities/sources/installSkills.js.map +1 -1
  89. package/dist/src/capabilities/sources/runUpdate.js +68 -2
  90. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  91. package/dist/src/capabilities/sources/update.verb.js +3 -3
  92. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  93. package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
  94. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
  95. package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
  96. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
  97. package/dist/src/constants.js +13 -2
  98. package/dist/src/constants.js.map +1 -1
  99. package/dist/src/kernel/index.js +11 -2
  100. package/dist/src/kernel/index.js.map +1 -1
  101. package/dist/src/kernel/interactivity.js +68 -0
  102. package/dist/src/kernel/interactivity.js.map +1 -0
  103. package/dist/src/kernel/packs/renderPack.js +3 -3
  104. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  105. package/dist/src/kernel/packs/resolveEntity.js +63 -12
  106. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  107. package/dist/src/kernel/packs/runBodies.js +57 -4
  108. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  109. package/dist/src/kernel/packs/schema.js +54 -2
  110. package/dist/src/kernel/packs/schema.js.map +1 -1
  111. package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
  112. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
  113. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +310 -43
  114. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  115. package/dist/src/kernel/packs/types.js.map +1 -1
  116. package/dist/src/kernel/project/cli/buildProgram.js +1 -1
  117. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  118. package/dist/src/kernel/project/cli/dispatch.js +46 -24
  119. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  120. package/dist/src/kernel/project/cli/globalFlags.js +4 -1
  121. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
  122. package/dist/src/kernel/project/cli/helpFormat.js +1 -2
  123. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
  124. package/dist/src/kernel/project/cli/rootHelp.js +42 -9
  125. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  126. package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
  127. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  128. package/dist/src/kernel/render/renderers.js +1 -1
  129. package/dist/src/kernel/render/style.js +29 -9
  130. package/dist/src/kernel/render/style.js.map +1 -1
  131. package/dist/src/kernel/render/vocabulary.js +174 -0
  132. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  133. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  134. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  135. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  136. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  137. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  138. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  139. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  140. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  141. package/dist/src/kernel/runtime/graphpack/read.js +14 -9
  142. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  143. package/dist/src/kernel/runtime/loadSession.js +3 -1
  144. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  145. package/dist/src/kernel/runtime/probe.js +1 -1
  146. package/dist/src/kernel/runtime/probe.js.map +1 -1
  147. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  148. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  149. package/dist/src/kernel/runtime/refs/resolve.js +9 -3
  150. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  151. package/dist/src/kernel/spec/emitReference.js +1 -1
  152. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  153. package/package.json +24 -20
  154. package/pragma.conf.ts +272 -34
  155. package/src/bin.ts +8 -1
  156. package/src/capabilities/block.parity.test.ts +25 -11
  157. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  158. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  159. package/src/capabilities/capabilities/hints.ts +1 -1
  160. package/src/capabilities/colophon/colophon.render.ts +1 -2
  161. package/src/capabilities/colophon/colophon.test.ts +2 -2
  162. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  163. package/src/capabilities/create/byteEquality.test.ts +28 -1
  164. package/src/capabilities/create/create.test.ts +1 -0
  165. package/src/capabilities/create/create.verb.ts +2 -1
  166. package/src/capabilities/create/createGrammar.test.ts +5 -2
  167. package/src/capabilities/create/createSurface.generated.ts +23 -1
  168. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  169. package/src/capabilities/create/mount.ts +3 -3
  170. package/src/capabilities/distribution.parity.test.ts +4 -0
  171. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  172. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  173. package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
  174. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
  175. package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
  176. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  177. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  178. package/src/capabilities/doctor/checks/index.ts +6 -6
  179. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  180. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  181. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  182. package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
  183. package/src/capabilities/doctor/doctor.render.test.ts +108 -32
  184. package/src/capabilities/doctor/doctor.render.ts +56 -80
  185. package/src/capabilities/doctor/doctor.test.ts +247 -35
  186. package/src/capabilities/doctor/doctor.verb.ts +7 -2
  187. package/src/capabilities/doctor/runChecks.ts +14 -11
  188. package/src/capabilities/doctor/types.ts +15 -24
  189. package/src/capabilities/emptyRecovery.test.ts +10 -2
  190. package/src/capabilities/graph/query.render.ts +2 -2
  191. package/src/capabilities/info/collectInfo.ts +8 -1
  192. package/src/capabilities/info/info.render.ts +6 -2
  193. package/src/capabilities/info/info.test.ts +37 -1
  194. package/src/capabilities/info/types.ts +10 -2
  195. package/src/capabilities/lazy.test.ts +26 -2
  196. package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
  197. package/src/capabilities/prompt/prompt.render.ts +2 -2
  198. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  199. package/src/capabilities/resources/resources.test.ts +38 -2
  200. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  201. package/src/capabilities/setup/buildPlan.ts +45 -41
  202. package/src/capabilities/setup/index.ts +1 -1
  203. package/src/capabilities/setup/mode.ts +2 -2
  204. package/src/capabilities/setup/operations/index.ts +6 -1
  205. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  206. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  207. package/src/capabilities/setup/operations/setupGenerator.ts +227 -84
  208. package/src/capabilities/setup/operations/setupLsp.ts +203 -38
  209. package/src/capabilities/setup/operations/setupMcp.ts +139 -62
  210. package/src/capabilities/setup/operations/setupSkills.ts +399 -60
  211. package/src/capabilities/setup/plan.render.ts +171 -76
  212. package/src/capabilities/setup/plan.ts +17 -12
  213. package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
  214. package/src/capabilities/setup/setup.render.test.ts +50 -36
  215. package/src/capabilities/setup/setup.render.ts +1 -1
  216. package/src/capabilities/setup/setup.test.ts +1126 -50
  217. package/src/capabilities/setup/setup.verb.ts +174 -68
  218. package/src/capabilities/setup/targets.ts +138 -60
  219. package/src/capabilities/setup/types.ts +10 -18
  220. package/src/capabilities/shared/index.ts +18 -6
  221. package/src/capabilities/shared/packageManager.test.ts +421 -6
  222. package/src/capabilities/shared/packageManager.ts +394 -59
  223. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  224. package/src/capabilities/skill/discover.ts +100 -11
  225. package/src/capabilities/skill/render.ts +2 -2
  226. package/src/capabilities/skill/skill.test.ts +17 -4
  227. package/src/capabilities/sources/installSkills.ts +181 -3
  228. package/src/capabilities/sources/runUpdate.ts +78 -2
  229. package/src/capabilities/sources/sources.test.ts +228 -0
  230. package/src/capabilities/sources/update.verb.ts +3 -3
  231. package/src/capabilities/surface.test.ts +14 -6
  232. package/src/capabilities/upgrade/runUpgrade.ts +45 -6
  233. package/src/capabilities/upgrade/types.ts +10 -2
  234. package/src/capabilities/upgrade/upgrade.render.ts +9 -2
  235. package/src/capabilities/upgrade/upgrade.test.ts +72 -0
  236. package/src/constants.ts +14 -1
  237. package/src/identity.test.ts +18 -5
  238. package/src/kernel/completion/entitySource.test.ts +15 -2
  239. package/src/kernel/completion/safety.test.ts +16 -9
  240. package/src/kernel/config/defaults.test.ts +19 -3
  241. package/src/kernel/config/readConfig.test.ts +1 -1
  242. package/src/kernel/index.ts +11 -2
  243. package/src/kernel/interactivity.test.ts +76 -0
  244. package/src/kernel/interactivity.ts +69 -0
  245. package/src/kernel/packs/compile.test.ts +92 -0
  246. package/src/kernel/packs/renderPack.ts +3 -3
  247. package/src/kernel/packs/resolveEntity.test.ts +75 -14
  248. package/src/kernel/packs/resolveEntity.ts +88 -24
  249. package/src/kernel/packs/runBodies.ts +84 -19
  250. package/src/kernel/packs/schema.ts +60 -4
  251. package/src/kernel/packs/sparql/applyFilters.ts +125 -4
  252. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  253. package/src/kernel/packs/sparql/buildLookupQuery.ts +327 -45
  254. package/src/kernel/packs/toolDescription.test.ts +35 -1
  255. package/src/kernel/packs/types.ts +125 -2
  256. package/src/kernel/project/cli/buildProgram.test.ts +73 -2
  257. package/src/kernel/project/cli/buildProgram.ts +8 -1
  258. package/src/kernel/project/cli/dispatch.test.ts +5 -4
  259. package/src/kernel/project/cli/dispatch.ts +48 -25
  260. package/src/kernel/project/cli/globalFlags.ts +4 -1
  261. package/src/kernel/project/cli/helpFormat.ts +1 -2
  262. package/src/kernel/project/cli/rootHelp.ts +44 -8
  263. package/src/kernel/project/mcp/registerVerb.ts +20 -1
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.ts +1 -1
  266. package/src/kernel/render/style.ts +32 -9
  267. package/src/kernel/render/vocabulary.ts +276 -0
  268. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  269. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  270. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  271. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  272. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  273. package/src/kernel/runtime/graphpack/read.ts +14 -12
  274. package/src/kernel/runtime/loadSession.ts +3 -1
  275. package/src/kernel/runtime/probe.ts +1 -1
  276. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  277. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  278. package/src/kernel/runtime/refs/resolve.ts +9 -3
  279. package/src/kernel/runtime/types.ts +17 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/types.ts +35 -6
  282. package/src/testing/PARITY_GAPS.ts +9 -3
  283. package/src/testing/behavioral/docExamples.test.ts +4 -2
  284. package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
  285. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  286. package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
  287. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  288. package/src/testing/eval/cases/readNouns.ts +245 -9
  289. package/src/testing/fixtures/blockGraph.ts +28 -13
  290. package/src/testing/fixtures/graph/canonical.ts +95 -12
  291. package/src/testing/helpers/runCli.test.ts +44 -0
  292. package/src/testing/helpers/runCli.ts +27 -3
  293. package/dist/src/capabilities/shared/bands.js +0 -16
  294. package/dist/src/capabilities/shared/bands.js.map +0 -1
  295. package/src/capabilities/shared/bands.ts +0 -16
@@ -523,11 +523,12 @@ export const createVerbs: Record<
523
523
  ),
524
524
  application: createVerb(
525
525
  "application",
526
- "Scaffold a full React application with SSR and routing.",
526
+ "Scaffold a full React application with routing, and either server-side rendering or a client-only SPA.",
527
527
  applicationParams,
528
528
  [
529
529
  { cmd: `${BIN_NAME} create application react my-app` },
530
530
  { cmd: `${BIN_NAME} create application react my-app --relay` },
531
+ { cmd: `${BIN_NAME} create application react my-app --rendering spa` },
531
532
  ],
532
533
  ),
533
534
  };
@@ -655,7 +655,10 @@ describe("the mounted create grammar (subprocess)", () => {
655
655
  "--type": "library",
656
656
  "--description": "A replied library.",
657
657
  },
658
- "application/react": { "--app-path": "replied-app" },
658
+ "application/react": {
659
+ "--app-path": "replied-app",
660
+ "--rendering": "spa",
661
+ },
659
662
  };
660
663
  for (const [commandPath, surface] of Object.entries(CREATE_SURFACE)) {
661
664
  it(`${commandPath}: the refusal's own instruction WORKS — its Missing tokens supplied back (+ --dry-run) preview, exit 0`, () => {
@@ -768,7 +771,7 @@ describe("the refusal cell through dispatchPrepared (in-process)", () => {
768
771
  describe("the mount's mode resolution — TTY driven through the seam", () => {
769
772
  // No suite can hand a subprocess a real TTY, so the §L rows that depend on
770
773
  // isTTY: true are pinned HERE, through the exported resolver the action
771
- // itself calls (with cliIsTTY()). The OLD decision block routed TTY
774
+ // itself calls (with canPrompt()). The OLD decision block routed TTY
772
775
  // dry-run/undo into Ink; these rows keep that from silently returning.
773
776
  const prompts = CREATE_SURFACE["component/react"]?.prompts ?? [];
774
777
  const flags = (dryRun: boolean, undo: boolean, yes: boolean) => ({
@@ -17,7 +17,7 @@ export const CREATE_SURFACE: Readonly<Record<string, SurfaceCommand>> = {
17
17
  "application",
18
18
  "react"
19
19
  ],
20
- "description": "Scaffold a complete React application with SSR and routing",
20
+ "description": "Scaffold a complete React application with routing, and either server-side rendering or a client-only SPA",
21
21
  "prompts": [
22
22
  {
23
23
  "name": "appPath",
@@ -41,6 +41,23 @@ export const CREATE_SURFACE: Readonly<Record<string, SurfaceCommand>> = {
41
41
  "default": false,
42
42
  "group": "Application"
43
43
  },
44
+ {
45
+ "name": "rendering",
46
+ "type": "select",
47
+ "message": "Rendering — ssr keeps the server layer (SSR servers and sitemap), spa is client-only",
48
+ "default": "ssr",
49
+ "choices": [
50
+ {
51
+ "label": "ssr - server-side rendering (Express + Bun servers, sitemap)",
52
+ "value": "ssr"
53
+ },
54
+ {
55
+ "label": "spa - client-only single-page app (no server layer)",
56
+ "value": "spa"
57
+ }
58
+ ],
59
+ "group": "Application"
60
+ },
44
61
  {
45
62
  "name": "relay",
46
63
  "type": "confirm",
@@ -278,6 +295,11 @@ export const CREATE_CLI_SYNTAX: Readonly<
278
295
  "takesValue": false,
279
296
  "kebabName": "intl"
280
297
  },
298
+ "rendering": {
299
+ "flag": "--rendering",
300
+ "takesValue": true,
301
+ "kebabName": "rendering"
302
+ },
281
303
  "relay": {
282
304
  "flag": "--relay",
283
305
  "takesValue": false,
@@ -258,9 +258,16 @@ describe("cross-CLI conformance matrix (PROTECTED)", () => {
258
258
  // here, which is the point).
259
259
  it("every declared generator binding path is driven by a conformance fixture", () => {
260
260
  expect(
261
- [...CONFORMANCE_FIXTURES]
262
- .map((fixture) => commandPathOf(fixture.generator))
263
- .sort(),
261
+ // Deduplicated: the assertion is that every declared path is COVERED, and
262
+ // one path may carry several fixtures — application/react is driven by
263
+ // both the SSR arm and the SPA arm.
264
+ [
265
+ ...new Set(
266
+ CONFORMANCE_FIXTURES.map((fixture) =>
267
+ commandPathOf(fixture.generator),
268
+ ),
269
+ ),
270
+ ].sort(),
264
271
  ).toEqual(
265
272
  Object.values(CREATE_GENERATORS)
266
273
  .flatMap((binding) => binding.paths as readonly string[])
@@ -47,9 +47,9 @@ import type { Task } from "@canonical/task";
47
47
  import { type Command, CommanderError } from "commander";
48
48
  import { PragmaError } from "../../kernel/error/index.js";
49
49
  import { renderErrorForFormat } from "../../kernel/error/renderError.js";
50
+ import { canPrompt } from "../../kernel/interactivity.js";
50
51
  import { MUTATION_FLAG_DOCS } from "../../kernel/project/cli/constants.js";
51
52
  import {
52
- cliIsTTY,
53
53
  dispatchPrepared,
54
54
  type MutationFlags,
55
55
  } from "../../kernel/project/cli/dispatch.js";
@@ -177,7 +177,7 @@ export function explicitLeafAnswers(
177
177
  * The mount's mode resolution — `decideInteraction` over the leaf's five
178
178
  * inputs, with the TTY fact INJECTED so the resolution is testable with
179
179
  * `tty: true` (no suite can drive a real TTY through a subprocess). The
180
- * action calls it with {@link cliIsTTY} — the same exported H3 gate the
180
+ * action calls it with {@link canPrompt} — the same exported H3 gate the
181
181
  * kernel's interaction context reads — so the mount and `runCreate` can
182
182
  * never disagree about interactivity.
183
183
  */
@@ -280,7 +280,7 @@ export function mountCreateTree(parent: Command, host: CliMountHost): void {
280
280
  generator.prompts,
281
281
  explicit,
282
282
  mutation,
283
- cliIsTTY(),
283
+ canPrompt(),
284
284
  );
285
285
  if (mode === "refuse") {
286
286
  writeRefusal(
@@ -30,6 +30,10 @@ if (!standardPack) {
30
30
  const CS = "http://pragma.canonical.com/codestandards#";
31
31
  const PREFIXES = {
32
32
  cs: CS,
33
+ // The store the fixture builds is queried with the story queries verbatim,
34
+ // and the category roll-up names `skos:broader` — a CORE prefix on the real
35
+ // build path (`buildPackPrefixes`), which this in-memory helper does not use.
36
+ skos: "http://www.w3.org/2004/02/skos/core#",
33
37
  owl: "http://www.w3.org/2002/07/owl#",
34
38
  rdfs: "http://www.w3.org/2000/01/rdf-schema#",
35
39
  xsd: "http://www.w3.org/2001/XMLSchema#",
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Covers the two things `checkNodeVersion` owns beyond the range comparison:
3
+ * the shape of the CheckResult, and that the failure path quotes the declared
4
+ * range rather than a number of its own. The range logic itself is pinned in
5
+ * `isSupportedNodeVersion.test.ts`.
6
+ */
7
+
8
+ import { afterEach, describe, expect, it, vi } from "vitest";
9
+ import { SUPPORTED_NODE_RANGE } from "../../../constants.js";
10
+ import { checkNodeVersion } from "./checkNodeVersion.js";
11
+
12
+ /** Swap `process.versions.node`, which is read-only in the normal way. */
13
+ function withNodeVersion(version: string) {
14
+ vi.spyOn(process, "versions", "get").mockReturnValue({
15
+ ...process.versions,
16
+ node: version,
17
+ });
18
+ }
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks();
22
+ });
23
+
24
+ describe("checkNodeVersion — reports against the declared range", () => {
25
+ it("passes on a supported version and reports it", async () => {
26
+ withNodeVersion("24.18.1");
27
+ const result = await checkNodeVersion();
28
+ expect(result).toEqual({
29
+ name: "Node version",
30
+ status: "pass",
31
+ detail: "v24.18.1",
32
+ });
33
+ });
34
+
35
+ it("fails on an unsupported version and quotes the declared range", async () => {
36
+ withNodeVersion("20.19.0");
37
+ const result = await checkNodeVersion();
38
+ expect(result.status).toBe("fail");
39
+ expect(result.detail).toContain("v20.19.0");
40
+ // The range must come from the manifest, never from a literal in the check.
41
+ expect(result.detail).toContain(SUPPORTED_NODE_RANGE);
42
+ expect(result.remedy).toContain(SUPPORTED_NODE_RANGE);
43
+ });
44
+
45
+ it("fails inside the 23.0-23.5 window the range excludes", async () => {
46
+ withNodeVersion("23.2.0");
47
+ expect((await checkNodeVersion()).status).toBe("fail");
48
+ });
49
+ });
@@ -1,25 +1,29 @@
1
+ import { SUPPORTED_NODE_RANGE } from "../../../constants.js";
1
2
  import type { CheckResult } from "../types.js";
2
-
3
- const MIN_NODE_MAJOR = 20;
3
+ import { isSupportedNodeVersion } from "./isSupportedNodeVersion.js";
4
4
 
5
5
  /**
6
- * Check that the Node.js major version is at least {@link MIN_NODE_MAJOR}.
6
+ * Check that the running Node.js version satisfies this package's declared
7
+ * `engines.node` range.
8
+ *
9
+ * The floor is a property of the manifest, not of this file: a check that
10
+ * hardcodes its own number can disagree with the range the package publishes,
11
+ * and a consumer then passes a check the install contract would have failed.
7
12
  *
8
13
  * @returns A CheckResult with the current version and pass/fail status.
9
14
  * @note Impure — reads `process.versions`.
10
15
  */
11
16
  export async function checkNodeVersion(): Promise<CheckResult> {
12
17
  const version = process.versions.node;
13
- const major = Number.parseInt(version.split(".")[0] ?? "0", 10);
14
18
 
15
- if (major >= MIN_NODE_MAJOR) {
19
+ if (isSupportedNodeVersion(version)) {
16
20
  return { name: "Node version", status: "pass", detail: `v${version}` };
17
21
  }
18
22
 
19
23
  return {
20
24
  name: "Node version",
21
25
  status: "fail",
22
- detail: `v${version} (requires >= ${MIN_NODE_MAJOR})`,
23
- remedy: `Install Node.js >= ${MIN_NODE_MAJOR}`,
26
+ detail: `v${version} (requires ${SUPPORTED_NODE_RANGE})`,
27
+ remedy: `Install Node.js ${SUPPORTED_NODE_RANGE}`,
24
28
  };
25
29
  }
@@ -60,6 +60,51 @@ function ignoredSuffix(items: readonly CheckItem[]): string {
60
60
  return ` · ${items.length} ${items.length === 1 ? "story" : "stories"} ignored`;
61
61
  }
62
62
 
63
+ /**
64
+ * The answering pack's provenance, one item per pack it was built from.
65
+ *
66
+ * `sourceRef` is a single comma-joined string — `@canonical/design-system@git:
67
+ * d6d8a6c8268cf2bd103e956a2540d6e36bd08d72, @canonical/anatomy-dsl@npm:0.2.2,
68
+ * …` — and printing it whole put four packs and two forty-character SHAs on one
69
+ * line, in a report whose every other multi-part check (`mcp`, `harnesses`)
70
+ * uses the sub-item mechanism. Provenance is the thing a reader is here for
71
+ * when they read this line at all: which revision of which pack answered.
72
+ *
73
+ * The scheme (`git`/`npm`/`self`/`file`) is kept because it says how to move
74
+ * the pin, and a git hash is cut to the seven characters every other tool in
75
+ * this workflow shows. A ref that does not parse is passed through whole rather
76
+ * than dropped — an unreadable provenance is still provenance, and hiding it
77
+ * would be the one failure this check exists to prevent.
78
+ *
79
+ * @param sourceRef - The manifest's comma-joined provenance string.
80
+ * @returns One passing item per pack, in the order the pack records them.
81
+ */
82
+ function packItems(sourceRef: string): CheckItem[] {
83
+ return sourceRef
84
+ .split(",")
85
+ .map((ref) => ref.trim())
86
+ .filter((ref) => ref !== "")
87
+ .map((ref) => {
88
+ const parsed = /^(.+)@(git|npm|self|file|link):(.+)$/.exec(ref);
89
+ if (parsed === null) return { label: ref, status: "pass" as const };
90
+ const [, pack, scheme, revision] = parsed as unknown as [
91
+ string,
92
+ string,
93
+ string,
94
+ string,
95
+ ];
96
+ const short =
97
+ scheme === "git" && /^[0-9a-f]{40}$/.test(revision)
98
+ ? revision.slice(0, 7)
99
+ : revision;
100
+ return {
101
+ label: pack,
102
+ detail: `${scheme} ${short}`,
103
+ status: "pass" as const,
104
+ };
105
+ });
106
+ }
107
+
63
108
  /**
64
109
  * Report which pack answers reads, its provenance, and its entity total.
65
110
  *
@@ -89,13 +134,30 @@ export async function checkPackageRefs(
89
134
  // The pack DOES answer reads, so the check itself passes; the ignored stories
90
135
  // are failing sub-items under it. (A `warn` status would ripple through the
91
136
  // renderer and the DoctorData counts to buy an icon.)
92
- const items = await ignoredStoryItems(decision);
93
- const suffix = ignoredSuffix(items);
137
+ const ignored = await ignoredStoryItems(decision);
138
+ const suffix = ignoredSuffix(ignored);
139
+ const sourceRef =
140
+ decision.kind === "embedded"
141
+ ? embeddedManifest().sourceRef
142
+ : (readManifest(decision.dir)?.sourceRef ?? "");
143
+ // Packs first, then anything wrong with them: the composition is the answer to
144
+ // "which revision am I reading", and a failing story is a note about one of
145
+ // the rows above it.
146
+ const packs = packItems(sourceRef);
147
+ const items = [...packs, ...ignored];
148
+ const composition = `${packs.length} ${packs.length === 1 ? "pack" : "packs"}, ${entities} entities${suffix}`;
149
+
94
150
  if (decision.kind === "embedded") {
95
151
  return {
96
152
  name,
97
153
  status: "pass",
98
- detail: `embedded snapshot @ ${embeddedManifest().sourceRef} — ${entities} entities${suffix} · \`${BIN_NAME} sources update\` to build from the configured packs`,
154
+ // The update hint stays in the DETAIL rather than moving to `remedy`:
155
+ // the renderer prints a remedy only where there is something to fix or
156
+ // to finish, and a snapshot that is answering reads correctly is
157
+ // neither. "shipped with the CLI" rather than "embedded snapshot" — the
158
+ // reader needs to know where these packs came from, not what the build
159
+ // calls the artefact.
160
+ detail: `shipped with the CLI — ${composition} · run \`${BIN_NAME} sources update\` to build from your own configured packs instead`,
99
161
  ...(items.length > 0 ? { items } : {}),
100
162
  };
101
163
  }
@@ -103,7 +165,10 @@ export async function checkPackageRefs(
103
165
  return {
104
166
  name,
105
167
  status: "pass",
106
- detail: `${manifest?.sourceRef ?? decision.contentHash.slice(0, 12)} — ${entities} entities, built ${manifest?.createdAt ?? "?"}${suffix}`,
168
+ detail:
169
+ packs.length > 0
170
+ ? `built ${manifest?.createdAt ?? "?"} — ${composition}`
171
+ : `${decision.contentHash.slice(0, 12)} — ${composition}, built ${manifest?.createdAt ?? "?"}`,
107
172
  ...(items.length > 0 ? { items } : {}),
108
173
  };
109
174
  }
@@ -94,10 +94,12 @@ function wireZfunc(): void {
94
94
  describe("checkShellCompletions — effect test (gate 1)", () => {
95
95
  it("passes the resolver gate: `pragma <TAB>` resolves nouns", async () => {
96
96
  // No shell → skip, but only AFTER the resolver answered — the detail proves
97
- // the effect test ran and succeeded.
97
+ // the effect test ran and succeeded. The proof is the same fact worded for
98
+ // a reader ("pragma answers `<TAB>`") rather than as "resolver OK": the
99
+ // gate it pins is unchanged, only the sentence that reports it.
98
100
  const result = await check(tmp(), null);
99
101
  expect(result.status).toBe("skip");
100
- expect(result.detail).toMatch(/resolver OK/);
102
+ expect(result.detail).toMatch(/answers `<TAB>`/);
101
103
  });
102
104
  });
103
105
 
@@ -137,7 +139,7 @@ describe("checkShellCompletions — install probe (gate 2)", () => {
137
139
  const result = await check(tmp(), "bash");
138
140
  expect(result.status).toBe("pass");
139
141
  expect(result.detail).toMatch(
140
- /bash \(the shell in use\) up to date and resolving/,
142
+ /bash \(the shell you are in\) — installed, up to date, and completing/,
141
143
  );
142
144
  });
143
145
 
@@ -200,7 +202,7 @@ describe("checkShellCompletions — zsh fpath activation (gate 3)", () => {
200
202
  const result = await check(tmp(), "zsh");
201
203
  expect(result.status).toBe("pass");
202
204
  expect(result.detail).toMatch(
203
- /zsh \(the shell in use\) up to date and resolving/,
205
+ /zsh \(the shell you are in\) — installed, up to date, and completing/,
204
206
  );
205
207
  });
206
208
  });
@@ -126,7 +126,7 @@ export async function checkShellCompletions(
126
126
  return {
127
127
  name: NAME,
128
128
  status: "fail",
129
- detail: `completion resolver failed: ${reason}`,
129
+ detail: `${BIN_NAME} cannot answer \`<TAB>\`: ${reason}`,
130
130
  remedy: `Report this as a bug — \`${BIN_NAME} __complete\` should never throw.`,
131
131
  };
132
132
  }
@@ -134,7 +134,7 @@ export async function checkShellCompletions(
134
134
  return {
135
135
  name: NAME,
136
136
  status: "fail",
137
- detail: `completion resolver returned no candidates for \`${BIN_NAME} <TAB>\``,
137
+ detail: `${BIN_NAME} answered \`<TAB>\` with nothing to complete`,
138
138
  remedy: "Report this as a bug — the noun context is always non-empty.",
139
139
  };
140
140
  }
@@ -147,7 +147,7 @@ export async function checkShellCompletions(
147
147
  return {
148
148
  name: NAME,
149
149
  status: "skip",
150
- detail: `resolver OK; the running shell cannot be identified (SHELL names ${d.detection.login}, the login shell)`,
150
+ detail: `${BIN_NAME} answers \`<TAB>\`, but the running shell cannot be identified — SHELL names ${d.detection.login}, which is your login shell`,
151
151
  remedy: `Run \`${INSTALL_REMEDY}\` from the shell you want completions in.`,
152
152
  };
153
153
  }
@@ -155,7 +155,7 @@ export async function checkShellCompletions(
155
155
  return {
156
156
  name: NAME,
157
157
  status: "skip",
158
- detail: "resolver OK; no bash, zsh, or fish in this process tree",
158
+ detail: `${BIN_NAME} answers \`<TAB>\`, but no bash, zsh, or fish was found in this process tree`,
159
159
  };
160
160
  }
161
161
  // The installed script delegates every name context to the binary. A script
@@ -174,7 +174,7 @@ export async function checkShellCompletions(
174
174
  status: installed ? "fail" : "available",
175
175
  detail: installed
176
176
  ? `${shell} script installed, but \`${BIN_NAME}\` is not on PATH — the script cannot run it`
177
- : `resolver OK; \`${BIN_NAME}\` is not on PATH — an installed script cannot run it`,
177
+ : `${BIN_NAME} answers \`<TAB>\`, but is not on PATH — an installed script cannot run it`,
178
178
  remedy: `Put \`${BIN_NAME}\` on your PATH.`,
179
179
  };
180
180
  }
@@ -186,7 +186,7 @@ export async function checkShellCompletions(
186
186
  return {
187
187
  name: NAME,
188
188
  status: "available",
189
- detail: `resolver OK; ${shell} script not installed`,
189
+ detail: `${BIN_NAME} answers \`<TAB>\`; the ${shell} script is not installed`,
190
190
  remedy: INSTALL_REMEDY,
191
191
  };
192
192
  }
@@ -194,7 +194,7 @@ export async function checkShellCompletions(
194
194
  return {
195
195
  name: NAME,
196
196
  status: "fail",
197
- detail: `resolver OK; ${shell} script at ${path} is out of date`,
197
+ detail: `${BIN_NAME} answers \`<TAB>\`; the ${shell} script at ${path} is out of date`,
198
198
  remedy: INSTALL_REMEDY,
199
199
  };
200
200
  }
@@ -212,6 +212,6 @@ export async function checkShellCompletions(
212
212
  return {
213
213
  name: NAME,
214
214
  status: "pass",
215
- detail: `${shell} (the shell in use) up to date and resolving (${candidates} nouns)`,
215
+ detail: `${shell} (the shell you are in) — installed, up to date, and completing ${candidates} commands`,
216
216
  };
217
217
  }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The harness inventory roll-up — the pure half, driven by fixtures.
3
+ *
4
+ * `doctor.test.ts` covers the wiring (both scopes, `--verbose`, the real
5
+ * registry). What is unit-tested here is the classification itself: the
6
+ * registry supplies the universe so the NEGATIVE is representable, the groups
7
+ * supply the state, and no combination of the two may ever produce a `fail`.
8
+ */
9
+
10
+ import { describe, expect, it } from "vitest";
11
+ import {
12
+ harnessInventory,
13
+ type InventoryGroup,
14
+ type InventoryHarness,
15
+ type InventoryMember,
16
+ type InventoryRow,
17
+ inventoryHealth,
18
+ inventoryItems,
19
+ isDetectedState,
20
+ type WriteState,
21
+ } from "./harnessInventory.js";
22
+
23
+ const REGISTRY: InventoryHarness[] = [
24
+ { id: "claude-code", name: "Claude Code", inScope: true },
25
+ { id: "cursor", name: "Cursor", inScope: true },
26
+ { id: "vscode", name: "VS Code", inScope: false },
27
+ ];
28
+
29
+ /** One harness's share of a file — its OWN write's state, never the file's. */
30
+ const named = (name: string, state: WriteState): InventoryMember => ({
31
+ name,
32
+ state,
33
+ });
34
+
35
+ const roll = (groups: InventoryGroup[]): InventoryRow[] =>
36
+ harnessInventory(REGISTRY, groups, "global");
37
+
38
+ describe("harnessInventory — the roll-up", () => {
39
+ it("classifies each registry harness against the scope's groups", () => {
40
+ const rows = roll([
41
+ {
42
+ path: "~/.claude.json",
43
+ harnesses: [named("Claude Code", "registered")],
44
+ },
45
+ { path: "~/.cursor/mcp.json", harnesses: [named("Cursor", "absent")] },
46
+ ]);
47
+
48
+ expect(rows.map((r) => `${r.harnessId}:${r.state}`)).toEqual([
49
+ "claude-code:registered",
50
+ "cursor:detected",
51
+ // Not detected AND has no location in this scope at all — the two are
52
+ // different facts and the row says which one applies.
53
+ "vscode:unscoped",
54
+ ]);
55
+ expect(rows[0]?.locations).toEqual(["~/.claude.json"]);
56
+ expect(rows[2]?.locations).toEqual([]);
57
+ });
58
+
59
+ it("keeps 'undetected' and 'unscoped' apart", () => {
60
+ // Same absence, two different reasons: Cursor could be here and is not,
61
+ // VS Code could never be here at all.
62
+ const rows = roll([]);
63
+ expect(rows.find((r) => r.harnessId === "cursor")?.state).toBe(
64
+ "undetected",
65
+ );
66
+ expect(rows.find((r) => r.harnessId === "vscode")?.state).toBe("unscoped");
67
+ });
68
+
69
+ it("reports a drifted group as drifted, not as registered", () => {
70
+ const rows = roll([
71
+ { path: "~/.claude.json", harnesses: [named("Claude Code", "drifted")] },
72
+ ]);
73
+ expect(rows[0]?.state).toBe("drifted");
74
+ });
75
+
76
+ it("a harness sharing two files is registered only when BOTH carry the entry", () => {
77
+ const both = harnessInventory(
78
+ REGISTRY,
79
+ [
80
+ { path: "a.json", harnesses: [named("Cursor", "registered")] },
81
+ { path: "b.json", harnesses: [named("Cursor", "registered")] },
82
+ ],
83
+ "global",
84
+ );
85
+ expect(both[1]?.state).toBe("registered");
86
+ expect(both[1]?.locations).toEqual(["a.json", "b.json"]);
87
+
88
+ const mixed = harnessInventory(
89
+ REGISTRY,
90
+ [
91
+ { path: "a.json", harnesses: [named("Cursor", "registered")] },
92
+ { path: "b.json", harnesses: [named("Cursor", "absent")] },
93
+ ],
94
+ "global",
95
+ );
96
+ expect(mixed[1]?.state).toBe("drifted");
97
+ });
98
+
99
+ it("gives each harness sharing ONE file its own state, not the file's", () => {
100
+ // The co-detection case, and the reason a group carries per-harness state:
101
+ // `.vscode/mcp.json` holds VS Code's `servers` and Cline's `mcpServers` as
102
+ // two independent entries. Register pragma for VS Code alone and the FILE
103
+ // classifies as drifted — one key current, one absent — but neither
104
+ // harness is drifted: VS Code is registered and Cline is merely detected.
105
+ // A fixture with one harness per file cannot see this.
106
+ const shared: InventoryHarness[] = [
107
+ { id: "cline", name: "Cline", inScope: true },
108
+ { id: "vscode", name: "VS Code", inScope: true },
109
+ ];
110
+ const rows = harnessInventory(
111
+ shared,
112
+ [
113
+ {
114
+ path: ".vscode/mcp.json",
115
+ harnesses: [named("Cline", "absent"), named("VS Code", "registered")],
116
+ },
117
+ ],
118
+ "project",
119
+ );
120
+
121
+ expect(rows.map((r) => `${r.harnessId}:${r.state}`)).toEqual([
122
+ "cline:detected",
123
+ "vscode:registered",
124
+ ]);
125
+ // Both are located in the same file — the sharing itself is unchanged.
126
+ for (const row of rows) expect(row.locations).toEqual([".vscode/mcp.json"]);
127
+ });
128
+
129
+ it("ignores a group naming a harness the registry does not know", () => {
130
+ // The inventory reports on the REGISTRY; a group is evidence, not a source
131
+ // of rows, so an unknown name adds nothing rather than inventing an entry.
132
+ const rows = roll([
133
+ { path: "x.json", harnesses: [named("Ghost Editor", "registered")] },
134
+ ]);
135
+ expect(rows).toHaveLength(REGISTRY.length);
136
+ expect(rows.map((r) => r.harnessName)).not.toContain("Ghost Editor");
137
+ });
138
+
139
+ it("names every row with the scope it was rolled up for", () => {
140
+ const rows = harnessInventory(REGISTRY, [], "project");
141
+ expect(rows.every((r) => r.scope === "project")).toBe(true);
142
+ });
143
+ });
144
+
145
+ describe("harnessInventory — the listing", () => {
146
+ const rows = roll([
147
+ { path: "~/.claude.json", harnesses: [named("Claude Code", "registered")] },
148
+ ]);
149
+
150
+ it("shows detected harnesses only by default", () => {
151
+ const items = inventoryItems(rows, false);
152
+ expect(items.map((i) => i.label)).toEqual(["Claude Code"]);
153
+ expect(items[0]?.detail).toBe("registered — ~/.claude.json");
154
+ });
155
+
156
+ it("--verbose widens the same rows to the whole registry", () => {
157
+ const items = inventoryItems(rows, true);
158
+ expect(items.map((i) => i.label)).toEqual([
159
+ "Claude Code",
160
+ "Cursor",
161
+ "VS Code",
162
+ ]);
163
+ expect(items[1]?.detail).toBe("not detected");
164
+ // Scope-aware, and it states the FACT rather than naming a partition:
165
+ // VS Code keeps its MCP config per project, so there is no global file for
166
+ // pragma to be in. `no Global band` named an internal word for that.
167
+ expect(items[2]?.detail).toBe(
168
+ "keeps no global config — it is per-project only",
169
+ );
170
+ });
171
+
172
+ it("never renders any state as a failure", () => {
173
+ for (const item of inventoryItems(rows, true)) {
174
+ expect(item.status).not.toBe("fail");
175
+ }
176
+ const every = harnessInventory(REGISTRY, [], "project");
177
+ for (const item of inventoryItems(every, true)) {
178
+ expect(item.status).not.toBe("fail");
179
+ }
180
+ });
181
+
182
+ it("is a listing, not a verdict: pass when the scope holds harnesses, skip when it does not", () => {
183
+ expect(inventoryHealth(rows, false).status).toBe("pass");
184
+ expect(inventoryHealth(rows, false).detail).toBe(
185
+ "1 detected · 1 registered",
186
+ );
187
+ expect(inventoryHealth(rows, true).detail).toBe(
188
+ "1 detected · 1 registered · 3 known",
189
+ );
190
+
191
+ const empty = roll([]);
192
+ expect(inventoryHealth(empty, false).status).toBe("skip");
193
+ expect(inventoryHealth(empty, false).detail).toBe("no harnesses detected");
194
+ expect(inventoryHealth(empty, false).items).toEqual([]);
195
+ expect(inventoryHealth(empty, true).detail).toBe(
196
+ "none of 3 known harnesses detected",
197
+ );
198
+ });
199
+
200
+ it("counts a drifted harness as detected but not as registered", () => {
201
+ const drifted = roll([
202
+ { path: "~/.claude.json", harnesses: [named("Claude Code", "drifted")] },
203
+ ]);
204
+ expect(inventoryHealth(drifted, false).detail).toBe(
205
+ "1 detected · 0 registered",
206
+ );
207
+ expect(inventoryItems(drifted, false)[0]?.status).toBe("available");
208
+ });
209
+ });
210
+
211
+ describe("isDetectedState", () => {
212
+ it("counts every state that means the harness is on the machine", () => {
213
+ expect(isDetectedState("registered")).toBe(true);
214
+ expect(isDetectedState("drifted")).toBe(true);
215
+ expect(isDetectedState("detected")).toBe(true);
216
+ expect(isDetectedState("undetected")).toBe(false);
217
+ expect(isDetectedState("unscoped")).toBe(false);
218
+ });
219
+ });