@canonical/pragma-cli 0.36.0 → 0.38.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 (312) hide show
  1. package/README.md +3 -2
  2. package/bundled-skills/adoption-a1-styles/SKILL.md +24 -0
  3. package/bundled-skills/adoption-a2-components/SKILL.md +22 -6
  4. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +1397 -327
  5. package/bundled-skills/anatomy-author/SKILL.md +840 -137
  6. package/bundled-skills/design-auditor/SKILL.md +133 -9
  7. package/bundled-skills/implement-component/SKILL.md +192 -23
  8. package/bundled-skills/specify-component/SKILL.md +124 -12
  9. package/bundled-skills/specify-pattern/SKILL.md +47 -8
  10. package/dist/package.json +23 -20
  11. package/dist/pragma.conf.js +963 -40
  12. package/dist/pragma.conf.js.map +1 -1
  13. package/dist/src/capabilities/capabilities/catalog.js +14 -5
  14. package/dist/src/capabilities/capabilities/catalog.js.map +1 -1
  15. package/dist/src/capabilities/capabilities/hints.js +33 -5
  16. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  17. package/dist/src/capabilities/config/show.render.js +30 -2
  18. package/dist/src/capabilities/config/show.render.js.map +1 -1
  19. package/dist/src/capabilities/create/create.verb.js +2 -1
  20. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  21. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  22. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  23. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  24. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  25. package/dist/src/capabilities/doctor/checks/harnessInventory.js +1 -1
  26. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  28. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  29. package/dist/src/capabilities/doctor/checks/targetHealth.js +104 -14
  30. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  31. package/dist/src/capabilities/graph/connect.render.js +101 -0
  32. package/dist/src/capabilities/graph/connect.render.js.map +1 -0
  33. package/dist/src/capabilities/graph/connect.types.js +21 -0
  34. package/dist/src/capabilities/graph/connect.types.js.map +1 -0
  35. package/dist/src/capabilities/graph/connect.verb.js +147 -0
  36. package/dist/src/capabilities/graph/connect.verb.js.map +1 -0
  37. package/dist/src/capabilities/graph/index.js +5 -3
  38. package/dist/src/capabilities/graph/index.js.map +1 -1
  39. package/dist/src/capabilities/graph/queryText.js +206 -0
  40. package/dist/src/capabilities/graph/queryText.js.map +1 -0
  41. package/dist/src/capabilities/graph/relationIndex.js +302 -0
  42. package/dist/src/capabilities/graph/relationIndex.js.map +1 -0
  43. package/dist/src/capabilities/graph/runConnect.js +456 -0
  44. package/dist/src/capabilities/graph/runConnect.js.map +1 -0
  45. package/dist/src/capabilities/graph/runQuery.js +21 -4
  46. package/dist/src/capabilities/graph/runQuery.js.map +1 -1
  47. package/dist/src/capabilities/setup/buildPlan.js +6 -2
  48. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  49. package/dist/src/capabilities/setup/operations/index.js +7 -2
  50. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  51. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  52. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  53. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  54. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  55. package/dist/src/capabilities/setup/operations/setupGenerator.js +72 -14
  56. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  57. package/dist/src/capabilities/setup/operations/setupLsp.js +372 -62
  58. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupMcp.js +150 -5
  60. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupSkills.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/writability.js +114 -0
  64. package/dist/src/capabilities/setup/operations/writability.js.map +1 -0
  65. package/dist/src/capabilities/setup/plan.js.map +1 -1
  66. package/dist/src/capabilities/setup/setup.verb.js +80 -31
  67. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  68. package/dist/src/capabilities/setup/targets.js +124 -42
  69. package/dist/src/capabilities/setup/targets.js.map +1 -1
  70. package/dist/src/capabilities/shared/index.js +1 -1
  71. package/dist/src/capabilities/shared/index.js.map +1 -1
  72. package/dist/src/constants.js +7 -1
  73. package/dist/src/constants.js.map +1 -1
  74. package/dist/src/kernel/error/renderError.js +3 -2
  75. package/dist/src/kernel/error/renderError.js.map +1 -1
  76. package/dist/src/kernel/error/validOptions.js +41 -0
  77. package/dist/src/kernel/error/validOptions.js.map +1 -0
  78. package/dist/src/kernel/packs/compile.js +90 -6
  79. package/dist/src/kernel/packs/compile.js.map +1 -1
  80. package/dist/src/kernel/packs/cursor.js +135 -0
  81. package/dist/src/kernel/packs/cursor.js.map +1 -0
  82. package/dist/src/kernel/packs/graphql/buildLookupDocument.js +9 -2
  83. package/dist/src/kernel/packs/graphql/buildLookupDocument.js.map +1 -1
  84. package/dist/src/kernel/packs/paging.js +108 -0
  85. package/dist/src/kernel/packs/paging.js.map +1 -0
  86. package/dist/src/kernel/packs/renderPack.js +154 -5
  87. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  88. package/dist/src/kernel/packs/resolveEntity.js +118 -19
  89. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  90. package/dist/src/kernel/packs/runBodies.js +156 -18
  91. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  92. package/dist/src/kernel/packs/schema.js +160 -12
  93. package/dist/src/kernel/packs/schema.js.map +1 -1
  94. package/dist/src/kernel/packs/sparql/authorQuery.js +227 -0
  95. package/dist/src/kernel/packs/sparql/authorQuery.js.map +1 -0
  96. package/dist/src/kernel/packs/sparql/buildListQuery.js +251 -0
  97. package/dist/src/kernel/packs/sparql/buildListQuery.js.map +1 -0
  98. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +125 -22
  99. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  100. package/dist/src/kernel/packs/sparql/filterValues.js +145 -0
  101. package/dist/src/kernel/packs/sparql/filterValues.js.map +1 -0
  102. package/dist/src/kernel/packs/sparql/index.js +24 -14
  103. package/dist/src/kernel/packs/sparql/index.js.map +1 -1
  104. package/dist/src/kernel/packs/sparql/runSelect.js +10 -4
  105. package/dist/src/kernel/packs/sparql/runSelect.js.map +1 -1
  106. package/dist/src/kernel/packs/sparql/searchTerm.js +25 -0
  107. package/dist/src/kernel/packs/sparql/searchTerm.js.map +1 -0
  108. package/dist/src/kernel/packs/storyRules.js +151 -0
  109. package/dist/src/kernel/packs/storyRules.js.map +1 -0
  110. package/dist/src/kernel/packs/tierScope.js +310 -0
  111. package/dist/src/kernel/packs/tierScope.js.map +1 -0
  112. package/dist/src/kernel/packs/types.js +80 -0
  113. package/dist/src/kernel/packs/types.js.map +1 -1
  114. package/dist/src/kernel/project/cli/buildProgram.js +10 -1
  115. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  116. package/dist/src/kernel/project/cli/dispatch.js +104 -3
  117. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  118. package/dist/src/kernel/project/cli/rootHelp.js +8 -5
  119. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  120. package/dist/src/kernel/project/cli/suggestNames.js +31 -7
  121. package/dist/src/kernel/project/cli/suggestNames.js.map +1 -1
  122. package/dist/src/kernel/project/mcp/registerVerb.js +5 -1
  123. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  124. package/dist/src/kernel/render/renderers.js +34 -2
  125. package/dist/src/kernel/render/renderers.js.map +1 -1
  126. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  127. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  128. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  129. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  130. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  131. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  132. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  133. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  134. package/dist/src/kernel/runtime/graphpack/read.js +17 -10
  135. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  136. package/dist/src/kernel/runtime/loadSession.js +3 -1
  137. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  138. package/dist/src/kernel/runtime/probe.js +1 -1
  139. package/dist/src/kernel/runtime/probe.js.map +1 -1
  140. package/dist/src/kernel/runtime/readEntity.js +19 -4
  141. package/dist/src/kernel/runtime/readEntity.js.map +1 -1
  142. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  143. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  144. package/dist/src/kernel/runtime/refs/resolve.js +38 -5
  145. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  146. package/dist/src/kernel/spec/emitReference.js +1 -1
  147. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  148. package/package.json +23 -20
  149. package/pragma.conf.ts +983 -40
  150. package/src/capabilities/block.parity.test.ts +207 -27
  151. package/src/capabilities/block.shipped.exec.test.ts +156 -14
  152. package/src/capabilities/block.tierRank.exec.test.ts +46 -9
  153. package/src/capabilities/capabilities/catalog.ts +14 -5
  154. package/src/capabilities/capabilities/hints.ts +40 -5
  155. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  156. package/src/capabilities/config/set.test.ts +24 -25
  157. package/src/capabilities/config/show.render.test.ts +9 -4
  158. package/src/capabilities/config/show.render.ts +30 -2
  159. package/src/capabilities/create/byteEquality.test.ts +28 -1
  160. package/src/capabilities/create/create.test.ts +1 -0
  161. package/src/capabilities/create/create.verb.ts +2 -1
  162. package/src/capabilities/create/createGrammar.test.ts +4 -1
  163. package/src/capabilities/create/createSurface.generated.ts +23 -1
  164. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  165. package/src/capabilities/distribution.parity.test.ts +12 -11
  166. package/src/capabilities/distribution.test.ts +77 -7
  167. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  168. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  169. package/src/capabilities/doctor/checks/harnessInventory.test.ts +13 -6
  170. package/src/capabilities/doctor/checks/harnessInventory.ts +8 -4
  171. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  172. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  173. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  174. package/src/capabilities/doctor/checks/targetHealth.ts +114 -13
  175. package/src/capabilities/doctor/doctor.test.ts +327 -36
  176. package/src/capabilities/emptyRecovery.test.ts +84 -28
  177. package/src/capabilities/graph/connect.render.ts +141 -0
  178. package/src/capabilities/graph/connect.shipped.exec.test.ts +370 -0
  179. package/src/capabilities/graph/connect.test.ts +361 -0
  180. package/src/capabilities/graph/connect.types.ts +136 -0
  181. package/src/capabilities/graph/connect.verb.ts +165 -0
  182. package/src/capabilities/graph/index.ts +5 -3
  183. package/src/capabilities/graph/query.shipped.exec.test.ts +66 -0
  184. package/src/capabilities/graph/query.test.ts +17 -0
  185. package/src/capabilities/graph/queryText.test.ts +121 -0
  186. package/src/capabilities/graph/queryText.ts +240 -0
  187. package/src/capabilities/graph/relationIndex.ts +372 -0
  188. package/src/capabilities/graph/runConnect.ts +577 -0
  189. package/src/capabilities/graph/runQuery.ts +21 -6
  190. package/src/capabilities/lazy.test.ts +32 -2
  191. package/src/capabilities/listBudget.shipped.exec.test.ts +239 -0
  192. package/src/capabilities/listLookup.shipped.exec.test.ts +387 -0
  193. package/src/capabilities/listQuery.shipped.exec.test.ts +400 -0
  194. package/src/capabilities/resources/resources.test.ts +38 -2
  195. package/src/capabilities/setup/buildPlan.ts +7 -1
  196. package/src/capabilities/setup/operations/index.ts +14 -0
  197. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  198. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  199. package/src/capabilities/setup/operations/setupGenerator.ts +93 -15
  200. package/src/capabilities/setup/operations/setupLsp.ts +544 -85
  201. package/src/capabilities/setup/operations/setupMcp.ts +212 -4
  202. package/src/capabilities/setup/operations/setupSkills.ts +7 -2
  203. package/src/capabilities/setup/operations/writability.test.ts +151 -0
  204. package/src/capabilities/setup/operations/writability.ts +135 -0
  205. package/src/capabilities/setup/plan.ts +11 -0
  206. package/src/capabilities/setup/setup.test.ts +992 -16
  207. package/src/capabilities/setup/setup.verb.ts +84 -36
  208. package/src/capabilities/setup/targets.ts +152 -48
  209. package/src/capabilities/setup/types.ts +41 -5
  210. package/src/capabilities/shared/index.ts +1 -0
  211. package/src/capabilities/sources/sources.test.ts +55 -0
  212. package/src/capabilities/surface.test.ts +59 -11
  213. package/src/capabilities/tier.parity.test.ts +2 -2
  214. package/src/capabilities/token.parity.test.ts +784 -0
  215. package/src/capabilities/tokenGraph.cli.test.ts +279 -0
  216. package/src/capabilities/tokenGraph.shipped.exec.test.ts +653 -0
  217. package/src/capabilities/variable.parity.test.ts +448 -0
  218. package/src/constants.ts +8 -0
  219. package/src/kernel/completion/shellDrive.test.ts +32 -30
  220. package/src/kernel/config/defaults.test.ts +21 -2
  221. package/src/kernel/config/readConfig.test.ts +1 -1
  222. package/src/kernel/error/renderError.ts +3 -2
  223. package/src/kernel/error/validOptions.test.ts +57 -0
  224. package/src/kernel/error/validOptions.ts +45 -0
  225. package/src/kernel/packs/collect.test.ts +2 -0
  226. package/src/kernel/packs/compile.test.ts +188 -15
  227. package/src/kernel/packs/compile.ts +109 -14
  228. package/src/kernel/packs/cursor.ts +135 -0
  229. package/src/kernel/packs/filteredEmpty.test.ts +213 -0
  230. package/src/kernel/packs/graphql/buildLookupDocument.ts +16 -9
  231. package/src/kernel/packs/graphqlSource.test.ts +152 -0
  232. package/src/kernel/packs/listNotice.test.ts +156 -0
  233. package/src/kernel/packs/paging.ts +110 -0
  234. package/src/kernel/packs/renderPack.ts +186 -13
  235. package/src/kernel/packs/resolveEntity.test.ts +62 -3
  236. package/src/kernel/packs/resolveEntity.ts +186 -26
  237. package/src/kernel/packs/runBodies.ts +196 -26
  238. package/src/kernel/packs/schema.ts +180 -13
  239. package/src/kernel/packs/sparql/authorQuery.test.ts +179 -0
  240. package/src/kernel/packs/sparql/authorQuery.ts +257 -0
  241. package/src/kernel/packs/sparql/buildListQuery.test.ts +344 -0
  242. package/src/kernel/packs/sparql/buildListQuery.ts +344 -0
  243. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +138 -0
  244. package/src/kernel/packs/sparql/buildLookupQuery.ts +140 -20
  245. package/src/kernel/packs/sparql/filterValues.test.ts +86 -0
  246. package/src/kernel/packs/sparql/filterValues.ts +182 -0
  247. package/src/kernel/packs/sparql/index.ts +30 -14
  248. package/src/kernel/packs/sparql/runSelect.test.ts +63 -0
  249. package/src/kernel/packs/sparql/runSelect.ts +42 -2
  250. package/src/kernel/packs/sparql/searchTerm.ts +29 -0
  251. package/src/kernel/packs/storyRules.ts +180 -0
  252. package/src/kernel/packs/tierScope.test.ts +487 -0
  253. package/src/kernel/packs/tierScope.ts +409 -0
  254. package/src/kernel/packs/toolDescription.test.ts +13 -10
  255. package/src/kernel/packs/types.ts +311 -9
  256. package/src/kernel/project/cli/buildProgram.ts +17 -1
  257. package/src/kernel/project/cli/dispatch.test.ts +87 -0
  258. package/src/kernel/project/cli/dispatch.ts +114 -4
  259. package/src/kernel/project/cli/rootHelp.ts +8 -5
  260. package/src/kernel/project/cli/suggest.test.ts +35 -0
  261. package/src/kernel/project/cli/suggestNames.ts +34 -7
  262. package/src/kernel/project/mcp/registerVerb.ts +5 -1
  263. package/src/kernel/render/contracts.ts +2 -0
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.test.ts +43 -0
  266. package/src/kernel/render/renderers.ts +34 -1
  267. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  268. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  269. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  270. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  271. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  272. package/src/kernel/runtime/graphpack/read.ts +17 -13
  273. package/src/kernel/runtime/loadSession.ts +3 -1
  274. package/src/kernel/runtime/probe.ts +1 -1
  275. package/src/kernel/runtime/readEntity.ts +19 -4
  276. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  277. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  278. package/src/kernel/runtime/refs/resolve.ts +39 -5
  279. package/src/kernel/runtime/types.ts +30 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  282. package/src/kernel/spec/types.ts +17 -0
  283. package/src/testing/PARITY_GAPS.ts +4 -4
  284. package/src/testing/behavioral/agentSession.mcp.test.ts +43 -39
  285. package/src/testing/behavioral/completion.test.ts +7 -6
  286. package/src/testing/behavioral/disclosure.test.ts +39 -37
  287. package/src/testing/behavioral/errorMatrix.mcp.test.ts +82 -64
  288. package/src/testing/behavioral/errorMatrix.storeless.test.ts +11 -10
  289. package/src/testing/behavioral/journeys.cli.test.ts +71 -32
  290. package/src/testing/behavioral/journeys.defaultPack.test.ts +6 -4
  291. package/src/testing/behavioral/journeys.firstInstall.test.ts +70 -5
  292. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +4 -3
  293. package/src/testing/behavioral/journeys.packageStories.test.ts +2 -1
  294. package/src/testing/behavioral/mcpSurface.test.ts +28 -25
  295. package/src/testing/behavioral/parity.test.ts +25 -23
  296. package/src/testing/eval/__snapshots__/eval.test.ts.snap +20 -0
  297. package/src/testing/eval/cases/effectful.ts +7 -1
  298. package/src/testing/eval/cases/readNouns.ts +100 -11
  299. package/src/testing/fixtures/blockGraph.ts +89 -5
  300. package/src/testing/fixtures/connectGraph.ts +159 -0
  301. package/src/testing/fixtures/graph/canonical.ts +128 -32
  302. package/src/testing/helpers/fsProbe.ts +88 -0
  303. package/src/testing/helpers/runCli.test.ts +44 -0
  304. package/src/testing/helpers/runCli.ts +27 -3
  305. package/src/testing/perf/budgets.test.ts +3 -1
  306. package/src/testing/perf/budgets.ts +138 -54
  307. package/dist/src/kernel/packs/sparql/applyFilters.js +0 -167
  308. package/dist/src/kernel/packs/sparql/applyFilters.js.map +0 -1
  309. package/dist/src/kernel/packs/sparql/applySearch.js +0 -25
  310. package/dist/src/kernel/packs/sparql/applySearch.js.map +0 -1
  311. package/src/kernel/packs/sparql/applyFilters.ts +0 -203
  312. package/src/kernel/packs/sparql/applySearch.ts +0 -33
@@ -11,18 +11,39 @@
11
11
  * NOT used: it hardcodes the `code` CLI, which is precisely what left
12
12
  * VSCodium/Cursor/Windsurf/Antigravity machines unable to install at all.
13
13
  *
14
- * `detectLsp` probes FOR REAL up front, and spawns NOTHING: an editor is
15
- * detected when its CLI resolves on PATH (the `editorClis` registry names
16
- * them), carries the extension when its extensions dir holds a
14
+ * `detectLsp` probes FOR REAL up front, and spawns NOTHING. An editor is
15
+ * detected THREE ways, and each row records which one found it, because "no
16
+ * editor found" and "found but nothing can be installed into it" are different
17
+ * answers and only one of them is about this machine lacking the editor:
18
+ *
19
+ * 1. its CLI on PATH (the `editorClis` registry names them);
20
+ * 2. its CLI inside its macOS APP BUNDLE — a stock macOS install puts nothing
21
+ * on PATH until the user runs the palette's "Shell Command: Install
22
+ * '<cli>' command in PATH", which is opt-in and usually never taken, so a
23
+ * PATH-only probe reported a machine that HAS VS Code as having none;
24
+ * 3. its per-user configuration DIRECTORY, which is the last trace a fresh
25
+ * install leaves. Nothing can be installed through a directory, so such an
26
+ * editor carries no CLI path at all and its remedy is the palette command
27
+ * that settles it.
28
+ *
29
+ * It carries the extension when its extensions dir holds a
17
30
  * `canonical.terrazzo-lsp-extension-<version>/` entry, and is "installed" when
18
- * that entry is also new enough to work. Running
19
- * `--list-extensions` instead would be the natural probe, but Cursor's Linux
20
- * launcher OPENS THE EDITOR on that flag — a detection step must never launch
21
- * an app, so the fs is the source of truth. The full extension id is matched
22
- * (not the old `terrazzo` substring, which any other terrazzo-named extension
23
- * could false-positive).
24
- *
25
- * No editor CLI found is a NAMED SKIP, not an error: the message says what was
31
+ * that entry is also new enough to work. Running `--list-extensions` instead
32
+ * would be the natural probe, but Cursor's Linux launcher OPENS THE EDITOR on
33
+ * that flag — a detection step must never launch an app, so the fs is the
34
+ * source of truth. The full extension id is matched (not the old `terrazzo`
35
+ * substring, which any other terrazzo-named extension could false-positive).
36
+ *
37
+ * Detection also asks whether the extensions directory could be WRITTEN, via
38
+ * `probeWritable`, BEFORE any install is composed. A home-manager-managed
39
+ * `~/.vscode/extensions` is a symlink into `/nix/store`, so
40
+ * `--install-extension` there failed with a raw fs error or silently changed
41
+ * nothing; that is now a named skip carrying the declaration that does work.
42
+ * Per the owner, 2026-09-16, the decision is made by the DIRECTORY, never by
43
+ * the OS: a Nix user's home is ordinary, and a non-Nix user can have a
44
+ * read-only mount.
45
+ *
46
+ * Every one of those is a NAMED SKIP, not an error: the message says what was
26
47
  * looked for and what to do, and the run exits 0 — there is nothing on this
27
48
  * machine the step could honestly fail at. Real failures (fetch, sideload)
28
49
  * travel the task failure channel (`checkExecOk`) with a recovery that names a
@@ -36,24 +57,81 @@ import type { EditorCliDefinition, PlatformEnv } from "@canonical/harnesses";
36
57
  import {
37
58
  type ExecResult,
38
59
  exec,
60
+ exists,
39
61
  flatMap,
40
62
  mkdir,
63
+ pure,
64
+ sequence,
41
65
  sequence_,
42
66
  type Task,
43
67
  } from "@canonical/task";
44
68
  import { BIN_NAME } from "../../../constants.js";
45
- import { checkExecOk, guardMissingBinary } from "../../shared/index.js";
46
- import type { LspState } from "../types.js";
69
+ import { PragmaError } from "../../../kernel/error/index.js";
70
+ import {
71
+ checkExecOk,
72
+ failPragma,
73
+ guardMissingBinary,
74
+ } from "../../shared/index.js";
75
+ import { type Roots, shortenPath } from "../plan.js";
76
+ import type { LspState, WriteBlock } from "../types.js";
77
+ import { type FsProbe, probeWritable } from "./writability.js";
78
+
79
+ /**
80
+ * The extension's publisher and name. Separate constants because the Nix
81
+ * declaration names all three parts (`vscodeExtPublisher`, `vscodeExtName`,
82
+ * `vscodeExtUniqueId`), and composing the id from them is what keeps the three
83
+ * from drifting apart in one string.
84
+ */
85
+ const EXTENSION_PUBLISHER = "canonical";
86
+ const EXTENSION_NAME = "terrazzo-lsp-extension";
47
87
 
48
88
  /** The full extension id, as the editor's extensions dir spells it. */
49
- const EXTENSION_ID = "canonical.terrazzo-lsp-extension";
89
+ const EXTENSION_ID = `${EXTENSION_PUBLISHER}.${EXTENSION_NAME}`;
50
90
 
51
91
  /** The npm package whose tarball bundles the VSIX. */
52
92
  const LSP_PACKAGE = "@canonical/terrazzo-lsp-extension";
53
93
 
54
94
  /**
55
- * One detected editor: its registry row, its extensions directory, and the two
56
- * DIFFERENT questions about the extension.
95
+ * What a probe found, and the evidence that travels with it — a UNION, because
96
+ * the three probes do not produce the same thing.
97
+ *
98
+ * A CLI probe yields a file to run; the user-directory probe yields only the
99
+ * knowledge that the editor is here. Making `cliPath` optional on one flat
100
+ * record said instead that any editor MIGHT have one, which put a
101
+ * `cliPath ?? editor.cli` fallback at every exec site — four of them, none
102
+ * reachable, each quietly ready to spawn a bare name on a machine where the
103
+ * probe had already proved there is none.
104
+ */
105
+ type EditorSource =
106
+ | {
107
+ /**
108
+ * WHICH CLI probe found it. Reported per editor by both surfaces,
109
+ * because it is the fact that tells a user whose `setup lsp` did nothing
110
+ * whether the editor was missed or merely unreachable — the question the
111
+ * old single-line "no editor CLI on PATH" could not answer.
112
+ */
113
+ readonly foundBy: "path" | "bundle";
114
+ /**
115
+ * The CLI file the probe actually found. Every exec runs THIS path
116
+ * rather than the bare name: a bundle CLI is not on PATH at all, and
117
+ * even for a PATH hit the probed file is the one this run verified
118
+ * exists — re-resolving the bare name at spawn time could reach another.
119
+ */
120
+ readonly cliPath: string;
121
+ }
122
+ | {
123
+ /**
124
+ * Found by its per-user configuration DIRECTORY and nothing else. There
125
+ * is no CLI to run, so this editor is never what the installer runs for;
126
+ * the palette command that creates one is its remedy.
127
+ */
128
+ readonly foundBy: "user-dir";
129
+ };
130
+
131
+ /**
132
+ * What every detected editor carries, whichever probe found it: its registry
133
+ * row, its extensions directory, and the two DIFFERENT questions about the
134
+ * extension.
57
135
  *
58
136
  * `present` and `installed` are deliberately separate, and collapsing them is a
59
137
  * silent wrong-removal. `installed` is version-gated — an entry older than
@@ -64,7 +142,7 @@ const LSP_PACKAGE = "@canonical/terrazzo-lsp-extension";
64
142
  * So removal keys on `present` (any matching entry at all) and everything else
65
143
  * keys on `installed` (present AND new enough to work).
66
144
  */
67
- export interface DetectedEditor {
145
+ interface EditorFacts {
68
146
  readonly editor: EditorCliDefinition;
69
147
  /** Any `canonical.terrazzo-lsp-extension-*` entry, whatever its version. */
70
148
  readonly present: boolean;
@@ -76,14 +154,75 @@ export interface DetectedEditor {
76
154
  * {@link composeLspRemoval}); when `present` is true, this is that path.
77
155
  */
78
156
  readonly extensionsDir: string;
157
+ /**
158
+ * The matching entry NAMES under {@link extensionsDir} — the artifacts
159
+ * `present` is true about. The removal's postcondition probes exactly
160
+ * these after the uninstall (see {@link composeLspRemoval}): the editor
161
+ * CLI's exit code and the directory listing are DIFFERENT success
162
+ * criteria, and only the directory is the one detection (and doctor)
163
+ * believe.
164
+ */
165
+ readonly presentEntries: readonly string[];
166
+ /**
167
+ * Why this editor's extensions directory must not be written, when that is
168
+ * the case. Absent means the folder is fine — which is not the same as "the
169
+ * install can proceed": an editor found only by its user directory has no
170
+ * CLI to run whatever its folder permits (see {@link installable}).
171
+ *
172
+ * A block is carried on the DETECTION rather than discovered by the writer
173
+ * on purpose. The write is what used to discover it, and it discovered it as
174
+ * a raw `EACCES` on a `failed` row with no next step; probing first turns
175
+ * the same machine into a named skip with a remedy, and lets doctor report it
176
+ * without writing anything at all.
177
+ */
178
+ readonly block?: WriteBlock;
79
179
  }
80
180
 
181
+ /** One detected editor: the facts, plus what found it. */
182
+ export type DetectedEditor = EditorFacts & EditorSource;
183
+
184
+ /** An editor with a CLI to run — what {@link installable} narrows to. */
185
+ type RunnableEditor = EditorFacts & Extract<EditorSource, { cliPath: string }>;
186
+
187
+ /**
188
+ * An editor nothing can be installed into: no CLI to run at all, or a folder
189
+ * this command must not write to. The two shapes {@link describeBlock} answers
190
+ * for, and the type that lets it read `block` without re-asking whether it is
191
+ * there.
192
+ */
193
+ type BlockedEditor =
194
+ | (EditorFacts & Extract<EditorSource, { foundBy: "user-dir" }>)
195
+ | (EditorFacts & {
196
+ readonly foundBy: "path" | "bundle";
197
+ readonly cliPath: string;
198
+ readonly block: WriteBlock;
199
+ });
200
+
201
+ /**
202
+ * Whether an install can act on an editor at all: there is a CLI to RUN, and
203
+ * its extensions folder can be WRITTEN.
204
+ *
205
+ * One predicate, because the answer is one question. Five call sites used to
206
+ * spell their own half of it (`!e.installed && e.block === undefined`,
207
+ * `e.present && e.block === undefined`, …), so adding a third way to be
208
+ * unreachable would have meant finding all five.
209
+ *
210
+ * @param e - One detected editor.
211
+ * @returns Whether the sideload can run for it.
212
+ * @note Pure — reads the detection record.
213
+ */
214
+ const installable = (e: DetectedEditor): e is RunnableEditor =>
215
+ e.foundBy !== "user-dir" && e.block === undefined;
216
+
217
+ /** The editors nothing can be installed into, in registry order. */
218
+ const blockedEditors = (d: LspDetection): readonly BlockedEditor[] =>
219
+ d.editors.filter((e): e is BlockedEditor => !installable(e));
220
+
81
221
  /**
82
- * The detected LSP state: the editors whose CLI is on PATH (each with its
83
- * installed-state), the staging dir the VSIX is fetched into, and the
84
- * aggregate {@link LspState} — `unknown` when NO editor CLI was found (the
85
- * step becomes a named skip), `installed` when every found editor already has
86
- * the extension, `absent` otherwise (the installer runs for the missing ones).
222
+ * The detected LSP state: every editor the three probes found (each with its
223
+ * provenance, its CLI file when there is one, its installed-state and any
224
+ * block on its extensions folder), the staging dir the VSIX is fetched into,
225
+ * and the aggregate {@link LspState}.
87
226
  *
88
227
  * `probed` is every CLI name that was LOOKED for, kept so the skip row can name
89
228
  * them: a skip that says only "no editor found" leaves the user guessing what
@@ -98,8 +237,9 @@ export interface LspDetection {
98
237
  }
99
238
 
100
239
  /**
101
- * Whether an editor CLI resolves on PATH, given the candidate paths the host
102
- * would consider for it.
240
+ * The FIRST candidate path that exists, or `undefined` when none does — used
241
+ * for both candidate lists (PATH, then the macOS app bundle), so a caller
242
+ * learns not only whether the CLI is there but which file it is.
103
243
  *
104
244
  * The candidates come from `@canonical/harnesses`' `executableCandidates` —
105
245
  * the same helper harness detection resolves `process` signals with — rather
@@ -111,9 +251,13 @@ export interface LspDetection {
111
251
  * the editor — a wrong skip reads as correct, which is worse than a wrong
112
252
  * failure. One implementation of the platform rules means the probe cannot
113
253
  * drift away from detection's.
254
+ *
255
+ * @param candidates - The candidate paths, strongest first.
256
+ * @returns The first candidate that exists, or `undefined` when none does.
257
+ * @note Impure — stats each candidate until one exists.
114
258
  */
115
- const cliOnPath = (candidates: readonly string[]): boolean =>
116
- candidates.some((candidate) => existsSync(candidate));
259
+ const resolveCli = (candidates: readonly string[]): string | undefined =>
260
+ candidates.find((candidate) => existsSync(candidate));
117
261
 
118
262
  /**
119
263
  * The oldest extension release whose bundled language server actually starts.
@@ -187,12 +331,13 @@ const compareVersions = (left: number[], right: number[]): number => {
187
331
  const extensionState = (
188
332
  editor: EditorCliDefinition,
189
333
  platform: PlatformEnv,
190
- ): { present: boolean; current: boolean } => {
334
+ ): { present: boolean; current: boolean; entries: string[] } => {
191
335
  let entries: string[];
192
336
  try {
193
337
  entries = readdirSync(editor.extensionsDir(platform));
194
338
  } catch {
195
- return { present: false, current: false }; // No dir — nothing installed.
339
+ // No dir — nothing installed.
340
+ return { present: false, current: false, entries: [] };
196
341
  }
197
342
  const prefix = `${EXTENSION_ID}-`;
198
343
  const matching = entries.filter((entry) =>
@@ -203,32 +348,138 @@ const extensionState = (
203
348
  if (version === undefined) return false; // Unreadable: cannot vouch for it.
204
349
  return compareVersions(version, MINIMUM) >= 0;
205
350
  });
206
- return { present: matching.length > 0, current };
351
+ return { present: matching.length > 0, current, entries: matching };
352
+ };
353
+
354
+ /**
355
+ * The path helpers the editor probe resolves candidates with, captured from
356
+ * the dynamic `@canonical/harnesses` import that keeps the package off the
357
+ * fast path (so they cannot be static imports here).
358
+ */
359
+ type EditorPaths = Pick<
360
+ typeof import("@canonical/harnesses"),
361
+ "appBundleCandidates" | "executableCandidates" | "vscodeUserDir"
362
+ >;
363
+
364
+ /**
365
+ * WHICH of the three probes finds this editor, strongest first — or
366
+ * `undefined` when none does.
367
+ *
368
+ * A resolved CLI always wins, because a CLI is what an install RUNS. The
369
+ * per-user directory is last and yields no path: it proves the editor is on
370
+ * this machine and nothing more.
371
+ *
372
+ * @param editor - One registry row.
373
+ * @param host - The captured platform.
374
+ * @param paths - The harness path helpers.
375
+ * @returns The provenance and its evidence, or `undefined`.
376
+ * @note Impure — stats PATH entries, app bundles and the user directory.
377
+ */
378
+ const findEditorSource = (
379
+ editor: EditorCliDefinition,
380
+ host: PlatformEnv,
381
+ paths: EditorPaths,
382
+ ): EditorSource | undefined => {
383
+ const onPath = resolveCli(paths.executableCandidates(editor.cli, host));
384
+ if (onPath !== undefined) return { foundBy: "path", cliPath: onPath };
385
+ const inBundle = resolveCli(
386
+ paths.appBundleCandidates(editor.cli, host, editor.darwinBundles),
387
+ );
388
+ if (inBundle !== undefined) return { foundBy: "bundle", cliPath: inBundle };
389
+ // The registry names the PRODUCT; `vscodeUserDir` owns the platform switch,
390
+ // so this probe, the harness rows' `homeConfigPath` and their detection
391
+ // signals cannot name different directories.
392
+ const userDir =
393
+ editor.product === undefined
394
+ ? undefined
395
+ : paths.vscodeUserDir(editor.product, host);
396
+ if (userDir !== undefined && existsSync(userDir)) {
397
+ return { foundBy: "user-dir" };
398
+ }
399
+ return undefined;
207
400
  };
208
401
 
209
402
  /**
210
- * Probe every registry editor: CLI presence via PATH lookup, installed-state
211
- * via the extensions dir — no spawns (see the module docblock for why).
403
+ * One registry row as a {@link DetectedEditor} — or `undefined` when this
404
+ * machine has no trace of the editor at all.
405
+ *
406
+ * @param editor - One registry row.
407
+ * @param host - The captured platform.
408
+ * @param paths - The harness path helpers.
409
+ * @param probe - The writability filesystem seam; defaults to the real one.
410
+ * @returns The detection record, or `undefined`.
411
+ * @note Impure — reads PATH dirs, app bundles, the user directory and the
412
+ * extensions directory off the real fs.
413
+ */
414
+ const findEditor = (
415
+ editor: EditorCliDefinition,
416
+ host: PlatformEnv,
417
+ paths: EditorPaths,
418
+ probe?: FsProbe,
419
+ ): DetectedEditor | undefined => {
420
+ const source = findEditorSource(editor, host, paths);
421
+ if (source === undefined) return undefined;
422
+ const { present, current, entries } = extensionState(editor, host);
423
+ const extensionsDir = editor.extensionsDir(host);
424
+ // Writability is asked only where there is a CLI to run. An editor found
425
+ // only by its user directory cannot be installed into whatever its folder
426
+ // permits, and the palette command is its remedy either way — so the probe
427
+ // would cost a stat to answer a question nothing reads.
428
+ const block =
429
+ source.foundBy === "user-dir"
430
+ ? undefined
431
+ : probeWritable(extensionsDir, probe);
432
+ return {
433
+ editor,
434
+ ...source,
435
+ present,
436
+ installed: current,
437
+ extensionsDir,
438
+ presentEntries: entries,
439
+ ...(block === undefined ? {} : { block }),
440
+ };
441
+ };
442
+
443
+ /**
444
+ * Probe every registry editor: its CLI on PATH, then inside its macOS app
445
+ * bundle, then its per-user directory; its installed-state via the extensions
446
+ * dir; and whether that dir could be written. No spawns (see the module
447
+ * docblock for why).
212
448
  *
213
449
  * @param _cwd - Unused; kept so every `detectX` shares the (cwd) shape.
450
+ * @param platform - The captured host; defaults to the live one. Injected so a
451
+ * test can drive the darwin arm — the one this repo's CI host cannot run.
452
+ * @param probe - The writability filesystem seam; defaults to the real one.
214
453
  * @returns The detected {@link LspDetection}.
215
- * @note Impure — reads PATH dirs and editor extension dirs off the real fs.
454
+ * @note Impure — reads PATH dirs, app bundles and editor extension dirs off
455
+ * the real fs.
216
456
  */
217
- export async function detectLsp(_cwd: string): Promise<LspDetection> {
218
- const { editorClis, executableCandidates, readPlatformEnv, userDataBase } =
219
- await import("@canonical/harnesses");
220
- const platform = readPlatformEnv();
221
- const editors: DetectedEditor[] = editorClis
222
- .filter((editor) => cliOnPath(executableCandidates(editor.cli, platform)))
223
- .map((editor) => {
224
- const { present, current } = extensionState(editor, platform);
225
- return {
226
- editor,
227
- present,
228
- installed: current,
229
- extensionsDir: editor.extensionsDir(platform),
230
- };
231
- });
457
+ export async function detectLsp(
458
+ _cwd: string,
459
+ platform?: PlatformEnv,
460
+ probe?: FsProbe,
461
+ ): Promise<LspDetection> {
462
+ const {
463
+ appBundleCandidates,
464
+ editorClis,
465
+ executableCandidates,
466
+ readPlatformEnv,
467
+ userDataBase,
468
+ vscodeUserDir,
469
+ } = await import("@canonical/harnesses");
470
+ // Not a default parameter value: `readPlatformEnv` arrives through the
471
+ // dynamic import that keeps `@canonical/harnesses` off the fast path, so it
472
+ // cannot be named in the signature.
473
+ const host = platform ?? readPlatformEnv();
474
+ const paths: EditorPaths = {
475
+ appBundleCandidates,
476
+ executableCandidates,
477
+ vscodeUserDir,
478
+ };
479
+ const editors: DetectedEditor[] = editorClis.flatMap((editor) => {
480
+ const found = findEditor(editor, host, paths, probe);
481
+ return found === undefined ? [] : [found];
482
+ });
232
483
  const state: LspState =
233
484
  editors.length === 0
234
485
  ? "unknown"
@@ -240,7 +491,7 @@ export async function detectLsp(_cwd: string): Promise<LspDetection> {
240
491
  state,
241
492
  editors,
242
493
  probed: editorClis.map((editor) => editor.cli),
243
- stagingDir: join(userDataBase(platform), BIN_NAME, "lsp"),
494
+ stagingDir: join(userDataBase(host), BIN_NAME, "lsp"),
244
495
  };
245
496
  }
246
497
 
@@ -259,27 +510,174 @@ export async function detectLsp(_cwd: string): Promise<LspDetection> {
259
510
  export const selectedEditors = (
260
511
  d: LspDetection,
261
512
  chosen?: readonly string[],
262
- ): readonly DetectedEditor[] => {
263
- const pending = d.editors.filter((e) => !e.installed);
513
+ ): readonly RunnableEditor[] => {
514
+ // A BLOCKED editor is never pending. It is not a candidate the user declined
515
+ // — it is one this command cannot act on, and composing its sideload anyway
516
+ // is what produced the raw fs failure the block exists to replace.
517
+ const pending = d.editors.filter(
518
+ (e): e is RunnableEditor => !e.installed && installable(e),
519
+ );
264
520
  return chosen === undefined
265
521
  ? pending
266
522
  : pending.filter((e) => chosen.includes(e.editor.cli));
267
523
  };
268
524
 
525
+ /**
526
+ * The editors an install could act on, ignoring the wizard's selection — the
527
+ * question both plan rows and doctor's row ask to decide whether there is any
528
+ * work here at all.
529
+ *
530
+ * @param d - The detection gathered up front.
531
+ * @returns The editors missing the extension that can be written to.
532
+ */
533
+ export const installableEditors = (
534
+ d: LspDetection,
535
+ ): readonly RunnableEditor[] =>
536
+ d.editors.filter((e): e is RunnableEditor => !e.installed && installable(e));
537
+
269
538
  /** The editor names in a detection (for messages/results). */
270
539
  export const lspEditorNames = (d: LspDetection): string[] =>
271
540
  d.editors.map((e) => e.editor.name);
272
541
 
273
542
  /**
274
- * The named-skip reason for a machine with no VS Code-family CLI on PATH. It
275
- * names every CLI that was probed, because a skip the user cannot act on is
276
- * only honest if it says what would have counted.
543
+ * The named-skip reason for a machine with no VS Code-family editor at all. It
544
+ * names every CLI that was probed AND every place it was looked for, because a
545
+ * skip the user cannot act on is only honest if it says what would have
546
+ * counted — and "not on PATH" was a misleading half of the answer once the
547
+ * bundle and user-directory probes existed.
277
548
  *
278
549
  * @param d - The detection gathered up front.
279
550
  * @returns The reason line.
280
551
  */
281
552
  export const lspSkipReason = (d: LspDetection): string =>
282
- `no VS Code-family editor CLI on PATH (${d.probed.join(", ")})`;
553
+ `no VS Code-family editor found — looked for ${d.probed.join(", ")} on PATH, under /Applications or ~/Applications, or by its user directory`;
554
+
555
+ /**
556
+ * How one editor was found, as a phrase both surfaces print — the provenance a
557
+ * user needs to tell "pragma missed my editor" from "pragma found it and could
558
+ * not act". Authored once, because `setup`'s child row and `doctor`'s item are
559
+ * the same finding.
560
+ *
561
+ * The evidence path travels with it wherever there is one: the CLI file the
562
+ * probe resolved. An editor found only by its user directory has no CLI, and
563
+ * the phrase alone is the whole finding there.
564
+ *
565
+ * @param e - One detected editor.
566
+ * @param roots - The two roots every path renders relative to.
567
+ * @returns The phrase, e.g. `via PATH (/usr/bin/codium)`.
568
+ */
569
+ export const describeEditorSource = (
570
+ e: DetectedEditor,
571
+ roots: Roots,
572
+ ): string => {
573
+ if (e.foundBy === "user-dir") return "via user directory";
574
+ const where = e.foundBy === "path" ? "PATH" : "app bundle";
575
+ return `via ${where} (${shortenPath(e.cliPath, roots)})`;
576
+ };
577
+
578
+ /**
579
+ * The home-manager declaration for a Nix-managed extensions folder.
580
+ *
581
+ * It does NOT name a marketplace id, because there is no listing — the module
582
+ * docblock above says so, and sideloading the bundled VSIX is the only install
583
+ * path that exists. So the line gives both halves a home-manager user needs:
584
+ * where to get the file, and the attribute that installs it.
585
+ *
586
+ * The expression is VALID NIX, which the shorter `{ src = <path>; }` sketch it
587
+ * replaces was not twice over: `buildVscodeExtension` has no defaults for
588
+ * `name`, `vscodeExtPublisher`, `vscodeExtName` or `vscodeExtUniqueId`
589
+ * (nixpkgs `pkgs/build-support/vscode/extensions`), and an unquoted Nix path
590
+ * literal cannot carry the `@` in `@canonical/…` — it is `builtins.path`
591
+ * around a quoted string here for that reason. `version` is the ONE attribute
592
+ * a user must fill in, so the line says so rather than guessing a number that
593
+ * would be wrong the next release.
594
+ */
595
+ const nixDeclarationRemedy = (d: LspDetection): string =>
596
+ `fetch the VSIX with \`bun add ${LSP_PACKAGE}@latest\` in ${d.stagingDir}, then declare it yourself, filling in the version from the fetched package.json: programs.vscode.profiles.default.extensions = [ (pkgs.vscode-utils.buildVscodeExtension { name = "${EXTENSION_NAME}"; version = "<version>"; vscodeExtPublisher = "${EXTENSION_PUBLISHER}"; vscodeExtName = "${EXTENSION_NAME}"; vscodeExtUniqueId = "${EXTENSION_ID}"; src = builtins.path { path = "${lspVsixPath(d)}"; }; }) ];`;
597
+
598
+ /**
599
+ * Why nothing can be installed into a blocked editor, and the ONE action that
600
+ * settles it — both answers from one dispatch.
601
+ *
602
+ * Two `switch`es over the same three causes (one for the reason, one for the
603
+ * remedy) is how a fourth cause gets a reason and no remedy; a table whose row
604
+ * is a `{reason, remedy}` pair cannot drift that way. The remedy is a thunk
605
+ * because only it needs the detection (for the staging dir and the VSIX path),
606
+ * and the reason is read per child row where no detection is in hand.
607
+ *
608
+ * The reason does NOT name the editor. Every place it lands already does: the
609
+ * setup child row is labelled `<cli> — <name> · <found via>`, doctor's item is
610
+ * labelled with the name, and the row headline prefixes it
611
+ * ({@link firstLspBlock}). Including it here made doctor print "VS Code: via
612
+ * user directory · VS Code is installed but has no …" — the name twice in one
613
+ * line and three times in two.
614
+ *
615
+ * @param e - An editor nothing can be installed into.
616
+ * @returns Its reason, and a remedy awaiting the detection.
617
+ * @note Pure — composes strings from the detection record.
618
+ */
619
+ const describeBlock = (
620
+ e: BlockedEditor,
621
+ ): { reason: string; remedy: (d: LspDetection) => string } => {
622
+ // Keyed by PROVENANCE, not by a block: an editor found only by its user
623
+ // directory has no CLI whatever its folder permits, so "no launcher" is a
624
+ // fact about how it was found. Its folder is never probed (see `findEditor`).
625
+ if (e.foundBy === "user-dir") {
626
+ return {
627
+ reason: "no command-line launcher — found only its user directory",
628
+ remedy: () =>
629
+ `run "Shell Command: Install '${e.editor.cli}' command in PATH" from ${e.editor.name}'s command palette, then run \`${BIN_NAME} setup lsp\` again`,
630
+ };
631
+ }
632
+ switch (e.block.kind) {
633
+ case "nix-store":
634
+ return {
635
+ reason: `extensions folder managed by Nix (it resolves to ${e.block.resolved})`,
636
+ remedy: nixDeclarationRemedy,
637
+ };
638
+ case "read-only":
639
+ return {
640
+ // The folder the row names is the one a `chmod` has to reach, so the
641
+ // remedy can say "the extensions folder" instead of repeating it.
642
+ reason: `extensions folder is not writable (${e.block.path})`,
643
+ remedy: () =>
644
+ `make the extensions folder writable, then run \`${BIN_NAME} setup lsp\` again`,
645
+ };
646
+ }
647
+ };
648
+
649
+ /**
650
+ * Why nothing can be installed into one editor — one line, as the renderer
651
+ * prints one — or `undefined` when the install can act on it.
652
+ *
653
+ * @param e - One detected editor.
654
+ * @returns The reason, or `undefined` when the editor is actionable.
655
+ */
656
+ export const lspBlockReason = (e: DetectedEditor): string | undefined =>
657
+ installable(e) ? undefined : describeBlock(e).reason;
658
+
659
+ /**
660
+ * The FIRST editor nothing can be installed into, with its row headline and
661
+ * its remedy already derived — or `undefined` when every detected editor is
662
+ * actionable.
663
+ *
664
+ * It hands back the derived strings rather than the editor, because the
665
+ * blockedness is what makes the reason and the remedy exist: a caller that
666
+ * took the editor and asked for them separately had to assert that both were
667
+ * there, twice, on both surfaces. The headline names the editor, since nothing
668
+ * else on a ROW has.
669
+ *
670
+ * @param d - The detection gathered up front.
671
+ * @returns The first block's headline and remedy, or `undefined`.
672
+ */
673
+ export const firstLspBlock = (
674
+ d: LspDetection,
675
+ ): { readonly headline: string; readonly remedy: string } | undefined => {
676
+ const first = blockedEditors(d).at(0);
677
+ if (first === undefined) return undefined;
678
+ const { reason, remedy } = describeBlock(first);
679
+ return { headline: `${first.editor.name}: ${reason}`, remedy: remedy(d) };
680
+ };
283
681
 
284
682
  /**
285
683
  * The remedy beneath that skip. It states plainly that nothing is possible here
@@ -305,7 +703,7 @@ export function composeLsp(
305
703
  d: LspDetection,
306
704
  chosen?: readonly string[],
307
705
  ): Task<void> {
308
- // Nothing to install: no editor CLI on PATH (a named skip the plan row
706
+ // Nothing to install: no editor found at all (a named skip the plan row
309
707
  // carries) or every detected editor already has it. Both compose NOTHING —
310
708
  // what the run SAYS about this target is the plan row's business.
311
709
  if (d.state !== "absent") return sequence_([]);
@@ -338,15 +736,20 @@ export function composeLsp(
338
736
  ]),
339
737
  );
340
738
 
341
- const sideloads = pending.map(({ editor }) => {
342
- const command = `${editor.cli} --install-extension ${vsixPath}`;
739
+ const sideloads = pending.map(({ editor, cliPath }) => {
740
+ // The CLI FILE detection resolved, which is the only thing a pending
741
+ // editor HAS: an editor found in its macOS app bundle has nothing on PATH
742
+ // to spawn, and even a PATH hit is better spawned as the file this run
743
+ // verified.
744
+ const bin = cliPath;
745
+ const command = `${bin} --install-extension ${vsixPath}`;
343
746
  return guardMissingBinary(
344
- editor.cli,
747
+ bin,
345
748
  {
346
- message: `The \`${editor.cli}\` CLI disappeared from PATH mid-run — restore it, then run \`${BIN_NAME} setup lsp\` again.`,
749
+ message: `${editor.name}'s CLI disappeared mid-run — restore it, then run \`${BIN_NAME} setup lsp\` again.`,
347
750
  },
348
751
  flatMap(
349
- exec(editor.cli, ["--install-extension", vsixPath], d.stagingDir),
752
+ exec(bin, ["--install-extension", vsixPath], d.stagingDir),
350
753
  (result) =>
351
754
  checkExecOk(command, result as ExecResult, {
352
755
  message:
@@ -372,12 +775,17 @@ export function composeLsp(
372
775
  * @param d - The detection gathered up front.
373
776
  * @returns The editors carrying a copy.
374
777
  */
375
- export const ownedLspEditors = (d: LspDetection): readonly DetectedEditor[] =>
376
- d.editors.filter((e) => e.present);
778
+ export const ownedLspEditors = (d: LspDetection): readonly RunnableEditor[] =>
779
+ // A blocked editor is excluded even when it carries a copy: the uninstall
780
+ // would need a CLI this machine has not got, or a write into a directory
781
+ // pragma must not touch. The row says so rather than composing a reversal
782
+ // that cannot run.
783
+ d.editors.filter((e): e is RunnableEditor => e.present && installable(e));
377
784
 
378
785
  /**
379
786
  * Compose the removal: one `<editor cli> --uninstall-extension <id>` per owned
380
- * editor, carried as the `undo` of a forward no-op.
787
+ * editor, carried as the `undo` of a forward no-op — and each uninstall
788
+ * asserts its own GOAL, not just its exit code.
381
789
  *
382
790
  * The docblock this replaces claimed "an `exec` carries no reversal". That was
383
791
  * factually wrong about the effect model — `Exec` has an `undo` slot and `exec`
@@ -391,37 +799,88 @@ export const ownedLspEditors = (d: LspDetection): readonly DetectedEditor[] =>
391
799
  * `present` proves already exists — reads nothing and is a genuine no-op. Phase
392
800
  * two then runs the uninstall against the real editor.
393
801
  *
802
+ * THE POSTCONDITION is the removal's real success criterion. The exec's exit
803
+ * code and the extensions DIRECTORY are two different judges — detection
804
+ * deliberately reads the directory (probing via the CLI can launch an
805
+ * editor), and VS Code-family CLIs are known to exit 0 while deferring or
806
+ * skipping the deletion — so after `checkExecOk` the undo probes the exact
807
+ * entries detection said this command owns, and FAILS (with the manual
808
+ * deletion as the remedy — never the command that just claimed success) when
809
+ * any survived. An in-task failure here is safe precisely because
810
+ * `runCollectedUndos` executes each undo isolated: it used to abort every
811
+ * reversal still pending, which is why no removal could afford to assert its
812
+ * own goal.
813
+ *
394
814
  * @param d - The detection gathered up front.
395
- * @returns A Task whose undo uninstalls the extension from each owned editor.
815
+ * @param undoKey - Correlation key stamped on each reversal, echoed on its
816
+ * `UndoOutcome` so the caller can read the outcomes back per row.
817
+ * @returns A Task whose undo uninstalls the extension from each owned editor
818
+ * and verifies the copy is gone.
396
819
  */
397
- export function composeLspRemoval(d: LspDetection): Task<void> {
820
+ export function composeLspRemoval(
821
+ d: LspDetection,
822
+ undoKey?: string,
823
+ ): Task<void> {
398
824
  const owned = ownedLspEditors(d);
399
825
  // Nothing on this machine carries a copy — there is no honest work to model,
400
826
  // and the plan row says so. Same shape as the other rows' empty removals.
401
827
  if (owned.length === 0) return sequence_([]);
402
828
  return sequence_(
403
- owned.map(({ editor, extensionsDir }) => {
404
- const command = `${editor.cli} --uninstall-extension ${EXTENSION_ID}`;
829
+ owned.map(({ editor, cliPath, extensionsDir, presentEntries }) => {
830
+ // The resolved CLI file, for the reason `composeLsp` spells out.
831
+ const bin = cliPath;
832
+ const command = `${bin} --uninstall-extension ${EXTENSION_ID}`;
833
+ const ownedPaths = presentEntries.map((entry) =>
834
+ join(extensionsDir, entry),
835
+ );
836
+ const uninstall = flatMap(
837
+ exec(bin, ["--uninstall-extension", EXTENSION_ID], extensionsDir),
838
+ (result) =>
839
+ checkExecOk(command, result as ExecResult, {
840
+ message:
841
+ `${editor.name} refused the uninstall (its output is above). ` +
842
+ `Remove it by hand with \`${command}\`.`,
843
+ }),
844
+ );
845
+ // The probe must apply DETECTION'S presence criterion, or the check can
846
+ // quietly stop checking: `extensionState` calls an entry present when
847
+ // `readdirSync` lists it, and a default `exists` (`fs.access`) follows
848
+ // symlinks — so a dangling matching symlink would be "installed" to
849
+ // detection and "absent" here, and an editor that exits 0 without
850
+ // removing it would report `undone` while the next detection finds it
851
+ // again. `followSymlinks: false` is `lstat` semantics: the entry
852
+ // itself, exactly what the directory listing answers.
853
+ const verified = flatMap(uninstall, () =>
854
+ flatMap(
855
+ sequence(ownedPaths.map((p) => exists(p, { followSymlinks: false }))),
856
+ (stillThere) => {
857
+ const survivors = ownedPaths.filter((_, i) => stillThere[i]);
858
+ if (survivors.length === 0) return pure(undefined);
859
+ // The remedy presents the surviving paths as DATA, never as a
860
+ // synthesized shell line: the names come from `readdirSync`, so
861
+ // no quoting is trustworthy, and one command cannot be right for
862
+ // every platform the editor registry supports.
863
+ return failPragma(
864
+ new PragmaError({
865
+ code: "UNSUPPORTED",
866
+ message: `\`${command}\` exited 0, but the extension is still present after the undo ran.`,
867
+ recovery: {
868
+ message: `Delete ${survivors.length === 1 ? "this entry" : "these entries"} from ${editor.name}'s extensions folder by hand, then restart it: ${survivors.join(" · ")}`,
869
+ },
870
+ }),
871
+ );
872
+ },
873
+ ),
874
+ );
405
875
  return mkdir(extensionsDir, true, {
406
876
  undo: guardMissingBinary(
407
- editor.cli,
877
+ bin,
408
878
  {
409
- message: `The \`${editor.cli}\` CLI disappeared from PATH mid-run — restore it, then run \`${BIN_NAME} setup lsp --undo\` again.`,
879
+ message: `${editor.name}'s CLI disappeared mid-run — restore it, then run \`${BIN_NAME} setup lsp --undo\` again.`,
410
880
  },
411
- flatMap(
412
- exec(
413
- editor.cli,
414
- ["--uninstall-extension", EXTENSION_ID],
415
- extensionsDir,
416
- ),
417
- (result) =>
418
- checkExecOk(command, result as ExecResult, {
419
- message:
420
- `${editor.name} refused the uninstall (its output is above). ` +
421
- `Remove it by hand with \`${command}\`.`,
422
- }),
423
- ),
881
+ verified,
424
882
  ),
883
+ undoKey,
425
884
  });
426
885
  }),
427
886
  );
@@ -441,10 +900,10 @@ export function composeLspRemoval(d: LspDetection): Task<void> {
441
900
  * @returns The uninstall command, or undefined.
442
901
  */
443
902
  export function lspUninstallRemedy(d: LspDetection): string | undefined {
444
- const cli = ownedLspEditors(d).at(0)?.editor.cli;
445
- return cli === undefined
903
+ const owner = ownedLspEditors(d).at(0);
904
+ return owner === undefined
446
905
  ? undefined
447
- : `${cli} --uninstall-extension ${EXTENSION_ID}`;
906
+ : `${owner.cliPath} --uninstall-extension ${EXTENSION_ID}`;
448
907
  }
449
908
 
450
909
  /** The VSIX path a failed sideload leaves behind for a manual retry. */