@canonical/pragma-cli 0.32.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/README.md +72 -0
  2. package/dist/pragma +0 -0
  3. package/package.json +19 -15
  4. package/pragma.conf.ts +664 -0
  5. package/src/bin.ts +15 -6
  6. package/src/capabilities/block.parity.test.ts +250 -0
  7. package/src/capabilities/capabilities/capabilities.render.ts +3 -2
  8. package/src/capabilities/capabilities/capabilities.test.ts +10 -1
  9. package/src/capabilities/capabilities/capabilities.verb.ts +32 -14
  10. package/src/capabilities/capabilities/catalog.ts +20 -8
  11. package/src/capabilities/capabilities/hints.ts +21 -36
  12. package/src/capabilities/capabilities/types.ts +2 -0
  13. package/src/capabilities/colophon/collectColophon.ts +34 -19
  14. package/src/capabilities/colophon/colophon.render.test.ts +31 -4
  15. package/src/capabilities/colophon/colophon.render.ts +13 -4
  16. package/src/capabilities/colophon/colophon.test.ts +3 -3
  17. package/src/capabilities/colophon/colophon.verb.ts +11 -4
  18. package/src/capabilities/colophon/markdownTerminal.ts +33 -14
  19. package/src/capabilities/colophon/types.ts +16 -7
  20. package/src/capabilities/config/collectConfigShow.test.ts +57 -0
  21. package/src/capabilities/config/collectConfigShow.ts +41 -4
  22. package/src/capabilities/config/fields.ts +10 -108
  23. package/src/capabilities/config/set.test.ts +6 -6
  24. package/src/capabilities/config/set.verb.ts +12 -8
  25. package/src/capabilities/config/show.render.test.ts +137 -0
  26. package/src/capabilities/config/show.render.ts +57 -16
  27. package/src/capabilities/config/show.verb.ts +9 -8
  28. package/src/capabilities/config/types.ts +8 -0
  29. package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
  30. package/src/capabilities/create/byteEquality.test.ts +82 -101
  31. package/src/capabilities/create/compiledCreate.subprocess.test.ts +53 -16
  32. package/src/capabilities/create/constants.ts +75 -0
  33. package/src/capabilities/create/create.mcp.test.ts +2 -2
  34. package/src/capabilities/create/create.test.ts +192 -16
  35. package/src/capabilities/create/create.verb.ts +208 -79
  36. package/src/capabilities/create/index.ts +2 -10
  37. package/src/capabilities/create/pickGenerator.ts +34 -28
  38. package/src/capabilities/create/templates.embedded.generated.ts +4 -29
  39. package/src/capabilities/create/types.ts +10 -6
  40. package/src/capabilities/{standard/parity.test.ts → distribution.parity.test.ts} +24 -14
  41. package/src/capabilities/distribution.test.ts +120 -0
  42. package/src/capabilities/distribution.ts +59 -0
  43. package/src/capabilities/doctor/checks/checkConfigFile.ts +6 -5
  44. package/src/capabilities/doctor/checks/checkKeStore.ts +2 -2
  45. package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
  46. package/src/capabilities/doctor/checks/checkMcpConfigured.ts +15 -8
  47. package/src/capabilities/doctor/checks/checkPackageRefs.ts +79 -45
  48. package/src/capabilities/doctor/checks/checkPragmaVersion.ts +3 -2
  49. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +61 -11
  50. package/src/capabilities/doctor/checks/checkShellCompletions.ts +63 -23
  51. package/src/capabilities/doctor/checks/checkSkillsSymlinked.ts +3 -2
  52. package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
  53. package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
  54. package/src/capabilities/doctor/doctor.render.test.ts +170 -0
  55. package/src/capabilities/doctor/doctor.render.ts +117 -34
  56. package/src/capabilities/doctor/doctor.test.ts +68 -9
  57. package/src/capabilities/doctor/doctor.verb.ts +6 -2
  58. package/src/capabilities/doctor/runChecks.ts +33 -9
  59. package/src/capabilities/doctor/types.ts +16 -1
  60. package/src/capabilities/emptyRecovery.test.ts +32 -15
  61. package/src/capabilities/graph/inspect.verb.ts +3 -2
  62. package/src/capabilities/graph/query.verb.ts +3 -2
  63. package/src/capabilities/graph/runQuery.ts +8 -5
  64. package/src/capabilities/index.ts +22 -18
  65. package/src/capabilities/info/collectInfo.ts +10 -6
  66. package/src/capabilities/info/info.render.ts +3 -2
  67. package/src/capabilities/info/info.verb.ts +3 -2
  68. package/src/capabilities/lazy.test.ts +93 -2
  69. package/src/capabilities/ontology/index.ts +10 -3
  70. package/src/capabilities/ontology/ontology.test.ts +157 -5
  71. package/src/capabilities/ontology/verbs.ts +203 -104
  72. package/src/capabilities/prompt/prompt.render.test.ts +94 -0
  73. package/src/capabilities/prompt/prompt.render.ts +23 -6
  74. package/src/capabilities/prompt/prompt.test.ts +68 -1
  75. package/src/capabilities/prompt/verbs.ts +24 -11
  76. package/src/capabilities/reference.test.ts +32 -0
  77. package/src/capabilities/resources/provider.ts +58 -25
  78. package/src/capabilities/resources/resources.test.ts +176 -21
  79. package/src/capabilities/scopeTypeSync.test.ts +37 -0
  80. package/src/capabilities/setup/index.ts +7 -1
  81. package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
  82. package/src/capabilities/setup/operations/setupGenerator.ts +166 -63
  83. package/src/capabilities/setup/operations/setupLsp.ts +56 -12
  84. package/src/capabilities/setup/operations/setupMcp.ts +216 -39
  85. package/src/capabilities/setup/operations/setupSkills.ts +10 -4
  86. package/src/capabilities/setup/setup.render.test.ts +109 -0
  87. package/src/capabilities/setup/setup.render.ts +61 -14
  88. package/src/capabilities/setup/setup.test.ts +352 -1
  89. package/src/capabilities/setup/setup.verb.ts +68 -9
  90. package/src/capabilities/setup/shell.ts +29 -3
  91. package/src/capabilities/setup/types.ts +59 -2
  92. package/src/capabilities/shared/bands.ts +16 -0
  93. package/src/capabilities/skill/discover.ts +11 -5
  94. package/src/capabilities/skill/render.ts +4 -5
  95. package/src/capabilities/skill/verbs.ts +7 -7
  96. package/src/capabilities/sources/collectStatus.ts +69 -49
  97. package/src/capabilities/sources/index.ts +1 -1
  98. package/src/capabilities/sources/installSkills.ts +2 -2
  99. package/src/capabilities/sources/runUpdate.ts +146 -98
  100. package/src/capabilities/sources/sources.test.ts +264 -84
  101. package/src/capabilities/sources/status.render.test.ts +94 -0
  102. package/src/capabilities/sources/status.render.ts +55 -33
  103. package/src/capabilities/sources/status.verb.ts +15 -8
  104. package/src/capabilities/sources/types.ts +18 -25
  105. package/src/capabilities/sources/update.render.ts +9 -2
  106. package/src/capabilities/sources/update.verb.ts +12 -23
  107. package/src/capabilities/surface.test.ts +65 -17
  108. package/src/capabilities/tier.parity.test.ts +144 -0
  109. package/src/capabilities/upgrade/upgrade.verb.ts +7 -3
  110. package/src/constants.test.ts +9 -7
  111. package/src/constants.ts +49 -23
  112. package/src/identity.test.ts +333 -0
  113. package/src/index.ts +2 -2
  114. package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +18 -16
  115. package/src/kernel/completion/complete.test.ts +3 -5
  116. package/src/kernel/completion/emitScripts.test.ts +1 -1
  117. package/src/kernel/completion/emitScripts.ts +4 -2
  118. package/src/kernel/completion/entitySource.test.ts +185 -41
  119. package/src/kernel/completion/entitySource.ts +131 -113
  120. package/src/kernel/completion/model.test.ts +3 -3
  121. package/src/kernel/completion/model.ts +11 -1
  122. package/src/kernel/completion/parse.test.ts +2 -4
  123. package/src/kernel/completion/safety.test.ts +82 -36
  124. package/src/kernel/completion/shellDrive.test.ts +592 -0
  125. package/src/kernel/completion/templates/fish.ts +28 -6
  126. package/src/kernel/completion/types.ts +3 -3
  127. package/src/kernel/config/__snapshots__/firstRun.test.ts.snap +1 -1
  128. package/src/kernel/config/defaults.test.ts +85 -0
  129. package/src/kernel/config/defaults.ts +12 -23
  130. package/src/kernel/config/evaluateProjectConfig.ts +2 -3
  131. package/src/kernel/config/findProjectConfig.ts +4 -3
  132. package/src/kernel/config/firstRun.ts +12 -5
  133. package/src/kernel/config/globalConfig.ts +2 -2
  134. package/src/kernel/config/index.ts +4 -5
  135. package/src/kernel/config/paths.ts +17 -17
  136. package/src/kernel/config/readConfig.test.ts +242 -6
  137. package/src/kernel/config/readConfig.ts +8 -8
  138. package/src/kernel/config/schema.test.ts +50 -0
  139. package/src/kernel/config/schema.ts +97 -10
  140. package/src/kernel/config/types.ts +73 -19
  141. package/src/kernel/copy.test.ts +453 -0
  142. package/src/kernel/error/PragmaError.test.ts +5 -5
  143. package/src/kernel/error/constants.ts +3 -2
  144. package/src/kernel/error/index.ts +1 -1
  145. package/src/kernel/error/recovery.test.ts +22 -45
  146. package/src/kernel/error/recovery.ts +22 -27
  147. package/src/kernel/error/renderError.ts +1 -1
  148. package/src/kernel/error/types.ts +5 -4
  149. package/src/kernel/packs/collect.test.ts +195 -11
  150. package/src/kernel/packs/collect.ts +213 -58
  151. package/src/kernel/packs/compile.test.ts +163 -7
  152. package/src/kernel/packs/compile.ts +15 -12
  153. package/src/kernel/packs/disclosure.test.ts +5 -2
  154. package/src/kernel/packs/graphql/fetchGraphqlLookup.ts +11 -10
  155. package/src/kernel/packs/graphqlSource.test.ts +6 -3
  156. package/src/kernel/packs/iri.ts +5 -7
  157. package/src/kernel/packs/renderPack.ts +5 -3
  158. package/src/kernel/packs/resolveEntity.ts +21 -17
  159. package/src/kernel/packs/runBodies.ts +15 -14
  160. package/src/kernel/packs/schema.ts +96 -10
  161. package/src/kernel/packs/sparql/runSelect.test.ts +104 -10
  162. package/src/kernel/packs/sparql/runSelect.ts +73 -34
  163. package/src/kernel/packs/toolDescription.test.ts +10 -2
  164. package/src/kernel/packs/types.ts +39 -4
  165. package/src/kernel/packs/uniqueness.ts +4 -4
  166. package/src/kernel/project/cli/buildProgram.test.ts +60 -11
  167. package/src/kernel/project/cli/buildProgram.ts +11 -0
  168. package/src/kernel/project/cli/dispatch.test.ts +67 -0
  169. package/src/kernel/project/cli/dispatch.ts +34 -12
  170. package/src/kernel/project/cli/globalFlags.test.ts +12 -4
  171. package/src/kernel/project/cli/globalFlags.ts +14 -14
  172. package/src/kernel/project/cli/helpFormat.ts +1 -1
  173. package/src/kernel/project/cli/rootHelp.ts +109 -107
  174. package/src/kernel/project/mcp/buildServer.ts +11 -6
  175. package/src/kernel/project/mcp/instructions.ts +12 -7
  176. package/src/kernel/project/mcp/mcpError.ts +52 -0
  177. package/src/kernel/project/mcp/parity.test.ts +39 -1
  178. package/src/kernel/project/mcp/prompts/prompts.test.ts +128 -5
  179. package/src/kernel/project/mcp/prompts/provider.ts +51 -7
  180. package/src/kernel/project/mcp/prompts/source.ts +68 -67
  181. package/src/kernel/project/mcp/registerVerb.ts +25 -6
  182. package/src/kernel/project/mcp/serve.ts +11 -3
  183. package/src/kernel/project/mcp/toolDescriptions.test.ts +6 -2
  184. package/src/kernel/render/disclosure.ts +4 -2
  185. package/src/kernel/render/formatters.ts +5 -4
  186. package/src/kernel/render/prefixes.test.ts +36 -0
  187. package/src/kernel/render/prefixes.ts +40 -13
  188. package/src/kernel/render/renderers.test.ts +35 -1
  189. package/src/kernel/render/renderers.ts +25 -7
  190. package/src/kernel/render/style.test.ts +31 -0
  191. package/src/kernel/render/style.ts +74 -0
  192. package/src/kernel/runtime/graphpack/build.ts +57 -3
  193. package/src/kernel/runtime/graphpack/buildIndex.test.ts +149 -0
  194. package/src/kernel/runtime/graphpack/buildIndex.ts +173 -50
  195. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +5 -8
  196. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +3 -4
  197. package/src/kernel/runtime/graphpack/embedded/pack.stories.generated.ts +3 -0
  198. package/src/kernel/runtime/graphpack/embedded.ts +37 -11
  199. package/src/kernel/runtime/graphpack/graphpack.test.ts +274 -6
  200. package/src/kernel/runtime/graphpack/hash.ts +3 -2
  201. package/src/kernel/runtime/graphpack/index.ts +1 -1
  202. package/src/kernel/runtime/graphpack/manifest.ts +114 -12
  203. package/src/kernel/runtime/graphpack/read.ts +32 -22
  204. package/src/kernel/runtime/graphpack/schemas.ts +74 -0
  205. package/src/kernel/runtime/graphpack/stories.ts +84 -0
  206. package/src/kernel/runtime/graphpack/types.ts +72 -45
  207. package/src/kernel/runtime/loadSession.ts +4 -12
  208. package/src/kernel/runtime/paths.ts +64 -15
  209. package/src/kernel/runtime/probe.test.ts +8 -4
  210. package/src/kernel/runtime/readEntity.ts +6 -5
  211. package/src/kernel/runtime/refs/gitOps.ts +1 -19
  212. package/src/kernel/runtime/refs/parseRef.ts +7 -7
  213. package/src/kernel/runtime/refs/resolve.ts +141 -65
  214. package/src/kernel/runtime/resolveSources.test.ts +63 -24
  215. package/src/kernel/runtime/resolveSources.ts +36 -25
  216. package/src/kernel/runtime/storeReadiness.ts +62 -0
  217. package/src/kernel/runtime/storeless.test.ts +1 -1
  218. package/src/kernel/runtime/types.ts +1 -1
  219. package/src/kernel/runtime/wasmEmbed.test.ts +25 -20
  220. package/src/kernel/spec/emitReference.test.ts +196 -0
  221. package/src/kernel/spec/emitReference.ts +624 -0
  222. package/src/kernel/spec/emitSurface.ts +13 -7
  223. package/src/kernel/spec/index.ts +2 -0
  224. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  225. package/src/kernel/spec/surfaceConformance.ts +8 -0
  226. package/src/kernel/spec/types.ts +29 -9
  227. package/src/kernel/spec/validate.ts +2 -1
  228. package/src/kernel/vocabulary.test.ts +164 -0
  229. package/src/kernel/vocabulary.ts +146 -0
  230. package/src/testing/PARITY_GAPS.ts +9 -3
  231. package/src/testing/behavioral/README.md +3 -3
  232. package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
  233. package/src/testing/behavioral/completion.test.ts +21 -8
  234. package/src/testing/behavioral/disclosure.test.ts +12 -0
  235. package/src/testing/behavioral/docExamples.test.ts +283 -0
  236. package/src/testing/behavioral/errorMatrix.mcp.test.ts +4 -3
  237. package/src/testing/behavioral/errorMatrix.storeless.test.ts +3 -2
  238. package/src/testing/behavioral/exitCodes.e2e.test.ts +6 -5
  239. package/src/testing/behavioral/harness.foundation.test.ts +14 -5
  240. package/src/testing/behavioral/journeys.cli.test.ts +116 -73
  241. package/src/testing/behavioral/journeys.defaultPack.test.ts +477 -0
  242. package/src/testing/behavioral/journeys.firstInstall.test.ts +167 -0
  243. package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
  244. package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
  245. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +179 -0
  246. package/src/testing/behavioral/journeys.packageStories.test.ts +188 -0
  247. package/src/testing/behavioral/liveReadSurface.ts +4 -3
  248. package/src/testing/behavioral/mcpSurface.test.ts +10 -5
  249. package/src/testing/behavioral/staleCommands.test.ts +86 -0
  250. package/src/testing/eval/__snapshots__/eval.test.ts.snap +9 -9
  251. package/src/testing/eval/cases/assembly.ts +16 -9
  252. package/src/testing/eval/cases/effectful.ts +31 -16
  253. package/src/testing/eval/cases/readNouns.ts +23 -18
  254. package/src/testing/eval/cases/stable.ts +5 -1
  255. package/src/testing/eval/eval.test.ts +7 -1
  256. package/src/testing/fixtures/fixtureCapability.ts +201 -1
  257. package/src/testing/fixtures/graph/canonical.ts +6 -3
  258. package/src/testing/fixtures/graph/defaultPack.ts +179 -0
  259. package/src/testing/fixtures/graph/owlExport.ts +97 -0
  260. package/src/testing/helpers/extractSnippets.ts +29 -0
  261. package/src/testing/helpers/fixtureGraph.ts +33 -16
  262. package/src/testing/helpers/golden.ts +1 -1
  263. package/src/testing/helpers/packRuntime.ts +2 -3
  264. package/src/testing/helpers/projectCli.ts +2 -1
  265. package/src/testing/helpers/projectMcp.ts +6 -0
  266. package/src/testing/perf/budgets.test.ts +4 -0
  267. package/src/testing/perf/budgets.ts +22 -7
  268. package/src/testing/perf/globalSetup.ts +121 -6
  269. package/src/types.ts +2 -2
  270. package/src/capabilities/block/blockList.verb.ts +0 -150
  271. package/src/capabilities/block/index.ts +0 -25
  272. package/src/capabilities/block/pack.ts +0 -155
  273. package/src/capabilities/block/parity.test.ts +0 -180
  274. package/src/capabilities/block/tierChain.ts +0 -78
  275. package/src/capabilities/colophon/pragmaColophon.ts +0 -66
  276. package/src/capabilities/config/field.test.ts +0 -188
  277. package/src/capabilities/modifier/index.ts +0 -15
  278. package/src/capabilities/modifier/pack.ts +0 -64
  279. package/src/capabilities/standard/index.ts +0 -18
  280. package/src/capabilities/standard/pack.ts +0 -128
  281. package/src/capabilities/tier/index.ts +0 -19
  282. package/src/capabilities/tier/lookup.render.ts +0 -34
  283. package/src/capabilities/tier/lookup.test.ts +0 -68
  284. package/src/capabilities/tier/lookup.verb.ts +0 -44
  285. package/src/capabilities/tier/pack.ts +0 -28
  286. package/src/capabilities/tier/runLookup.ts +0 -59
  287. package/src/capabilities/token/addConfig.render.ts +0 -27
  288. package/src/capabilities/token/addConfig.test.ts +0 -62
  289. package/src/capabilities/token/addConfig.verb.ts +0 -40
  290. package/src/capabilities/token/index.ts +0 -19
  291. package/src/capabilities/token/pack.ts +0 -58
  292. package/src/capabilities/token/runAddConfig.ts +0 -95
  293. package/src/kernel/completion/bashDrive.test.ts +0 -73
  294. package/src/kernel/runtime/graphpack/embedded/sample.ttl +0 -39
  295. package/src/kernel/runtime/lock.ts +0 -90
@@ -0,0 +1,32 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+ import { emitReference } from "../kernel/spec/emitReference.js";
5
+ import { capabilities } from "./index.js";
6
+
7
+ /** Read a committed reference page exactly as a consumer (or lychee) would. */
8
+ function readCommitted(relPath: string): string {
9
+ return readFileSync(
10
+ fileURLToPath(new URL(`../../docs/reference/${relPath}`, import.meta.url)),
11
+ "utf-8",
12
+ );
13
+ }
14
+
15
+ describe("reference docs drift-guard — emitReference == committed (PROTECTED)", () => {
16
+ const emitted = emitReference(capabilities);
17
+
18
+ it("every generated page is byte-identical to the committed file", () => {
19
+ for (const [relPath, expected] of emitted) {
20
+ expect(readCommitted(relPath)).toBe(expected);
21
+ }
22
+ });
23
+
24
+ it("the committed file set matches the generated key set (no orphans, none missing)", () => {
25
+ const committed = readdirSync(
26
+ fileURLToPath(new URL("../../docs/reference/", import.meta.url)),
27
+ )
28
+ .filter((name) => name.endsWith(".md"))
29
+ .sort();
30
+ expect(committed).toEqual([...emitted.keys()].sort());
31
+ });
32
+ });
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Listing and autocomplete are STORELESS: they read the enriched `index.json`
5
5
  * (readPackIndex) off disk, so the resource surface costs nothing until an agent
6
- * reads one. A read is STORE-BACKED through the SHARED {@link readEntity} — the
7
- * same reader `graph inspect` uses — so a resource read and a `graph inspect` of
8
- * the same URI return identical content (the mirror contract).
6
+ * reads one. They read it for the pack the BOOT DECISION names, so the listing
7
+ * can never advertise entities whose reads then fail STORE_UNAVAILABLE. A read
8
+ * is STORE-BACKED through the SHARED {@link readEntity} — the same reader
9
+ * `graph inspect` uses — so a resource read and a `graph inspect` of the same
10
+ * URI return identical content (the mirror contract).
9
11
  *
10
12
  * Degradation (Risk4): on a missing or legacy (pre-v2) index the listing returns
11
13
  * a single "run `pragma sources update`" hint — never a live re-index of the
@@ -16,10 +18,14 @@
16
18
  * the `--help`/`__complete` fast path.
17
19
  */
18
20
 
21
+ import { BIN_NAME } from "../../constants.js";
19
22
  import { readPackIndex } from "../../kernel/completion/entitySource.js";
23
+ import { asPragmaError } from "../../kernel/error/fromTaskError.js";
20
24
  import { PragmaError } from "../../kernel/error/PragmaError.js";
25
+ import { mcpErrorFrom } from "../../kernel/project/mcp/mcpError.js";
21
26
  import type { PackIndex } from "../../kernel/runtime/graphpack/types.js";
22
27
  import { readEntity } from "../../kernel/runtime/readEntity.js";
28
+ import { resolveSources } from "../../kernel/runtime/resolveSources.js";
23
29
  import type { McpResourceProvider } from "../../kernel/spec/types.js";
24
30
 
25
31
  /** Max autocomplete suggestions returned for a partial URI. */
@@ -30,13 +36,25 @@ const CLASS_PRIORITY = 0.9;
30
36
  /** MCP annotation priority for an individual (ABox) entry. */
31
37
  const INDIVIDUAL_PRIORITY = 0.3;
32
38
 
33
- /** The `pragma:` URI scheme + the single reserved-expansion template variable. */
39
+ /**
40
+ * The `pragma:` URI scheme + the single reserved-expansion template variable.
41
+ *
42
+ * A LITERAL, and it must stay one until the wire identifiers move together.
43
+ * `surface/surface.v2.json` freezes `pragma:{+uri}` as protocol identity, and
44
+ * `buildResourceList` mints `pragma:<prefixed>` for every entry it lists —
45
+ * three writings of one decision. Deriving only this one made a fork advertise
46
+ * `recipes:{+uri}` over a list of 653 `pragma:` URIs: every resource the server
47
+ * offered became unreadable, and the readable form was never advertised.
48
+ * `resources.test.ts` pins the pair through {@link resourceProvider}'s declared
49
+ * surface; `identity.test.ts` masks this template out of its leak scan for the
50
+ * same reason.
51
+ */
34
52
  const URI_TEMPLATE = "pragma:{+uri}";
35
53
 
36
54
  const TEMPLATE_DESCRIPTION =
37
55
  "Knowledge-graph entities from the local pack. Read any entry by its prefixed " +
38
56
  "URI (e.g. pragma:ds:button); autocomplete matches URI or label substrings. " +
39
- "Content mirrors `pragma graph inspect <uri>`.";
57
+ `Content mirrors \`${BIN_NAME} graph inspect <uri>\`.`;
40
58
 
41
59
  /** Agent-navigability annotations mirrored onto a listed resource. */
42
60
  export interface ResourceAnnotations {
@@ -78,8 +96,7 @@ export function buildResourceList(
78
96
  {
79
97
  uri: "pragma:sources",
80
98
  name: "Store not indexed",
81
- description:
82
- "No enriched entity index. Run `pragma sources update` to build it.",
99
+ description: `No enriched entity index. Run \`${BIN_NAME} sources update\` to build it.`,
83
100
  mimeType: "application/json",
84
101
  },
85
102
  ];
@@ -88,7 +105,18 @@ export function buildResourceList(
88
105
  const boxRank = (e: typeof a) => (e.box === "tbox" ? 0 : 1);
89
106
  return boxRank(a) - boxRank(b) || a.name.localeCompare(b.name);
90
107
  });
91
- return ordered.map((entity) => {
108
+ // Dedup by resource URI: an OWL-punned subject (a class/property IRI ALSO
109
+ // asserted as a domain individual) is indexed as TWO entities — a tbox and an
110
+ // abox facet — that compact to the SAME `pragma:<uri>`. Mapping both would
111
+ // list one URI twice (A8). The sort above puts the tbox facet first, so
112
+ // keeping the first occurrence per URI surfaces the schema facet (its higher
113
+ // priority + instance count) and drops the punned duplicate.
114
+ const seen = new Set<string>();
115
+ const resources: ListedResource[] = [];
116
+ for (const entity of ordered) {
117
+ const uri = `pragma:${entity.prefixed ?? entity.name}`;
118
+ if (seen.has(uri)) continue;
119
+ seen.add(uri);
92
120
  const isTbox = entity.box === "tbox";
93
121
  // Port of the old shell's `_meta` taxonomy: `pragma/box` + a priority that
94
122
  // ranks schema (classes/properties) above individuals, so an agent browsing
@@ -104,8 +132,8 @@ export function buildResourceList(
104
132
  if (typeof instanceCount === "number") {
105
133
  meta["pragma/instanceCount"] = instanceCount;
106
134
  }
107
- return {
108
- uri: `pragma:${entity.prefixed ?? entity.name}`,
135
+ resources.push({
136
+ uri,
109
137
  name: entity.label || entity.name,
110
138
  ...(entity.description ? { description: entity.description } : {}),
111
139
  mimeType: "application/json" as const,
@@ -114,8 +142,9 @@ export function buildResourceList(
114
142
  priority: isTbox ? CLASS_PRIORITY : INDIVIDUAL_PRIORITY,
115
143
  },
116
144
  _meta: meta,
117
- };
118
- });
145
+ });
146
+ }
147
+ return resources;
119
148
  }
120
149
 
121
150
  /**
@@ -180,14 +209,21 @@ export const resourceProvider: McpResourceProvider = {
180
209
  const { ResourceTemplate } = await import(
181
210
  "@modelcontextprotocol/sdk/server/mcp.js"
182
211
  );
212
+ const { McpError, ErrorCode } = await import(
213
+ "@modelcontextprotocol/sdk/types.js"
214
+ );
215
+ // The index of the pack the boot decision names — `undefined` when the
216
+ // store is unavailable, which `buildResourceList` renders as the recovery
217
+ // entry rather than a catalogue of unreadable URIs.
218
+ const activeIndex = async (): Promise<PackIndex | undefined> =>
219
+ readPackIndex(resolveSources(await runtime.loadConfig(), runtime.cwd));
220
+
183
221
  const template = new ResourceTemplate(URI_TEMPLATE, {
184
- list: async () => ({
185
- resources: buildResourceList(readPackIndex(runtime.cwd)),
186
- }),
222
+ list: async () => ({ resources: buildResourceList(await activeIndex()) }),
187
223
  complete: {
188
224
  uri: async (value: string) =>
189
225
  rankUriCompletions(
190
- readPackIndex(runtime.cwd)?.entities ?? [],
226
+ (await activeIndex())?.entities ?? [],
191
227
  value,
192
228
  COMPLETION_LIMIT,
193
229
  ),
@@ -215,15 +251,12 @@ export const resourceProvider: McpResourceProvider = {
215
251
  ],
216
252
  };
217
253
  } catch (error) {
218
- const message =
219
- error instanceof PragmaError
220
- ? JSON.stringify({ code: error.code, message: error.message })
221
- : String(error);
222
- return {
223
- contents: [
224
- { uri: url.href, mimeType: "text/plain", text: message },
225
- ],
226
- };
254
+ // Surface the failure as a JSON-RPC error (the resource-read analogue
255
+ // of a tool result's `isError`), preserving the machine code AND the
256
+ // recovery in `data` — never swallow it into `text/plain` content an
257
+ // agent reads as a successful entity (which dropped the recovery and
258
+ // masked a cold store as though the entity itself were malformed).
259
+ throw mcpErrorFrom(asPragmaError(error), { McpError, ErrorCode });
227
260
  }
228
261
  },
229
262
  );
@@ -7,6 +7,10 @@
7
7
  * degrades to a `pragma sources update` hint (never a live re-index).
8
8
  */
9
9
 
10
+ import { mkdtempSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
10
14
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
11
15
  import { readPackIndex } from "../../kernel/completion/entitySource.js";
12
16
  import { verbKey } from "../../kernel/packs/uniqueness.js";
@@ -17,7 +21,35 @@ import type { VerbSpec } from "../../kernel/spec/types.js";
17
21
  import { TEST_FLAGS } from "../../testing/helpers/projectCli.js";
18
22
  import { projectMcp } from "../../testing/helpers/projectMcp.js";
19
23
  import { graphModule } from "../graph/index.js";
20
- import { buildResourceList, rankUriCompletions } from "./provider.js";
24
+ import {
25
+ buildResourceList,
26
+ rankUriCompletions,
27
+ resourceProvider,
28
+ } from "./provider.js";
29
+
30
+ /**
31
+ * Two entities of the committed embedded pack, chosen for what they PROVE: an
32
+ * abox individual and the tbox class it instantiates, so the schema-before-
33
+ * individual ordering has both sides. Named, not counted — the roster moves
34
+ * whenever the design system does.
35
+ */
36
+ const BUTTON_NAME = "ds:global.component.button";
37
+ const BUTTON_URI = `pragma:${BUTTON_NAME}`;
38
+ const COMPONENT_URI = "pragma:ds:Component";
39
+
40
+ /**
41
+ * Blank out blank-node labels.
42
+ *
43
+ * Oxigraph re-mints blank-node identifiers every time it loads a store, so two
44
+ * independently booted sessions over the SAME pack name the same blank node
45
+ * differently. Those labels are store-local handles, not content — and this
46
+ * test's subject is that both surfaces return the same CONTENT for the same
47
+ * entity. (That the labels are not stable across processes is a real property
48
+ * of exposing blank nodes through `graph inspect`; the toy pack this suite used
49
+ * to run against simply had none.)
50
+ */
51
+ const withoutBlankNodeLabels = (value: InspectResult): unknown =>
52
+ JSON.parse(JSON.stringify(value).replace(/_:[0-9a-f]+/g, "_:b"));
21
53
 
22
54
  describe("resource listing (storeless, over the pack index)", () => {
23
55
  it("degrades to a recovery entry on a missing or legacy index", () => {
@@ -32,37 +64,99 @@ describe("resource listing (storeless, over the pack index)", () => {
32
64
  });
33
65
 
34
66
  it("lists the enriched (v2) embedded index, schema entries first", () => {
35
- const index = readPackIndex(process.cwd());
67
+ const index = readPackIndex({ kind: "embedded" });
36
68
  expect(index?.version).toBe(2);
37
69
  const resources = buildResourceList(index);
38
- expect(resources.some((r) => r.uri === "pragma:ex:Button")).toBe(true);
70
+ expect(resources.some((r) => r.uri === BUTTON_URI)).toBe(true);
39
71
  // A class (tbox) sorts before an individual (abox).
40
- const componentIdx = resources.findIndex(
41
- (r) => r.uri === "pragma:ex:Component",
42
- );
43
- const buttonIdx = resources.findIndex((r) => r.uri === "pragma:ex:Button");
72
+ const componentIdx = resources.findIndex((r) => r.uri === COMPONENT_URI);
73
+ const buttonIdx = resources.findIndex((r) => r.uri === BUTTON_URI);
44
74
  expect(componentIdx).toBeLessThan(buttonIdx);
45
75
  });
46
76
 
77
+ it("emits ONE resource per URI for an OWL-punned subject (A8)", () => {
78
+ // A punned subject (a class IRI also asserted as an individual) is indexed
79
+ // as TWO facets — tbox + abox — sharing one prefixed URI. The listing must
80
+ // dedup them so the MCP resource surface carries no duplicate URI. The abox
81
+ // facet is listed first here to prove the dedup relies on the schema-first
82
+ // SORT, not input order.
83
+ const punned: PackIndex = {
84
+ version: 2,
85
+ contentHash: "test",
86
+ prefixes: {},
87
+ entities: [
88
+ {
89
+ name: "ex:Slider",
90
+ type: "ex:Category",
91
+ uri: "https://ex.test/#Slider",
92
+ prefixed: "ex:Slider",
93
+ types: ["owl:Class", "ex:Category"],
94
+ label: "Slider",
95
+ box: "abox",
96
+ description: null,
97
+ },
98
+ {
99
+ name: "ex:Slider",
100
+ type: "owl:Class",
101
+ uri: "https://ex.test/#Slider",
102
+ prefixed: "ex:Slider",
103
+ types: ["owl:Class", "ex:Category"],
104
+ label: "Slider",
105
+ box: "tbox",
106
+ description: null,
107
+ },
108
+ ],
109
+ instanceCountByType: {},
110
+ };
111
+ const slider = buildResourceList(punned).filter(
112
+ (r) => r.uri === "pragma:ex:Slider",
113
+ );
114
+ expect(slider).toHaveLength(1);
115
+ // The retained facet is the schema (tbox) one — surfaced above individuals.
116
+ expect(slider.at(0)?._meta?.["pragma/box"]).toBe("tbox");
117
+ });
118
+
47
119
  it("enriches each entry with the _meta taxonomy (pragma/box + priority)", () => {
48
- const resources = buildResourceList(readPackIndex(process.cwd()));
49
- const component = resources.find((r) => r.uri === "pragma:ex:Component");
50
- const button = resources.find((r) => r.uri === "pragma:ex:Button");
120
+ const resources = buildResourceList(readPackIndex({ kind: "embedded" }));
121
+ const component = resources.find((r) => r.uri === COMPONENT_URI);
122
+ const button = resources.find((r) => r.uri === BUTTON_URI);
51
123
  // A schema class: tbox, higher priority, carries its instance count.
52
124
  expect(component?._meta?.["pragma/box"]).toBe("tbox");
53
125
  expect(component?.annotations?.audience).toEqual(["assistant"]);
54
126
  expect(component?.annotations?.priority).toBe(0.9);
55
- expect(component?._meta?.["pragma/instanceCount"]).toBe(3);
127
+ expect(component?._meta?.["pragma/instanceCount"]).toBeGreaterThan(0);
56
128
  // An individual: abox, lower priority, no instance count.
57
129
  expect(button?._meta?.["pragma/box"]).toBe("abox");
58
130
  expect(button?.annotations?.priority).toBe(0.3);
59
131
  expect(button?._meta?.["pragma/instanceCount"]).toBeUndefined();
60
132
  });
61
133
 
134
+ it("mints every URI in the scheme the declared template routes", () => {
135
+ // Two writings of one decision: the template `register` installs (and the
136
+ // covenant publishes as `mcpSurface.resources`) and the scheme every listed
137
+ // URI carries. Nothing compared them, and a half-derivation left a fork
138
+ // advertising `recipes:{+uri}` over a list of `pragma:` URIs — every
139
+ // resource offered was unreadable and the readable form was unadvertised.
140
+ // DERIVED from the declared surface, so this cannot be satisfied by two
141
+ // literals that agree today.
142
+ const declared = resourceProvider.surface?.templates?.[0];
143
+ const scheme = `${declared?.split(":")[0]}:`;
144
+ const listed = [
145
+ ...buildResourceList(readPackIndex({ kind: "embedded" })),
146
+ ...buildResourceList(undefined),
147
+ ];
148
+ expect(listed.length).toBeGreaterThan(1);
149
+ expect(listed.filter((r) => !r.uri.startsWith(scheme))).toEqual([]);
150
+ });
151
+
62
152
  it("ranks autocomplete over prefixed URI and label", () => {
63
- const index = readPackIndex(process.cwd());
64
- const hits = rankUriCompletions(index?.entities ?? [], "but", 10);
65
- expect(hits).toContain("ex:Button");
153
+ const index = readPackIndex({ kind: "embedded" });
154
+ const hits = rankUriCompletions(
155
+ index?.entities ?? [],
156
+ "global.component.button",
157
+ 10,
158
+ );
159
+ expect(hits).toContain(BUTTON_NAME);
66
160
  });
67
161
  });
68
162
 
@@ -77,7 +171,7 @@ describe("resource surface over the server (embedded pack)", () => {
77
171
 
78
172
  it("registers the {+uri} template and lists resources (not tools)", async () => {
79
173
  const resources = await harness.listResources();
80
- expect(resources.some((r) => r.uri === "pragma:ex:Button")).toBe(true);
174
+ expect(resources.some((r) => r.uri === BUTTON_URI)).toBe(true);
81
175
  // Resources are NOT tools — the graph module's tools (inspect + the PR6
82
176
  // SPARQL escape hatch) are what appear in the tool surface.
83
177
  const tools = await harness.listTools();
@@ -88,12 +182,12 @@ describe("resource surface over the server (embedded pack)", () => {
88
182
  });
89
183
 
90
184
  it("autocompletes a partial URI through the template", async () => {
91
- const values = await harness.completeResource("But");
92
- expect(values).toContain("ex:Button");
185
+ const values = await harness.completeResource("global.component.butt");
186
+ expect(values).toContain(BUTTON_NAME);
93
187
  });
94
188
 
95
189
  it("reads an entity, mirroring `graph inspect` content", async () => {
96
- const read = await harness.readResource("pragma:ex:Button");
190
+ const read = await harness.readResource(BUTTON_URI);
97
191
  expect(read.mimeType).toBe("application/json");
98
192
  const fromResource = JSON.parse(read.text) as InspectResult;
99
193
 
@@ -103,12 +197,73 @@ describe("resource surface over the server (embedded pack)", () => {
103
197
  (v) => verbKey(v.path) === "graph inspect",
104
198
  ) as VerbSpec;
105
199
  const fromCli = (await inspect.run(
106
- { uri: "ex:Button" },
200
+ { uri: BUTTON_NAME },
107
201
  rt,
108
202
  )) as InspectResult;
109
203
  (await rt.store.get()).store.dispose();
110
204
 
111
- expect(fromResource).toEqual(fromCli);
112
- expect(fromResource.uri).toBe("https://pragma.canonical.com/sample#Button");
205
+ expect(withoutBlankNodeLabels(fromResource)).toEqual(
206
+ withoutBlankNodeLabels(fromCli),
207
+ );
208
+ expect(fromResource.uri).toBe(
209
+ "https://ds.canonical.com/global.component.button",
210
+ );
211
+ });
212
+
213
+ it("rejects a read of an absent entity as InvalidParams carrying ENTITY_NOT_FOUND", async () => {
214
+ // The warm embedded pack resolves the `ds:` prefix but holds no `Nonexistent`
215
+ // subject, so the read fails ENTITY_NOT_FOUND — the caller's fault, which
216
+ // `mcpErrorFrom` maps to JSON-RPC InvalidParams, NOT the InternalError a cold
217
+ // store (STORE_UNAVAILABLE) earns.
218
+ const error = await harness.readResource("pragma:ds:Nonexistent").then(
219
+ () => undefined,
220
+ (caught: unknown) => caught,
221
+ );
222
+ expect(error, "the read must throw, not swallow to content").toBeDefined();
223
+ expect((error as Error).message).toMatch(/not found/i);
224
+ const failure = error as { code?: number; data?: { code?: string } };
225
+ expect(failure.data?.code).toBe("ENTITY_NOT_FOUND");
226
+ expect(failure.code).toBe(ErrorCode.InvalidParams);
227
+ expect(failure.code).not.toBe(ErrorCode.InternalError);
228
+ });
229
+ });
230
+
231
+ /**
232
+ * D3 — a resource-read failure surfaces as a JSON-RPC error (the read analogue of
233
+ * a tool result's `isError`) carrying the recovery, NOT swallowed into a
234
+ * `text/plain` "success" body that drops the recovery and reads as though the
235
+ * entity itself were malformed. A project that declares its own packs and has
236
+ * never built them is the cold store — it must NOT be served the distribution's
237
+ * embedded graph; the read boots the store, so it refuses with
238
+ * STORE_UNAVAILABLE.
239
+ */
240
+ describe("resource read — cold-store failure surfaces isError + recovery (D3)", () => {
241
+ let harness: Awaited<ReturnType<typeof projectMcp>>;
242
+ beforeAll(async () => {
243
+ const cwd = mkdtempSync(join(tmpdir(), "pragma-resource-cold-"));
244
+ writeFileSync(
245
+ join(cwd, "pragma.config.ts"),
246
+ `export default { packs: [{ name: "unbuilt", source: "file:///pragma-never-built" }] };\n`,
247
+ );
248
+ harness = await projectMcp([graphModule], cwd);
249
+ });
250
+ afterAll(async () => {
251
+ await harness.cleanup();
252
+ });
253
+
254
+ it("rejects (does not return text/plain content) with the STORE_UNAVAILABLE recovery", async () => {
255
+ const error = await harness.readResource(BUTTON_URI).then(
256
+ () => undefined,
257
+ (caught: unknown) => caught,
258
+ );
259
+ expect(error, "the read must throw, not swallow to content").toBeDefined();
260
+ expect((error as Error).message).toMatch(/store unavailable/i);
261
+ const data = (
262
+ error as {
263
+ data?: { code?: string; recovery?: { mcp?: { tool?: string } } };
264
+ }
265
+ ).data;
266
+ expect(data?.code).toBe("STORE_UNAVAILABLE");
267
+ expect(data?.recovery?.mcp?.tool).toBe("sources_update");
113
268
  });
114
269
  });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Type-sync pin (PROTECTED): the CLI's redeclared `ScopeBand`/`ScopeSelection`
3
+ * mirrors must stay structurally EQUAL to the `@canonical/harnesses` types.
4
+ *
5
+ * The CLI redeclares these in `doctor/types.ts` and `setup/types.ts` on purpose
6
+ * — so those statically-reachable type modules never pull the harnesses runtime
7
+ * onto the fast-path module graph (the lazy-dispatch invariant). That freedom is
8
+ * only safe if the mirrors do not DRIFT from the source of truth, which this
9
+ * `expectTypeOf` pin enforces at `tsc` time.
10
+ *
11
+ * Every harnesses import here is `import type` (type-only, erased at emit), so
12
+ * this file loads no harnesses runtime — the `lazy.test` no-runtime-import guard
13
+ * stays green (it also never reaches this test file: it walks the graph from
14
+ * `capabilities/index`, which imports no test).
15
+ */
16
+
17
+ import type {
18
+ ScopeBand as HarnessScopeBand,
19
+ ScopeSelection as HarnessScopeSelection,
20
+ } from "@canonical/harnesses";
21
+ import { describe, expectTypeOf, it } from "vitest";
22
+ import type { ScopeBand as DoctorScopeBand } from "./doctor/types.js";
23
+ import type {
24
+ ScopeBand as SetupScopeBand,
25
+ ScopeSelection as SetupScopeSelection,
26
+ } from "./setup/types.js";
27
+
28
+ describe("scope type-sync — CLI mirrors == @canonical/harnesses", () => {
29
+ it("both ScopeBand mirrors equal the harnesses ScopeBand", () => {
30
+ expectTypeOf<DoctorScopeBand>().toEqualTypeOf<HarnessScopeBand>();
31
+ expectTypeOf<SetupScopeBand>().toEqualTypeOf<HarnessScopeBand>();
32
+ });
33
+
34
+ it("the setup ScopeSelection mirror equals the harnesses ScopeSelection", () => {
35
+ expectTypeOf<SetupScopeSelection>().toEqualTypeOf<HarnessScopeSelection>();
36
+ });
37
+ });
@@ -3,4 +3,10 @@
3
3
  */
4
4
 
5
5
  export { setupModule } from "./setup.verb.js";
6
- export type { SetupResult, SetupSkillsResult, SymlinkAction } from "./types.js";
6
+ export type {
7
+ ConfiguredTarget,
8
+ ScopeSelection,
9
+ SetupResult,
10
+ SetupSkillsResult,
11
+ SymlinkAction,
12
+ } from "./types.js";
@@ -10,6 +10,7 @@
10
10
  * `setup completions`").
11
11
  */
12
12
 
13
+ import { existsSync, readFileSync } from "node:fs";
13
14
  import { dirname } from "node:path";
14
15
  import {
15
16
  deleteFile,
@@ -26,15 +27,39 @@ import {
26
27
  detectShell,
27
28
  type ShellId,
28
29
  } from "../shell.js";
30
+ import type { CompletionsState } from "../types.js";
29
31
 
30
32
  /**
31
33
  * The detected completion install target: the shell, the absolute script path,
32
- * and the emitted script body — all `null` when no shell could be detected.
34
+ * the emitted script body — all `null` when no shell could be detected — and the
35
+ * prior on-disk {@link CompletionsState} (whether an up-to-date/stale script is
36
+ * already installed), read up front so the recap and a `--dry-run` are accurate
37
+ * and an identical rewrite is skipped.
33
38
  */
34
39
  export interface CompletionsDetection {
35
40
  readonly shell: ShellId | null;
36
41
  readonly path: string | null;
37
42
  readonly script: string | null;
43
+ readonly state: CompletionsState;
44
+ }
45
+
46
+ /**
47
+ * Classify the completion script already at `path` against the body we would
48
+ * write: `installed` when byte-identical (a re-run skips it), `stale` when a
49
+ * different script is present, `absent` when no file exists.
50
+ *
51
+ * @param path - The install path.
52
+ * @param script - The body a write would emit.
53
+ * @returns The prior {@link CompletionsState}.
54
+ * @note Impure — reads the install path.
55
+ */
56
+ function classifyCompletions(path: string, script: string): CompletionsState {
57
+ if (!existsSync(path)) return "absent";
58
+ try {
59
+ return readFileSync(path, "utf8") === script ? "installed" : "stale";
60
+ } catch {
61
+ return "stale";
62
+ }
38
63
  }
39
64
 
40
65
  /**
@@ -52,7 +77,7 @@ export async function detectCompletions(
52
77
  cwd: string,
53
78
  ): Promise<CompletionsDetection> {
54
79
  const shell = detectShell();
55
- if (!shell) return { shell: null, path: null, script: null };
80
+ if (!shell) return { shell: null, path: null, script: null, state: "absent" };
56
81
 
57
82
  const [{ capabilities }, { emitScripts }, { readConfig }] = await Promise.all(
58
83
  [
@@ -68,18 +93,16 @@ export async function detectCompletions(
68
93
  .filter(([, enabled]) => enabled === false)
69
94
  .map(([family]) => family)
70
95
  : undefined;
71
- return {
72
- shell,
73
- path: completionScriptPath(shell),
74
- script: emitScripts(capabilities, {
75
- ...(completion?.minChars !== undefined
76
- ? { minChars: completion.minChars }
77
- : {}),
78
- ...(disabledFamilies && disabledFamilies.length > 0
79
- ? { disabledFamilies }
80
- : {}),
81
- })[shell],
82
- };
96
+ const path = completionScriptPath(shell);
97
+ const script = emitScripts(capabilities, {
98
+ ...(completion?.minChars !== undefined
99
+ ? { minChars: completion.minChars }
100
+ : {}),
101
+ ...(disabledFamilies && disabledFamilies.length > 0
102
+ ? { disabledFamilies }
103
+ : {}),
104
+ })[shell];
105
+ return { shell, path, script, state: classifyCompletions(path, script) };
83
106
  }
84
107
 
85
108
  /**
@@ -89,6 +112,12 @@ export async function detectCompletions(
89
112
  * interprets a generator's task TWICE — once for the confirm-gate preview and
90
113
  * once to perform it — so the composed task must survive a second drive.
91
114
  *
115
+ * The prior `state` drives the MESSAGE only ("already up to date" / "Updating" /
116
+ * "Installing"), NOT whether the write happens: the (idempotent) write is always
117
+ * composed so it carries its `undo` — `--undo` reverses the SAME task and must
118
+ * find the reversible effect, and `execute` interprets the task twice. A
119
+ * byte-identical rewrite in the `installed` case is harmless.
120
+ *
92
121
  * @param d - The detection gathered up front.
93
122
  * @returns A Task that writes the script (with an undo), or warns if no shell.
94
123
  */
@@ -98,9 +127,13 @@ export function composeCompletions(d: CompletionsDetection): Task<void> {
98
127
  "Could not detect your shell — set $SHELL to zsh, bash, or fish.",
99
128
  );
100
129
  }
101
- const { path, script, shell } = d;
130
+ const { path, script, shell, state } = d;
131
+ const opening =
132
+ state === "installed"
133
+ ? `${shell} completions already up to date at ${path}.`
134
+ : `${state === "stale" ? "Updating" : "Installing"} ${shell} completions to ${path}...`;
102
135
  return sequence_([
103
- info(`Installing ${shell} completions to ${path}...`),
136
+ info(opening),
104
137
  mkdir(dirname(path), true),
105
138
  writeFile(path, script, { undo: deleteFile(path) }),
106
139
  info(activationHint(shell)),