@canonical/pragma-cli 0.34.0 → 0.36.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 (832) hide show
  1. package/README.md +153 -30
  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/.build-ok +0 -0
  13. package/dist/package.json +84 -0
  14. package/dist/pragma.conf.js +1016 -0
  15. package/dist/pragma.conf.js.map +1 -0
  16. package/dist/src/bin.js +356 -0
  17. package/dist/src/bin.js.map +1 -0
  18. package/dist/src/capabilities/capabilities/capabilities.render.js +70 -0
  19. package/dist/src/capabilities/capabilities/capabilities.render.js.map +1 -0
  20. package/dist/src/capabilities/capabilities/capabilities.verb.js +59 -0
  21. package/dist/src/capabilities/capabilities/capabilities.verb.js.map +1 -0
  22. package/dist/src/capabilities/capabilities/catalog.js +130 -0
  23. package/dist/src/capabilities/capabilities/catalog.js.map +1 -0
  24. package/dist/src/capabilities/capabilities/hints.js +195 -0
  25. package/dist/src/capabilities/capabilities/hints.js.map +1 -0
  26. package/dist/src/capabilities/capabilities/index.js +11 -0
  27. package/dist/src/capabilities/capabilities/index.js.map +1 -0
  28. package/dist/src/capabilities/capabilities/types.js +11 -0
  29. package/dist/src/capabilities/capabilities/types.js.map +1 -0
  30. package/dist/src/capabilities/colophon/collectColophon.js +83 -0
  31. package/dist/src/capabilities/colophon/collectColophon.js.map +1 -0
  32. package/dist/src/capabilities/colophon/colophon.render.js +59 -0
  33. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -0
  34. package/dist/src/capabilities/colophon/colophon.verb.js +43 -0
  35. package/dist/src/capabilities/colophon/colophon.verb.js.map +1 -0
  36. package/dist/src/capabilities/colophon/index.js +5 -0
  37. package/dist/src/capabilities/colophon/index.js.map +1 -0
  38. package/dist/src/capabilities/colophon/markdownTerminal.js +90 -0
  39. package/dist/src/capabilities/colophon/markdownTerminal.js.map +1 -0
  40. package/dist/src/capabilities/colophon/types.js +10 -0
  41. package/dist/src/capabilities/colophon/types.js.map +1 -0
  42. package/dist/src/capabilities/config/collectConfigShow.js +59 -0
  43. package/dist/src/capabilities/config/collectConfigShow.js.map +1 -0
  44. package/dist/src/capabilities/config/field.render.js +22 -0
  45. package/dist/src/capabilities/config/field.render.js.map +1 -0
  46. package/dist/src/capabilities/config/fields.js +46 -0
  47. package/dist/src/capabilities/config/fields.js.map +1 -0
  48. package/dist/src/capabilities/config/get.verb.js +61 -0
  49. package/dist/src/capabilities/config/get.verb.js.map +1 -0
  50. package/dist/src/capabilities/config/index.js +5 -0
  51. package/dist/src/capabilities/config/index.js.map +1 -0
  52. package/dist/src/capabilities/config/runField.js +59 -0
  53. package/dist/src/capabilities/config/runField.js.map +1 -0
  54. package/dist/src/capabilities/config/runGet.js +28 -0
  55. package/dist/src/capabilities/config/runGet.js.map +1 -0
  56. package/dist/src/capabilities/config/runSet.js +53 -0
  57. package/dist/src/capabilities/config/runSet.js.map +1 -0
  58. package/dist/src/capabilities/config/set.verb.js +66 -0
  59. package/dist/src/capabilities/config/set.verb.js.map +1 -0
  60. package/dist/src/capabilities/config/show.render.js +84 -0
  61. package/dist/src/capabilities/config/show.render.js.map +1 -0
  62. package/dist/src/capabilities/config/show.verb.js +44 -0
  63. package/dist/src/capabilities/config/show.verb.js.map +1 -0
  64. package/dist/src/capabilities/config/types.js +5 -0
  65. package/dist/src/capabilities/config/types.js.map +1 -0
  66. package/dist/src/capabilities/config/unset.verb.js +49 -0
  67. package/dist/src/capabilities/config/unset.verb.js.map +1 -0
  68. package/dist/src/capabilities/create/cliProjection.js +195 -0
  69. package/dist/src/capabilities/create/cliProjection.js.map +1 -0
  70. package/dist/src/capabilities/create/constants.js +47 -0
  71. package/dist/src/capabilities/create/constants.js.map +1 -0
  72. package/dist/src/capabilities/create/create.render.js +100 -0
  73. package/dist/src/capabilities/create/create.render.js.map +1 -0
  74. package/dist/src/capabilities/create/create.verb.js +438 -0
  75. package/dist/src/capabilities/create/create.verb.js.map +1 -0
  76. package/dist/src/capabilities/create/createHelp.js +47 -0
  77. package/dist/src/capabilities/create/createHelp.js.map +1 -0
  78. package/dist/src/capabilities/create/createSurface.generated.js +394 -0
  79. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -0
  80. package/dist/src/capabilities/create/generatorToVerbSpec.js +106 -0
  81. package/dist/src/capabilities/create/generatorToVerbSpec.js.map +1 -0
  82. package/dist/src/capabilities/create/index.js +18 -0
  83. package/dist/src/capabilities/create/index.js.map +1 -0
  84. package/dist/src/capabilities/create/messages.js +16 -0
  85. package/dist/src/capabilities/create/messages.js.map +1 -0
  86. package/dist/src/capabilities/create/mount.js +294 -0
  87. package/dist/src/capabilities/create/mount.js.map +1 -0
  88. package/dist/src/capabilities/create/pathJail.js +65 -0
  89. package/dist/src/capabilities/create/pathJail.js.map +1 -0
  90. package/dist/src/capabilities/create/pickGenerator.js +53 -0
  91. package/dist/src/capabilities/create/pickGenerator.js.map +1 -0
  92. package/dist/src/capabilities/create/types.js +2 -0
  93. package/dist/src/capabilities/create/types.js.map +1 -0
  94. package/dist/src/capabilities/distribution.js +40 -0
  95. package/dist/src/capabilities/distribution.js.map +1 -0
  96. package/dist/src/capabilities/doctor/checks/checkKeStore.js +40 -0
  97. package/dist/src/capabilities/doctor/checks/checkKeStore.js.map +1 -0
  98. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +21 -0
  99. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -0
  100. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +148 -0
  101. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -0
  102. package/dist/src/capabilities/doctor/checks/checkPragmaVersion.js +19 -0
  103. package/dist/src/capabilities/doctor/checks/checkPragmaVersion.js.map +1 -0
  104. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +199 -0
  105. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -0
  106. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  107. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  108. package/dist/src/capabilities/doctor/checks/index.js +27 -0
  109. package/dist/src/capabilities/doctor/checks/index.js.map +1 -0
  110. package/dist/src/capabilities/doctor/checks/mcpCommand.js +57 -0
  111. package/dist/src/capabilities/doctor/checks/mcpCommand.js.map +1 -0
  112. package/dist/src/capabilities/doctor/checks/targetHealth.js +349 -0
  113. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -0
  114. package/dist/src/capabilities/doctor/doctor.render.js +164 -0
  115. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -0
  116. package/dist/src/capabilities/doctor/doctor.verb.js +49 -0
  117. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -0
  118. package/dist/src/capabilities/doctor/index.js +5 -0
  119. package/dist/src/capabilities/doctor/index.js.map +1 -0
  120. package/dist/src/capabilities/doctor/runChecks.js +86 -0
  121. package/dist/src/capabilities/doctor/runChecks.js.map +1 -0
  122. package/dist/src/capabilities/doctor/types.js +5 -0
  123. package/dist/src/capabilities/doctor/types.js.map +1 -0
  124. package/dist/src/capabilities/graph/index.js +19 -0
  125. package/dist/src/capabilities/graph/index.js.map +1 -0
  126. package/dist/src/capabilities/graph/inspect.render.js +76 -0
  127. package/dist/src/capabilities/graph/inspect.render.js.map +1 -0
  128. package/dist/src/capabilities/graph/inspect.verb.js +53 -0
  129. package/dist/src/capabilities/graph/inspect.verb.js.map +1 -0
  130. package/dist/src/capabilities/graph/query.render.js +73 -0
  131. package/dist/src/capabilities/graph/query.render.js.map +1 -0
  132. package/dist/src/capabilities/graph/query.verb.js +49 -0
  133. package/dist/src/capabilities/graph/query.verb.js.map +1 -0
  134. package/dist/src/capabilities/graph/runQuery.js +41 -0
  135. package/dist/src/capabilities/graph/runQuery.js.map +1 -0
  136. package/dist/src/capabilities/index.js +56 -0
  137. package/dist/src/capabilities/index.js.map +1 -0
  138. package/dist/src/capabilities/info/collectInfo.js +72 -0
  139. package/dist/src/capabilities/info/collectInfo.js.map +1 -0
  140. package/dist/src/capabilities/info/index.js +5 -0
  141. package/dist/src/capabilities/info/index.js.map +1 -0
  142. package/dist/src/capabilities/info/info.render.js +57 -0
  143. package/dist/src/capabilities/info/info.render.js.map +1 -0
  144. package/dist/src/capabilities/info/info.verb.js +42 -0
  145. package/dist/src/capabilities/info/info.verb.js.map +1 -0
  146. package/dist/src/capabilities/info/types.js +5 -0
  147. package/dist/src/capabilities/info/types.js.map +1 -0
  148. package/dist/src/capabilities/meta/complete.verb.js +43 -0
  149. package/dist/src/capabilities/meta/complete.verb.js.map +1 -0
  150. package/dist/src/capabilities/meta/index.js +13 -0
  151. package/dist/src/capabilities/meta/index.js.map +1 -0
  152. package/dist/src/capabilities/meta/mcp.verb.js +34 -0
  153. package/dist/src/capabilities/meta/mcp.verb.js.map +1 -0
  154. package/dist/src/capabilities/ontology/index.js +21 -0
  155. package/dist/src/capabilities/ontology/index.js.map +1 -0
  156. package/dist/src/capabilities/ontology/queries.js +123 -0
  157. package/dist/src/capabilities/ontology/queries.js.map +1 -0
  158. package/dist/src/capabilities/ontology/render.js +66 -0
  159. package/dist/src/capabilities/ontology/render.js.map +1 -0
  160. package/dist/src/capabilities/ontology/verbs.js +181 -0
  161. package/dist/src/capabilities/ontology/verbs.js.map +1 -0
  162. package/dist/src/capabilities/prompt/index.js +16 -0
  163. package/dist/src/capabilities/prompt/index.js.map +1 -0
  164. package/dist/src/capabilities/prompt/prompt.render.js +78 -0
  165. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -0
  166. package/dist/src/capabilities/prompt/types.js +10 -0
  167. package/dist/src/capabilities/prompt/types.js.map +1 -0
  168. package/dist/src/capabilities/prompt/verbs.js +93 -0
  169. package/dist/src/capabilities/prompt/verbs.js.map +1 -0
  170. package/dist/src/capabilities/resources/index.js +10 -0
  171. package/dist/src/capabilities/resources/index.js.map +1 -0
  172. package/dist/src/capabilities/resources/provider.js +423 -0
  173. package/dist/src/capabilities/resources/provider.js.map +1 -0
  174. package/dist/src/capabilities/setup/buildPlan.js +172 -0
  175. package/dist/src/capabilities/setup/buildPlan.js.map +1 -0
  176. package/dist/src/capabilities/setup/index.js +5 -0
  177. package/dist/src/capabilities/setup/index.js.map +1 -0
  178. package/dist/src/capabilities/setup/mode.js +53 -0
  179. package/dist/src/capabilities/setup/mode.js.map +1 -0
  180. package/dist/src/capabilities/setup/operations/index.js +39 -0
  181. package/dist/src/capabilities/setup/operations/index.js.map +1 -0
  182. package/dist/src/capabilities/setup/operations/setupCompletions.js +160 -0
  183. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -0
  184. package/dist/src/capabilities/setup/operations/setupConfig.js +101 -0
  185. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -0
  186. package/dist/src/capabilities/setup/operations/setupGenerator.js +390 -0
  187. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -0
  188. package/dist/src/capabilities/setup/operations/setupLsp.js +329 -0
  189. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -0
  190. package/dist/src/capabilities/setup/operations/setupMcp.js +253 -0
  191. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -0
  192. package/dist/src/capabilities/setup/operations/setupSkills.js +594 -0
  193. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -0
  194. package/dist/src/capabilities/setup/plan.js +128 -0
  195. package/dist/src/capabilities/setup/plan.js.map +1 -0
  196. package/dist/src/capabilities/setup/plan.render.js +271 -0
  197. package/dist/src/capabilities/setup/plan.render.js.map +1 -0
  198. package/dist/src/capabilities/setup/setup.render.js +54 -0
  199. package/dist/src/capabilities/setup/setup.render.js.map +1 -0
  200. package/dist/src/capabilities/setup/setup.verb.js +387 -0
  201. package/dist/src/capabilities/setup/setup.verb.js.map +1 -0
  202. package/dist/src/capabilities/setup/shell.js +196 -0
  203. package/dist/src/capabilities/setup/shell.js.map +1 -0
  204. package/dist/src/capabilities/setup/targets.js +328 -0
  205. package/dist/src/capabilities/setup/targets.js.map +1 -0
  206. package/dist/src/capabilities/setup/types.js +5 -0
  207. package/dist/src/capabilities/setup/types.js.map +1 -0
  208. package/dist/src/capabilities/shared/assertExecOk.js +137 -0
  209. package/dist/src/capabilities/shared/assertExecOk.js.map +1 -0
  210. package/dist/src/capabilities/shared/index.js +27 -0
  211. package/dist/src/capabilities/shared/index.js.map +1 -0
  212. package/dist/src/capabilities/shared/packageManager.js +349 -0
  213. package/dist/src/capabilities/shared/packageManager.js.map +1 -0
  214. package/dist/src/capabilities/shared/registry.js +55 -0
  215. package/dist/src/capabilities/shared/registry.js.map +1 -0
  216. package/dist/src/capabilities/skill/discover.js +281 -0
  217. package/dist/src/capabilities/skill/discover.js.map +1 -0
  218. package/dist/src/capabilities/skill/index.js +10 -0
  219. package/dist/src/capabilities/skill/index.js.map +1 -0
  220. package/dist/src/capabilities/skill/render.js +49 -0
  221. package/dist/src/capabilities/skill/render.js.map +1 -0
  222. package/dist/src/capabilities/skill/verbs.js +83 -0
  223. package/dist/src/capabilities/skill/verbs.js.map +1 -0
  224. package/dist/src/capabilities/sources/collectStatus.js +103 -0
  225. package/dist/src/capabilities/sources/collectStatus.js.map +1 -0
  226. package/dist/src/capabilities/sources/index.js +13 -0
  227. package/dist/src/capabilities/sources/index.js.map +1 -0
  228. package/dist/src/capabilities/sources/installSkills.js +309 -0
  229. package/dist/src/capabilities/sources/installSkills.js.map +1 -0
  230. package/dist/src/capabilities/sources/runUpdate.js +460 -0
  231. package/dist/src/capabilities/sources/runUpdate.js.map +1 -0
  232. package/dist/src/capabilities/sources/status.render.js +64 -0
  233. package/dist/src/capabilities/sources/status.render.js.map +1 -0
  234. package/dist/src/capabilities/sources/status.verb.js +40 -0
  235. package/dist/src/capabilities/sources/status.verb.js.map +1 -0
  236. package/dist/src/capabilities/sources/types.js +6 -0
  237. package/dist/src/capabilities/sources/types.js.map +1 -0
  238. package/dist/src/capabilities/sources/update.render.js +34 -0
  239. package/dist/src/capabilities/sources/update.render.js.map +1 -0
  240. package/dist/src/capabilities/sources/update.verb.js +55 -0
  241. package/dist/src/capabilities/sources/update.verb.js.map +1 -0
  242. package/dist/src/capabilities/upgrade/index.js +5 -0
  243. package/dist/src/capabilities/upgrade/index.js.map +1 -0
  244. package/dist/src/capabilities/upgrade/runUpgrade.js +135 -0
  245. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -0
  246. package/dist/src/capabilities/upgrade/types.js +5 -0
  247. package/dist/src/capabilities/upgrade/types.js.map +1 -0
  248. package/dist/src/capabilities/upgrade/upgrade.render.js +52 -0
  249. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -0
  250. package/dist/src/capabilities/upgrade/upgrade.verb.js +52 -0
  251. package/dist/src/capabilities/upgrade/upgrade.verb.js.map +1 -0
  252. package/dist/src/capabilities/version/index.js +10 -0
  253. package/dist/src/capabilities/version/index.js.map +1 -0
  254. package/dist/src/capabilities/version/version.verb.js +36 -0
  255. package/dist/src/capabilities/version/version.verb.js.map +1 -0
  256. package/dist/src/constants.js +87 -0
  257. package/dist/src/constants.js.map +1 -0
  258. package/dist/src/index.js +12 -0
  259. package/dist/src/index.js.map +1 -0
  260. package/dist/src/kernel/completion/complete.js +66 -0
  261. package/dist/src/kernel/completion/complete.js.map +1 -0
  262. package/dist/src/kernel/completion/emitScripts.js +92 -0
  263. package/dist/src/kernel/completion/emitScripts.js.map +1 -0
  264. package/dist/src/kernel/completion/entitySource.js +285 -0
  265. package/dist/src/kernel/completion/entitySource.js.map +1 -0
  266. package/dist/src/kernel/completion/index.js +14 -0
  267. package/dist/src/kernel/completion/index.js.map +1 -0
  268. package/dist/src/kernel/completion/model.js +301 -0
  269. package/dist/src/kernel/completion/model.js.map +1 -0
  270. package/dist/src/kernel/completion/parse.js +253 -0
  271. package/dist/src/kernel/completion/parse.js.map +1 -0
  272. package/dist/src/kernel/completion/rank.js +101 -0
  273. package/dist/src/kernel/completion/rank.js.map +1 -0
  274. package/dist/src/kernel/completion/resolve.js +100 -0
  275. package/dist/src/kernel/completion/resolve.js.map +1 -0
  276. package/dist/src/kernel/completion/templates/bash.js +209 -0
  277. package/dist/src/kernel/completion/templates/bash.js.map +1 -0
  278. package/dist/src/kernel/completion/templates/fish.js +214 -0
  279. package/dist/src/kernel/completion/templates/fish.js.map +1 -0
  280. package/dist/src/kernel/completion/templates/index.js +23 -0
  281. package/dist/src/kernel/completion/templates/index.js.map +1 -0
  282. package/dist/src/kernel/completion/templates/shared.js +98 -0
  283. package/dist/src/kernel/completion/templates/shared.js.map +1 -0
  284. package/dist/src/kernel/completion/templates/zsh.js +201 -0
  285. package/dist/src/kernel/completion/templates/zsh.js.map +1 -0
  286. package/dist/src/kernel/completion/types.js +13 -0
  287. package/dist/src/kernel/completion/types.js.map +1 -0
  288. package/dist/src/kernel/config/defaults.js +17 -0
  289. package/dist/src/kernel/config/defaults.js.map +1 -0
  290. package/dist/src/kernel/config/evaluateProjectConfig.js +126 -0
  291. package/dist/src/kernel/config/evaluateProjectConfig.js.map +1 -0
  292. package/dist/src/kernel/config/findProjectConfig.js +43 -0
  293. package/dist/src/kernel/config/findProjectConfig.js.map +1 -0
  294. package/dist/src/kernel/config/firstRun.js +34 -0
  295. package/dist/src/kernel/config/firstRun.js.map +1 -0
  296. package/dist/src/kernel/config/globalConfig.js +79 -0
  297. package/dist/src/kernel/config/globalConfig.js.map +1 -0
  298. package/dist/src/kernel/config/index.js +16 -0
  299. package/dist/src/kernel/config/index.js.map +1 -0
  300. package/dist/src/kernel/config/paths.js +35 -0
  301. package/dist/src/kernel/config/paths.js.map +1 -0
  302. package/dist/src/kernel/config/readConfig.js +78 -0
  303. package/dist/src/kernel/config/readConfig.js.map +1 -0
  304. package/dist/src/kernel/config/schema.js +130 -0
  305. package/dist/src/kernel/config/schema.js.map +1 -0
  306. package/dist/src/kernel/config/types.js +19 -0
  307. package/dist/src/kernel/config/types.js.map +1 -0
  308. package/dist/src/kernel/config/writeConfigField.js +59 -0
  309. package/dist/src/kernel/config/writeConfigField.js.map +1 -0
  310. package/dist/src/kernel/copy.js +43 -0
  311. package/dist/src/kernel/copy.js.map +1 -0
  312. package/dist/src/kernel/error/PragmaError.js +149 -0
  313. package/dist/src/kernel/error/PragmaError.js.map +1 -0
  314. package/dist/src/kernel/error/constants.js +40 -0
  315. package/dist/src/kernel/error/constants.js.map +1 -0
  316. package/dist/src/kernel/error/fromTaskError.js +96 -0
  317. package/dist/src/kernel/error/fromTaskError.js.map +1 -0
  318. package/dist/src/kernel/error/index.js +13 -0
  319. package/dist/src/kernel/error/index.js.map +1 -0
  320. package/dist/src/kernel/error/recovery.js +52 -0
  321. package/dist/src/kernel/error/recovery.js.map +1 -0
  322. package/dist/src/kernel/error/renderError.js +115 -0
  323. package/dist/src/kernel/error/renderError.js.map +1 -0
  324. package/dist/src/kernel/error/serialize.js +25 -0
  325. package/dist/src/kernel/error/serialize.js.map +1 -0
  326. package/dist/src/kernel/error/types.js +9 -0
  327. package/dist/src/kernel/error/types.js.map +1 -0
  328. package/dist/src/kernel/index.js +34 -0
  329. package/dist/src/kernel/index.js.map +1 -0
  330. package/dist/src/kernel/interactivity.js +68 -0
  331. package/dist/src/kernel/interactivity.js.map +1 -0
  332. package/dist/src/kernel/packs/collect.js +187 -0
  333. package/dist/src/kernel/packs/collect.js.map +1 -0
  334. package/dist/src/kernel/packs/compile.js +304 -0
  335. package/dist/src/kernel/packs/compile.js.map +1 -0
  336. package/dist/src/kernel/packs/disclosure.js +76 -0
  337. package/dist/src/kernel/packs/disclosure.js.map +1 -0
  338. package/dist/src/kernel/packs/glob.js +38 -0
  339. package/dist/src/kernel/packs/glob.js.map +1 -0
  340. package/dist/src/kernel/packs/graphql/buildLookupDocument.js +258 -0
  341. package/dist/src/kernel/packs/graphql/buildLookupDocument.js.map +1 -0
  342. package/dist/src/kernel/packs/graphql/fetchGraphqlLookup.js +157 -0
  343. package/dist/src/kernel/packs/graphql/fetchGraphqlLookup.js.map +1 -0
  344. package/dist/src/kernel/packs/graphql/index.js +20 -0
  345. package/dist/src/kernel/packs/graphql/index.js.map +1 -0
  346. package/dist/src/kernel/packs/graphql/nameMap.js +63 -0
  347. package/dist/src/kernel/packs/graphql/nameMap.js.map +1 -0
  348. package/dist/src/kernel/packs/index.js +34 -0
  349. package/dist/src/kernel/packs/index.js.map +1 -0
  350. package/dist/src/kernel/packs/iri.js +110 -0
  351. package/dist/src/kernel/packs/iri.js.map +1 -0
  352. package/dist/src/kernel/packs/renderPack.js +129 -0
  353. package/dist/src/kernel/packs/renderPack.js.map +1 -0
  354. package/dist/src/kernel/packs/resolveEntity.js +274 -0
  355. package/dist/src/kernel/packs/resolveEntity.js.map +1 -0
  356. package/dist/src/kernel/packs/runBodies.js +140 -0
  357. package/dist/src/kernel/packs/runBodies.js.map +1 -0
  358. package/dist/src/kernel/packs/sample.js +56 -0
  359. package/dist/src/kernel/packs/sample.js.map +1 -0
  360. package/dist/src/kernel/packs/schema.js +421 -0
  361. package/dist/src/kernel/packs/schema.js.map +1 -0
  362. package/dist/src/kernel/packs/sparql/applyFilters.js +167 -0
  363. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -0
  364. package/dist/src/kernel/packs/sparql/applySearch.js +25 -0
  365. package/dist/src/kernel/packs/sparql/applySearch.js.map +1 -0
  366. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +480 -0
  367. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -0
  368. package/dist/src/kernel/packs/sparql/escape.js +21 -0
  369. package/dist/src/kernel/packs/sparql/escape.js.map +1 -0
  370. package/dist/src/kernel/packs/sparql/index.js +31 -0
  371. package/dist/src/kernel/packs/sparql/index.js.map +1 -0
  372. package/dist/src/kernel/packs/sparql/runSelect.js +101 -0
  373. package/dist/src/kernel/packs/sparql/runSelect.js.map +1 -0
  374. package/dist/src/kernel/packs/types.js +30 -0
  375. package/dist/src/kernel/packs/types.js.map +1 -0
  376. package/dist/src/kernel/packs/uniqueness.js +30 -0
  377. package/dist/src/kernel/packs/uniqueness.js.map +1 -0
  378. package/dist/src/kernel/project/cli/buildProgram.js +263 -0
  379. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -0
  380. package/dist/src/kernel/project/cli/constants.js +27 -0
  381. package/dist/src/kernel/project/cli/constants.js.map +1 -0
  382. package/dist/src/kernel/project/cli/dispatch.js +420 -0
  383. package/dist/src/kernel/project/cli/dispatch.js.map +1 -0
  384. package/dist/src/kernel/project/cli/exitCodes.js +66 -0
  385. package/dist/src/kernel/project/cli/exitCodes.js.map +1 -0
  386. package/dist/src/kernel/project/cli/globalFlags.js +219 -0
  387. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -0
  388. package/dist/src/kernel/project/cli/helpFormat.js +58 -0
  389. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -0
  390. package/dist/src/kernel/project/cli/index.js +14 -0
  391. package/dist/src/kernel/project/cli/index.js.map +1 -0
  392. package/dist/src/kernel/project/cli/rootHelp.js +227 -0
  393. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -0
  394. package/dist/src/kernel/project/cli/suggest.js +75 -0
  395. package/dist/src/kernel/project/cli/suggest.js.map +1 -0
  396. package/dist/src/kernel/project/cli/suggestNames.js +96 -0
  397. package/dist/src/kernel/project/cli/suggestNames.js.map +1 -0
  398. package/dist/src/kernel/project/cli/verbHelp.js +131 -0
  399. package/dist/src/kernel/project/cli/verbHelp.js.map +1 -0
  400. package/dist/src/kernel/project/mcp/buildServer.js +67 -0
  401. package/dist/src/kernel/project/mcp/buildServer.js.map +1 -0
  402. package/dist/src/kernel/project/mcp/envelope.js +37 -0
  403. package/dist/src/kernel/project/mcp/envelope.js.map +1 -0
  404. package/dist/src/kernel/project/mcp/index.js +8 -0
  405. package/dist/src/kernel/project/mcp/index.js.map +1 -0
  406. package/dist/src/kernel/project/mcp/instructions.js +44 -0
  407. package/dist/src/kernel/project/mcp/instructions.js.map +1 -0
  408. package/dist/src/kernel/project/mcp/mcpError.js +42 -0
  409. package/dist/src/kernel/project/mcp/mcpError.js.map +1 -0
  410. package/dist/src/kernel/project/mcp/prompts/index.js +36 -0
  411. package/dist/src/kernel/project/mcp/prompts/index.js.map +1 -0
  412. package/dist/src/kernel/project/mcp/prompts/provider.js +98 -0
  413. package/dist/src/kernel/project/mcp/prompts/provider.js.map +1 -0
  414. package/dist/src/kernel/project/mcp/prompts/source.js +154 -0
  415. package/dist/src/kernel/project/mcp/prompts/source.js.map +1 -0
  416. package/dist/src/kernel/project/mcp/registerVerb.js +315 -0
  417. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -0
  418. package/dist/src/kernel/project/mcp/serve.js +35 -0
  419. package/dist/src/kernel/project/mcp/serve.js.map +1 -0
  420. package/dist/src/kernel/render/compactUri.js +24 -0
  421. package/dist/src/kernel/render/compactUri.js.map +1 -0
  422. package/dist/src/kernel/render/contracts.js +10 -0
  423. package/dist/src/kernel/render/contracts.js.map +1 -0
  424. package/dist/src/kernel/render/disclosure.js +34 -0
  425. package/dist/src/kernel/render/disclosure.js.map +1 -0
  426. package/dist/src/kernel/render/envelope.js +30 -0
  427. package/dist/src/kernel/render/envelope.js.map +1 -0
  428. package/dist/src/kernel/render/formatters.js +27 -0
  429. package/dist/src/kernel/render/formatters.js.map +1 -0
  430. package/dist/src/kernel/render/index.js +15 -0
  431. package/dist/src/kernel/render/index.js.map +1 -0
  432. package/dist/src/kernel/render/prefixes.js +46 -0
  433. package/dist/src/kernel/render/prefixes.js.map +1 -0
  434. package/dist/src/kernel/render/renderers.js +468 -0
  435. package/dist/src/kernel/render/renderers.js.map +1 -0
  436. package/dist/src/kernel/render/style.js +105 -0
  437. package/dist/src/kernel/render/style.js.map +1 -0
  438. package/dist/src/kernel/render/turtle.js +194 -0
  439. package/dist/src/kernel/render/turtle.js.map +1 -0
  440. package/dist/src/kernel/render/vocabulary.js +174 -0
  441. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  442. package/dist/src/kernel/render/writeStdout.js +43 -0
  443. package/dist/src/kernel/render/writeStdout.js.map +1 -0
  444. package/dist/src/kernel/runtime/boot.js +49 -0
  445. package/dist/src/kernel/runtime/boot.js.map +1 -0
  446. package/dist/src/kernel/runtime/facade.js +39 -0
  447. package/dist/src/kernel/runtime/facade.js.map +1 -0
  448. package/dist/src/kernel/runtime/graphpack/build.js +122 -0
  449. package/dist/src/kernel/runtime/graphpack/build.js.map +1 -0
  450. package/dist/src/kernel/runtime/graphpack/buildIndex.js +303 -0
  451. package/dist/src/kernel/runtime/graphpack/buildIndex.js.map +1 -0
  452. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +6 -0
  453. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -0
  454. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +4 -0
  455. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -0
  456. package/dist/src/kernel/runtime/graphpack/embedded/pack.stories.generated.js +4 -0
  457. package/dist/src/kernel/runtime/graphpack/embedded/pack.stories.generated.js.map +1 -0
  458. package/dist/src/kernel/runtime/graphpack/embedded.js +82 -0
  459. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -0
  460. package/dist/src/kernel/runtime/graphpack/hash.js +38 -0
  461. package/dist/src/kernel/runtime/graphpack/hash.js.map +1 -0
  462. package/dist/src/kernel/runtime/graphpack/index.js +11 -0
  463. package/dist/src/kernel/runtime/graphpack/index.js.map +1 -0
  464. package/dist/src/kernel/runtime/graphpack/manifest.js +145 -0
  465. package/dist/src/kernel/runtime/graphpack/manifest.js.map +1 -0
  466. package/dist/src/kernel/runtime/graphpack/read.js +92 -0
  467. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -0
  468. package/dist/src/kernel/runtime/graphpack/schemas.js +70 -0
  469. package/dist/src/kernel/runtime/graphpack/schemas.js.map +1 -0
  470. package/dist/src/kernel/runtime/graphpack/stories.js +68 -0
  471. package/dist/src/kernel/runtime/graphpack/stories.js.map +1 -0
  472. package/dist/src/kernel/runtime/graphpack/types.js +55 -0
  473. package/dist/src/kernel/runtime/graphpack/types.js.map +1 -0
  474. package/dist/src/kernel/runtime/index.js +8 -0
  475. package/dist/src/kernel/runtime/index.js.map +1 -0
  476. package/dist/src/kernel/runtime/loadSession.js +34 -0
  477. package/dist/src/kernel/runtime/loadSession.js.map +1 -0
  478. package/dist/src/kernel/runtime/paths.js +78 -0
  479. package/dist/src/kernel/runtime/paths.js.map +1 -0
  480. package/dist/src/kernel/runtime/probe.js +37 -0
  481. package/dist/src/kernel/runtime/probe.js.map +1 -0
  482. package/dist/src/kernel/runtime/readEntity.js +363 -0
  483. package/dist/src/kernel/runtime/readEntity.js.map +1 -0
  484. package/dist/src/kernel/runtime/refs/gitOps.js +65 -0
  485. package/dist/src/kernel/runtime/refs/gitOps.js.map +1 -0
  486. package/dist/src/kernel/runtime/refs/index.js +29 -0
  487. package/dist/src/kernel/runtime/refs/index.js.map +1 -0
  488. package/dist/src/kernel/runtime/refs/parseRef.js +70 -0
  489. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -0
  490. package/dist/src/kernel/runtime/refs/resolve.js +332 -0
  491. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -0
  492. package/dist/src/kernel/runtime/resolveSources.js +62 -0
  493. package/dist/src/kernel/runtime/resolveSources.js.map +1 -0
  494. package/dist/src/kernel/runtime/store.js +75 -0
  495. package/dist/src/kernel/runtime/store.js.map +1 -0
  496. package/dist/src/kernel/runtime/storeReadiness.js +51 -0
  497. package/dist/src/kernel/runtime/storeReadiness.js.map +1 -0
  498. package/dist/src/kernel/runtime/types.js +11 -0
  499. package/dist/src/kernel/runtime/types.js.map +1 -0
  500. package/dist/src/kernel/spec/asVerb.js +21 -0
  501. package/dist/src/kernel/spec/asVerb.js.map +1 -0
  502. package/dist/src/kernel/spec/emitReference.js +578 -0
  503. package/dist/src/kernel/spec/emitReference.js.map +1 -0
  504. package/dist/src/kernel/spec/emitSurface.js +202 -0
  505. package/dist/src/kernel/spec/emitSurface.js.map +1 -0
  506. package/dist/src/kernel/spec/index.js +11 -0
  507. package/dist/src/kernel/spec/index.js.map +1 -0
  508. package/dist/src/kernel/spec/surfaceConformance.js +103 -0
  509. package/dist/src/kernel/spec/surfaceConformance.js.map +1 -0
  510. package/dist/src/kernel/spec/types.js +14 -0
  511. package/dist/src/kernel/spec/types.js.map +1 -0
  512. package/dist/src/kernel/spec/validate.js +110 -0
  513. package/dist/src/kernel/spec/validate.js.map +1 -0
  514. package/dist/src/kernel/vocabulary.js +97 -0
  515. package/dist/src/kernel/vocabulary.js.map +1 -0
  516. package/dist/src/types.js +11 -0
  517. package/dist/src/types.js.map +1 -0
  518. package/package.json +22 -23
  519. package/pragma.conf.ts +486 -90
  520. package/src/bin.ts +274 -82
  521. package/src/capabilities/block.parity.test.ts +29 -12
  522. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  523. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  524. package/src/capabilities/capabilities/capabilities.render.ts +1 -1
  525. package/src/capabilities/capabilities/capabilities.verb.ts +1 -1
  526. package/src/capabilities/capabilities/catalog.ts +3 -3
  527. package/src/capabilities/capabilities/hints.ts +32 -7
  528. package/src/capabilities/capabilities/index.ts +1 -1
  529. package/src/capabilities/colophon/collectColophon.ts +35 -24
  530. package/src/capabilities/colophon/colophon.render.ts +24 -1
  531. package/src/capabilities/colophon/colophon.test.ts +42 -21
  532. package/src/capabilities/colophon/colophon.verb.ts +4 -4
  533. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  534. package/src/capabilities/config/collectConfigShow.ts +3 -3
  535. package/src/capabilities/config/field.render.ts +4 -5
  536. package/src/capabilities/config/fields.ts +17 -10
  537. package/src/capabilities/config/get.verb.ts +70 -0
  538. package/src/capabilities/config/getUnset.test.ts +264 -0
  539. package/src/capabilities/config/index.ts +5 -1
  540. package/src/capabilities/config/runField.ts +40 -22
  541. package/src/capabilities/config/runGet.ts +34 -0
  542. package/src/capabilities/config/runSet.ts +35 -9
  543. package/src/capabilities/config/set.test.ts +48 -14
  544. package/src/capabilities/config/set.verb.ts +8 -7
  545. package/src/capabilities/config/show.render.ts +1 -1
  546. package/src/capabilities/config/show.verb.ts +9 -6
  547. package/src/capabilities/config/types.ts +18 -4
  548. package/src/capabilities/config/unset.verb.ts +58 -0
  549. package/src/capabilities/create/byteEquality.subprocess.test.ts +2 -3
  550. package/src/capabilities/create/byteEquality.test.ts +25 -11
  551. package/src/capabilities/create/cliProjection.ts +227 -0
  552. package/src/capabilities/create/constants.ts +36 -61
  553. package/src/capabilities/create/create.mcp.test.ts +89 -5
  554. package/src/capabilities/create/create.render.ts +71 -7
  555. package/src/capabilities/create/create.test.ts +433 -219
  556. package/src/capabilities/create/create.verb.ts +297 -328
  557. package/src/capabilities/create/createGrammar.test.ts +865 -0
  558. package/src/capabilities/create/createHelp.ts +71 -0
  559. package/src/capabilities/create/createSurface.generated.ts +400 -0
  560. package/src/capabilities/create/crossCli.subprocess.test.ts +745 -0
  561. package/src/capabilities/create/cwdJail.test.ts +24 -5
  562. package/src/capabilities/create/generatorToVerbSpec.test.ts +186 -0
  563. package/src/capabilities/create/generatorToVerbSpec.ts +13 -11
  564. package/src/capabilities/create/index.ts +7 -4
  565. package/src/capabilities/create/messages.ts +18 -0
  566. package/src/capabilities/create/mount.ts +391 -0
  567. package/src/capabilities/create/pathJail.test.ts +90 -0
  568. package/src/capabilities/create/pathJail.ts +8 -5
  569. package/src/capabilities/create/pickGenerator.ts +23 -35
  570. package/src/capabilities/create/shippedCreate.subprocess.test.ts +164 -0
  571. package/src/capabilities/create/types.ts +20 -0
  572. package/src/capabilities/distribution.parity.test.ts +4 -0
  573. package/src/capabilities/distribution.test.ts +3 -1
  574. package/src/capabilities/distribution.ts +10 -15
  575. package/src/capabilities/doctor/checks/checkKeStore.ts +7 -5
  576. package/src/capabilities/doctor/checks/checkPackageRefs.ts +75 -8
  577. package/src/capabilities/doctor/checks/checkPragmaVersion.ts +2 -2
  578. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +76 -29
  579. package/src/capabilities/doctor/checks/checkShellCompletions.ts +63 -17
  580. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  581. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  582. package/src/capabilities/doctor/checks/index.ts +27 -0
  583. package/src/capabilities/doctor/checks/mcpCommand.test.ts +100 -0
  584. package/src/capabilities/doctor/checks/mcpCommand.ts +69 -0
  585. package/src/capabilities/doctor/checks/targetHealth.ts +444 -0
  586. package/src/capabilities/doctor/doctor.render.test.ts +125 -38
  587. package/src/capabilities/doctor/doctor.render.ts +74 -74
  588. package/src/capabilities/doctor/doctor.test.ts +338 -37
  589. package/src/capabilities/doctor/doctor.verb.ts +8 -3
  590. package/src/capabilities/doctor/runChecks.ts +65 -63
  591. package/src/capabilities/doctor/types.ts +22 -15
  592. package/src/capabilities/emptyRecovery.test.ts +21 -8
  593. package/src/capabilities/graph/examples.exec.test.ts +107 -0
  594. package/src/capabilities/graph/index.ts +1 -1
  595. package/src/capabilities/graph/inspect.render.ts +70 -22
  596. package/src/capabilities/graph/inspect.test.ts +260 -5
  597. package/src/capabilities/graph/inspect.verb.ts +18 -6
  598. package/src/capabilities/graph/query.render.ts +15 -3
  599. package/src/capabilities/graph/query.verb.ts +3 -3
  600. package/src/capabilities/graph/runQuery.ts +2 -3
  601. package/src/capabilities/index.ts +3 -1
  602. package/src/capabilities/info/collectInfo.ts +13 -5
  603. package/src/capabilities/info/info.render.ts +7 -3
  604. package/src/capabilities/info/info.test.ts +37 -1
  605. package/src/capabilities/info/info.verb.ts +1 -1
  606. package/src/capabilities/info/types.ts +10 -2
  607. package/src/capabilities/lazy.test.ts +32 -0
  608. package/src/capabilities/meta/complete.verb.ts +1 -1
  609. package/src/capabilities/meta/index.ts +1 -1
  610. package/src/capabilities/meta/mcp.verb.ts +10 -8
  611. package/src/capabilities/ontology/index.ts +15 -11
  612. package/src/capabilities/ontology/ontology.test.ts +6 -55
  613. package/src/capabilities/ontology/queries.ts +2 -2
  614. package/src/capabilities/ontology/render.ts +3 -4
  615. package/src/capabilities/ontology/verbs.ts +27 -68
  616. package/src/capabilities/prompt/index.ts +2 -2
  617. package/src/capabilities/prompt/prompt.render.ts +6 -2
  618. package/src/capabilities/prompt/types.ts +1 -1
  619. package/src/capabilities/prompt/verbs.ts +4 -5
  620. package/src/capabilities/reference.test.ts +63 -0
  621. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  622. package/src/capabilities/resources/provider.ts +342 -85
  623. package/src/capabilities/resources/resources.test.ts +355 -59
  624. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  625. package/src/capabilities/setup/buildPlan.ts +240 -0
  626. package/src/capabilities/setup/index.ts +7 -7
  627. package/src/capabilities/setup/mode.ts +74 -0
  628. package/src/capabilities/setup/operations/index.ts +79 -0
  629. package/src/capabilities/setup/operations/setupCompletions.ts +97 -24
  630. package/src/capabilities/setup/operations/setupConfig.ts +124 -0
  631. package/src/capabilities/setup/operations/setupGenerator.ts +478 -334
  632. package/src/capabilities/setup/operations/setupLsp.ts +420 -67
  633. package/src/capabilities/setup/operations/setupMcp.ts +238 -127
  634. package/src/capabilities/setup/operations/setupSkills.ts +667 -105
  635. package/src/capabilities/setup/plan.render.ts +336 -0
  636. package/src/capabilities/setup/plan.ts +241 -0
  637. package/src/capabilities/setup/setup.mcp.correctness.test.ts +432 -0
  638. package/src/capabilities/setup/setup.render.test.ts +297 -75
  639. package/src/capabilities/setup/setup.render.ts +45 -77
  640. package/src/capabilities/setup/setup.test.ts +1819 -225
  641. package/src/capabilities/setup/setup.verb.ts +288 -78
  642. package/src/capabilities/setup/shell.test.ts +86 -0
  643. package/src/capabilities/setup/shell.ts +137 -11
  644. package/src/capabilities/setup/targets.ts +435 -0
  645. package/src/capabilities/setup/types.ts +23 -70
  646. package/src/capabilities/shared/assertExecOk.ts +68 -10
  647. package/src/capabilities/shared/index.ts +43 -0
  648. package/src/capabilities/shared/packageManager.test.ts +470 -0
  649. package/src/capabilities/shared/packageManager.ts +425 -34
  650. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  651. package/src/capabilities/skill/discover.ts +131 -10
  652. package/src/capabilities/skill/index.ts +1 -1
  653. package/src/capabilities/skill/render.ts +10 -9
  654. package/src/capabilities/skill/skill.test.ts +22 -7
  655. package/src/capabilities/skill/verbs.ts +3 -4
  656. package/src/capabilities/sources/collectStatus.ts +7 -2
  657. package/src/capabilities/sources/index.ts +1 -1
  658. package/src/capabilities/sources/installSkills.ts +265 -10
  659. package/src/capabilities/sources/runUpdate.ts +131 -23
  660. package/src/capabilities/sources/sources.test.ts +382 -2
  661. package/src/capabilities/sources/status.render.ts +1 -1
  662. package/src/capabilities/sources/status.verb.ts +1 -1
  663. package/src/capabilities/sources/update.render.ts +1 -1
  664. package/src/capabilities/sources/update.verb.ts +4 -4
  665. package/src/capabilities/surface.test.ts +136 -19
  666. package/src/capabilities/upgrade/runUpgrade.ts +53 -14
  667. package/src/capabilities/upgrade/types.ts +10 -2
  668. package/src/capabilities/upgrade/upgrade.render.ts +13 -7
  669. package/src/capabilities/upgrade/upgrade.test.ts +75 -3
  670. package/src/capabilities/upgrade/upgrade.verb.ts +1 -1
  671. package/src/capabilities/version/index.ts +12 -0
  672. package/src/capabilities/version/version.test.ts +66 -0
  673. package/src/capabilities/version/version.verb.ts +39 -0
  674. package/src/constants.ts +36 -1
  675. package/src/identity.test.ts +57 -29
  676. package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +15 -13
  677. package/src/kernel/completion/complete.test.ts +12 -2
  678. package/src/kernel/completion/complete.ts +1 -1
  679. package/src/kernel/completion/emitScripts.ts +1 -1
  680. package/src/kernel/completion/entitySource.test.ts +16 -3
  681. package/src/kernel/completion/entitySource.ts +21 -8
  682. package/src/kernel/completion/model.test.ts +164 -12
  683. package/src/kernel/completion/model.ts +94 -14
  684. package/src/kernel/completion/parse.test.ts +4 -7
  685. package/src/kernel/completion/parse.ts +15 -0
  686. package/src/kernel/completion/resolve.test.ts +0 -1
  687. package/src/kernel/completion/safety.test.ts +50 -35
  688. package/src/kernel/completion/shellDrive.test.ts +28 -9
  689. package/src/kernel/completion/templates/bash.ts +7 -2
  690. package/src/kernel/completion/templates/index.ts +23 -0
  691. package/src/kernel/completion/types.ts +7 -0
  692. package/src/kernel/config/defaults.test.ts +27 -3
  693. package/src/kernel/config/defaults.ts +4 -3
  694. package/src/kernel/config/evaluateProjectConfig.ts +31 -8
  695. package/src/kernel/config/firstRun.test.ts +44 -38
  696. package/src/kernel/config/firstRun.ts +24 -88
  697. package/src/kernel/config/index.ts +1 -1
  698. package/src/kernel/config/readConfig.test.ts +6 -3
  699. package/src/kernel/config/readConfig.ts +1 -1
  700. package/src/kernel/config/schema.ts +3 -1
  701. package/src/kernel/config/types.ts +3 -1
  702. package/src/kernel/copy.test.ts +34 -4
  703. package/src/kernel/copy.ts +54 -0
  704. package/src/kernel/error/PragmaError.test.ts +5 -2
  705. package/src/kernel/error/PragmaError.ts +4 -2
  706. package/src/kernel/error/constants.ts +7 -5
  707. package/src/kernel/error/fromTaskError.ts +28 -6
  708. package/src/kernel/error/recovery.ts +14 -0
  709. package/src/kernel/error/renderError.ts +42 -1
  710. package/src/kernel/error/types.ts +21 -1
  711. package/src/kernel/index.ts +35 -0
  712. package/src/kernel/interactivity.test.ts +76 -0
  713. package/src/kernel/interactivity.ts +69 -0
  714. package/src/kernel/packs/collect.ts +12 -16
  715. package/src/kernel/packs/compile.test.ts +222 -1
  716. package/src/kernel/packs/compile.ts +93 -3
  717. package/src/kernel/packs/disclosure.ts +2 -2
  718. package/src/kernel/packs/graphql/buildLookupDocument.ts +1 -1
  719. package/src/kernel/packs/graphql/fetchGraphqlLookup.ts +3 -4
  720. package/src/kernel/packs/graphql/index.ts +20 -0
  721. package/src/kernel/packs/index.ts +40 -0
  722. package/src/kernel/packs/iri.test.ts +73 -0
  723. package/src/kernel/packs/iri.ts +66 -14
  724. package/src/kernel/packs/renderPack.ts +25 -6
  725. package/src/kernel/packs/resolveEntity.test.ts +274 -0
  726. package/src/kernel/packs/resolveEntity.ts +200 -42
  727. package/src/kernel/packs/runBodies.ts +86 -22
  728. package/src/kernel/packs/sample.ts +1 -1
  729. package/src/kernel/packs/schema.ts +78 -5
  730. package/src/kernel/packs/sparql/applyFilters.ts +136 -16
  731. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  732. package/src/kernel/packs/sparql/buildLookupQuery.ts +367 -17
  733. package/src/kernel/packs/sparql/index.ts +37 -0
  734. package/src/kernel/packs/sparql/runSelect.test.ts +1 -1
  735. package/src/kernel/packs/sparql/runSelect.ts +3 -4
  736. package/src/kernel/packs/toolDescription.test.ts +77 -4
  737. package/src/kernel/packs/types.ts +136 -0
  738. package/src/kernel/packs/uniqueness.ts +1 -1
  739. package/src/kernel/project/cli/buildProgram.test.ts +218 -12
  740. package/src/kernel/project/cli/buildProgram.ts +84 -18
  741. package/src/kernel/project/cli/constants.ts +34 -0
  742. package/src/kernel/project/cli/dispatch.test.ts +160 -2
  743. package/src/kernel/project/cli/dispatch.ts +226 -45
  744. package/src/kernel/project/cli/exitCodes.ts +1 -1
  745. package/src/kernel/project/cli/globalFlags.test.ts +117 -1
  746. package/src/kernel/project/cli/globalFlags.ts +134 -31
  747. package/src/kernel/project/cli/helpFormat.ts +14 -2
  748. package/src/kernel/project/cli/rootHelp.ts +74 -12
  749. package/src/kernel/project/cli/suggest.test.ts +33 -1
  750. package/src/kernel/project/cli/suggest.ts +25 -1
  751. package/src/kernel/project/cli/verbHelp.test.ts +30 -0
  752. package/src/kernel/project/cli/verbHelp.ts +23 -7
  753. package/src/kernel/project/mcp/buildServer.ts +7 -4
  754. package/src/kernel/project/mcp/envelope.ts +2 -2
  755. package/src/kernel/project/mcp/instructions.ts +2 -2
  756. package/src/kernel/project/mcp/mcpError.ts +1 -1
  757. package/src/kernel/project/mcp/parity.test.ts +5 -2
  758. package/src/kernel/project/mcp/prompts/index.ts +37 -0
  759. package/src/kernel/project/mcp/prompts/source.ts +2 -2
  760. package/src/kernel/project/mcp/registerVerb.ts +55 -10
  761. package/src/kernel/project/mcp/serve.ts +2 -2
  762. package/src/kernel/render/__snapshots__/renderers.test.ts.snap +7 -4
  763. package/src/kernel/render/contracts.ts +21 -0
  764. package/src/kernel/render/envelope.ts +2 -3
  765. package/src/kernel/render/formatters.ts +1 -1
  766. package/src/kernel/render/renderers.test.ts +243 -6
  767. package/src/kernel/render/renderers.ts +222 -20
  768. package/src/kernel/render/style.test.ts +75 -24
  769. package/src/kernel/render/style.ts +59 -6
  770. package/src/kernel/render/turtle.ts +222 -0
  771. package/src/kernel/render/vocabulary.ts +276 -0
  772. package/src/kernel/runtime/graphpack/buildIndex.ts +1 -1
  773. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +3 -3
  774. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  775. package/src/kernel/runtime/graphpack/embedded.ts +3 -3
  776. package/src/kernel/runtime/graphpack/manifest.ts +1 -1
  777. package/src/kernel/runtime/graphpack/read.ts +1 -2
  778. package/src/kernel/runtime/loadSession.ts +1 -1
  779. package/src/kernel/runtime/probe.ts +6 -6
  780. package/src/kernel/runtime/readEntity.ts +472 -28
  781. package/src/kernel/runtime/refs/index.ts +39 -0
  782. package/src/kernel/runtime/refs/parseRef.ts +2 -2
  783. package/src/kernel/runtime/refs/resolve.ts +4 -4
  784. package/src/kernel/runtime/storeReadiness.ts +1 -2
  785. package/src/kernel/runtime/types.ts +15 -1
  786. package/src/kernel/runtime/wasmEmbed.test.ts +37 -39
  787. package/src/kernel/spec/emitReference.ts +105 -30
  788. package/src/kernel/spec/emitSurface.ts +48 -6
  789. package/src/kernel/spec/surfaceConformance.test.ts +4 -2
  790. package/src/kernel/spec/types.ts +277 -4
  791. package/src/testing/PARITY_GAPS.ts +9 -3
  792. package/src/testing/behavioral/docExamples.test.ts +16 -0
  793. package/src/testing/behavioral/errorMatrix.mcp.test.ts +112 -9
  794. package/src/testing/behavioral/firstRun.e2e.test.ts +24 -24
  795. package/src/testing/behavioral/journeys.cli.test.ts +1 -1
  796. package/src/testing/behavioral/journeys.defaultPack.test.ts +7 -7
  797. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  798. package/src/testing/behavioral/journeys.implementation.test.ts +206 -0
  799. package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +2 -2
  800. package/src/testing/behavioral/journeys.packageStories.test.ts +10 -7
  801. package/src/testing/behavioral/mcpServe.e2e.test.ts +57 -2
  802. package/src/testing/behavioral/mcpSurface.test.ts +1 -1
  803. package/src/testing/behavioral/rootCli.e2e.test.ts +26 -2
  804. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  805. package/src/testing/eval/cases/effectful.ts +3 -2
  806. package/src/testing/eval/cases/readNouns.ts +245 -9
  807. package/src/testing/fixtures/blockGraph.ts +104 -10
  808. package/src/testing/fixtures/graph/canonical.ts +182 -13
  809. package/src/testing/fixtures/graph/defaultPack.ts +1 -1
  810. package/src/testing/helpers/blankCanonicalRanges.ts +36 -0
  811. package/src/testing/helpers/projectMcp.ts +32 -6
  812. package/src/testing/helpers/runCli.ts +35 -18
  813. package/src/testing/perf/budgets.test.ts +20 -9
  814. package/src/testing/perf/budgets.ts +58 -9
  815. package/src/testing/perf/codegen.test.ts +126 -0
  816. package/src/testing/perf/globalSetup.test.ts +286 -0
  817. package/src/testing/perf/globalSetup.ts +505 -23
  818. package/src/testing/perf/measure.test.ts +38 -0
  819. package/src/testing/perf/measure.ts +23 -4
  820. package/src/testing/setupXdgIsolation.ts +81 -21
  821. package/src/testing/tempRoot.globalSetup.ts +122 -0
  822. package/dist/pragma +0 -0
  823. package/src/capabilities/create/compiledCreate.subprocess.test.ts +0 -189
  824. package/src/capabilities/create/templates.embedded.generated.ts +0 -28
  825. package/src/capabilities/doctor/checks/checkConfigFile.ts +0 -42
  826. package/src/capabilities/doctor/checks/checkMcpCommands.ts +0 -114
  827. package/src/capabilities/doctor/checks/checkMcpConfigured.ts +0 -68
  828. package/src/capabilities/doctor/checks/checkSkillsSymlinked.ts +0 -61
  829. package/src/capabilities/doctor/checks/deriveBand.test.ts +0 -42
  830. package/src/capabilities/doctor/checks/deriveBand.ts +0 -28
  831. package/src/capabilities/shared/bands.ts +0 -16
  832. package/src/kernel/config/__snapshots__/firstRun.test.ts.snap +0 -11
@@ -15,18 +15,23 @@
15
15
 
16
16
  import {
17
17
  existsSync,
18
+ lstatSync,
18
19
  mkdirSync,
19
20
  mkdtempSync,
20
21
  readFileSync,
22
+ readlinkSync,
21
23
  rmSync,
24
+ statSync,
25
+ symlinkSync,
22
26
  writeFileSync,
23
27
  } from "node:fs";
24
28
  import { tmpdir } from "node:os";
25
- import { join } from "node:path";
29
+ import { join, relative, sep } from "node:path";
26
30
  import { execute } from "@canonical/summon-core";
27
- import { dryRun, type Effect, type Task } from "@canonical/task";
28
- import { runTask } from "@canonical/task/node";
29
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
31
+ import { collectUndos, dryRun, type Effect, type Task } from "@canonical/task";
32
+ import { runTask, runUndo } from "@canonical/task/node";
33
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
34
+ import { BIN_NAME } from "../../constants.js";
30
35
  import { emitScripts } from "../../kernel/completion/emitScripts.js";
31
36
  import { asPragmaError } from "../../kernel/error/fromTaskError.js";
32
37
  import { executeVerb } from "../../kernel/project/cli/dispatch.js";
@@ -36,10 +41,19 @@ import type { VerbSpec } from "../../kernel/spec/types.js";
36
41
  import { projectCli } from "../../testing/helpers/projectCli.js";
37
42
  import { projectMcp } from "../../testing/helpers/projectMcp.js";
38
43
  import { capabilities } from "../index.js";
39
- import { buildSetupPlan } from "./operations/setupGenerator.js";
40
- import { composeSkills, detectSkills } from "./operations/setupSkills.js";
44
+ import { detectCompletions } from "./operations/setupCompletions.js";
45
+ import { buildSetupRun, type RowEvent } from "./operations/setupGenerator.js";
46
+ import {
47
+ composeSkills,
48
+ composeSkillsRemoval,
49
+ detectSkills,
50
+ ownedSkillLinks,
51
+ skillsSkipReason,
52
+ skillsSkipRemedy,
53
+ } from "./operations/setupSkills.js";
54
+ import { DRY_RUN_HINT, PREVIEW_HINT } from "./setup.render.js";
41
55
  import { setupModule } from "./setup.verb.js";
42
- import { completionScriptPath } from "./shell.js";
56
+ import { completionScriptPath, detectShell, type ShellId } from "./shell.js";
43
57
 
44
58
  const FLAGS: GlobalFlags = {
45
59
  llm: false,
@@ -63,85 +77,72 @@ const tmp = (prefix: string): string => {
63
77
  return dir;
64
78
  };
65
79
 
80
+ /**
81
+ * The shell this test process is actually running in.
82
+ *
83
+ * Detection reads the PROCESS TREE now, not `$SHELL` — `$SHELL` is the login
84
+ * shell and forcing it proved nothing about the shell in use. So the
85
+ * completions cases assert against whatever shell really started the run, and
86
+ * stand down where that cannot be established (a CI runner with no shell
87
+ * ancestor), rather than asserting a shell nobody is in.
88
+ */
89
+ const DETECTED = detectShell();
90
+ const SHELL = DETECTED.kind === "detected" ? DETECTED.shell : null;
91
+ const withShell = it.skipIf(SHELL === null);
92
+
93
+ /**
94
+ * A PATH directory carrying a `pragma` stub. The completions target refuses to
95
+ * install a script whose `pragma __complete` cannot be found — an inert script
96
+ * is worse than none — so every PATH these tests build has to satisfy that.
97
+ */
98
+ const stubPath = (): string => {
99
+ const dir = tmp("pragma-setup-path-");
100
+ writeFileSync(join(dir, "pragma"), "");
101
+ return dir;
102
+ };
103
+
66
104
  let prevHome: string | undefined;
67
- let prevShell: string | undefined;
105
+ let prevPath: string | undefined;
68
106
  beforeEach(() => {
69
107
  prevHome = process.env.HOME;
70
- prevShell = process.env.SHELL;
108
+ prevPath = process.env.PATH;
71
109
  process.env.HOME = tmp("pragma-setup-home-");
72
- process.env.SHELL = "/usr/bin/zsh";
110
+ process.env.PATH = stubPath();
73
111
  });
74
112
  afterEach(() => {
75
113
  process.env.HOME = prevHome;
76
- process.env.SHELL = prevShell;
114
+ process.env.PATH = prevPath;
77
115
  for (const dir of roots) rmSync(dir, { recursive: true, force: true });
78
116
  roots.length = 0;
79
117
  });
80
118
 
81
119
  describe("setup completions", () => {
82
- it("writes exactly emitScripts(capabilities)[shell]", async () => {
83
- const path = completionScriptPath("zsh");
84
- const outcome = await executeVerb(
85
- completionsVerb,
86
- {},
87
- YES,
88
- bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
89
- );
90
- expect(outcome.exitCode).toBe(0);
91
- expect(existsSync(path)).toBe(true);
92
- expect(readFileSync(path, "utf-8")).toBe(emitScripts(capabilities).zsh);
93
- });
120
+ it("sees a Windows `.cmd` shim, so the row does not skip on Windows", async () => {
121
+ // npm installs this CLI as `pragma.cmd` on Windows and never as a bare
122
+ // name, so joining the name onto each PATH directory found nothing: the
123
+ // row skipped with "not on PATH" on a machine where the command runs.
124
+ // Spelled to match a default `PATHEXT` entry exactly: this test runs on a
125
+ // case-sensitive filesystem, while Windows matches `pragma.cmd` against
126
+ // `.CMD` case-insensitively.
127
+ const dir = tmp("pragma-setup-winpath-");
128
+ writeFileSync(join(dir, `${BIN_NAME}.CMD`), "");
94
129
 
95
- it("bakes the completion config (minChars + per-family opt-out) into the emitted script", async () => {
96
- // Proves the reconciled `detectCompletions(cwd)` reads `completion` config
97
- // and threads {minChars, disabledFamilies} into `emitScripts` — a silent
98
- // regression if the fold had dropped autocomplete's config-threading for
99
- // setup's argument-free detect.
100
- const cwd = tmp("pragma-setup-proj-");
101
- writeFileSync(
102
- join(cwd, "pragma.config.ts"),
103
- "export default { completion: { minChars: 5, families: { block: false } } };\n",
104
- );
105
- const path = completionScriptPath("zsh");
106
- const outcome = await executeVerb(
107
- completionsVerb,
108
- {},
109
- YES,
110
- bootRuntime(FLAGS, cwd),
111
- );
112
- expect(outcome.exitCode).toBe(0);
113
- const written = readFileSync(path, "utf-8");
114
- // The installed script is EXACTLY the config-baked emit (minChars 5, the
115
- // `block` family scrubbed) — the config was threaded end to end.
116
- expect(written).toBe(
117
- emitScripts(capabilities, {
118
- minChars: 5,
119
- disabledFamilies: ["block"],
120
- }).zsh,
121
- );
122
- // Both knobs actually moved the output: it differs from the default emit
123
- // (minChars), and from the minChars-only emit (the family opt-out).
124
- expect(written).not.toBe(emitScripts(capabilities).zsh);
125
- expect(written).not.toBe(emitScripts(capabilities, { minChars: 5 }).zsh);
126
- });
127
-
128
- it("--dry-run previews the write against the detected shell, writing nothing", async () => {
129
- const path = completionScriptPath("zsh");
130
- const outcome = await executeVerb(
131
- completionsVerb,
132
- {},
133
- DRY,
134
- bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
130
+ const detected = await detectCompletions(
131
+ dir,
132
+ { kind: "unknown" },
133
+ {
134
+ platform: "win32",
135
+ env: { PATH: dir },
136
+ home: dir,
137
+ isWsl: false,
138
+ },
135
139
  );
136
- expect(outcome.stdout).toContain("Write file");
137
- expect(outcome.stdout).toContain(path);
138
- // The confirm gate / answer prompts are never part of a plan.
139
- expect(outcome.stdout).not.toContain("Prompt");
140
- expect(existsSync(path)).toBe(false);
140
+
141
+ expect(detected.binOnPath).toBe(true);
141
142
  });
142
143
 
143
- it("warns and writes nothing when no shell is detected", async () => {
144
- process.env.SHELL = "";
144
+ withShell("writes exactly emitScripts(capabilities)[shell]", async () => {
145
+ const path = completionScriptPath(SHELL as ShellId);
145
146
  const outcome = await executeVerb(
146
147
  completionsVerb,
147
148
  {},
@@ -149,11 +150,94 @@ describe("setup completions", () => {
149
150
  bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
150
151
  );
151
152
  expect(outcome.exitCode).toBe(0);
152
- expect(existsSync(completionScriptPath("zsh"))).toBe(false);
153
+ expect(existsSync(path)).toBe(true);
154
+ expect(readFileSync(path, "utf-8")).toBe(
155
+ emitScripts(capabilities)[SHELL as ShellId],
156
+ );
153
157
  });
154
158
 
155
- it("--undo reverses the write", async () => {
156
- const path = completionScriptPath("zsh");
159
+ withShell(
160
+ "bakes the completion config (minChars + per-family opt-out) into the emitted script",
161
+ async () => {
162
+ // Proves the reconciled `detectCompletions(cwd)` reads `completion` config
163
+ // and threads {minChars, disabledFamilies} into `emitScripts` — a silent
164
+ // regression if the fold had dropped autocomplete's config-threading for
165
+ // setup's argument-free detect.
166
+ const cwd = tmp("pragma-setup-proj-");
167
+ writeFileSync(
168
+ join(cwd, "pragma.config.ts"),
169
+ "export default { completion: { minChars: 5, families: { block: false } } };\n",
170
+ );
171
+ const path = completionScriptPath(SHELL as ShellId);
172
+ const outcome = await executeVerb(
173
+ completionsVerb,
174
+ {},
175
+ YES,
176
+ bootRuntime(FLAGS, cwd),
177
+ );
178
+ expect(outcome.exitCode).toBe(0);
179
+ const written = readFileSync(path, "utf-8");
180
+ // The installed script is EXACTLY the config-baked emit (minChars 5, the
181
+ // `block` family scrubbed) — the config was threaded end to end.
182
+ expect(written).toBe(
183
+ emitScripts(capabilities, {
184
+ minChars: 5,
185
+ disabledFamilies: ["block"],
186
+ })[SHELL as ShellId],
187
+ );
188
+ // Both knobs actually moved the output: it differs from the default emit
189
+ // (minChars), and from the minChars-only emit (the family opt-out).
190
+ expect(written).not.toBe(emitScripts(capabilities)[SHELL as ShellId]);
191
+ expect(written).not.toBe(
192
+ emitScripts(capabilities, { minChars: 5 })[SHELL as ShellId],
193
+ );
194
+ },
195
+ );
196
+
197
+ withShell(
198
+ "--dry-run previews the write against the detected shell, writing nothing",
199
+ async () => {
200
+ const path = completionScriptPath(SHELL as ShellId);
201
+ const outcome = await executeVerb(
202
+ completionsVerb,
203
+ {},
204
+ DRY,
205
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
206
+ );
207
+ // The dry-run renders the PLAN through the verb's formatPlan seam, not the
208
+ // kernel's raw effect dump: one row, the shell it detected, and the script
209
+ // path rendered against the header's roots instead of repeated absolutely.
210
+ expect(outcome.stdout).toContain("Setup plan — global");
211
+ expect(outcome.stdout).toMatch(
212
+ new RegExp(`completions\\s+install\\s+${SHELL} →`),
213
+ );
214
+ // The confirm gate / answer prompts are never part of a plan.
215
+ expect(outcome.stdout).not.toContain("Prompt");
216
+ expect(existsSync(path)).toBe(false);
217
+ },
218
+ );
219
+
220
+ withShell(
221
+ "refuses to install a script whose `pragma` the shell cannot find",
222
+ async () => {
223
+ // The script delegates every name context to `pragma __complete`. With
224
+ // the binary unreachable the file installs cleanly, doctor goes green,
225
+ // and TAB does nothing — so this is a named skip, not a silent write.
226
+ process.env.PATH = tmp("pragma-setup-nobin-"); // no `pragma` in it
227
+ const outcome = await executeVerb(
228
+ completionsVerb,
229
+ {},
230
+ YES,
231
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
232
+ );
233
+ expect(outcome.exitCode).toBe(0);
234
+ expect(outcome.stdout).toContain("cannot find on PATH");
235
+ expect(existsSync(completionScriptPath(SHELL as ShellId))).toBe(false);
236
+ },
237
+ );
238
+
239
+ withShell("--undo reverses the write", async () => {
240
+ const path = completionScriptPath(SHELL as ShellId);
157
241
  const cwd = tmp("pragma-setup-proj-");
158
242
  await executeVerb(completionsVerb, {}, YES, bootRuntime(FLAGS, cwd));
159
243
  expect(existsSync(path)).toBe(true);
@@ -168,9 +252,11 @@ describe("setup mcp — recap gate", () => {
168
252
  mkdirSync(join(cwd, ".cursor"), { recursive: true }); // makes Cursor detected
169
253
  const configPath = join(cwd, ".cursor", "mcp.json");
170
254
 
255
+ // `--local`: the project scope is opt-in now, so a bare `setup mcp` would
256
+ // write the HOME config and leave this repository untouched.
171
257
  const outcome = await executeVerb(
172
258
  verbOf("mcp"),
173
- {},
259
+ { local: true },
174
260
  YES,
175
261
  bootRuntime(FLAGS, cwd),
176
262
  );
@@ -185,7 +271,7 @@ describe("setup mcp — recap gate", () => {
185
271
  mkdirSync(join(cwd, ".cursor"), { recursive: true });
186
272
  const configPath = join(cwd, ".cursor", "mcp.json");
187
273
 
188
- const { generator } = await buildSetupPlan(
274
+ const { generator } = await buildSetupRun(
189
275
  bootRuntime(FLAGS, cwd),
190
276
  "mcp",
191
277
  "both",
@@ -207,7 +293,7 @@ describe("setup mcp — scope & dedup", () => {
207
293
  let prevPath: string | undefined;
208
294
  beforeEach(() => {
209
295
  prevPath = process.env.PATH;
210
- process.env.PATH = tmp("pragma-scope-path-");
296
+ process.env.PATH = stubPath();
211
297
  });
212
298
  afterEach(() => {
213
299
  process.env.PATH = prevPath;
@@ -230,7 +316,7 @@ describe("setup mcp — scope & dedup", () => {
230
316
  writeFileSync(join(clineExt, "package.json"), "{}");
231
317
  const outcome = await executeVerb(
232
318
  verbOf("mcp"),
233
- {},
319
+ { local: true },
234
320
  YES,
235
321
  bootRuntime(FLAGS, cwd),
236
322
  );
@@ -317,43 +403,498 @@ describe("setup mcp — scope & dedup", () => {
317
403
  ).toBe(true);
318
404
  expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(false);
319
405
  });
406
+
407
+ it("a GLOBAL entry omits cwd — registrations from two directories are byte-identical", async () => {
408
+ // A per-user server must not be pinned to whatever directory `setup mcp
409
+ // --global` happened to run from (a registration made from ~/Downloads
410
+ // used to serve ~/Downloads forever, and re-running from repo B flipped
411
+ // the machine scope to repo B).
412
+ const home = process.env.HOME ?? "";
413
+ const configPath = join(home, ".codeium", "windsurf", "mcp_config.json");
414
+ const register = async (cwd: string): Promise<string> => {
415
+ mkdirSync(join(cwd, ".windsurf"), { recursive: true });
416
+ await executeVerb(
417
+ verbOf("mcp"),
418
+ { global: true },
419
+ YES,
420
+ bootRuntime(FLAGS, cwd),
421
+ );
422
+ return readFileSync(configPath, "utf-8");
423
+ };
424
+ const fromA = await register(tmp("pragma-setup-projA-"));
425
+ const fromB = await register(tmp("pragma-setup-projB-"));
426
+ expect(fromA).toBe(fromB);
427
+ const entry = JSON.parse(fromA).mcpServers?.pragma;
428
+ expect(entry).toEqual({ command: "pragma", args: ["mcp", "serve"] });
429
+ expect(entry).not.toHaveProperty("cwd");
430
+ });
431
+
432
+ it("a stale cwd-pinned GLOBAL entry converges: drifted once, configured after", async () => {
433
+ const home = process.env.HOME ?? "";
434
+ const configPath = join(home, ".codeium", "windsurf", "mcp_config.json");
435
+ mkdirSync(join(home, ".codeium", "windsurf"), { recursive: true });
436
+ writeFileSync(
437
+ configPath,
438
+ JSON.stringify({
439
+ mcpServers: {
440
+ pragma: {
441
+ command: "pragma",
442
+ args: ["mcp"],
443
+ cwd: "/home/u/Downloads",
444
+ },
445
+ },
446
+ }),
447
+ );
448
+ const cwd = tmp("pragma-setup-proj-");
449
+ mkdirSync(join(cwd, ".windsurf"), { recursive: true });
450
+ const rt = bootRuntime(FLAGS, cwd);
451
+ const { detectMcp, mcpGroupState } = await import(
452
+ "./operations/setupMcp.js"
453
+ );
454
+ // The pinned entry reads as drift (the omitted cwd is a controlled field).
455
+ const before = await detectMcp(rt, "global");
456
+ expect(mcpGroupState(before, configPath)).toBe("drifted");
457
+ // One write converges it...
458
+ await executeVerb(
459
+ verbOf("mcp"),
460
+ { global: true },
461
+ YES,
462
+ bootRuntime(FLAGS, cwd),
463
+ );
464
+ expect(
465
+ JSON.parse(readFileSync(configPath, "utf-8")).mcpServers.pragma,
466
+ ).toEqual({ command: "pragma", args: ["mcp", "serve"] });
467
+ // ...and it stays `registered` afterwards (no churn on every run).
468
+ const after = await detectMcp(bootRuntime(FLAGS, cwd), "global");
469
+ expect(mcpGroupState(after, configPath)).toBe("registered");
470
+ });
471
+
472
+ it("a PROJECT entry still records the project root as cwd", async () => {
473
+ const cwd = tmp("pragma-setup-proj-");
474
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
475
+ await executeVerb(
476
+ verbOf("mcp"),
477
+ { local: true },
478
+ YES,
479
+ bootRuntime(FLAGS, cwd),
480
+ );
481
+ const entry = JSON.parse(
482
+ readFileSync(join(cwd, ".cursor", "mcp.json"), "utf-8"),
483
+ ).mcpServers?.pragma;
484
+ expect(entry.cwd).toBe(cwd);
485
+ });
320
486
  });
321
487
 
322
- describe("setup mcp — customize opt-in gate (Item 6)", () => {
323
- it("gates the per-file multiselect behind an explicit customize=true", async () => {
488
+ describe("setup mcp — the per-file question, asked directly", () => {
489
+ it("asks the per-file multiselect with NO `customize` meta-question in front", async () => {
490
+ // `answers.customize` was read in exactly one place in the repo — this
491
+ // prompt's `when` — and by nothing else: not `generate`, not `applied`, not
492
+ // any plan, recap, renderer or JSON projection. Answering no just meant the
493
+ // child prompt went unasked, its key was absent, and the compose bodies
494
+ // read that as "all" — so everything detected was configured anyway.
324
495
  const cwd = tmp("pragma-setup-proj-");
325
- mkdirSync(join(cwd, ".cursor"), { recursive: true }); // ⇒ one MCP target group
326
- const { generator } = await buildSetupPlan(
496
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
497
+ mkdirSync(join(cwd, ".gemini"), { recursive: true }); // 2 children ⇒ narrowable
498
+ const { generator } = await buildSetupRun(
327
499
  bootRuntime(FLAGS, cwd),
328
500
  "mcp",
329
- "both",
501
+ "project",
330
502
  );
331
- const customize = generator.prompts.find((p) => p.name === "customize");
503
+ expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
332
504
  const targets = generator.prompts.find((p) => p.name === "mcpTargets");
333
- // The gate is an opt-in confirm that defaults to NOT customizing.
334
- expect(customize?.type).toBe("confirm");
335
- expect(customize?.default).toBe(false);
336
- // The per-file multiselect only surfaces after an explicit yes — so the
337
- // "all" default configures every deduped file without an extra question.
338
- expect(targets?.when?.({})).toBe(false);
339
- expect(targets?.when?.({ customize: false })).toBe(false);
340
- expect(targets?.when?.({ customize: true })).toBe(true);
505
+ expect(targets).toBeDefined();
506
+ // It is asked whenever its row is in the run — the row's own selection is
507
+ // the only gate, and the choices carry the detected state per file.
508
+ expect(targets?.when?.({})).toBe(true);
509
+ expect(
510
+ (targets?.choices as { label: string }[]).every((c) =>
511
+ /— (add|update|unchanged)$/.test(c.label),
512
+ ),
513
+ ).toBe(true);
514
+ });
515
+
516
+ it("recaps the files the run KEPT, not the ones it offered", async () => {
517
+ // The note counted every child of the plan row, so narrowing two MCP files
518
+ // down to one still recapped `2 added`: the plan and the result described
519
+ // different work.
520
+ const cwd = tmp("pragma-setup-proj-");
521
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
522
+ mkdirSync(join(cwd, ".gemini"), { recursive: true });
523
+ const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
524
+ const row = run.plan.rows.find((r) => r.target === "mcp");
525
+ expect(row?.children?.length).toBe(2);
526
+ const kept = row?.children?.[0]?.key as string;
527
+
528
+ const applied = run.applied({
529
+ targets: ["project:mcp"],
530
+ mcpTargets: [kept],
531
+ });
532
+ expect(applied.rows.find((r) => r.target === "mcp")?.outcome?.note).toBe(
533
+ "1 added",
534
+ );
535
+ });
536
+ });
537
+
538
+ describe("setup — live row events (the wizard's step feed)", () => {
539
+ it("brackets each row with start/done, in the recap's own sentence", async () => {
540
+ // The wizard's live progress renders the PLAN's rows, not the effect
541
+ // transcript — one `start` as a row's body is entered, one `done` carrying
542
+ // the same `target detail — note` sentence the recap will print, so the
543
+ // watcher and the recap reader see one dialect.
544
+ const cwd = tmp("pragma-setup-proj-");
545
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
546
+ const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
547
+ const events: RowEvent[] = [];
548
+ run.setRowListener((event) => events.push(event));
549
+ await runTask(run.generator.generate({}), { onLog: () => {} });
550
+ expect(events.map((event) => event.status)).toEqual(["start", "done"]);
551
+ expect(events[0]?.key).toBe("project:mcp");
552
+ // The start label is the row without an outcome; the done label appends
553
+ // the SAME note `applied()` puts on the recap row.
554
+ const applied = run.applied({});
555
+ const note = applied.rows.find((r) => r.target === "mcp")?.outcome?.note;
556
+ expect(note).toBe("1 added");
557
+ expect(events[1]?.label).toBe(`${events[0]?.label} — ${note}`);
558
+ });
559
+
560
+ it("no listener registered costs nothing — the run is unchanged", async () => {
561
+ // `--yes`, `--dry-run` and MCP never register one; the bracket emits into
562
+ // the void and the composed effects are identical.
563
+ const cwd = tmp("pragma-setup-proj-");
564
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
565
+ const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
566
+ await runTask(run.generator.generate({}), { onLog: () => {} });
567
+ expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(true);
568
+ });
569
+
570
+ it("a failing row reports `failed` with its cause, and its siblings still bracket", async () => {
571
+ // Same arrangement as the S1-1 isolation test: an editor on PATH with no
572
+ // `bun`, so the LSP row's compose fails. The event stream must name the
573
+ // failure — the wizard paints that row ✗ live — while the run continues.
574
+ const prevPath = process.env.PATH;
575
+ const prevData = process.env.XDG_DATA_HOME;
576
+ process.env.XDG_DATA_HOME = tmp("pragma-rowevents-data-");
577
+ const dir = stubPath();
578
+ writeFileSync(join(dir, "code"), "");
579
+ process.env.PATH = dir;
580
+ try {
581
+ const run = await buildSetupRun(
582
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
583
+ "lsp",
584
+ "global",
585
+ );
586
+ const events: RowEvent[] = [];
587
+ run.setRowListener((event) => events.push(event));
588
+ await runTask(run.generator.generate({}), { onLog: () => {} });
589
+ expect(events.map((event) => event.status)).toEqual(["start", "failed"]);
590
+ expect(events[1]?.label).toContain("bun");
591
+ } finally {
592
+ process.env.PATH = prevPath;
593
+ if (prevData === undefined) delete process.env.XDG_DATA_HOME;
594
+ else process.env.XDG_DATA_HOME = prevData;
595
+ }
596
+ });
597
+ });
598
+
599
+ describe("setup lsp — per-editor multiselect (child rows)", () => {
600
+ let prevPath: string | undefined;
601
+ let stubDir = "";
602
+ beforeEach(() => {
603
+ prevPath = process.env.PATH;
604
+ stubDir = stubPath();
605
+ // Two VS Code forks on PATH, neither carrying the extension.
606
+ writeFileSync(join(stubDir, "code"), "");
607
+ writeFileSync(join(stubDir, "codium"), "");
608
+ process.env.PATH = stubDir;
609
+ });
610
+ afterEach(() => {
611
+ process.env.PATH = prevPath;
612
+ });
613
+
614
+ it("offers one child per detected editor, like the mcp row offers files", async () => {
615
+ const { plan } = await buildSetupRun(
616
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
617
+ "lsp",
618
+ "global",
619
+ );
620
+ const row = plan.rows.find((r) => r.target === "lsp");
621
+ expect(row?.action).toBe("install");
622
+ expect(row?.children?.map((c) => c.key)).toEqual(["code", "codium"]);
623
+ expect(row?.children?.every((c) => c.action === "add")).toBe(true);
624
+ });
625
+
626
+ it("marks an editor that already has the extension unchanged", async () => {
627
+ mkdirSync(
628
+ join(
629
+ process.env.HOME ?? "",
630
+ ".vscode",
631
+ "extensions",
632
+ "canonical.terrazzo-lsp-extension-1.2.3",
633
+ ),
634
+ { recursive: true },
635
+ );
636
+ const { plan } = await buildSetupRun(
637
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
638
+ "lsp",
639
+ "global",
640
+ );
641
+ const children = plan.rows.find((r) => r.target === "lsp")?.children;
642
+ expect(children?.find((c) => c.key === "code")?.action).toBe("unchanged");
643
+ expect(children?.find((c) => c.key === "codium")?.action).toBe("add");
644
+ });
645
+
646
+ it("asks the per-editor multiselect directly, with both forks pre-selected", async () => {
647
+ const { generator } = await buildSetupRun(
648
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
649
+ "lsp",
650
+ "global",
651
+ );
652
+ expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
653
+ const editors = generator.prompts.find((p) => p.name === "lspEditors");
654
+ // Both pending editors are pre-selected, so pressing enter installs into
655
+ // every fork — the same default the meta-question used to stand for.
656
+ expect(editors?.default).toEqual(["code", "codium"]);
657
+ expect(editors?.when?.({})).toBe(true);
658
+ });
659
+
660
+ it("sideloads only into the editors the user kept", async () => {
661
+ const { detectLsp, composeLsp } = await import("./operations/setupLsp.js");
662
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
663
+ const { effects } = dryRun(composeLsp(detected, ["codium"]));
664
+ const execs = effects.filter((e) => e._tag === "Exec") as (Effect & {
665
+ _tag: "Exec";
666
+ command: string;
667
+ })[];
668
+ // The fetch still runs once; the sideload runs for `codium` alone.
669
+ expect(execs.map((e) => e.command)).toEqual(["bun", "codium"]);
670
+ });
671
+
672
+ it("composes nothing at all when every editor is deselected", async () => {
673
+ // Not even the package fetch: there is nothing to install it into.
674
+ const { detectLsp, composeLsp } = await import("./operations/setupLsp.js");
675
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
676
+ const { effects } = dryRun(composeLsp(detected, []));
677
+ expect(effects.some((e) => e._tag === "Exec")).toBe(false);
678
+ });
679
+
680
+ it("the removal is `none` when no editor carries the extension", async () => {
681
+ // Both forks are on PATH, neither has a copy — there is nothing this
682
+ // command owns, so a removal has honestly nothing to do.
683
+ const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
684
+ "./operations/setupLsp.js"
685
+ );
686
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
687
+ expect(ownedLspEditors(detected)).toEqual([]);
688
+ expect(collectUndos(composeLspRemoval(detected))).toEqual([]);
689
+
690
+ const { plan } = await buildSetupRun(
691
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
692
+ "lsp",
693
+ "global",
694
+ true,
695
+ );
696
+ const row = plan.rows.find((r) => r.target === "lsp");
697
+ expect(row?.action).toBe("none");
698
+ expect(row?.detail).toBe("no editor carries the extension");
699
+ });
700
+ });
701
+
702
+ describe("setup lsp — the removal contract the other four rows implement", () => {
703
+ let prevPath: string | undefined;
704
+ let stubDir = "";
705
+ beforeEach(() => {
706
+ prevPath = process.env.PATH;
707
+ stubDir = stubPath();
708
+ writeFileSync(join(stubDir, "code"), "");
709
+ writeFileSync(join(stubDir, "codium"), "");
710
+ process.env.PATH = stubDir;
711
+ });
712
+ afterEach(() => {
713
+ process.env.PATH = prevPath;
714
+ });
715
+
716
+ /** Seed a versioned extension copy in an editor's extensions dir. */
717
+ const seedExtension = (dir: string, version: string): void => {
718
+ mkdirSync(
719
+ join(
720
+ process.env.HOME ?? "",
721
+ dir,
722
+ "extensions",
723
+ `canonical.terrazzo-lsp-extension-${version}`,
724
+ ),
725
+ { recursive: true },
726
+ );
727
+ };
728
+
729
+ it("carries one `--uninstall-extension` exec per OWNED editor as its undo", async () => {
730
+ // The row hardcoded a skip behind a docblock claiming "an `exec` carries no
731
+ // reversal" — factually wrong: `Exec` has an `undo` slot, and the sibling
732
+ // `composeMcpRemoval` already uses this exact carrier shape.
733
+ seedExtension(".vscode", "1.2.3");
734
+ const { detectLsp, composeLspRemoval } = await import(
735
+ "./operations/setupLsp.js"
736
+ );
737
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
738
+
739
+ // The FORWARD side must be walkable with every effect mocked: it is a
740
+ // `mkdir` of the editor's own extensions dir, which reads nothing.
741
+ const forward = dryRun(composeLspRemoval(detected));
742
+ expect(forward.effects.map((e) => e._tag)).toEqual(["MakeDir"]);
743
+ expect(forward.effects.some((e) => e._tag === "Exec")).toBe(false);
744
+
745
+ const undos = collectUndos(composeLspRemoval(detected));
746
+ expect(undos).toHaveLength(1);
747
+ const execs = undos
748
+ .flatMap((u) => dryRun(u).effects)
749
+ .filter((e) => e._tag === "Exec") as (Effect & {
750
+ _tag: "Exec";
751
+ command: string;
752
+ args: string[];
753
+ })[];
754
+ expect(execs.map((e) => e.command)).toEqual(["code"]);
755
+ expect(execs[0].args).toEqual([
756
+ "--uninstall-extension",
757
+ "canonical.terrazzo-lsp-extension",
758
+ ]);
759
+ });
760
+
761
+ it("REGRESSION: a pre-0.8.3 copy still plans `install` forward but `remove` back", async () => {
762
+ // The conflation that would have caused a silent wrong-skip.
763
+ // `extensionInstalled` was version-gated, so an editor carrying a dead
764
+ // pre-0.8.3 copy — one this command itself installed — reported
765
+ // `installed: false`, and a removal keyed on it would have walked past the
766
+ // dead extension while reporting success.
767
+ seedExtension(".vscode", "0.8.1");
768
+ const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
769
+ "./operations/setupLsp.js"
770
+ );
771
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
772
+ const code = detected.editors.find((e) => e.editor.cli === "code");
773
+ // Present, but not current — the two facts stay separate.
774
+ expect(code?.present).toBe(true);
775
+ expect(code?.installed).toBe(false);
776
+
777
+ // Forward is UNCHANGED by this fix: the dead copy is still "not installed".
778
+ const forward = await buildSetupRun(
779
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
780
+ "lsp",
781
+ "global",
782
+ );
783
+ expect(forward.plan.rows.find((r) => r.target === "lsp")?.action).toBe(
784
+ "install",
785
+ );
786
+
787
+ // Removal keys on ownership, so the dead copy IS removed.
788
+ expect(ownedLspEditors(detected).map((e) => e.editor.cli)).toEqual([
789
+ "code",
790
+ ]);
791
+ const { plan } = await buildSetupRun(
792
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
793
+ "lsp",
794
+ "global",
795
+ true,
796
+ );
797
+ const row = plan.rows.find((r) => r.target === "lsp");
798
+ expect(row?.action).toBe("remove");
799
+ expect(row?.children?.map((c) => c.key)).toEqual(["code"]);
800
+ expect(
801
+ collectUndos(composeLspRemoval(detected))
802
+ .flatMap((u) => dryRun(u).effects)
803
+ .filter((e) => e._tag === "Exec"),
804
+ ).toHaveLength(1);
805
+ });
806
+
807
+ it("the remedy names an OWNING editor, not the first one on PATH", async () => {
808
+ // With Cursor (or here: `code`) installed and the extension only in
809
+ // VSCodium, the printed remedy named the wrong editor — a command that
810
+ // would report success having uninstalled nothing.
811
+ seedExtension(".vscode-oss", "1.2.3"); // codium's extensions dir
812
+ const { detectLsp, lspUninstallRemedy } = await import(
813
+ "./operations/setupLsp.js"
814
+ );
815
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
816
+ expect(detected.editors[0]?.editor.cli).toBe("code"); // first on PATH
817
+ expect(lspUninstallRemedy(detected)).toBe(
818
+ "codium --uninstall-extension canonical.terrazzo-lsp-extension",
819
+ );
820
+ });
821
+
822
+ it("still SKIPS with the named reason when no editor CLI is on PATH", async () => {
823
+ process.env.PATH = stubPath(); // no editor CLI at all
824
+ const { plan } = await buildSetupRun(
825
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
826
+ "lsp",
827
+ "global",
828
+ true,
829
+ );
830
+ const row = plan.rows.find((r) => r.target === "lsp");
831
+ expect(row?.action).toBe("skip");
832
+ expect(row?.reason).toContain("no VS Code-family editor CLI on PATH");
833
+ });
834
+
835
+ it("end to end: `setup lsp --undo` reports `Undid 1 step(s)` against a STUB cli", async () => {
836
+ // The reported symptom was `Undid 0 step(s).` at exit 0. The stub is a
837
+ // marker-writing script — never a real editor: an undo test must not be
838
+ // able to mutate the machine running it.
839
+ const marker = join(tmp("pragma-lsp-undo-"), "uninstalled");
840
+ const dir = stubPath();
841
+ writeFileSync(
842
+ join(dir, "code"),
843
+ `#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\n`,
844
+ { mode: 0o755 },
845
+ );
846
+ process.env.PATH = dir;
847
+ seedExtension(".vscode", "1.2.3");
848
+
849
+ const outcome = await executeVerb(
850
+ verbOf("lsp"),
851
+ {},
852
+ UNDO,
853
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
854
+ );
855
+ expect(outcome.exitCode).toBe(0);
856
+ expect(outcome.stdout).toBe("Undid 1 step(s).\n");
857
+ expect(readFileSync(marker, "utf-8")).toBe(
858
+ "--uninstall-extension\ncanonical.terrazzo-lsp-extension\n",
859
+ );
860
+ // NON-GOAL guard: the staging dir is a cache, not this removal's business.
861
+ // Nothing here deletes it, and nothing here touches the VSIX.
862
+ const { detectLsp, composeLspRemoval } = await import(
863
+ "./operations/setupLsp.js"
864
+ );
865
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
866
+ const all = [
867
+ ...dryRun(composeLspRemoval(detected)).effects,
868
+ ...collectUndos(composeLspRemoval(detected)).flatMap(
869
+ (u) => dryRun(u).effects,
870
+ ),
871
+ ];
872
+ expect(all.some((e) => e._tag === "DeleteFile")).toBe(false);
873
+ expect(all.some((e) => e._tag === "DeleteDirectory")).toBe(false);
341
874
  });
342
875
  });
343
876
 
344
877
  describe("setup (run-all wizard) — scope threading", () => {
345
878
  // Isolate PATH so an ambient `claude`/`codex` can't inject a harness via a
346
879
  // `process` signal — detection is driven only by the dirs each test makes.
880
+ // A stubbed `code` CLI makes the LSP step's editor detection deterministic.
347
881
  let prevPath: string | undefined;
882
+ let editorPath = "";
348
883
  beforeEach(() => {
349
884
  prevPath = process.env.PATH;
350
- process.env.PATH = tmp("pragma-runall-path-");
885
+ editorPath = stubPath();
886
+ process.env.PATH = editorPath;
351
887
  });
352
888
  afterEach(() => {
353
889
  process.env.PATH = prevPath;
354
890
  });
355
891
 
356
- /** Seed a discoverable skill so the project-band skills step is offerable. */
892
+ /** Put a stub `code` on the isolated PATH so an LSP install is composable. */
893
+ const seedEditorCli = (): void => {
894
+ writeFileSync(join(editorPath, "code"), "");
895
+ };
896
+
897
+ /** Seed a discoverable skill so the project-scope skills step is offerable. */
357
898
  const seedSkill = (cwd: string): void => {
358
899
  const dir = join(cwd, ".pragma", "skills", "s");
359
900
  mkdirSync(dir, { recursive: true });
@@ -363,10 +904,10 @@ describe("setup (run-all wizard) — scope threading", () => {
363
904
  );
364
905
  };
365
906
 
366
- it("--local omits the global-band completions + lsp steps, keeping project steps", async () => {
907
+ it("--local omits the global-scope completions + lsp steps, keeping project steps", async () => {
367
908
  const cwd = tmp("pragma-setup-proj-");
368
- mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-band MCP target
369
- seedSkill(cwd); // project-band skills step
909
+ mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-scope MCP target
910
+ seedSkill(cwd); // project-scope skills step
370
911
  const outcome = await executeVerb(
371
912
  setupSelfVerb,
372
913
  { local: true },
@@ -375,39 +916,159 @@ describe("setup (run-all wizard) — scope threading", () => {
375
916
  );
376
917
  expect(outcome.exitCode).toBe(0);
377
918
  const plan = outcome.stdout ?? "";
378
- // The global-band steps are gone under --local (the bug: they used to run).
379
- expect(plan).not.toContain(completionScriptPath("zsh"));
380
- expect(plan).not.toContain("terrazzo-lsp-extension");
381
- // The project-band steps remain: MCP into .cursor, skills into .agents/skills.
919
+ // A scope FILTERS the run-all: the out-of-scope targets are still named, as
920
+ // rows saying why they are not in this run, so nothing is silently absent —
921
+ // and the reason names the flag that WOULD reach them, so the row is not a
922
+ // dead end either.
923
+ expect(plan).toContain("completions nothing to do");
924
+ expect(plan).toContain("this one is global only — run it with `--global`");
925
+ // The per-project steps remain: MCP into .cursor, skills into .agents/skills.
382
926
  expect(plan).toContain("mcp.json");
383
927
  expect(plan).toContain(".agents/skills");
384
928
  });
385
929
 
386
- it("--global omits the project-band skills step, keeping global steps", async () => {
930
+ withShell(
931
+ "--global omits the project-scope skills step, keeping global steps",
932
+ async () => {
933
+ const cwd = tmp("pragma-setup-proj-");
934
+ seedSkill(cwd); // WOULD be offered under the default `both`
935
+ seedEditorCli(); // vscode detected ⇒ the LSP install is in the plan
936
+ const outcome = await executeVerb(
937
+ setupSelfVerb,
938
+ { global: true },
939
+ DRY,
940
+ bootRuntime(FLAGS, cwd),
941
+ );
942
+ expect(outcome.exitCode).toBe(0);
943
+ const plan = outcome.stdout ?? "";
944
+ // The global-scope steps are present under --global. Paths render against
945
+ // the header's roots, so the row shows `~/.zfunc/_pragma`, not an absolute
946
+ // prefix repeated on every line.
947
+ expect(plan).toMatch(new RegExp(`completions\\s+install\\s+${SHELL} →`));
948
+ expect(plan).toContain("lsp");
949
+ // The project-scope skills step is gone (the bug: it used to run under
950
+ // --global). `shortenPath` renders a project path as `./…` and a global
951
+ // one as `~/…`, and the two scopes' cross-client directories differ only
952
+ // by that marker — so the covenant is asserted on the PROJECT spelling.
953
+ // A bare "not .agents/skills" no longer separates them: the global scope
954
+ // legitimately links its own `~/.agents/skills` now that the bundled
955
+ // snapshot gives it skills on a machine that has run nothing.
956
+ expect(plan).not.toContain(`.${sep}.agents${sep}skills`);
957
+ expect(plan).toContain(`~${sep}.agents${sep}skills`);
958
+ },
959
+ );
960
+ });
961
+
962
+ describe("setup skills", () => {
963
+ it("empty skills are a named skip at exit 0, not a failure", async () => {
964
+ // A skip is "nothing to do here, honestly named" — the same semantics
965
+ // doctor gives its skip glyph. Failing the direct sub-verb made a dotfiles
966
+ // script fail on a machine that simply has no skills installed yet, which
967
+ // teaches people to ignore the exit code.
387
968
  const cwd = tmp("pragma-setup-proj-");
388
- seedSkill(cwd); // WOULD be offered under the default `both`
389
969
  const outcome = await executeVerb(
390
- setupSelfVerb,
391
- { global: true },
392
- DRY,
970
+ verbOf("skills"),
971
+ { local: true },
972
+ YES,
393
973
  bootRuntime(FLAGS, cwd),
394
974
  );
395
975
  expect(outcome.exitCode).toBe(0);
396
- const plan = outcome.stdout ?? "";
397
- // The global-band steps are present under --global.
398
- expect(plan).toContain(completionScriptPath("zsh"));
399
- expect(plan).toContain("terrazzo-lsp-extension");
400
- // The project-band skills step is gone (the bug: it used to run under --global).
401
- expect(plan).not.toContain(".agents/skills");
976
+ expect(outcome.stdout).toContain("skills");
977
+ expect(outcome.stdout).toContain("this project holds no skills");
978
+ // The skip is no longer a dead end: it names the action that settles it on
979
+ // THIS machine. The reason does NOT invert — the row still skips.
980
+ expect(outcome.stdout).toContain("SKILL.md, then run this again");
402
981
  });
403
- });
404
982
 
405
- describe("setup skills", () => {
406
- it("empty skills raise EMPTY_RESULTS on the direct sub-verb", async () => {
983
+ it("an EXISTING but empty project skills root is not reported as absent", async () => {
984
+ // `available` is false for an absent root and for an empty one alike, so
985
+ // the shared reason line said "is absent" about a directory the user is
986
+ // looking at. `rootExists` is the flag that already separates those two
987
+ // states everywhere else in the module; the wording now honours it.
407
988
  const cwd = tmp("pragma-setup-proj-");
408
- await expect(
409
- executeVerb(verbOf("skills"), {}, YES, bootRuntime(FLAGS, cwd)),
410
- ).rejects.toMatchObject({ code: "EMPTY_RESULTS" });
989
+ mkdirSync(join(cwd, ".pragma", "skills"), { recursive: true });
990
+
991
+ const { plan } = await buildSetupRun(
992
+ bootRuntime(FLAGS, cwd),
993
+ "skills",
994
+ "project",
995
+ );
996
+ const row = plan.rows.find((r) => r.target === "skills");
997
+ expect(row?.action).toBe("skip"); // still nothing to do...
998
+ expect(row?.reason).toContain("is empty"); // ...for the honest reason
999
+ expect(row?.reason).not.toContain("does not exist");
1000
+ });
1001
+
1002
+ it("a genuinely MISSING project skills root still reports absent", async () => {
1003
+ // The other half of the distinction: the empty-root wording must not have
1004
+ // been bought by making every skip vague.
1005
+ const { plan } = await buildSetupRun(
1006
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1007
+ "skills",
1008
+ "project",
1009
+ );
1010
+ const row = plan.rows.find((r) => r.target === "skills");
1011
+ expect(row?.action).toBe("skip");
1012
+ expect(row?.reason).toContain("does not exist");
1013
+ });
1014
+
1015
+ it("the GLOBAL scope OFFERS skills on a machine that has run nothing", async () => {
1016
+ // THE BEHAVIOUR CHANGE, asserted at the scope that used to be empty. The
1017
+ // global scope's only source was the installed root, so an empty
1018
+ // `XDG_DATA_HOME` — a fresh install — planned `skip` and told the user to
1019
+ // go and run `sources update` first. The bundled snapshot is the scope's
1020
+ // second root, so the row is actionable with no network and no prior
1021
+ // command. The empty-root WORDING this replaces is still asserted, on the
1022
+ // pure reason/remedy pair, in the cell below.
1023
+ const prevData = process.env.XDG_DATA_HOME;
1024
+ process.env.XDG_DATA_HOME = tmp("pragma-skills-data-");
1025
+ try {
1026
+ const { plan } = await buildSetupRun(
1027
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1028
+ "skills",
1029
+ "global",
1030
+ );
1031
+ const row = plan.rows.find((r) => r.target === "skills");
1032
+ expect(row?.action).toBe("link");
1033
+ expect(row?.detail).toMatch(/\d+ skills? →/);
1034
+
1035
+ // End to end, inside the same jail (an ambient data root that happened to
1036
+ // hold a skill would stop this exercising the fresh-machine condition).
1037
+ const outcome = await executeVerb(
1038
+ verbOf("skills"),
1039
+ {},
1040
+ YES,
1041
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1042
+ );
1043
+ expect(outcome.exitCode).toBe(0);
1044
+ expect(outcome.stdout).not.toContain("no skills installed");
1045
+ } finally {
1046
+ // `process.env.X = undefined` STORES THE STRING "undefined"; it does not
1047
+ // unset. The variable is always set here (`setupXdgIsolation.ts` gives
1048
+ // every test file its own XDG root), so this restores rather than
1049
+ // deletes in practice — but a test must not leave a poisoned path behind
1050
+ // for the files that run after it if that ever stops being true.
1051
+ if (prevData === undefined) delete process.env.XDG_DATA_HOME;
1052
+ else process.env.XDG_DATA_HOME = prevData;
1053
+ }
1054
+ });
1055
+
1056
+ it("the GLOBAL skip still names `sources update` when it does happen", async () => {
1057
+ // The remedy the cell above used to assert end-to-end. That state is no
1058
+ // longer reachable with a snapshot in the package — it takes a build whose
1059
+ // `bundled-skills/` is missing AND an empty installed root — so it is
1060
+ // asserted where it actually lives: on the pure pair that both the setup row
1061
+ // and the doctor row read, which is what stops the two surfaces wording one
1062
+ // finding differently. A skip with no next step is the defect.
1063
+ expect(
1064
+ skillsSkipReason("~/.local/share/pragma/skills", "global", true),
1065
+ ).toBe(
1066
+ "nothing to link — no skills installed yet; they arrive with the packs " +
1067
+ `\`${BIN_NAME} sources update\` builds`,
1068
+ );
1069
+ expect(
1070
+ skillsSkipRemedy("~/.local/share/pragma/skills", "global"),
1071
+ ).toContain(`${BIN_NAME} sources update`);
411
1072
  });
412
1073
 
413
1074
  it("detects a created action and composes a symlink carrying an undo", async () => {
@@ -419,7 +1080,7 @@ describe("setup skills", () => {
419
1080
  "---\nname: my-skill\ndescription: A test skill.\n---\n",
420
1081
  );
421
1082
 
422
- const detected = await detectSkills(bootRuntime(FLAGS, cwd));
1083
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
423
1084
  expect(detected.available).toBe(true);
424
1085
  expect(detected.skillCount).toBe(1);
425
1086
  expect(detected.actions.some((a) => a.action === "created")).toBe(true);
@@ -432,16 +1093,431 @@ describe("setup skills", () => {
432
1093
  expect(symlinkEffect?.undo).toBeDefined();
433
1094
  expect(existsSync(join(cwd, ".agents", "skills", "my-skill"))).toBe(false);
434
1095
  });
435
- });
436
1096
 
437
- describe("setup lsp — missing-binary guard (bunx absent)", () => {
438
- it("surfaces a NAMED UNSUPPORTED (not INTERNAL_ERROR) when bunx is off PATH", async () => {
439
- // Drives the REAL composeLsp exec (YES — not a dry-run mock) with `bunx`
440
- // removed from PATH, so the spawn REJECTS with ENOENT. The reconciled guard
441
- // at composeLsp's use site names it; without the guard the raw reject
442
- // collapses to INTERNAL_ERROR ("please report this issue") at the boundary.
1097
+ it("NEVER deletes a user's own symlink that collides with a shipped skill (REGRESSION)", async () => {
1098
+ // Bundled skills ship on every install, so a folder-name collision with a
1099
+ // user's own link is reachable with NO user action — where previously an
1100
+ // empty skill set meant no action was ever composed for that path.
1101
+ // `composeSkills` composes every non-skipped row, so classifying a
1102
+ // resolving foreign link as `replaced` deletes the user's link.
1103
+ const cwd = tmp("pragma-setup-proj-");
1104
+ const skillDir = join(cwd, ".pragma", "skills", "my-skill");
1105
+ mkdirSync(skillDir, { recursive: true });
1106
+ writeFileSync(
1107
+ join(skillDir, "SKILL.md"),
1108
+ "---\nname: my-skill\ndescription: A test skill.\n---\n",
1109
+ );
1110
+
1111
+ // Somewhere entirely outside every pragma root, and it RESOLVES.
1112
+ const mine = join(cwd, "my-own-work", "my-skill");
1113
+ mkdirSync(mine, { recursive: true });
1114
+ writeFileSync(join(mine, "SKILL.md"), "---\nname: mine\n---\n");
1115
+
1116
+ const linkDir = join(cwd, ".agents", "skills");
1117
+ mkdirSync(linkDir, { recursive: true });
1118
+ const linkPath = join(linkDir, "my-skill");
1119
+ symlinkSync(mine, linkPath);
1120
+
1121
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1122
+ const action = detected.actions.find((a) => a.linkPath === linkPath);
1123
+ expect(action?.action).toBe("skipped");
1124
+ expect(action?.owned).toBe(false);
1125
+
1126
+ // Neither the preview nor the real run may touch it.
1127
+ const { effects } = dryRun(composeSkills(detected));
1128
+ expect(
1129
+ effects.filter(
1130
+ (e) =>
1131
+ e._tag === "DeleteFile" && (e as { path?: string }).path === linkPath,
1132
+ ),
1133
+ ).toHaveLength(0);
1134
+
1135
+ await runTask(composeSkills(detected));
1136
+ expect(readlinkSync(linkPath)).toBe(mine);
1137
+ expect(existsSync(join(linkPath, "SKILL.md"))).toBe(true);
1138
+ });
1139
+
1140
+ it("sweeps a link into a REMOVED package directory once its skill is gone (REGRESSION)", async () => {
1141
+ // The upgrade shape under a version-stamped layout (pnpm, npx, volta): the
1142
+ // link points into `…@0.34.0/bundled-skills/<name>`, a directory the
1143
+ // upgrade replaced. Ownership by residence in a CURRENT root cannot see it,
1144
+ // and once the release drops that skill no per-skill action covers the path
1145
+ // either — so the dangling link would survive every reconcile, invisible to
1146
+ // setup and to doctor alike.
1147
+ const cwd = tmp("pragma-setup-proj-");
1148
+ const skillDir = join(cwd, ".pragma", "skills", "still-here");
1149
+ mkdirSync(skillDir, { recursive: true });
1150
+ writeFileSync(
1151
+ join(skillDir, "SKILL.md"),
1152
+ "---\nname: still-here\ndescription: A test skill.\n---\n",
1153
+ );
1154
+
1155
+ const linkDir = join(cwd, ".agents", "skills");
1156
+ mkdirSync(linkDir, { recursive: true });
1157
+ // Never created: this is the point — the old package directory is gone.
1158
+ const retired = join(cwd, "pragma-cli@0.34.0", "bundled-skills", "retired");
1159
+ const linkPath = join(linkDir, "retired");
1160
+ symlinkSync(retired, linkPath);
1161
+
1162
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1163
+ const orphan = detected.orphans.find((o) => o.linkPath === linkPath);
1164
+ expect(orphan).toBeDefined();
1165
+ expect(orphan?.owned).toBe(true);
1166
+ });
1167
+
1168
+ it("classifies a DANGLING symlink as replaced and repairs it (S1-2)", async () => {
1169
+ // `existsSync` FOLLOWS a symlink, so a dangling link used to read as
1170
+ // "absent" → plan `created` → the real symlink() crashed EEXIST labeled
1171
+ // INTERNAL_ERROR ("please report this issue"), leaving the broken link in
1172
+ // place — and the dry-run previewed a clean create the run then belied.
1173
+ const cwd = tmp("pragma-setup-proj-");
1174
+ const skillDir = join(cwd, ".pragma", "skills", "my-skill");
1175
+ mkdirSync(skillDir, { recursive: true });
1176
+ writeFileSync(
1177
+ join(skillDir, "SKILL.md"),
1178
+ "---\nname: my-skill\ndescription: A test skill.\n---\n",
1179
+ );
1180
+ const linkDir = join(cwd, ".agents", "skills");
1181
+ mkdirSync(linkDir, { recursive: true });
1182
+ const linkPath = join(linkDir, "my-skill");
1183
+ symlinkSync(join(cwd, "nonexistent-target"), linkPath);
1184
+
1185
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1186
+ const action = detected.actions.find((a) => a.linkPath === linkPath);
1187
+ expect(action?.action).toBe("replaced");
1188
+
1189
+ // The dry-run preview now PREDICTS the repair: a delete before the relink.
1190
+ const { effects } = dryRun(composeSkills(detected));
1191
+ const deletes = effects.filter(
1192
+ (e) =>
1193
+ e._tag === "DeleteFile" && (e as { path?: string }).path === linkPath,
1194
+ );
1195
+ expect(deletes.length).toBe(1);
1196
+
1197
+ // The real run repairs the link instead of crashing EEXIST.
1198
+ await runTask(composeSkills(detected));
1199
+ expect(readlinkSync(linkPath)).toBe(skillDir);
1200
+ });
1201
+
1202
+ it("skips a real (non-symlink) directory at the link path, never deleting it", async () => {
1203
+ // A hand-placed skill directory is not this command's to delete — the
1204
+ // sibling planner (sources/installSkills.ts) already refuses to clobber
1205
+ // it, and the two planners must agree on classification.
1206
+ const cwd = tmp("pragma-setup-proj-");
1207
+ const skillDir = join(cwd, ".pragma", "skills", "my-skill");
1208
+ mkdirSync(skillDir, { recursive: true });
1209
+ writeFileSync(
1210
+ join(skillDir, "SKILL.md"),
1211
+ "---\nname: my-skill\ndescription: A test skill.\n---\n",
1212
+ );
1213
+ const realDir = join(cwd, ".agents", "skills", "my-skill");
1214
+ mkdirSync(realDir, { recursive: true });
1215
+ writeFileSync(join(realDir, "SKILL.md"), "hand-placed\n");
1216
+
1217
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1218
+ const action = detected.actions.find((a) => a.linkPath === realDir);
1219
+ expect(action?.action).toBe("skipped");
1220
+
1221
+ const { effects } = dryRun(composeSkills(detected));
1222
+ expect(
1223
+ effects.some(
1224
+ (e) =>
1225
+ e._tag === "DeleteFile" && (e as { path?: string }).path === realDir,
1226
+ ),
1227
+ ).toBe(false);
1228
+ expect(readFileSync(join(realDir, "SKILL.md"), "utf-8")).toBe(
1229
+ "hand-placed\n",
1230
+ );
1231
+ });
1232
+
1233
+ it("never owns — and never removes — a link into a SIBLING of the skill root", async () => {
1234
+ // Ownership was a string prefix, and `<sourceRoot>-backup/my-skill` starts
1235
+ // with `<sourceRoot>`. So a link the USER made into their own backup
1236
+ // directory was classified as ours, and `--undo` deleted it: a file this
1237
+ // command never created and had no business touching.
1238
+ const cwd = tmp("pragma-setup-proj-");
1239
+ const skillDir = join(cwd, ".pragma", "skills", "my-skill");
1240
+ mkdirSync(skillDir, { recursive: true });
1241
+ writeFileSync(
1242
+ join(skillDir, "SKILL.md"),
1243
+ "---\nname: my-skill\ndescription: A test skill.\n---\n",
1244
+ );
1245
+ const backup = join(cwd, ".pragma", "skills-backup", "my-skill");
1246
+ mkdirSync(backup, { recursive: true });
1247
+ writeFileSync(join(backup, "SKILL.md"), "the user's own copy\n");
1248
+ const linkDir = join(cwd, ".agents", "skills");
1249
+ mkdirSync(linkDir, { recursive: true });
1250
+ const linkPath = join(linkDir, "my-skill");
1251
+ symlinkSync(backup, linkPath);
1252
+
1253
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1254
+ const action = detected.actions.find((a) => a.linkPath === linkPath);
1255
+ expect(action?.owned).toBe(false);
1256
+ expect(ownedSkillLinks(detected).map((a) => a.linkPath)).not.toContain(
1257
+ linkPath,
1258
+ );
1259
+
1260
+ // The removal is what `--undo` runs: it must leave both the link and the
1261
+ // directory it points at exactly where they were.
1262
+ await runUndo(composeSkillsRemoval(detected));
1263
+ expect(readlinkSync(linkPath)).toBe(backup);
1264
+ expect(readFileSync(join(backup, "SKILL.md"), "utf-8")).toBe(
1265
+ "the user's own copy\n",
1266
+ );
1267
+ });
1268
+
1269
+ it("removes an owned link whose skill is gone from an emptied source root", async () => {
1270
+ // Detection returned before target discovery whenever the source root held
1271
+ // no skills, so `--undo` looked at an empty action list, reported zero work
1272
+ // and left this command's own links on disk forever.
1273
+ const cwd = tmp("pragma-setup-proj-");
1274
+ const skillsRoot = join(cwd, ".pragma", "skills");
1275
+ const skillDir = join(skillsRoot, "my-skill");
1276
+ mkdirSync(skillDir, { recursive: true });
1277
+ writeFileSync(
1278
+ join(skillDir, "SKILL.md"),
1279
+ "---\nname: my-skill\ndescription: A test skill.\n---\n",
1280
+ );
1281
+ const linkDir = join(cwd, ".agents", "skills");
1282
+ mkdirSync(linkDir, { recursive: true });
1283
+ const linkPath = join(linkDir, "my-skill");
1284
+ symlinkSync(skillDir, linkPath);
1285
+ // The skill is removed upstream: the link is now ours, owned, and dangling.
1286
+ rmSync(skillDir, { recursive: true, force: true });
1287
+
1288
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1289
+ expect(detected.available).toBe(false);
1290
+ expect(ownedSkillLinks(detected).map((a) => a.linkPath)).toEqual([
1291
+ linkPath,
1292
+ ]);
1293
+
1294
+ await runUndo(composeSkillsRemoval(detected));
1295
+ // `existsSync` follows the link, so the dangling one is read through lstat.
1296
+ expect(() => lstatSync(linkPath)).toThrow();
1297
+ });
1298
+
1299
+ it("owns a RELATIVE link into the skill root, resolved against the link dir", async () => {
1300
+ // `readlink` reports the raw target, which may be relative — resolving it
1301
+ // against the link's own directory is what makes the containment test
1302
+ // answer about a real path rather than a string.
1303
+ const cwd = tmp("pragma-setup-proj-");
1304
+ const skillDir = join(cwd, ".pragma", "skills", "my-skill");
1305
+ mkdirSync(skillDir, { recursive: true });
1306
+ writeFileSync(
1307
+ join(skillDir, "SKILL.md"),
1308
+ "---\nname: my-skill\ndescription: A test skill.\n---\n",
1309
+ );
1310
+ const linkDir = join(cwd, ".agents", "skills");
1311
+ mkdirSync(linkDir, { recursive: true });
1312
+ const linkPath = join(linkDir, "my-skill");
1313
+ symlinkSync(relative(linkDir, skillDir), linkPath);
1314
+
1315
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1316
+ const action = detected.actions.find((a) => a.linkPath === linkPath);
1317
+ expect(action?.owned).toBe(true);
1318
+ // A relative link that RESOLVES to the skill is already correct. Comparing
1319
+ // the raw `readlink` string to an absolute path classified it `replaced`,
1320
+ // so it churned on every run and doctor called it "points elsewhere".
1321
+ expect(action?.action).toBe("skipped");
1322
+ expect(dryRun(composeSkills(detected)).effects).toEqual([]);
1323
+
1324
+ await runUndo(composeSkillsRemoval(detected));
1325
+ expect(existsSync(linkPath)).toBe(false);
1326
+ });
1327
+ });
1328
+
1329
+ describe("setup skills — the forward pass is a RECONCILE", () => {
1330
+ /** Seed one project skill and return its source dir. */
1331
+ const seed = (cwd: string, name: string): string => {
1332
+ const dir = join(cwd, ".pragma", "skills", name);
1333
+ mkdirSync(dir, { recursive: true });
1334
+ writeFileSync(
1335
+ join(dir, "SKILL.md"),
1336
+ `---\nname: ${name}\ndescription: A skill.\n---\n`,
1337
+ );
1338
+ return dir;
1339
+ };
1340
+
1341
+ it("removes the link a dropped skill left behind, without --undo", async () => {
1342
+ // The reported bug. `d.orphans` was computed and read ONLY by `--undo`,
1343
+ // which removes every owned link rather than the stale ones — so no command
1344
+ // reconciled, and a skill dropped upstream kept its link forever.
1345
+ const cwd = tmp("pragma-setup-proj-");
1346
+ const kept = seed(cwd, "kept");
1347
+ const dropped = seed(cwd, "dropped");
1348
+ const linkDir = join(cwd, ".agents", "skills");
1349
+ mkdirSync(linkDir, { recursive: true });
1350
+ symlinkSync(kept, join(linkDir, "kept"));
1351
+ symlinkSync(dropped, join(linkDir, "dropped"));
1352
+ rmSync(dropped, { recursive: true, force: true }); // retired upstream
1353
+
1354
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1355
+ expect(detected.orphans.map((o) => o.skillName)).toEqual(["dropped"]);
1356
+
1357
+ await runTask(composeSkills(detected));
1358
+ // `existsSync` FOLLOWS a link, so a dangling one false-negatives — lstat.
1359
+ expect(() => lstatSync(join(linkDir, "dropped"))).toThrow();
1360
+ expect(readlinkSync(join(linkDir, "kept"))).toBe(kept);
1361
+ });
1362
+
1363
+ it("plans the sweep as an ACTIONABLE row, not a deselected `none`", async () => {
1364
+ // An orphan-only tree planned `none` → `defaultSelected(none)` is false →
1365
+ // the row was never composed, so even the fixed reconcile would not run.
1366
+ const cwd = tmp("pragma-setup-proj-");
1367
+ const dropped = seed(cwd, "dropped");
1368
+ const linkDir = join(cwd, ".agents", "skills");
1369
+ mkdirSync(linkDir, { recursive: true });
1370
+ symlinkSync(dropped, join(linkDir, "dropped"));
1371
+ rmSync(dropped, { recursive: true, force: true });
1372
+
1373
+ const { plan } = await buildSetupRun(
1374
+ bootRuntime(FLAGS, cwd),
1375
+ "skills",
1376
+ "project",
1377
+ );
1378
+ const row = plan.rows.find((r) => r.target === "skills");
1379
+ expect(row?.action).toBe("update");
1380
+ expect(row?.selected).toBe(true);
1381
+ expect(row?.detail).toContain("1 stale link to remove");
1382
+ });
1383
+
1384
+ it("the sweep is reversible — undo puts the stale link back", async () => {
1385
+ const cwd = tmp("pragma-setup-proj-");
1386
+ const dropped = seed(cwd, "dropped");
1387
+ const linkDir = join(cwd, ".agents", "skills");
1388
+ mkdirSync(linkDir, { recursive: true });
1389
+ const linkPath = join(linkDir, "dropped");
1390
+ symlinkSync(dropped, linkPath);
1391
+ rmSync(dropped, { recursive: true, force: true });
1392
+
1393
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1394
+ const { undoCount } = await runUndo(composeSkills(detected));
1395
+ expect(undoCount).toBeGreaterThan(0);
1396
+ expect(readlinkSync(linkPath)).toBe(dropped);
1397
+ });
1398
+
1399
+ it("is idempotent — a second pass composes zero effects", async () => {
1400
+ const cwd = tmp("pragma-setup-proj-");
1401
+ const dropped = seed(cwd, "dropped");
1402
+ const linkDir = join(cwd, ".agents", "skills");
1403
+ mkdirSync(linkDir, { recursive: true });
1404
+ symlinkSync(dropped, join(linkDir, "dropped"));
1405
+ rmSync(dropped, { recursive: true, force: true });
1406
+
1407
+ await runTask(
1408
+ composeSkills(await detectSkills(bootRuntime(FLAGS, cwd), "project")),
1409
+ );
1410
+ const second = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1411
+ expect(dryRun(composeSkills(second)).effects).toEqual([]);
1412
+ });
1413
+
1414
+ it("SAFETY: an ABSENT source root means no opinion — nothing is swept", async () => {
1415
+ // `available` conflated "the root is empty" with "the root does not exist".
1416
+ // Sweeping on that alone would wipe every pragma link out of the harness
1417
+ // dirs on any machine where XDG_DATA_HOME moved or sources update never ran.
1418
+ const cwd = tmp("pragma-setup-proj-");
1419
+ const skillDir = seed(cwd, "gone");
1420
+ const linkDir = join(cwd, ".agents", "skills");
1421
+ mkdirSync(linkDir, { recursive: true });
1422
+ const linkPath = join(linkDir, "gone");
1423
+ symlinkSync(skillDir, linkPath);
1424
+ // The whole source ROOT disappears, not just one skill.
1425
+ rmSync(join(cwd, ".pragma", "skills"), { recursive: true, force: true });
1426
+
1427
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1428
+ expect(detected.rootExists).toBe(false);
1429
+ expect(detected.available).toBe(false);
1430
+ expect(detected.orphans).toEqual([]);
1431
+ expect(dryRun(composeSkills(detected)).effects).toEqual([]);
1432
+
1433
+ await runTask(composeSkills(detected));
1434
+ expect(readlinkSync(linkPath)).toBe(skillDir);
1435
+ });
1436
+
1437
+ it("SAFETY: a real dir, a resolving user link and a `-backup` sibling all survive", async () => {
1438
+ // The three shapes the sweep must never touch, in one tree: a hand-placed
1439
+ // directory, a link into someone's own checkout, and a link into a SIBLING
1440
+ // whose name merely EXTENDS the root's (the old string-prefix bug).
1441
+ const cwd = tmp("pragma-setup-proj-");
1442
+ const root = join(cwd, ".pragma", "skills");
1443
+ mkdirSync(root, { recursive: true }); // exists, but holds no skill
1444
+ const backup = join(cwd, ".pragma", "skills-backup", "sibling");
1445
+ mkdirSync(backup, { recursive: true });
1446
+ writeFileSync(join(backup, "SKILL.md"), "the user's own copy\n");
1447
+ const elsewhere = join(cwd, "elsewhere", "mine");
1448
+ mkdirSync(elsewhere, { recursive: true });
1449
+
1450
+ const linkDir = join(cwd, ".agents", "skills");
1451
+ mkdirSync(linkDir, { recursive: true });
1452
+ const realDir = join(linkDir, "hand-placed");
1453
+ mkdirSync(realDir, { recursive: true });
1454
+ writeFileSync(join(realDir, "SKILL.md"), "hand-placed\n");
1455
+ symlinkSync(elsewhere, join(linkDir, "mine"));
1456
+ symlinkSync(backup, join(linkDir, "sibling"));
1457
+
1458
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1459
+ expect(detected.rootExists).toBe(true); // the sweep IS entitled to act
1460
+ expect(detected.orphans).toEqual([]); // ...and finds nothing of its own
1461
+ expect(dryRun(composeSkills(detected)).effects).toEqual([]);
1462
+
1463
+ await runTask(composeSkills(detected));
1464
+ expect(readFileSync(join(realDir, "SKILL.md"), "utf-8")).toBe(
1465
+ "hand-placed\n",
1466
+ );
1467
+ expect(readlinkSync(join(linkDir, "mine"))).toBe(elsewhere);
1468
+ expect(readlinkSync(join(linkDir, "sibling"))).toBe(backup);
1469
+ expect(readFileSync(join(backup, "SKILL.md"), "utf-8")).toBe(
1470
+ "the user's own copy\n",
1471
+ );
1472
+ });
1473
+ });
1474
+
1475
+ describe("setup — detections settle independently", () => {
1476
+ it("gives a rejecting detection its own failed row, leaving the rest planned", async () => {
1477
+ // The targets are independent, and one `Promise.all` made them share a
1478
+ // fate: a single rejecting detection prevented a run being built at all,
1479
+ // so NONE of the other targets ran and doctor rendered no scoped rows.
1480
+ const { TARGETS } = await import("./targets.js");
1481
+ const lsp = TARGETS.find((t) => t.id === "lsp") as (typeof TARGETS)[number];
1482
+ const spy = vi
1483
+ .spyOn(lsp, "detect")
1484
+ .mockRejectedValue(new Error("EACCES: permission denied, open '/x'"));
1485
+ try {
1486
+ const run = await buildSetupRun(
1487
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1488
+ "all",
1489
+ "global",
1490
+ );
1491
+ const row = run.plan.rows.find((r) => r.target === "lsp");
1492
+ expect(row?.reason).toContain("EACCES");
1493
+ // The point of the fix: the siblings are still planned.
1494
+ expect(
1495
+ run.plan.rows.filter((r) => r.target !== "lsp").length,
1496
+ ).toBeGreaterThan(0);
1497
+ // And the row is attributable, not a quiet skip: it exits non-zero.
1498
+ const applied = run.applied({});
1499
+ expect(
1500
+ applied.rows.find((r) => r.target === "lsp")?.outcome?.status,
1501
+ ).toBe("failed");
1502
+ } finally {
1503
+ spy.mockRestore();
1504
+ }
1505
+ });
1506
+ });
1507
+
1508
+ describe("setup lsp — prerequisites (bun absent / no editor CLI)", () => {
1509
+ it("surfaces a NAMED UNSUPPORTED (not INTERNAL_ERROR) when bun is off PATH", async () => {
1510
+ // Drives the REAL composeLsp fetch (YES — not a dry-run mock) with a
1511
+ // stubbed `code` CLI on PATH (so the sideload is attempted) but no `bun`,
1512
+ // so the fetch spawn REJECTS with ENOENT. The guard names it; without the
1513
+ // guard the raw reject collapses to INTERNAL_ERROR ("please report this
1514
+ // issue") at the boundary.
443
1515
  const prevPath = process.env.PATH;
444
- process.env.PATH = tmp("pragma-empty-path-"); // empty dir ⇒ bunx unresolvable
1516
+ const prevData = process.env.XDG_DATA_HOME;
1517
+ const stubDir = stubPath();
1518
+ writeFileSync(join(stubDir, "code"), ""); // present ⇒ vscode is a target
1519
+ process.env.PATH = stubDir;
1520
+ process.env.XDG_DATA_HOME = tmp("pragma-lsp-data-"); // jail the staging dir
445
1521
  let thrown: unknown;
446
1522
  try {
447
1523
  await executeVerb(
@@ -454,16 +1530,207 @@ describe("setup lsp — missing-binary guard (bunx absent)", () => {
454
1530
  thrown = error;
455
1531
  } finally {
456
1532
  process.env.PATH = prevPath;
1533
+ process.env.XDG_DATA_HOME = prevData;
457
1534
  }
458
1535
  expect(thrown).toBeDefined();
459
1536
  const err = asPragmaError(thrown);
460
1537
  expect(err.code).toBe("UNSUPPORTED");
461
1538
  expect(err.code).not.toBe("INTERNAL_ERROR");
462
- expect(err.message).toContain("bunx");
1539
+ expect(err.message).toContain("bun");
463
1540
  expect(err.message).toMatch(/not found on your PATH/i);
464
1541
  // The recovery is the actionable install hint, not "report this issue".
465
1542
  expect(err.recovery?.message ?? "").not.toMatch(/report this issue/i);
466
1543
  });
1544
+
1545
+ it("sideloads the bundled VSIX into EACH detected editor missing it (per-editor CLI, not a hardcoded `code`)", async () => {
1546
+ // Two editor CLIs on PATH; VS Code already has the extension (its
1547
+ // extensions dir carries a versioned copy), VSCodium does not — so the
1548
+ // composed plan fetches the package once (bun, into a durable staging dir)
1549
+ // and sideloads ONLY into `codium`. This is the VSCodium fix: the editor
1550
+ // CLI is a registry lookup, no longer the hardcoded `code`.
1551
+ const prevPath = process.env.PATH;
1552
+ const stubDir = tmp("pragma-forks-path-");
1553
+ writeFileSync(join(stubDir, "code"), "");
1554
+ writeFileSync(join(stubDir, "codium"), "");
1555
+ process.env.PATH = stubDir;
1556
+ try {
1557
+ mkdirSync(
1558
+ join(
1559
+ process.env.HOME ?? "",
1560
+ ".vscode",
1561
+ "extensions",
1562
+ "canonical.terrazzo-lsp-extension-1.2.3",
1563
+ ),
1564
+ { recursive: true },
1565
+ );
1566
+ const { detectLsp, composeLsp } = await import(
1567
+ "./operations/setupLsp.js"
1568
+ );
1569
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
1570
+ expect(detected.state).toBe("absent");
1571
+ expect(detected.editors.map((e) => [e.editor.cli, e.installed])).toEqual([
1572
+ ["code", true],
1573
+ ["codium", false],
1574
+ ]);
1575
+
1576
+ const { effects } = dryRun(composeLsp(detected));
1577
+ const execs = effects.filter((e) => e._tag === "Exec") as (Effect & {
1578
+ _tag: "Exec";
1579
+ command: string;
1580
+ args: string[];
1581
+ })[];
1582
+ // One fetch (bun add, durable staging) + one sideload per PENDING editor.
1583
+ expect(execs.map((e) => e.command)).toEqual(["bun", "codium"]);
1584
+ expect(execs[0].args).toEqual([
1585
+ "add",
1586
+ "@canonical/terrazzo-lsp-extension@latest",
1587
+ ]);
1588
+ expect(execs[1].args[0]).toBe("--install-extension");
1589
+ // The VSIX path is the staging dir's — durable, not a bunx /tmp cache.
1590
+ expect(execs[1].args[1]).toContain(detected.stagingDir);
1591
+ expect(execs[1].args[1]).toContain("terrazzo-lsp.vsix");
1592
+ } finally {
1593
+ process.env.PATH = prevPath;
1594
+ }
1595
+ });
1596
+
1597
+ it("treats an extension too old to WORK as not installed", async () => {
1598
+ // Every VSIX before 0.8.3 ships a language server that cannot resolve its
1599
+ // own dependencies — it only appeared to run because the extension prefers
1600
+ // Bun and Bun fetches missing packages off the network at spawn time.
1601
+ // Matching any version meant such a machine reported `installed`, so
1602
+ // `setup lsp` skipped it and `doctor` called it healthy: a wrong skip that
1603
+ // reads as correct, which is worse than a wrong failure. Everyone who ran
1604
+ // setup before that release would have kept a dead server forever.
1605
+ const prevPath = process.env.PATH;
1606
+ const stubDir = tmp("pragma-stale-ext-path-");
1607
+ writeFileSync(join(stubDir, "code"), "");
1608
+ process.env.PATH = stubDir;
1609
+ try {
1610
+ mkdirSync(
1611
+ join(
1612
+ process.env.HOME ?? "",
1613
+ ".vscode",
1614
+ "extensions",
1615
+ "canonical.terrazzo-lsp-extension-0.8.1",
1616
+ ),
1617
+ { recursive: true },
1618
+ );
1619
+ const { detectLsp } = await import("./operations/setupLsp.js");
1620
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
1621
+ expect(detected.state).not.toBe("installed");
1622
+ } finally {
1623
+ process.env.PATH = prevPath;
1624
+ }
1625
+ });
1626
+
1627
+ it("treats a version it cannot READ as too old, not as newer", async () => {
1628
+ // The fail-closed claim has to survive the parser. `Number.parseInt`
1629
+ // accepts numeric prefixes and a `|| 0` fallback turns NaN into zero, so
1630
+ // `1.invalid.0` and `1.0.0junk` both read as [1,0,0] — comparing NEWER
1631
+ // than the minimum and reporting a broken extension as installed, which
1632
+ // is the wrong skip this check exists to prevent.
1633
+ const prevPath = process.env.PATH;
1634
+ const stubDir = tmp("pragma-badver-path-");
1635
+ writeFileSync(join(stubDir, "code"), "");
1636
+ process.env.PATH = stubDir;
1637
+ try {
1638
+ for (const suffix of ["1.invalid.0", "1.0.0junk", "garbage", ""]) {
1639
+ mkdirSync(
1640
+ join(
1641
+ process.env.HOME ?? "",
1642
+ ".vscode",
1643
+ "extensions",
1644
+ `canonical.terrazzo-lsp-extension-${suffix}`,
1645
+ ),
1646
+ { recursive: true },
1647
+ );
1648
+ }
1649
+ const { detectLsp } = await import("./operations/setupLsp.js");
1650
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
1651
+ expect(detected.state).not.toBe("installed");
1652
+ } finally {
1653
+ process.env.PATH = prevPath;
1654
+ }
1655
+ });
1656
+
1657
+ it("reports already-installed (a true no-op) when every detected editor has the extension", async () => {
1658
+ const prevPath = process.env.PATH;
1659
+ const stubDir = tmp("pragma-installed-path-");
1660
+ writeFileSync(join(stubDir, "code"), "");
1661
+ process.env.PATH = stubDir;
1662
+ try {
1663
+ mkdirSync(
1664
+ join(
1665
+ process.env.HOME ?? "",
1666
+ ".vscode",
1667
+ "extensions",
1668
+ "canonical.terrazzo-lsp-extension-1.2.3",
1669
+ ),
1670
+ { recursive: true },
1671
+ );
1672
+ const { detectLsp, composeLsp } = await import(
1673
+ "./operations/setupLsp.js"
1674
+ );
1675
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
1676
+ expect(detected.state).toBe("installed");
1677
+ const { effects } = dryRun(composeLsp(detected));
1678
+ expect(effects.some((e) => e._tag === "Exec")).toBe(false);
1679
+ } finally {
1680
+ process.env.PATH = prevPath;
1681
+ }
1682
+ });
1683
+
1684
+ it("matches the FULL extension id, not any terrazzo-named directory", async () => {
1685
+ // The old probe matched the substring "terrazzo", which any other
1686
+ // terrazzo-named extension would false-positive.
1687
+ const prevPath = process.env.PATH;
1688
+ const stubDir = tmp("pragma-substr-path-");
1689
+ writeFileSync(join(stubDir, "code"), "");
1690
+ process.env.PATH = stubDir;
1691
+ try {
1692
+ mkdirSync(
1693
+ join(
1694
+ process.env.HOME ?? "",
1695
+ ".vscode",
1696
+ "extensions",
1697
+ "someoneelse.terrazzo-tools-9.9.9",
1698
+ ),
1699
+ { recursive: true },
1700
+ );
1701
+ const { detectLsp } = await import("./operations/setupLsp.js");
1702
+ const detected = await detectLsp(tmp("pragma-setup-proj-"));
1703
+ expect(detected.state).toBe("absent");
1704
+ } finally {
1705
+ process.env.PATH = prevPath;
1706
+ }
1707
+ });
1708
+
1709
+ it("skips with a NAMED message (exit 0) when no editor CLI exists at all", async () => {
1710
+ // A machine with no VS Code-family CLI cannot be installed into — the
1711
+ // honest outcome is a named skip, not the old UNSUPPORTED with a
1712
+ // "permissions or network" guess and a dead /tmp path (§3, S1-1's trigger).
1713
+ const prevPath = process.env.PATH;
1714
+ process.env.PATH = stubPath(); // empty ⇒ no editor CLI
1715
+ try {
1716
+ const outcome = await executeVerb(
1717
+ verbOf("lsp"),
1718
+ {},
1719
+ YES,
1720
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1721
+ );
1722
+ expect(outcome.exitCode).toBe(0);
1723
+ expect(outcome.stdout).toContain("skipped");
1724
+ expect(outcome.stdout).toContain("no VS Code-family editor CLI on PATH");
1725
+ // The remedy names what would count, and states plainly that nothing is
1726
+ // possible here yet rather than offering a command this machine lacks.
1727
+ expect(outcome.stdout).toContain(
1728
+ "no action is possible on this machine yet",
1729
+ );
1730
+ } finally {
1731
+ process.env.PATH = prevPath;
1732
+ }
1733
+ });
467
1734
  });
468
1735
 
469
1736
  describe("setup — idempotent detection of already-present config", () => {
@@ -471,7 +1738,7 @@ describe("setup — idempotent detection of already-present config", () => {
471
1738
  let prevPath: string | undefined;
472
1739
  beforeEach(() => {
473
1740
  prevPath = process.env.PATH;
474
- process.env.PATH = tmp("pragma-idem-path-");
1741
+ process.env.PATH = stubPath();
475
1742
  });
476
1743
  afterEach(() => {
477
1744
  process.env.PATH = prevPath;
@@ -483,42 +1750,41 @@ describe("setup — idempotent detection of already-present config", () => {
483
1750
  const configPath = join(cwd, ".cursor", "mcp.json");
484
1751
 
485
1752
  // First run writes the pragma entry.
486
- await executeVerb(verbOf("mcp"), {}, YES, bootRuntime(FLAGS, cwd));
1753
+ await executeVerb(
1754
+ verbOf("mcp"),
1755
+ { local: true },
1756
+ YES,
1757
+ bootRuntime(FLAGS, cwd),
1758
+ );
487
1759
  const firstBody = readFileSync(configPath, "utf-8");
488
1760
 
489
- // Detection now classifies the group `configured`.
490
- const { toResult } = await buildSetupPlan(
1761
+ // The plan's mcp row now reads `none` — nothing to do — and its child for
1762
+ // this file reads `unchanged`.
1763
+ const { plan } = await buildSetupRun(
491
1764
  bootRuntime(FLAGS, cwd),
492
1765
  "mcp",
493
- "both",
1766
+ "project",
494
1767
  );
495
- const result = toResult({});
496
- expect(result.kind).toBe("mcp");
497
- if (result.kind === "mcp") {
498
- expect(result.targets.at(0)?.state).toBe("configured");
499
- }
1768
+ const row = plan.rows.find((r) => r.target === "mcp");
1769
+ expect(row?.action).toBe("none");
1770
+ expect(row?.children?.find((c) => c.key === configPath)?.action).toBe(
1771
+ "unchanged",
1772
+ );
1773
+ // An already-current row is offered DE-selected, so a re-run never proposes
1774
+ // to rewrite what is already correct.
1775
+ expect(row?.selected).toBe(false);
500
1776
 
501
- // The wizard default-DESELECTS a configured target (so a plain re-run offers
502
- // it unchecked) — proven on the generated multiselect's default.
503
- const { generator } = await buildSetupPlan(
1777
+ // A real second run is idempotent: the file stays byte-identical AND the
1778
+ // converged row composes no effect at all, so the mtime is untouched.
1779
+ const before = statSync(configPath).mtimeMs;
1780
+ await executeVerb(
1781
+ verbOf("mcp"),
1782
+ { local: true },
1783
+ YES,
504
1784
  bootRuntime(FLAGS, cwd),
505
- "mcp",
506
- "both",
507
1785
  );
508
- const multiselect = generator.prompts.find((p) => p.name === "mcpTargets");
509
- expect(multiselect?.default).toEqual([]); // the only file is configured
510
- expect(
511
- (multiselect?.choices ?? []).some((c) =>
512
- typeof c === "object" && "label" in c
513
- ? c.label.includes("already configured")
514
- : false,
515
- ),
516
- ).toBe(true);
517
-
518
- // A real second run is idempotent: the file stays byte-identical (the
519
- // read-modify-write re-merges the SAME pragma entry).
520
- await executeVerb(verbOf("mcp"), {}, YES, bootRuntime(FLAGS, cwd));
521
1786
  expect(readFileSync(configPath, "utf-8")).toBe(firstBody);
1787
+ expect(statSync(configPath).mtimeMs).toBe(before);
522
1788
  });
523
1789
 
524
1790
  it("mcp: a drifted pragma entry (wrong cwd) reads as `drifted` and is updated", async () => {
@@ -539,111 +1805,438 @@ describe("setup — idempotent detection of already-present config", () => {
539
1805
  )}\n`,
540
1806
  );
541
1807
 
542
- const { toResult, generator } = await buildSetupPlan(
1808
+ const { plan } = await buildSetupRun(
543
1809
  bootRuntime(FLAGS, cwd),
544
1810
  "mcp",
545
- "both",
1811
+ "project",
546
1812
  );
547
- const result = toResult({});
548
- if (result.kind === "mcp") {
549
- expect(result.targets.at(0)?.state).toBe("drifted");
550
- }
551
- // A drifted target stays SELECTED by default (it needs the update).
552
- const multiselect = generator.prompts.find((p) => p.name === "mcpTargets");
553
- expect(multiselect?.default).toEqual([configPath]);
1813
+ const row = plan.rows.find((r) => r.target === "mcp");
1814
+ // A drifted file is an `update` child, and the row stays SELECTED by
1815
+ // default because it needs the write.
1816
+ expect(row?.children?.find((c) => c.key === configPath)?.action).toBe(
1817
+ "update",
1818
+ );
1819
+ expect(row?.action).toBe("update");
1820
+ expect(row?.selected).toBe(true);
554
1821
 
555
- await executeVerb(verbOf("mcp"), {}, YES, bootRuntime(FLAGS, cwd));
1822
+ await executeVerb(
1823
+ verbOf("mcp"),
1824
+ { local: true },
1825
+ YES,
1826
+ bootRuntime(FLAGS, cwd),
1827
+ );
556
1828
  const config = JSON.parse(readFileSync(configPath, "utf-8"));
557
1829
  expect(config.mcpServers.pragma.cwd).toBe(cwd); // updated to the real cwd
558
1830
  });
559
1831
 
560
- it("completions: a second run detects the up-to-date script (state=installed)", async () => {
561
- const cwd = tmp("pragma-setup-proj-");
562
- const path = completionScriptPath("zsh");
563
- // First install.
564
- await executeVerb(completionsVerb, {}, YES, bootRuntime(FLAGS, cwd));
565
- const firstBody = readFileSync(path, "utf-8");
1832
+ withShell(
1833
+ "completions: a second run detects the up-to-date script (state=installed)",
1834
+ async () => {
1835
+ const cwd = tmp("pragma-setup-proj-");
1836
+ const path = completionScriptPath(SHELL as ShellId);
1837
+ // First install.
1838
+ await executeVerb(completionsVerb, {}, YES, bootRuntime(FLAGS, cwd));
1839
+ const firstBody = readFileSync(path, "utf-8");
566
1840
 
567
- // Detection now classifies the installed script `installed`.
568
- const { toResult } = await buildSetupPlan(
569
- bootRuntime(FLAGS, cwd),
570
- "completions",
571
- "both",
572
- );
573
- const result = toResult({});
574
- if (result.kind === "completions") {
575
- expect(result.state).toBe("installed");
576
- }
1841
+ // The plan's completions row now reads `none` — the installed bytes are
1842
+ // current, so a re-run composes nothing.
1843
+ const { plan } = await buildSetupRun(
1844
+ bootRuntime(FLAGS, cwd),
1845
+ "completions",
1846
+ "global",
1847
+ );
1848
+ expect(plan.rows.find((r) => r.target === "completions")?.action).toBe(
1849
+ "none",
1850
+ );
577
1851
 
578
- // A real second run is idempotent — the byte-identical script survives.
579
- await executeVerb(completionsVerb, {}, YES, bootRuntime(FLAGS, cwd));
580
- expect(readFileSync(path, "utf-8")).toBe(firstBody);
581
- });
1852
+ // A real second run is idempotent — the byte-identical script survives.
1853
+ await executeVerb(completionsVerb, {}, YES, bootRuntime(FLAGS, cwd));
1854
+ expect(readFileSync(path, "utf-8")).toBe(firstBody);
1855
+ },
1856
+ );
582
1857
 
583
- it("completions: a stale script (different body) reads as `stale`", async () => {
584
- const cwd = tmp("pragma-setup-proj-");
585
- const path = completionScriptPath("zsh");
586
- mkdirSync(join(path, ".."), { recursive: true });
587
- writeFileSync(path, "# stale hand-edited completion\n");
588
- const { toResult } = await buildSetupPlan(
589
- bootRuntime(FLAGS, cwd),
590
- "completions",
591
- "both",
592
- );
593
- const result = toResult({});
594
- if (result.kind === "completions") {
595
- expect(result.state).toBe("stale");
596
- }
597
- });
1858
+ withShell(
1859
+ "completions: a stale script (different body) reads as `stale`",
1860
+ async () => {
1861
+ const cwd = tmp("pragma-setup-proj-");
1862
+ const path = completionScriptPath(SHELL as ShellId);
1863
+ mkdirSync(join(path, ".."), { recursive: true });
1864
+ writeFileSync(path, "# stale hand-edited completion\n");
1865
+ const { plan } = await buildSetupRun(
1866
+ bootRuntime(FLAGS, cwd),
1867
+ "completions",
1868
+ "global",
1869
+ );
1870
+ // A hand-edited script is `update`, not `install`: the row says which of
1871
+ // the two it is, because overwriting someone's edit is worth naming.
1872
+ expect(plan.rows.find((r) => r.target === "completions")?.action).toBe(
1873
+ "update",
1874
+ );
1875
+ },
1876
+ );
598
1877
 
599
1878
  it("lsp: reports `unknown` when the `code` CLI is absent from PATH", async () => {
600
1879
  // PATH is the isolated empty dir (beforeEach), so `code` is unresolvable:
601
1880
  // detection cannot enumerate and reports `unknown` (installer still runs).
602
- const { toResult } = await buildSetupPlan(
1881
+ const { plan } = await buildSetupRun(
603
1882
  bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
604
1883
  "lsp",
605
- "both",
1884
+ "global",
606
1885
  );
607
- const result = toResult({});
608
- expect(result.kind).toBe("lsp");
609
- if (result.kind === "lsp") {
610
- expect(result.state).toBe("unknown");
611
- }
1886
+ // No editor CLI resolves, so the row is a SKIP carrying its reason —
1887
+ // never a silent omission, and never a failure.
1888
+ const row = plan.rows.find((r) => r.target === "lsp");
1889
+ expect(row?.action).toBe("skip");
1890
+ expect(row?.reason).toContain("no VS Code-family editor CLI on PATH");
612
1891
  });
613
1892
  });
614
1893
 
615
1894
  describe("setup (run-all wizard)", () => {
616
- it("--dry-run previews every DETECTED step (completions + lsp + mcp), writing nothing", async () => {
1895
+ // Pin PATH to a stub dir carrying only a `code` CLI: the LSP step's editor
1896
+ // detection (and the ambient-harness process signals) must not depend on
1897
+ // what happens to be installed on the host running the tests.
1898
+ let prevPath: string | undefined;
1899
+ beforeEach(() => {
1900
+ prevPath = process.env.PATH;
1901
+ const dir = stubPath();
1902
+ writeFileSync(join(dir, "code"), "");
1903
+ process.env.PATH = dir;
1904
+ });
1905
+ afterEach(() => {
1906
+ process.env.PATH = prevPath;
1907
+ });
1908
+
1909
+ withShell(
1910
+ "--dry-run previews every DETECTED step (completions + lsp + mcp), writing nothing",
1911
+ async () => {
1912
+ const cwd = tmp("pragma-setup-proj-");
1913
+ mkdirSync(join(cwd, ".cursor"), { recursive: true }); // harness detected
1914
+ const outcome = await executeVerb(
1915
+ setupSelfVerb,
1916
+ {},
1917
+ DRY,
1918
+ bootRuntime(FLAGS, cwd),
1919
+ );
1920
+ expect(outcome.exitCode).toBe(0);
1921
+ const plan = outcome.stdout ?? "";
1922
+ // EVERY target is a visible row — including the ones that will skip. The
1923
+ // run-all used to build its choices only from detectable steps, so a
1924
+ // target it could not offer vanished from the plan and from the recap.
1925
+ for (const id of ["config", "completions", "lsp", "mcp", "skills"]) {
1926
+ expect(plan).toContain(id);
1927
+ }
1928
+ expect(plan).toContain(`${SHELL} →`); // completions row
1929
+ expect(plan).not.toContain("Prompt"); // recap gate / multiselects filtered
1930
+ // Nothing is written by a preview.
1931
+ expect(existsSync(completionScriptPath(SHELL as ShellId))).toBe(false);
1932
+ expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(false);
1933
+ },
1934
+ );
1935
+
1936
+ it("a failing LSP step no longer aborts the run — MCP and skills still apply, exit reflects the failure (S1-1)", async () => {
1937
+ // The audit's headline break: with an unsatisfiable LSP prerequisite the
1938
+ // single advertised onboarding command configured NOTHING (no MCP write,
1939
+ // no skills link) and exited 1. Steps are independent — one failure must
1940
+ // report, let the rest proceed, and only then fail the run. Here the LSP
1941
+ // step has an editor (`code` stub on PATH) but no `bun`, so its fetch
1942
+ // fails; MCP (.cursor) and skills (a seeded skill) must still apply.
1943
+ const prevData = process.env.XDG_DATA_HOME;
1944
+ process.env.XDG_DATA_HOME = tmp("pragma-s11-data-"); // jail the staging dir
617
1945
  const cwd = tmp("pragma-setup-proj-");
618
- mkdirSync(join(cwd, ".cursor"), { recursive: true }); // harness detected
1946
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
1947
+ const skillDir = join(cwd, ".pragma", "skills", "s");
1948
+ mkdirSync(skillDir, { recursive: true });
1949
+ writeFileSync(
1950
+ join(skillDir, "SKILL.md"),
1951
+ "---\nname: s\ndescription: A skill.\n---\n",
1952
+ );
1953
+ let thrown: unknown;
1954
+ try {
1955
+ // `--scope both`: the failing target (lsp) is global-only, and the two
1956
+ // that must survive it write the project scope, so the run has to cover
1957
+ // both. A bare run-all is the GLOBAL scope now and would not touch this
1958
+ // repository at all.
1959
+ await executeVerb(
1960
+ setupSelfVerb,
1961
+ { scope: "both" },
1962
+ YES,
1963
+ bootRuntime(FLAGS, cwd),
1964
+ );
1965
+ } catch (error) {
1966
+ thrown = error;
1967
+ } finally {
1968
+ process.env.XDG_DATA_HOME = prevData;
1969
+ }
1970
+ // The satisfiable steps ran to completion...
1971
+ expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(true);
1972
+ expect(existsSync(join(cwd, ".agents", "skills", "s"))).toBe(true);
1973
+ // ...and the run still failed, with the ORIGINAL step error surfaced.
1974
+ expect(thrown).toBeDefined();
1975
+ const err = asPragmaError(thrown);
1976
+ expect(err.code).toBe("UNSUPPORTED");
1977
+ // One failed row reports ITS OWN cause — a count would throw away the only
1978
+ // sentence that says what is wrong.
1979
+ expect(err.message).toContain("lsp");
1980
+ expect(err.message).toContain("bun");
1981
+ });
1982
+
1983
+ it("omits skills gracefully when none are discovered (no mid-wizard EMPTY_RESULTS)", async () => {
1984
+ // A run-all in a scope with no skills must NOT throw — it just doesn't offer
1985
+ // the skills step. Reaching a clean plan proves the graceful degrade.
1986
+ //
1987
+ // POINTED AT THE PROJECT BAND, which is where an empty skills root is still
1988
+ // an ordinary condition: the global scope now always has the bundled
1989
+ // snapshot behind it, so a run-all there can no longer produce the empty
1990
+ // row this cell exists to survive. The condition is the same one; only the
1991
+ // scope that can still reach it has changed.
619
1992
  const outcome = await executeVerb(
620
1993
  setupSelfVerb,
1994
+ { local: true },
1995
+ DRY,
1996
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1997
+ );
1998
+ expect(outcome.exitCode).toBe(0);
1999
+ // The skills row is PRESENT and says why it cannot act, rather than being
2000
+ // dropped from a plan that then claims completeness.
2001
+ expect(outcome.stdout).toContain("skills");
2002
+ expect(outcome.stdout).toContain("this project holds no skills");
2003
+ expect(outcome.stdout).toContain("lsp");
2004
+ });
2005
+
2006
+ it("the GLOBAL run-all offers the skills row on a fresh machine", async () => {
2007
+ // The other side of the same graceful-degrade question, and the one a fresh
2008
+ // install actually meets: a bare run-all is the global scope, whose skills
2009
+ // row is now offerable from the bundled snapshot rather than skipped with a
2010
+ // remedy the user has to go away and run.
2011
+ const prevData = process.env.XDG_DATA_HOME;
2012
+ process.env.XDG_DATA_HOME = tmp("pragma-runall-data-");
2013
+ try {
2014
+ const outcome = await executeVerb(
2015
+ setupSelfVerb,
2016
+ {},
2017
+ DRY,
2018
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2019
+ );
2020
+ expect(outcome.exitCode).toBe(0);
2021
+ expect(outcome.stdout).toContain("skills");
2022
+ expect(outcome.stdout).not.toContain("no skills installed");
2023
+ } finally {
2024
+ if (prevData === undefined) delete process.env.XDG_DATA_HOME;
2025
+ else process.env.XDG_DATA_HOME = prevData;
2026
+ }
2027
+ });
2028
+ });
2029
+
2030
+ describe("setup — a converged machine is reported, never asked", () => {
2031
+ // A converged run composes ZERO effects, and the confirm gate then mounted
2032
+ // Ink to render summon-core's "No operations planned." above "Proceed?".
2033
+ // On a sub-verb, whose prompt list is empty, that contentless gate was the
2034
+ // first and only thing the user ever saw.
2035
+ let prevPath: string | undefined;
2036
+ beforeEach(() => {
2037
+ prevPath = process.env.PATH;
2038
+ process.env.PATH = stubPath(); // no editor CLI ⇒ lsp skips
2039
+ });
2040
+ afterEach(() => {
2041
+ process.env.PATH = prevPath;
2042
+ });
2043
+
2044
+ const NO = { dryRun: false, undo: false, yes: false };
2045
+
2046
+ it("non-interactive + no --yes: the recap, NOT the `run again with --yes` hint", async () => {
2047
+ // The compounding defect: a converged plan printed "Nothing was applied.
2048
+ // Run again with --yes to apply." over a machine where there was nothing
2049
+ // to apply in the first place.
2050
+ const outcome = await executeVerb(
2051
+ verbOf("lsp"),
2052
+ {},
2053
+ NO,
2054
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2055
+ );
2056
+ expect(outcome.exitCode).toBe(0);
2057
+ expect(outcome.stdout).not.toContain("Run again with --yes");
2058
+ expect(outcome.stdout).not.toContain("Setup plan");
2059
+ // It says what was detected and what happened to it — doctor's house shape.
2060
+ expect(outcome.stdout).toContain("skipped");
2061
+ expect(outcome.stdout).toContain("no VS Code-family editor CLI on PATH");
2062
+ });
2063
+
2064
+ it("a converged TTY run without --yes mounts NO Ink and prints the recap", async () => {
2065
+ // The acceptance case. Faking a terminal on both streams is what makes
2066
+ // `cliIsTTY()` true, which is the ONLY gate between this run and the wizard.
2067
+ const prevIn = process.stdin.isTTY;
2068
+ const prevErr = process.stderr.isTTY;
2069
+ (process.stdin as { isTTY?: boolean }).isTTY = true;
2070
+ (process.stderr as { isTTY?: boolean }).isTTY = true;
2071
+ let outcome: Awaited<ReturnType<typeof executeVerb>>;
2072
+ try {
2073
+ outcome = await executeVerb(
2074
+ verbOf("lsp"),
2075
+ {},
2076
+ NO,
2077
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2078
+ );
2079
+ } finally {
2080
+ (process.stdin as { isTTY?: boolean }).isTTY = prevIn;
2081
+ (process.stderr as { isTTY?: boolean }).isTTY = prevErr;
2082
+ }
2083
+ expect(outcome.exitCode).toBe(0);
2084
+ expect(outcome.stdout).not.toContain("Proceed");
2085
+ expect(outcome.stdout).toContain("skipped");
2086
+ // The same lazy-React probe the PROTECTED guard below uses: had the
2087
+ // contentless gate mounted, Ink would be in the module cache.
2088
+ const isReactPkg = (k: string) =>
2089
+ /[\\/](react|react-dom|ink)@\d/.test(k) ||
2090
+ /[\\/]node_modules[\\/](react|react-dom|ink)[\\/]/.test(k);
2091
+ expect(Object.keys(require.cache ?? {}).filter(isReactPkg)).toEqual([]);
2092
+ });
2093
+
2094
+ it("--dry-run is untouched — it still ends with the dry-run hint", async () => {
2095
+ // The guard sits AFTER the `previewing` computation precisely so a preview
2096
+ // keeps saying it was a preview.
2097
+ const outcome = await executeVerb(
2098
+ verbOf("lsp"),
621
2099
  {},
622
2100
  DRY,
2101
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2102
+ );
2103
+ expect(outcome.exitCode).toBe(0);
2104
+ expect(outcome.stdout).toContain("Setup plan");
2105
+ expect(outcome.stdout).toContain(DRY_RUN_HINT);
2106
+ });
2107
+
2108
+ it("a PARTIALLY converged plan is NOT short-circuited", async () => {
2109
+ // One actionable row is enough: the run goes through `execute` as before,
2110
+ // so the wizard is still reachable on a machine with something to do.
2111
+ const cwd = tmp("pragma-setup-proj-");
2112
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2113
+ const outcome = await executeVerb(
2114
+ verbOf("mcp"),
2115
+ { local: true },
2116
+ NO,
623
2117
  bootRuntime(FLAGS, cwd),
624
2118
  );
625
2119
  expect(outcome.exitCode).toBe(0);
626
- const plan = outcome.stdout ?? "";
627
- expect(plan).toContain(completionScriptPath("zsh")); // completions step
628
- expect(plan).toContain("terrazzo-lsp-extension"); // lsp step (exec, mocked)
629
- expect(plan).toContain("mcp.json"); // mcp step
630
- expect(plan).not.toContain("Prompt"); // recap gate / multiselects filtered
631
- // Nothing is written by a preview.
632
- expect(existsSync(completionScriptPath("zsh"))).toBe(false);
633
- expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(false);
2120
+ // Non-interactive without --yes ⇒ still the preview, hint and all.
2121
+ expect(outcome.stdout).toContain("Setup plan");
2122
+ expect(outcome.stdout).toContain(PREVIEW_HINT);
634
2123
  });
635
2124
 
636
- it("omits skills gracefully when none are discovered (no mid-wizard EMPTY_RESULTS)", async () => {
637
- // A run-all in a project with no skills must NOT throw — it just doesn't
638
- // offer the skills step. Reaching a clean plan proves the graceful degrade.
2125
+ it("the run-all on a fully converged machine recaps every row at exit 0", async () => {
2126
+ const cwd = tmp("pragma-setup-proj-");
2127
+ // First pass configures whatever this machine can hold...
2128
+ await executeVerb(setupSelfVerb, {}, YES, bootRuntime(FLAGS, cwd));
2129
+ // ...the second has nothing left to do.
639
2130
  const outcome = await executeVerb(
640
2131
  setupSelfVerb,
641
2132
  {},
642
- DRY,
643
- bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2133
+ NO,
2134
+ bootRuntime(FLAGS, cwd),
644
2135
  );
645
2136
  expect(outcome.exitCode).toBe(0);
646
- expect(outcome.stdout).toContain("terrazzo-lsp-extension"); // lsp still there
2137
+ expect(outcome.stdout).not.toContain("Run again with --yes");
2138
+ expect(outcome.stdout).toContain("targets configured");
2139
+ });
2140
+ });
2141
+
2142
+ describe("setup — the detection summary", () => {
2143
+ let prevPath: string | undefined;
2144
+ beforeEach(() => {
2145
+ prevPath = process.env.PATH;
2146
+ process.env.PATH = stubPath();
2147
+ });
2148
+ afterEach(() => {
2149
+ process.env.PATH = prevPath;
2150
+ });
2151
+
2152
+ /**
2153
+ * Drive a run, capturing STDERR — which is where the report seam writes, and
2154
+ * the same stream the Ink frame would use. The dispatcher builds its own
2155
+ * runtime for a mutation, so an `rt.report` set on the boot runtime is
2156
+ * replaced; capturing the stream is what actually pins the behaviour.
2157
+ */
2158
+ const onStderr = async (
2159
+ verb: VerbSpec,
2160
+ params: Record<string, unknown>,
2161
+ flags: GlobalFlags,
2162
+ cwd: string,
2163
+ mutation = YES,
2164
+ ): Promise<string[]> => {
2165
+ const chunks: string[] = [];
2166
+ const spy = vi
2167
+ .spyOn(process.stderr, "write")
2168
+ .mockImplementation((chunk: unknown): boolean => {
2169
+ chunks.push(String(chunk));
2170
+ return true;
2171
+ });
2172
+ try {
2173
+ await executeVerb(verb, params, mutation, bootRuntime(flags, cwd));
2174
+ } finally {
2175
+ spy.mockRestore();
2176
+ }
2177
+ return chunks;
2178
+ };
2179
+
2180
+ /** The one write the report seam made for the summary block, if any. */
2181
+ const summaryOf = (chunks: readonly string[]): string =>
2182
+ chunks.find((c) => c.startsWith("Detected —")) ?? "";
2183
+
2184
+ it("names what was detected BEFORE anything is asked, detected rows only", async () => {
2185
+ const cwd = tmp("pragma-setup-proj-");
2186
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2187
+ const chunks = await onStderr(setupSelfVerb, { local: true }, FLAGS, cwd);
2188
+ // The report seam writes the block as ONE write, before the generator is
2189
+ // driven — so it is also the first thing on the stream.
2190
+ expect(chunks[0]?.startsWith("Detected —")).toBe(true);
2191
+ const summary = summaryOf(chunks);
2192
+ // FIRST — the Ink frame and this line both go to stderr, so only a line
2193
+ // written before `execute` lands above the frame in scrollback.
2194
+ expect(summary).toContain("Detected —");
2195
+ expect(summary).toContain("mcp");
2196
+ // A skip is "nothing detected here", so it is out of the default view...
2197
+ expect(summary).not.toContain("user-level only");
2198
+ expect(summary).toContain("--verbose lists them");
2199
+ });
2200
+
2201
+ it("--verbose widens it to every row, with no `not detected` footer", async () => {
2202
+ const cwd = tmp("pragma-setup-proj-");
2203
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2204
+ const summary = summaryOf(
2205
+ await onStderr(
2206
+ setupSelfVerb,
2207
+ { local: true },
2208
+ { ...FLAGS, verbose: true },
2209
+ cwd,
2210
+ ),
2211
+ );
2212
+ // ...and back in under `--verbose`, which consumes the EXISTING global flag
2213
+ // rather than adding a signal.
2214
+ expect(summary).toContain("this one is global only");
2215
+ expect(summary).not.toContain("--verbose lists them");
2216
+ });
2217
+
2218
+ it("says nothing on a --dry-run — the plan table IS that run's output", async () => {
2219
+ const cwd = tmp("pragma-setup-proj-");
2220
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2221
+ const chunks = await onStderr(
2222
+ setupSelfVerb,
2223
+ { local: true },
2224
+ FLAGS,
2225
+ cwd,
2226
+ DRY,
2227
+ );
2228
+ expect(summaryOf(chunks)).toBe("");
2229
+ });
2230
+
2231
+ it("is silent over MCP — `rt.report` is a no-op there, so no Ink and no noise", async () => {
2232
+ // The seam is the point: a plain-string report, not an Ink mount, is what
2233
+ // keeps this off the React graph AND out of the MCP data stream.
2234
+ const cwd = tmp("pragma-setup-proj-");
2235
+ const mcp = await projectMcp([setupModule], cwd);
2236
+ const result = await mcp.callTool("setup", { confirm: true });
2237
+ await mcp.cleanup();
2238
+ expect(result.ok).toBe(true);
2239
+ expect(JSON.stringify(result.data)).not.toContain("Detected —");
647
2240
  });
648
2241
  });
649
2242
 
@@ -669,7 +2262,7 @@ describe("lazy-React discipline (PROTECTED)", () => {
669
2262
  });
670
2263
 
671
2264
  describe("setup — mixed-noun wiring & MCP surface", () => {
672
- it("registers a single `setup` command with an action and four sub-verbs", () => {
2265
+ it("registers a single `setup` command with an action and five sub-verbs", () => {
673
2266
  const program = projectCli([setupModule]);
674
2267
  const setups = program.commands.filter((c) => c.name() === "setup");
675
2268
  expect(setups).toHaveLength(1);
@@ -678,9 +2271,10 @@ describe("setup — mixed-noun wiring & MCP surface", () => {
678
2271
  expect(setup?.options.map((o) => o.long)).toEqual(
679
2272
  expect.arrayContaining(["--dry-run", "--undo", "--yes"]),
680
2273
  );
681
- // The four CLI-only sub-verbs hang under it.
2274
+ // The five CLI-only sub-verbs hang under it — one per target-table row.
682
2275
  expect(setup?.commands.map((c) => c.name()).sort()).toEqual([
683
2276
  "completions",
2277
+ "config",
684
2278
  "lsp",
685
2279
  "mcp",
686
2280
  "skills",