@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
@@ -13,7 +13,9 @@
13
13
  * routing, and MCP plan-first with the sub-verbs absent from the catalog.
14
14
  */
15
15
 
16
+ import { spawnSync } from "node:child_process";
16
17
  import {
18
+ chmodSync,
17
19
  existsSync,
18
20
  lstatSync,
19
21
  mkdirSync,
@@ -26,7 +28,7 @@ import {
26
28
  writeFileSync,
27
29
  } from "node:fs";
28
30
  import { tmpdir } from "node:os";
29
- import { join, relative, sep } from "node:path";
31
+ import { basename, join, relative, sep } from "node:path";
30
32
  import { execute } from "@canonical/summon-core";
31
33
  import { collectUndos, dryRun, type Effect, type Task } from "@canonical/task";
32
34
  import { runTask, runUndo } from "@canonical/task/node";
@@ -38,11 +40,25 @@ import { executeVerb } from "../../kernel/project/cli/dispatch.js";
38
40
  import { bootRuntime } from "../../kernel/runtime/boot.js";
39
41
  import type { GlobalFlags } from "../../kernel/runtime/types.js";
40
42
  import type { VerbSpec } from "../../kernel/spec/types.js";
43
+ import { storeProbe } from "../../testing/helpers/fsProbe.js";
41
44
  import { projectCli } from "../../testing/helpers/projectCli.js";
42
45
  import { projectMcp } from "../../testing/helpers/projectMcp.js";
43
46
  import { capabilities } from "../index.js";
47
+ import { resolveRoots } from "./buildPlan.js";
44
48
  import { detectCompletions } from "./operations/setupCompletions.js";
45
49
  import { buildSetupRun, type RowEvent } from "./operations/setupGenerator.js";
50
+ import {
51
+ composeLsp,
52
+ describeEditorSource,
53
+ detectLsp,
54
+ firstLspBlock,
55
+ lspBlockReason,
56
+ lspUninstallRemedy,
57
+ lspVsixPath,
58
+ ownedLspEditors,
59
+ selectedEditors,
60
+ } from "./operations/setupLsp.js";
61
+ import { composeMcp, detectMcp, firstMcpBlock } from "./operations/setupMcp.js";
46
62
  import {
47
63
  composeSkills,
48
64
  composeSkillsRemoval,
@@ -51,9 +67,12 @@ import {
51
67
  skillsSkipReason,
52
68
  skillsSkipRemedy,
53
69
  } from "./operations/setupSkills.js";
70
+ import type { Roots } from "./plan.js";
54
71
  import { DRY_RUN_HINT, PREVIEW_HINT } from "./setup.render.js";
55
72
  import { setupModule } from "./setup.verb.js";
56
73
  import { completionScriptPath, detectShell, type ShellId } from "./shell.js";
74
+ import { findTarget, type TargetDefinition } from "./targets.js";
75
+ import type { Scope } from "./types.js";
57
76
 
58
77
  const FLAGS: GlobalFlags = {
59
78
  llm: false,
@@ -90,6 +109,17 @@ const DETECTED = detectShell();
90
109
  const SHELL = DETECTED.kind === "detected" ? DETECTED.shell : null;
91
110
  const withShell = it.skipIf(SHELL === null);
92
111
 
112
+ /**
113
+ * The cases that arrange a real read-only directory with `chmod 0o500`.
114
+ *
115
+ * Root ignores the mode bits — `access(W_OK)` succeeds on a `0o500` directory
116
+ * it owns — so `probeWritable` finds nothing to block and the case asserts the
117
+ * opposite of what it means. GitHub-hosted runners are non-root and run these;
118
+ * a devcontainer or a bare `docker run` of the suite is root and stands down,
119
+ * which is honest rather than red.
120
+ */
121
+ const notRoot = it.skipIf(process.getuid?.() === 0);
122
+
93
123
  /**
94
124
  * A PATH directory carrying a `pragma` stub. The completions target refuses to
95
125
  * install a script whose `pragma __complete` cannot be found — an inert script
@@ -101,17 +131,62 @@ const stubPath = (): string => {
101
131
  return dir;
102
132
  };
103
133
 
134
+ /**
135
+ * The machine's real PATH, captured before the suite isolates it. A stub
136
+ * editor script that has to run a real utility (`rm`) restores this INSIDE
137
+ * itself — hardcoding `/bin:/usr/bin` breaks on hosts that keep coreutils
138
+ * elsewhere, and the isolated PATH the tests run under has no `rm`.
139
+ */
140
+ const HOST_PATH = process.env.PATH ?? "";
141
+
142
+ /** Run a host binary under the real PATH, whatever a case has set. */
143
+ const onHost = (
144
+ command: string,
145
+ args: readonly string[],
146
+ ): ReturnType<typeof spawnSync<string>> =>
147
+ spawnSync(command, [...args], {
148
+ encoding: "utf-8",
149
+ env: { ...process.env, PATH: HOST_PATH },
150
+ });
151
+
152
+ /**
153
+ * The case that checks the Nix remedy against the real Nix parser. Nix is not
154
+ * a dependency of this repository, so where the binary is absent the case
155
+ * stands down and the structural assertions beside it carry the claim.
156
+ */
157
+ const withNix = it.skipIf(
158
+ onHost("nix-instantiate", ["--version"]).error !== undefined,
159
+ );
160
+
104
161
  let prevHome: string | undefined;
105
162
  let prevPath: string | undefined;
163
+ let prevXdg: string | undefined;
164
+ let prevAppData: string | undefined;
106
165
  beforeEach(() => {
107
166
  prevHome = process.env.HOME;
108
167
  prevPath = process.env.PATH;
168
+ prevXdg = process.env.XDG_CONFIG_HOME;
169
+ prevAppData = process.env.APPDATA;
109
170
  process.env.HOME = tmp("pragma-setup-home-");
110
171
  process.env.PATH = stubPath();
172
+ // PER-TEST, not per-file. The VS Code family's per-user `mcp.json` lives
173
+ // under `$XDG_CONFIG_HOME/<product>/User`, so a global `setup mcp` in one
174
+ // test CREATES that directory — and the file-level XDG isolation
175
+ // (`setupXdgIsolation.ts`) is shared by every test in this file, so the next
176
+ // one detected the editor by the directory its predecessor had just made.
177
+ // Which showed up as a wrong `lsp` row three describes away.
178
+ process.env.XDG_CONFIG_HOME = tmp("pragma-setup-xdg-");
179
+ // The win32 arm of the same directory. Unset on this host, but a developer
180
+ // running the suite on Windows would otherwise detect their real editor.
181
+ delete process.env.APPDATA;
111
182
  });
112
183
  afterEach(() => {
113
184
  process.env.HOME = prevHome;
114
185
  process.env.PATH = prevPath;
186
+ if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME;
187
+ else process.env.XDG_CONFIG_HOME = prevXdg;
188
+ if (prevAppData === undefined) delete process.env.APPDATA;
189
+ else process.env.APPDATA = prevAppData;
115
190
  for (const dir of roots) rmSync(dir, { recursive: true, force: true });
116
191
  roots.length = 0;
117
192
  });
@@ -507,9 +582,7 @@ describe("setup mcp — the per-file question, asked directly", () => {
507
582
  // the only gate, and the choices carry the detected state per file.
508
583
  expect(targets?.when?.({})).toBe(true);
509
584
  expect(
510
- (targets?.choices as { label: string }[]).every((c) =>
511
- /— (add|update|unchanged)$/.test(c.label),
512
- ),
585
+ targets?.choices?.every((c) => /— (add|update|unchanged)$/.test(c.label)),
513
586
  ).toBe(true);
514
587
  });
515
588
 
@@ -665,8 +738,11 @@ describe("setup lsp — per-editor multiselect (child rows)", () => {
665
738
  _tag: "Exec";
666
739
  command: string;
667
740
  })[];
668
- // The fetch still runs once; the sideload runs for `codium` alone.
669
- expect(execs.map((e) => e.command)).toEqual(["bun", "codium"]);
741
+ // The fetch still runs once; the sideload runs for `codium` alone — and
742
+ // it spawns the CLI FILE detection resolved, not the bare name, so an
743
+ // editor found inside a macOS app bundle (nothing on PATH) works too.
744
+ expect(execs.map((e) => basename(e.command))).toEqual(["bun", "codium"]);
745
+ expect(execs[1].command).toBe(join(stubDir, "codium"));
670
746
  });
671
747
 
672
748
  it("composes nothing at all when every editor is deselected", async () => {
@@ -751,7 +827,7 @@ describe("setup lsp — the removal contract the other four rows implement", ()
751
827
  command: string;
752
828
  args: string[];
753
829
  })[];
754
- expect(execs.map((e) => e.command)).toEqual(["code"]);
830
+ expect(execs.map((e) => basename(e.command))).toEqual(["code"]);
755
831
  expect(execs[0].args).toEqual([
756
832
  "--uninstall-extension",
757
833
  "canonical.terrazzo-lsp-extension",
@@ -815,7 +891,7 @@ describe("setup lsp — the removal contract the other four rows implement", ()
815
891
  const detected = await detectLsp(tmp("pragma-setup-proj-"));
816
892
  expect(detected.editors[0]?.editor.cli).toBe("code"); // first on PATH
817
893
  expect(lspUninstallRemedy(detected)).toBe(
818
- "codium --uninstall-extension canonical.terrazzo-lsp-extension",
894
+ `${join(stubDir, "codium")} --uninstall-extension canonical.terrazzo-lsp-extension`,
819
895
  );
820
896
  });
821
897
 
@@ -829,18 +905,22 @@ describe("setup lsp — the removal contract the other four rows implement", ()
829
905
  );
830
906
  const row = plan.rows.find((r) => r.target === "lsp");
831
907
  expect(row?.action).toBe("skip");
832
- expect(row?.reason).toContain("no VS Code-family editor CLI on PATH");
908
+ expect(row?.reason).toContain("no VS Code-family editor found");
833
909
  });
834
910
 
835
911
  it("end to end: `setup lsp --undo` reports `Undid 1 step(s)` against a STUB cli", async () => {
836
912
  // The reported symptom was `Undid 0 step(s).` at exit 0. The stub is a
837
913
  // marker-writing script — never a real editor: an undo test must not be
838
- // able to mutate the machine running it.
914
+ // able to mutate the machine running it. It DOES delete the seeded
915
+ // extension entry (inside this test's temp HOME), because the removal now
916
+ // asserts its own goal against the extensions directory: a stub that
917
+ // exits 0 leaving the entry behind is the LYING-editor case, pinned as a
918
+ // failure below.
839
919
  const marker = join(tmp("pragma-lsp-undo-"), "uninstalled");
840
920
  const dir = stubPath();
841
921
  writeFileSync(
842
922
  join(dir, "code"),
843
- `#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\n`,
923
+ `#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\nPATH='${HOST_PATH}' rm -rf "$HOME/.vscode/extensions/canonical.terrazzo-lsp-extension-"*\n`,
844
924
  { mode: 0o755 },
845
925
  );
846
926
  process.env.PATH = dir;
@@ -872,6 +952,205 @@ describe("setup lsp — the removal contract the other four rows implement", ()
872
952
  expect(all.some((e) => e._tag === "DeleteFile")).toBe(false);
873
953
  expect(all.some((e) => e._tag === "DeleteDirectory")).toBe(false);
874
954
  });
955
+
956
+ /** Drive `setup lsp --undo` capturing stderr (where the row lines land). */
957
+ const undoLspCapturing = async (): Promise<{
958
+ chunks: string[];
959
+ outcome?: { stdout?: string; stderr?: string; exitCode: number };
960
+ thrown?: unknown;
961
+ }> => {
962
+ const chunks: string[] = [];
963
+ const spy = vi
964
+ .spyOn(process.stderr, "write")
965
+ .mockImplementation((chunk: unknown): boolean => {
966
+ chunks.push(String(chunk));
967
+ return true;
968
+ });
969
+ try {
970
+ const outcome = await executeVerb(
971
+ verbOf("lsp"),
972
+ {},
973
+ UNDO,
974
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
975
+ );
976
+ return { chunks, outcome };
977
+ } catch (thrown) {
978
+ return { chunks, thrown };
979
+ } finally {
980
+ spy.mockRestore();
981
+ }
982
+ };
983
+
984
+ it("HEADLINE: an uninstall that exits 0 leaving the extension on disk reports FAILED, never `✓ removed`", async () => {
985
+ // The observed field defect: VS Code-family CLIs can exit 0 while
986
+ // deferring or skipping the deletion. The reversal's success criterion
987
+ // was that exit code; detection's criterion is the extensions DIRECTORY.
988
+ // The two disagreed permanently and the run printed `✓ … removed` over an
989
+ // extension that was still installed. The removal now asserts its own
990
+ // goal in-task — probe the owned entries after the uninstall — and the
991
+ // run reports the survival as a failure with a manual remedy.
992
+ const dir = stubPath();
993
+ writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
994
+ process.env.PATH = dir;
995
+ seedExtension(".vscode", "1.2.3");
996
+ const survivor = join(
997
+ process.env.HOME ?? "",
998
+ ".vscode",
999
+ "extensions",
1000
+ "canonical.terrazzo-lsp-extension-1.2.3",
1001
+ );
1002
+
1003
+ const { chunks, outcome, thrown } = await undoLspCapturing();
1004
+ const stderrText = chunks.join("");
1005
+
1006
+ // The world: the artifact survived, and the run must not claim otherwise.
1007
+ expect(existsSync(survivor)).toBe(true);
1008
+ expect(stderrText).not.toContain("— removed");
1009
+ expect(outcome).toBeUndefined();
1010
+ expect(thrown).toBeDefined();
1011
+ const err = asPragmaError(thrown);
1012
+ expect(err.code).toBe("UNSUPPORTED");
1013
+ expect(err.message).toContain("lsp");
1014
+ expect(err.message).toContain("still present after the undo ran");
1015
+ // The remedy names the surviving path as DATA for a manual deletion —
1016
+ // not the CLI command that has just demonstrably done nothing while
1017
+ // reporting success, and not a synthesized shell line (the entry names
1018
+ // come off the filesystem, so no quoting is trustworthy, and one command
1019
+ // cannot be right for every supported platform).
1020
+ expect(err.recovery?.message ?? "").toContain(survivor);
1021
+ expect(err.recovery?.message ?? "").not.toContain("rm -rf");
1022
+ expect(err.recovery?.message ?? "").not.toContain("--uninstall-extension");
1023
+ // The truthful row is reported (stderr), naming the survival.
1024
+ expect(stderrText).toContain("still present after the undo ran");
1025
+ });
1026
+
1027
+ it("a DANGLING matching symlink still counts as a survivor — the probe uses detection's criterion", async () => {
1028
+ // Detection calls an entry present when `readdirSync` lists it. A probe
1029
+ // built on default `exists` (`fs.access`) FOLLOWS symlinks, so a dangling
1030
+ // matching symlink would be "installed" to detection and "absent" to the
1031
+ // probe: an editor that exits 0 without removing it would report
1032
+ // `undone`, and the next detection would find it again — the exact
1033
+ // "reports success it did not achieve" this fix exists to close, rebuilt
1034
+ // inside the fix. The probe therefore asks about the entry itself
1035
+ // (lstat semantics), the same question the directory listing answers.
1036
+ const dir = stubPath();
1037
+ writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
1038
+ process.env.PATH = dir;
1039
+ const extensionsDir = join(process.env.HOME ?? "", ".vscode", "extensions");
1040
+ mkdirSync(extensionsDir, { recursive: true });
1041
+ const entry = join(extensionsDir, "canonical.terrazzo-lsp-extension-1.2.3");
1042
+ symlinkSync(join(extensionsDir, "no-such-target"), entry);
1043
+
1044
+ const { chunks, outcome, thrown } = await undoLspCapturing();
1045
+
1046
+ expect(lstatSync(entry).isSymbolicLink()).toBe(true); // still there
1047
+ expect(chunks.join("")).not.toContain("— removed");
1048
+ expect(outcome).toBeUndefined();
1049
+ const err = asPragmaError(thrown);
1050
+ expect(err.message).toContain("still present after the undo ran");
1051
+ expect(err.recovery?.message ?? "").toContain(entry);
1052
+ });
1053
+
1054
+ it("a truthful uninstall reports its `removed` row from the executed reversal", async () => {
1055
+ // The positive half of the headline: when the editor really deletes the
1056
+ // entry, the in-task postcondition passes, the reversal's outcome comes
1057
+ // back `undone`, and the row reports `removed` — same sentence as before
1058
+ // the fix, now derived from what ran instead of from the plan.
1059
+ const dir = stubPath();
1060
+ writeFileSync(
1061
+ join(dir, "code"),
1062
+ `#!/bin/sh\nPATH='${HOST_PATH}' rm -rf "$HOME/.vscode/extensions/canonical.terrazzo-lsp-extension-"*\n`,
1063
+ { mode: 0o755 },
1064
+ );
1065
+ process.env.PATH = dir;
1066
+ seedExtension(".vscode", "1.2.3");
1067
+
1068
+ const { chunks, outcome, thrown } = await undoLspCapturing();
1069
+ expect(thrown).toBeUndefined();
1070
+ expect(outcome?.exitCode).toBe(0);
1071
+ expect(outcome?.stdout).toBe("Undid 1 step(s).\n");
1072
+ expect(chunks.join("")).toContain("— removed");
1073
+ });
1074
+
1075
+ it("the `✓ … removed` rows cannot be produced by the collection walk alone", async () => {
1076
+ // The collection walk mocks every exec to exit 0 and every prompt to its
1077
+ // default — under the old code that was enough to print the whole green
1078
+ // recap before one real process had spawned. With an uninstall that
1079
+ // GENUINELY fails (exit 1), the executed reversal fails; if any `removed`
1080
+ // row still appeared on stderr, it could only have come from the mocked
1081
+ // walk.
1082
+ const dir = stubPath();
1083
+ writeFileSync(join(dir, "code"), "#!/bin/sh\nexit 1\n", { mode: 0o755 });
1084
+ process.env.PATH = dir;
1085
+ seedExtension(".vscode", "1.2.3");
1086
+
1087
+ const { chunks, outcome, thrown } = await undoLspCapturing();
1088
+ expect(chunks.join("")).not.toContain("— removed");
1089
+ expect(outcome).toBeUndefined();
1090
+ expect(thrown).toBeDefined();
1091
+ const err = asPragmaError(thrown);
1092
+ expect(err.message).toContain("--uninstall-extension");
1093
+ expect(err.message).toContain("exited with code 1");
1094
+ });
1095
+
1096
+ it("appliedUndo grades rows from the outcomes — one model, both directions", async () => {
1097
+ // The projection unit: the sink the FORWARD run fills is fed by the undo
1098
+ // interpreter's outcomes instead, and the same `outcomeFor` reads it
1099
+ // back. A failed outcome keyed to the row fails the row (carrying the
1100
+ // reversal's own remedy); an actionable row that no reversal reported
1101
+ // back for cannot claim `removed`; a run with clean outcomes reports
1102
+ // `removed`.
1103
+ seedExtension(".vscode", "1.2.3");
1104
+ const run = await buildSetupRun(
1105
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1106
+ "lsp",
1107
+ "global",
1108
+ true,
1109
+ );
1110
+
1111
+ const clean = run.appliedUndo([{ key: "global:lsp", status: "undone" }]);
1112
+ expect(clean.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
1113
+ status: "removed",
1114
+ note: "removed",
1115
+ });
1116
+
1117
+ const failed = run.appliedUndo([
1118
+ {
1119
+ key: "global:lsp",
1120
+ status: "failed",
1121
+ error: { code: "UNSUPPORTED", message: "still there" },
1122
+ },
1123
+ ]);
1124
+ expect(failed.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
1125
+ status: "failed",
1126
+ note: "still there",
1127
+ });
1128
+
1129
+ // No reversal reported back at all for an actionable row: never `removed`.
1130
+ const silent = run.appliedUndo([]);
1131
+ expect(silent.rows.find((r) => r.target === "lsp")?.outcome).toMatchObject({
1132
+ status: "failed",
1133
+ note: "no reversal was executed for this target",
1134
+ });
1135
+ });
1136
+
1137
+ it("a removal row with nothing owned reports `kept`, not a green noop", async () => {
1138
+ // The `kept` status was declared in the vocabulary and counted by
1139
+ // `planTally` but no producer ever emitted it. On a removal, a `none`
1140
+ // row IS the kept case: the run owned nothing here and deliberately left
1141
+ // the machine's state standing — standing aside (○), not work done (✓).
1142
+ const run = await buildSetupRun(
1143
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
1144
+ "lsp",
1145
+ "global",
1146
+ true,
1147
+ );
1148
+ expect(run.plan.rows.find((r) => r.target === "lsp")?.action).toBe("none");
1149
+ const kept = run.appliedUndo([]);
1150
+ expect(kept.rows.find((r) => r.target === "lsp")?.outcome).toEqual({
1151
+ status: "kept",
1152
+ });
1153
+ });
875
1154
  });
876
1155
 
877
1156
  describe("setup (run-all wizard) — scope threading", () => {
@@ -1296,6 +1575,40 @@ describe("setup skills", () => {
1296
1575
  expect(() => lstatSync(linkPath)).toThrow();
1297
1576
  });
1298
1577
 
1578
+ it("the removal plan's children are the FOLDERS the links live in, deduped", async () => {
1579
+ // `dirs` mapped `a.linkPath` — one path per LINK — so the `new Set(…)`
1580
+ // deduped nothing and the removal preview printed an N-path wall where
1581
+ // the forward plan says `N skills → M folders`. The folders are the
1582
+ // links' parent directories.
1583
+ const cwd = tmp("pragma-setup-proj-");
1584
+ const linkDir = join(cwd, ".agents", "skills");
1585
+ mkdirSync(linkDir, { recursive: true });
1586
+ for (const name of ["s1", "s2"]) {
1587
+ const skillDir = join(cwd, ".pragma", "skills", name);
1588
+ mkdirSync(skillDir, { recursive: true });
1589
+ writeFileSync(
1590
+ join(skillDir, "SKILL.md"),
1591
+ `---\nname: ${name}\ndescription: A test skill.\n---\n`,
1592
+ );
1593
+ symlinkSync(skillDir, join(linkDir, name));
1594
+ }
1595
+ const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
1596
+ expect(ownedSkillLinks(detected)).toHaveLength(2);
1597
+
1598
+ const { findTarget } = await import("./targets.js");
1599
+ const draft = findTarget("skills")?.removalPlan(
1600
+ detected as never,
1601
+ "project",
1602
+ { global: process.env.HOME ?? "", project: cwd },
1603
+ );
1604
+ expect(draft?.action).toBe("remove");
1605
+ expect(draft?.detail).toBe("2 links");
1606
+ // TWO links, ONE folder: the children name the folder once, not each link.
1607
+ expect(draft?.children?.map((c) => c.label)).toEqual([
1608
+ `.${sep}${join(".agents", "skills")}`,
1609
+ ]);
1610
+ });
1611
+
1299
1612
  it("owns a RELATIVE link into the skill root, resolved against the link dir", async () => {
1300
1613
  // `readlink` reports the raw target, which may be relative — resolving it
1301
1614
  // against the link's own directory is what makes the containment test
@@ -1579,8 +1892,10 @@ describe("setup lsp — prerequisites (bun absent / no editor CLI)", () => {
1579
1892
  command: string;
1580
1893
  args: string[];
1581
1894
  })[];
1582
- // One fetch (bun add, durable staging) + one sideload per PENDING editor.
1583
- expect(execs.map((e) => e.command)).toEqual(["bun", "codium"]);
1895
+ // One fetch (bun add, durable staging) + one sideload per PENDING editor,
1896
+ // spawned as the resolved CLI file rather than the bare name.
1897
+ expect(execs.map((e) => basename(e.command))).toEqual(["bun", "codium"]);
1898
+ expect(execs[1].command).toBe(join(stubDir, "codium"));
1584
1899
  expect(execs[0].args).toEqual([
1585
1900
  "add",
1586
1901
  "@canonical/terrazzo-lsp-extension@latest",
@@ -1721,7 +2036,11 @@ describe("setup lsp — prerequisites (bun absent / no editor CLI)", () => {
1721
2036
  );
1722
2037
  expect(outcome.exitCode).toBe(0);
1723
2038
  expect(outcome.stdout).toContain("skipped");
1724
- expect(outcome.stdout).toContain("no VS Code-family editor CLI on PATH");
2039
+ // The reason names every CLI AND every place it was looked for: "not on
2040
+ // PATH" became a misleading half-answer once the app-bundle and
2041
+ // user-directory probes existed.
2042
+ expect(outcome.stdout).toContain("no VS Code-family editor found");
2043
+ expect(outcome.stdout).toContain("under /Applications");
1725
2044
  // The remedy names what would count, and states plainly that nothing is
1726
2045
  // possible here yet rather than offering a command this machine lacks.
1727
2046
  expect(outcome.stdout).toContain(
@@ -1887,7 +2206,7 @@ describe("setup — idempotent detection of already-present config", () => {
1887
2206
  // never a silent omission, and never a failure.
1888
2207
  const row = plan.rows.find((r) => r.target === "lsp");
1889
2208
  expect(row?.action).toBe("skip");
1890
- expect(row?.reason).toContain("no VS Code-family editor CLI on PATH");
2209
+ expect(row?.reason).toContain("no VS Code-family editor found");
1891
2210
  });
1892
2211
  });
1893
2212
 
@@ -2058,7 +2377,7 @@ describe("setup — a converged machine is reported, never asked", () => {
2058
2377
  expect(outcome.stdout).not.toContain("Setup plan");
2059
2378
  // It says what was detected and what happened to it — doctor's house shape.
2060
2379
  expect(outcome.stdout).toContain("skipped");
2061
- expect(outcome.stdout).toContain("no VS Code-family editor CLI on PATH");
2380
+ expect(outcome.stdout).toContain("no VS Code-family editor found");
2062
2381
  });
2063
2382
 
2064
2383
  it("a converged TTY run without --yes mounts NO Ink and prints the recap", async () => {
@@ -2294,3 +2613,660 @@ describe("setup — mixed-noun wiring & MCP surface", () => {
2294
2613
  expect(Array.isArray((plan.data as { plan: unknown }).plan)).toBe(true);
2295
2614
  });
2296
2615
  });
2616
+
2617
+ /**
2618
+ * The VS Code family's per-user `mcp.json` — the location that turned `--local`
2619
+ * from a workaround back into a choice.
2620
+ *
2621
+ * `--local` used to be the ONLY way to get a pragma entry into VS Code, because
2622
+ * the registry row declared no global location at all. These cases pin both
2623
+ * bands and the `both` selection between them, on the real filesystem.
2624
+ */
2625
+ describe("setup mcp — the VS Code family's per-user mcp.json", () => {
2626
+ let prevPath: string | undefined;
2627
+ beforeEach(() => {
2628
+ prevPath = process.env.PATH;
2629
+ process.env.PATH = stubPath();
2630
+ });
2631
+ afterEach(() => {
2632
+ process.env.PATH = prevPath;
2633
+ });
2634
+
2635
+ /** The per-user file for one product, under the isolated XDG config home. */
2636
+ const userMcp = (product: string): string =>
2637
+ join(process.env.XDG_CONFIG_HOME as string, product, "User", "mcp.json");
2638
+
2639
+ /**
2640
+ * The user-level signal that earns VS Code its global band: the per-user
2641
+ * directory a real install creates on first launch. A committed `.vscode/`
2642
+ * cannot stand in for it — see the pair of cases at the end of this block.
2643
+ */
2644
+ const installVscode = (product = "Code"): void => {
2645
+ mkdirSync(join(process.env.XDG_CONFIG_HOME as string, product, "User"), {
2646
+ recursive: true,
2647
+ });
2648
+ };
2649
+
2650
+ it("--global reaches VS Code at $XDG_CONFIG_HOME/Code/User/mcp.json, under `servers`", async () => {
2651
+ const cwd = tmp("pragma-setup-proj-");
2652
+ mkdirSync(join(cwd, ".vscode"), { recursive: true });
2653
+ installVscode();
2654
+ const outcome = await executeVerb(
2655
+ verbOf("mcp"),
2656
+ { global: true },
2657
+ YES,
2658
+ bootRuntime(FLAGS, cwd),
2659
+ );
2660
+ expect(outcome.exitCode).toBe(0);
2661
+ const config = JSON.parse(readFileSync(userMcp("Code"), "utf-8"));
2662
+ // VS Code's own key, and a GLOBAL entry, so no `cwd` pins the machine
2663
+ // registration to whatever directory the run happened from.
2664
+ expect(config.servers?.pragma?.command).toBe("pragma");
2665
+ expect(config.servers?.pragma?.args).toEqual(["mcp", "serve"]);
2666
+ expect(config.servers?.pragma?.cwd).toBeUndefined();
2667
+ // The project file is untouched by a global run.
2668
+ expect(existsSync(join(cwd, ".vscode", "mcp.json"))).toBe(false);
2669
+ });
2670
+
2671
+ it("--local still writes .vscode/mcp.json — the semantics did not change", async () => {
2672
+ const cwd = tmp("pragma-setup-proj-");
2673
+ mkdirSync(join(cwd, ".vscode"), { recursive: true });
2674
+ await executeVerb(
2675
+ verbOf("mcp"),
2676
+ { local: true },
2677
+ YES,
2678
+ bootRuntime(FLAGS, cwd),
2679
+ );
2680
+ const config = JSON.parse(
2681
+ readFileSync(join(cwd, ".vscode", "mcp.json"), "utf-8"),
2682
+ );
2683
+ expect(config.servers?.pragma?.cwd).toBe(cwd);
2684
+ expect(existsSync(userMcp("Code"))).toBe(false);
2685
+ });
2686
+
2687
+ it("--scope both covers BOTH of VS Code's files, each with its own entry shape", async () => {
2688
+ // `--scope both` is two rows, not one merged band: the project row writes
2689
+ // `.vscode/mcp.json` with the `cwd` a per-repo registration pins, and the
2690
+ // global row writes the per-user file without one. (Inside the harnesses
2691
+ // layer, a `both` SELECTION resolves a dual-scope harness to its project
2692
+ // file alone — `configTargets.test.ts` pins that; this is the CLI's own
2693
+ // two-scope run, which is what the flag means here.)
2694
+ const cwd = tmp("pragma-setup-proj-");
2695
+ mkdirSync(join(cwd, ".vscode"), { recursive: true });
2696
+ installVscode();
2697
+ await executeVerb(
2698
+ verbOf("mcp"),
2699
+ { scope: "both" },
2700
+ YES,
2701
+ bootRuntime(FLAGS, cwd),
2702
+ );
2703
+ const project = JSON.parse(
2704
+ readFileSync(join(cwd, ".vscode", "mcp.json"), "utf-8"),
2705
+ );
2706
+ const user = JSON.parse(readFileSync(userMcp("Code"), "utf-8"));
2707
+ expect(project.servers?.pragma?.cwd).toBe(cwd);
2708
+ expect(user.servers?.pragma?.cwd).toBeUndefined();
2709
+ });
2710
+
2711
+ it("VSCodium's per-user file is its OWN, detected by its own directory", async () => {
2712
+ // Per the owner, 2026-09-16: VSCodium is an MCP client row. Its user
2713
+ // directory is the signal, and `VSCodium/User/mcp.json` is the file — a
2714
+ // bare `.vscode` project dir must never stand in for it.
2715
+ installVscode("VSCodium");
2716
+ const outcome = await executeVerb(
2717
+ verbOf("mcp"),
2718
+ { global: true },
2719
+ YES,
2720
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2721
+ );
2722
+ expect(outcome.exitCode).toBe(0);
2723
+ const config = JSON.parse(readFileSync(userMcp("VSCodium"), "utf-8"));
2724
+ expect(config.servers?.pragma?.command).toBe("pragma");
2725
+ // VS Code itself was not detected, so no `Code/User` file was invented.
2726
+ expect(existsSync(userMcp("Code"))).toBe(false);
2727
+ });
2728
+
2729
+ /**
2730
+ * The global write has to be EARNED by a signal about the MACHINE. A
2731
+ * committed `.vscode/` travels with the repository, so on its own it would
2732
+ * have created a per-user VS Code config for every contributor who clones —
2733
+ * VS Code installed or not.
2734
+ */
2735
+ it("a committed .vscode/ and an empty home writes NO per-user file", async () => {
2736
+ const cwd = tmp("pragma-setup-proj-");
2737
+ mkdirSync(join(cwd, ".vscode"), { recursive: true });
2738
+ const outcome = await executeVerb(
2739
+ verbOf("mcp"),
2740
+ { global: true },
2741
+ YES,
2742
+ bootRuntime(FLAGS, cwd),
2743
+ );
2744
+ expect(outcome.exitCode).toBe(0);
2745
+ expect(existsSync(userMcp("Code"))).toBe(false);
2746
+ // The row says there is nothing here, rather than inventing a location.
2747
+ expect(outcome.stdout).toContain("no AI harness on this machine");
2748
+ });
2749
+
2750
+ it("a home with Code/User earns it, `.vscode/` or not", async () => {
2751
+ installVscode();
2752
+ const outcome = await executeVerb(
2753
+ verbOf("mcp"),
2754
+ { global: true },
2755
+ YES,
2756
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2757
+ );
2758
+ expect(outcome.exitCode).toBe(0);
2759
+ const config = JSON.parse(readFileSync(userMcp("Code"), "utf-8"));
2760
+ expect(config.servers?.pragma?.command).toBe("pragma");
2761
+ });
2762
+ });
2763
+
2764
+ /**
2765
+ * A location this command must not write — Nix-managed, or read-only.
2766
+ *
2767
+ * Both used to be a `failed` row carrying a raw fs message and no next step,
2768
+ * on a machine configured exactly as its owner intends. The probe runs BEFORE
2769
+ * anything is composed, so both are named skips whose remedy is the one action
2770
+ * that works there.
2771
+ *
2772
+ * The Nix cases drive the `FsProbe` seam: no CI host has a `/nix/store`, and
2773
+ * the point of the block is that the decision is made by the DIRECTORY, so a
2774
+ * fixture that says "this path resolves into the store" is the whole input.
2775
+ */
2776
+ describe("setup — a Nix-managed or read-only location is a named skip", () => {
2777
+ let prevPath: string | undefined;
2778
+ beforeEach(() => {
2779
+ prevPath = process.env.PATH;
2780
+ process.env.PATH = stubPath();
2781
+ });
2782
+ afterEach(() => {
2783
+ process.env.PATH = prevPath;
2784
+ });
2785
+
2786
+ /** One row's forward draft, straight off the target table. */
2787
+ const draftOf = <D>(
2788
+ id: "mcp" | "lsp",
2789
+ detection: D,
2790
+ scope: Scope,
2791
+ roots: Roots,
2792
+ ) =>
2793
+ (findTarget(id) as unknown as TargetDefinition<D>).plan(
2794
+ detection,
2795
+ scope,
2796
+ roots,
2797
+ );
2798
+
2799
+ it("mcp: a store-managed config file is a row SKIP whose remedy is the entry itself", async () => {
2800
+ const cwd = tmp("pragma-setup-proj-");
2801
+ mkdirSync(join(cwd, ".vscode"), { recursive: true });
2802
+ const xdg = process.env.XDG_CONFIG_HOME as string;
2803
+ // The user-level signal that earns the global band (a committed `.vscode/`
2804
+ // does not), so there IS a per-user file for the probe to block.
2805
+ mkdirSync(join(xdg, "Code", "User"), { recursive: true });
2806
+ const rt = bootRuntime(FLAGS, cwd);
2807
+ const detection = await detectMcp(rt, "global", storeProbe(xdg));
2808
+ const draft = draftOf("mcp", detection, "global", await resolveRoots(rt));
2809
+
2810
+ expect(draft.action).toBe("skip");
2811
+ expect(draft.reason).toContain("managed by Nix");
2812
+ expect(draft.reason).toContain("/nix/store/");
2813
+ // The remedy is the EXACT entry a write would have emitted, through the
2814
+ // group's own serializer — so what the user pastes into their config
2815
+ // classifies as `registered` next time, not as drift to "repair" forever.
2816
+ expect(draft.remedy).toContain('"servers"');
2817
+ expect(draft.remedy).toContain('"pragma"');
2818
+ expect(draft.remedy).toContain('"mcp","serve"');
2819
+ // One line, because the renderer prints one dim line.
2820
+ expect(draft.remedy).not.toContain("\n");
2821
+ // The child says so too, rather than offering an `add` that cannot happen.
2822
+ expect(draft.children?.map((c) => c.action)).toEqual(["skip"]);
2823
+
2824
+ // And nothing is composed: the file is never opened.
2825
+ const { effects } = dryRun(composeMcp(detection, detection.groups));
2826
+ expect(effects).toEqual([]);
2827
+ });
2828
+
2829
+ it("mcp: a blocked TOML config gets its entry as TOML, not as JSON", async () => {
2830
+ // Codex keeps `config.toml`. A JSON body pasted into it is not an entry,
2831
+ // it is a syntax error — and this row's whole job is to say what to write.
2832
+ const cwd = tmp("pragma-setup-proj-");
2833
+ mkdirSync(join(cwd, ".codex"), { recursive: true });
2834
+ const rt = bootRuntime(FLAGS, cwd);
2835
+ const detection = await detectMcp(rt, "project", storeProbe(cwd));
2836
+ const draft = draftOf("mcp", detection, "project", await resolveRoots(rt));
2837
+
2838
+ expect(draft.action).toBe("skip");
2839
+ // TOML's dotted-key inline table: the same entry the writer's
2840
+ // `[mcp_servers.pragma]` table holds, in the one-line form a remedy can
2841
+ // print — and through the same value formatter, so the quoting matches.
2842
+ expect(draft.remedy).toContain("mcp_servers.pragma = {");
2843
+ expect(draft.remedy).toContain('command = "pragma"');
2844
+ expect(draft.remedy).toContain('args = ["mcp", "serve"]');
2845
+ expect(draft.remedy).not.toContain('{"mcp_servers"');
2846
+ expect(draft.remedy).not.toContain("\n");
2847
+ });
2848
+
2849
+ it("mcp: reports no block for a scope whose files are all writable", async () => {
2850
+ // The negative: with nothing blocked there is no reason and no remedy to
2851
+ // print, so the row falls through to its ordinary answer.
2852
+ const cwd = tmp("pragma-setup-proj-");
2853
+ mkdirSync(join(cwd, ".codex"), { recursive: true });
2854
+ const rt = bootRuntime(FLAGS, cwd);
2855
+ const detection = await detectMcp(rt, "project");
2856
+ expect(firstMcpBlock(detection, await resolveRoots(rt))).toBeUndefined();
2857
+ });
2858
+
2859
+ notRoot(
2860
+ "mcp: the blocked row exits 0 through the real verb, with nothing written",
2861
+ async () => {
2862
+ // A read-only directory, for real — the block is a property of the
2863
+ // filesystem, so at least one case has to meet one.
2864
+ const cwd = tmp("pragma-setup-proj-");
2865
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
2866
+ const home = process.env.HOME as string;
2867
+ const cursorDir = join(home, ".cursor");
2868
+ mkdirSync(cursorDir, { recursive: true });
2869
+ chmodSync(cursorDir, 0o500);
2870
+ try {
2871
+ const outcome = await executeVerb(
2872
+ verbOf("mcp"),
2873
+ { global: true },
2874
+ YES,
2875
+ bootRuntime(FLAGS, cwd),
2876
+ );
2877
+ expect(outcome.exitCode).toBe(0);
2878
+ expect(outcome.stdout).toContain("skipped");
2879
+ expect(outcome.stdout).toContain("is not writable");
2880
+ expect(existsSync(join(cursorDir, "mcp.json"))).toBe(false);
2881
+ } finally {
2882
+ chmodSync(cursorDir, 0o700);
2883
+ }
2884
+ },
2885
+ );
2886
+
2887
+ it("lsp: a store-managed extensions folder is a row SKIP naming the VSIX and the snippet", async () => {
2888
+ const stubDir = stubPath();
2889
+ writeFileSync(join(stubDir, "codium"), "");
2890
+ process.env.PATH = stubDir;
2891
+ const home = process.env.HOME as string;
2892
+ const extensions = join(home, ".vscode-oss", "extensions");
2893
+ mkdirSync(extensions, { recursive: true });
2894
+ const rt = bootRuntime(FLAGS, tmp("pragma-setup-proj-"));
2895
+ const detection = await detectLsp(
2896
+ rt.cwd,
2897
+ undefined,
2898
+ storeProbe(extensions),
2899
+ );
2900
+ const draft = draftOf("lsp", detection, "global", await resolveRoots(rt));
2901
+
2902
+ expect(draft.action).toBe("skip");
2903
+ expect(draft.reason).toContain("managed by Nix");
2904
+ // There is no marketplace listing, so the remedy cannot name an id: it
2905
+ // gives a home-manager user both halves — where the file comes from, and
2906
+ // the attribute that installs it.
2907
+ expect(draft.remedy).toContain("buildVscodeExtension");
2908
+ expect(draft.remedy).toContain(lspVsixPath(detection));
2909
+ expect(draft.remedy).not.toContain("\n");
2910
+ // The five attributes `buildVscodeExtension` has no defaults for, and the
2911
+ // quoted path: an unquoted Nix path literal cannot carry the `@` in
2912
+ // `@canonical/…` (it is a parse error), so `src` goes through
2913
+ // `builtins.path`. The `--parse` case below is the proof.
2914
+ for (const attr of [
2915
+ 'name = "terrazzo-lsp-extension"',
2916
+ "version = ",
2917
+ 'vscodeExtPublisher = "canonical"',
2918
+ 'vscodeExtName = "terrazzo-lsp-extension"',
2919
+ 'vscodeExtUniqueId = "canonical.terrazzo-lsp-extension"',
2920
+ "src = builtins.path { path = ",
2921
+ ]) {
2922
+ expect(draft.remedy).toContain(attr);
2923
+ }
2924
+ // And it says which one the user must fill in.
2925
+ expect(draft.remedy).toContain("version from the fetched package.json");
2926
+ // Nothing is composed, and the reversal owns nothing either.
2927
+ expect(dryRun(composeLsp(detection)).effects).toEqual([]);
2928
+ expect(ownedLspEditors(detection)).toEqual([]);
2929
+ });
2930
+
2931
+ withNix("lsp: the Nix remedy's expression really parses as Nix", async () => {
2932
+ // The remedy is a line a user pastes into their own config, so "it looks
2933
+ // like Nix" is not the bar. The sketch this replaced was a parse error
2934
+ // twice over (an unquoted path with an `@` in it, and no `name`), and a
2935
+ // remedy that cannot be used is worse than none. `nix-instantiate
2936
+ // --parse` is the parser itself; where it is not installed the case
2937
+ // stands down and the structural assertions above stand.
2938
+ const stubDir = stubPath();
2939
+ writeFileSync(join(stubDir, "codium"), "");
2940
+ process.env.PATH = stubDir;
2941
+ const extensions = join(
2942
+ process.env.HOME as string,
2943
+ ".vscode-oss",
2944
+ "extensions",
2945
+ );
2946
+ mkdirSync(extensions, { recursive: true });
2947
+ const rt = bootRuntime(FLAGS, tmp("pragma-setup-proj-"));
2948
+ const detection = await detectLsp(
2949
+ rt.cwd,
2950
+ undefined,
2951
+ storeProbe(extensions),
2952
+ );
2953
+ const remedy = draftOf("lsp", detection, "global", await resolveRoots(rt))
2954
+ .remedy as string;
2955
+ // The remedy is prose plus the expression; the expression starts at the
2956
+ // attribute path the user assigns.
2957
+ const expr = remedy.slice(remedy.indexOf("programs.vscode"));
2958
+ // Bound as a function argument so `--parse` is all that is exercised:
2959
+ // `pkgs` is the caller's, and nothing here should be evaluated.
2960
+ const file = join(tmp("pragma-setup-nix-"), "snippet.nix");
2961
+ writeFileSync(file, `{ pkgs }: { ${expr} }\n`);
2962
+ const result = onHost("nix-instantiate", ["--parse", file]);
2963
+ expect(result.stderr).toBe("");
2964
+ expect(result.status).toBe(0);
2965
+ });
2966
+
2967
+ notRoot(
2968
+ "lsp: a read-only extensions folder is a skip the WIZARD omits the editor from",
2969
+ async () => {
2970
+ // End to end over the real filesystem, and the reason the child prompt
2971
+ // filters skips: summon's choice shape has no disabled state, so an
2972
+ // offered editor is one the user can select into doing nothing.
2973
+ const stubDir = stubPath();
2974
+ writeFileSync(join(stubDir, "code"), "");
2975
+ writeFileSync(join(stubDir, "codium"), "");
2976
+ process.env.PATH = stubDir;
2977
+ const home = process.env.HOME as string;
2978
+ const blocked = join(home, ".vscode-oss", "extensions");
2979
+ mkdirSync(blocked, { recursive: true });
2980
+ chmodSync(blocked, 0o500);
2981
+ try {
2982
+ const { plan, generator } = await buildSetupRun(
2983
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
2984
+ "lsp",
2985
+ "global",
2986
+ );
2987
+ const row = plan.rows.find((r) => r.target === "lsp");
2988
+ // VS Code is installable, so the ROW is still an install — only the
2989
+ // one blocked editor is a skip, carrying its own reason.
2990
+ expect(row?.action).toBe("install");
2991
+ const codium = row?.children?.find((c) => c.key === "codium");
2992
+ expect(codium?.action).toBe("skip");
2993
+ expect(codium?.reason).toContain("not writable");
2994
+ const editors = generator.prompts.find(
2995
+ (pr) => pr.name === "lspEditors",
2996
+ );
2997
+ expect(editors?.choices?.map((c) => c.value)).toEqual(["code"]);
2998
+ expect(editors?.default).toEqual(["code"]);
2999
+ } finally {
3000
+ chmodSync(blocked, 0o700);
3001
+ }
3002
+ },
3003
+ );
3004
+ });
3005
+
3006
+ /**
3007
+ * HOW each editor was found — the provenance both surfaces print.
3008
+ *
3009
+ * A machine that reported "no editor found" while having the editor is the
3010
+ * defect these three probes exist for, and "which one found it" is the fact
3011
+ * that tells a user whose run did nothing which of the two happened.
3012
+ */
3013
+ describe("setup lsp — how each editor was found", () => {
3014
+ it("names `via PATH` and the CLI file, on the child row", async () => {
3015
+ const prevPath = process.env.PATH;
3016
+ const stubDir = stubPath();
3017
+ writeFileSync(join(stubDir, "codium"), "");
3018
+ process.env.PATH = stubDir;
3019
+ try {
3020
+ const { plan } = await buildSetupRun(
3021
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
3022
+ "lsp",
3023
+ "global",
3024
+ );
3025
+ const child = plan.rows
3026
+ .find((r) => r.target === "lsp")
3027
+ ?.children?.find((c) => c.key === "codium");
3028
+ expect(child?.label).toContain("via PATH");
3029
+ expect(child?.label).toContain(join(stubDir, "codium"));
3030
+ } finally {
3031
+ process.env.PATH = prevPath;
3032
+ }
3033
+ });
3034
+
3035
+ it("finds a macOS install inside its app bundle, and sideloads THAT path", async () => {
3036
+ // The colleague's machine: VS Code installed, nothing on PATH, because the
3037
+ // palette's "Install 'code' command in PATH" is opt-in. The darwin arm
3038
+ // cannot run on this host, so the platform is injected — which is exactly
3039
+ // what the seam is for.
3040
+ const home = tmp("pragma-darwin-home-");
3041
+ const bundleBin = join(
3042
+ home,
3043
+ "Applications",
3044
+ "Visual Studio Code.app",
3045
+ "Contents",
3046
+ "Resources",
3047
+ "app",
3048
+ "bin",
3049
+ );
3050
+ mkdirSync(bundleBin, { recursive: true });
3051
+ writeFileSync(join(bundleBin, "code"), "");
3052
+ const detection = await detectLsp(tmp("pragma-setup-proj-"), {
3053
+ platform: "darwin",
3054
+ env: {},
3055
+ home,
3056
+ isWsl: false,
3057
+ });
3058
+ expect(detection.editors.map((e) => [e.editor.id, e.foundBy])).toEqual([
3059
+ ["vscode", "bundle"],
3060
+ ]);
3061
+ expect(detection.editors[0]?.block).toBeUndefined();
3062
+ expect(detection.state).toBe("absent");
3063
+ expect(
3064
+ describeEditorSource(detection.editors[0], {
3065
+ global: home,
3066
+ project: "/p",
3067
+ }),
3068
+ ).toBe(
3069
+ "via app bundle (~/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code)",
3070
+ );
3071
+
3072
+ const execs = dryRun(composeLsp(detection)).effects.filter(
3073
+ (e) => e._tag === "Exec",
3074
+ ) as (Effect & { _tag: "Exec"; command: string })[];
3075
+ // The bundle CLI is on no PATH, so the bare name would spawn nothing.
3076
+ expect(execs.map((e) => e.command)).toEqual([
3077
+ "bun",
3078
+ join(bundleBin, "code"),
3079
+ ]);
3080
+ });
3081
+
3082
+ it("an editor found ONLY by its user directory is a skip naming the palette command", async () => {
3083
+ // A fresh install: no CLI anywhere, no extensions folder, just the
3084
+ // configuration directory the editor made on first launch. Nothing can be
3085
+ // installed through a directory, and the remedy is the one command that
3086
+ // changes that.
3087
+ const rt = bootRuntime(FLAGS, tmp("pragma-setup-proj-"));
3088
+ mkdirSync(join(process.env.XDG_CONFIG_HOME as string, "Code", "User"), {
3089
+ recursive: true,
3090
+ });
3091
+ const detection = await detectLsp(rt.cwd);
3092
+ expect(detection.editors.map((e) => [e.editor.id, e.foundBy])).toEqual([
3093
+ ["vscode", "user-dir"],
3094
+ ]);
3095
+ // No BLOCK: nothing is wrong with the folder. The provenance itself is why
3096
+ // nothing can be installed — there is no CLI to run.
3097
+ expect(detection.editors[0]?.block).toBeUndefined();
3098
+ expect(
3099
+ describeEditorSource(detection.editors[0], await resolveRoots(rt)),
3100
+ ).toBe("via user directory");
3101
+
3102
+ const { plan } = await buildSetupRun(rt, "lsp", "global");
3103
+ const row = plan.rows.find((r) => r.target === "lsp");
3104
+ expect(row?.action).toBe("skip");
3105
+ expect(row?.reason).toContain("no command-line launcher");
3106
+ expect(row?.children?.[0]?.action).toBe("skip");
3107
+ // The ROW headline names the editor; the child's own reason does not,
3108
+ // because its label already has — the name once per line, not three times.
3109
+ expect(row?.reason).toBe(
3110
+ "VS Code: no command-line launcher — found only its user directory",
3111
+ );
3112
+ expect(row?.children?.[0]?.reason).toBe(
3113
+ "no command-line launcher — found only its user directory",
3114
+ );
3115
+ expect(row?.children?.[0]?.label).toContain("VS Code");
3116
+ // And the row is not offered: there is nothing to select into doing.
3117
+ expect(row?.selected).toBe(false);
3118
+ });
3119
+
3120
+ it("the palette remedy names the editor's own CLI, and nothing composes", async () => {
3121
+ const rt = bootRuntime(FLAGS, tmp("pragma-setup-proj-"));
3122
+ mkdirSync(join(process.env.XDG_CONFIG_HOME as string, "VSCodium", "User"), {
3123
+ recursive: true,
3124
+ });
3125
+ const detection = await detectLsp(rt.cwd);
3126
+ const remedy = firstLspBlock(detection)?.remedy;
3127
+ expect(remedy).toContain("Install 'codium' command in PATH");
3128
+ expect(remedy).toContain("setup lsp");
3129
+ expect(remedy).not.toContain("\n");
3130
+ expect(dryRun(composeLsp(detection)).effects).toEqual([]);
3131
+ expect(selectedEditors(detection)).toEqual([]);
3132
+ });
3133
+
3134
+ it("reports no block for a machine whose editors are all actionable", async () => {
3135
+ // The negative of every case above: a plain PATH hit is not blocked, so
3136
+ // there is no headline and no remedy to print — and no uninstall command
3137
+ // either, since nothing here carries a copy.
3138
+ const prevPath = process.env.PATH;
3139
+ const stubDir = stubPath();
3140
+ writeFileSync(join(stubDir, "codium"), "");
3141
+ process.env.PATH = stubDir;
3142
+ try {
3143
+ const detection = await detectLsp(tmp("pragma-setup-proj-"));
3144
+ expect(firstLspBlock(detection)).toBeUndefined();
3145
+ expect(lspBlockReason(detection.editors[0])).toBeUndefined();
3146
+ expect(lspUninstallRemedy(detection)).toBeUndefined();
3147
+ } finally {
3148
+ process.env.PATH = prevPath;
3149
+ }
3150
+ });
3151
+ });
3152
+
3153
+ /**
3154
+ * The lsp row's remaining shapes: nothing to do, nothing to undo, and the
3155
+ * read-only block's own remedy.
3156
+ */
3157
+ describe("setup lsp — the row's quiet answers", () => {
3158
+ let prevPath: string | undefined;
3159
+ let stubDir = "";
3160
+ beforeEach(() => {
3161
+ prevPath = process.env.PATH;
3162
+ stubDir = stubPath();
3163
+ process.env.PATH = stubDir;
3164
+ });
3165
+ afterEach(() => {
3166
+ process.env.PATH = prevPath;
3167
+ });
3168
+
3169
+ /** Seed a versioned extension copy under HOME's `dir`. */
3170
+ const seed = (dir: string, version = "1.2.3"): void => {
3171
+ mkdirSync(
3172
+ join(
3173
+ process.env.HOME as string,
3174
+ dir,
3175
+ "extensions",
3176
+ `canonical.terrazzo-lsp-extension-${version}`,
3177
+ ),
3178
+ { recursive: true },
3179
+ );
3180
+ };
3181
+
3182
+ it("plans `no change` when every detected editor already carries it", async () => {
3183
+ writeFileSync(join(stubDir, "code"), "");
3184
+ writeFileSync(join(stubDir, "codium"), "");
3185
+ seed(".vscode");
3186
+ seed(".vscode-oss");
3187
+ const { plan } = await buildSetupRun(
3188
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
3189
+ "lsp",
3190
+ "global",
3191
+ );
3192
+ const row = plan.rows.find((r) => r.target === "lsp");
3193
+ expect(row?.action).toBe("none");
3194
+ expect(row?.detail).toBe("Terrazzo extension in VS Code, VSCodium");
3195
+ expect(row?.children?.every((c) => c.action === "unchanged")).toBe(true);
3196
+ });
3197
+
3198
+ notRoot(
3199
+ "a read-only folder with nothing else installable is a row skip with the chmod remedy",
3200
+ async () => {
3201
+ // One editor, and its extensions folder refuses W_OK — so there is nothing
3202
+ // installable at all and the row's whole answer is the block. The remedy
3203
+ // is the one action that settles it, and it does NOT name the VSIX: the
3204
+ // fetch never ran, so the file it would name is not there.
3205
+ writeFileSync(join(stubDir, "codium"), "");
3206
+ const extensions = join(
3207
+ process.env.HOME as string,
3208
+ ".vscode-oss",
3209
+ "extensions",
3210
+ );
3211
+ mkdirSync(extensions, { recursive: true });
3212
+ chmodSync(extensions, 0o500);
3213
+ try {
3214
+ const run = await buildSetupRun(
3215
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
3216
+ "lsp",
3217
+ "global",
3218
+ );
3219
+ // The remedy rides on the row's OUTCOME — the one dim line the recap
3220
+ // prints beneath a skip — so the assertion reads it where a user sees it.
3221
+ const row = run.applied({}).rows.find((r) => r.target === "lsp");
3222
+ expect(row?.action).toBe("skip");
3223
+ expect(row?.reason).toContain("not writable");
3224
+ expect(row?.outcome?.status).toBe("skipped");
3225
+ const remedy = row?.outcome?.remedy as string;
3226
+ expect(remedy).toBe(
3227
+ `make the extensions folder writable, then run \`${BIN_NAME} setup lsp\` again`,
3228
+ );
3229
+ // Never the VSIX. `composeLsp` fetches nothing when no editor is
3230
+ // installable, so a `--install-extension <vsix>` line would name a file
3231
+ // that is not on this machine.
3232
+ expect(remedy).not.toContain("--install-extension");
3233
+ expect(remedy).not.toContain("terrazzo-lsp.vsix");
3234
+ } finally {
3235
+ chmodSync(extensions, 0o700);
3236
+ }
3237
+ },
3238
+ );
3239
+
3240
+ notRoot(
3241
+ "a removal over a blocked editor is the same skip, not a reversal that cannot run",
3242
+ async () => {
3243
+ // The copy IS there, and the uninstall would have to write into a folder
3244
+ // pragma must not touch — so the row says so rather than composing a
3245
+ // reversal that would fail.
3246
+ writeFileSync(join(stubDir, "codium"), "");
3247
+ seed(".vscode-oss");
3248
+ const extensions = join(
3249
+ process.env.HOME as string,
3250
+ ".vscode-oss",
3251
+ "extensions",
3252
+ );
3253
+ chmodSync(extensions, 0o500);
3254
+ try {
3255
+ const run = await buildSetupRun(
3256
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
3257
+ "lsp",
3258
+ "global",
3259
+ true,
3260
+ );
3261
+ const row = run.applied({}).rows.find((r) => r.target === "lsp");
3262
+ expect(row?.action).toBe("skip");
3263
+ expect(row?.reason).toContain("not writable");
3264
+ expect(row?.outcome?.remedy).toContain(
3265
+ "make the extensions folder writable",
3266
+ );
3267
+ } finally {
3268
+ chmodSync(extensions, 0o700);
3269
+ }
3270
+ },
3271
+ );
3272
+ });