@canonical/pragma-cli 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. package/README.md +2 -2
  2. package/bundled-skills/add-standard/SKILL.md +338 -0
  3. package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
  4. package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
  5. package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
  6. package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
  7. package/bundled-skills/anatomy-author/SKILL.md +1226 -0
  8. package/bundled-skills/design-auditor/SKILL.md +455 -0
  9. package/bundled-skills/implement-component/SKILL.md +333 -0
  10. package/bundled-skills/specify-component/SKILL.md +331 -0
  11. package/bundled-skills/specify-pattern/SKILL.md +292 -0
  12. package/dist/package.json +24 -20
  13. package/dist/pragma.conf.js +266 -34
  14. package/dist/pragma.conf.js.map +1 -1
  15. package/dist/src/bin.js +1 -1
  16. package/dist/src/bin.js.map +1 -1
  17. package/dist/src/capabilities/capabilities/hints.js +1 -1
  18. package/dist/src/capabilities/capabilities/hints.js.map +1 -1
  19. package/dist/src/capabilities/colophon/colophon.render.js +1 -1
  20. package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
  21. package/dist/src/capabilities/create/create.verb.js +2 -1
  22. package/dist/src/capabilities/create/create.verb.js.map +1 -1
  23. package/dist/src/capabilities/create/createSurface.generated.js +23 -1
  24. package/dist/src/capabilities/create/createSurface.generated.js.map +1 -1
  25. package/dist/src/capabilities/create/mount.js +4 -3
  26. package/dist/src/capabilities/create/mount.js.map +1 -1
  27. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +11 -6
  28. package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -1
  29. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
  30. package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
  31. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
  32. package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
  33. package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
  34. package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
  35. package/dist/src/capabilities/doctor/checks/index.js +6 -6
  36. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js +38 -0
  37. package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
  38. package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
  39. package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
  40. package/dist/src/capabilities/doctor/doctor.render.js +43 -64
  41. package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
  42. package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
  43. package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
  44. package/dist/src/capabilities/doctor/runChecks.js +14 -11
  45. package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
  46. package/dist/src/capabilities/graph/query.render.js +2 -2
  47. package/dist/src/capabilities/graph/query.render.js.map +1 -1
  48. package/dist/src/capabilities/info/collectInfo.js +8 -2
  49. package/dist/src/capabilities/info/collectInfo.js.map +1 -1
  50. package/dist/src/capabilities/info/info.render.js +6 -2
  51. package/dist/src/capabilities/info/info.render.js.map +1 -1
  52. package/dist/src/capabilities/prompt/prompt.render.js +2 -2
  53. package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
  54. package/dist/src/capabilities/setup/buildPlan.js +43 -39
  55. package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
  56. package/dist/src/capabilities/setup/mode.js +2 -2
  57. package/dist/src/capabilities/setup/operations/index.js +4 -4
  58. package/dist/src/capabilities/setup/operations/index.js.map +1 -1
  59. package/dist/src/capabilities/setup/operations/setupCompletions.js +6 -2
  60. package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
  61. package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
  62. package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
  63. package/dist/src/capabilities/setup/operations/setupGenerator.js +156 -58
  64. package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
  65. package/dist/src/capabilities/setup/operations/setupLsp.js +136 -38
  66. package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
  67. package/dist/src/capabilities/setup/operations/setupMcp.js +101 -52
  68. package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
  69. package/dist/src/capabilities/setup/operations/setupSkills.js +329 -49
  70. package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
  71. package/dist/src/capabilities/setup/plan.js.map +1 -1
  72. package/dist/src/capabilities/setup/plan.render.js +144 -66
  73. package/dist/src/capabilities/setup/plan.render.js.map +1 -1
  74. package/dist/src/capabilities/setup/setup.render.js +1 -1
  75. package/dist/src/capabilities/setup/setup.verb.js +165 -63
  76. package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
  77. package/dist/src/capabilities/setup/targets.js +122 -51
  78. package/dist/src/capabilities/setup/targets.js.map +1 -1
  79. package/dist/src/capabilities/shared/index.js +7 -6
  80. package/dist/src/capabilities/shared/index.js.map +1 -1
  81. package/dist/src/capabilities/shared/packageManager.js +300 -54
  82. package/dist/src/capabilities/shared/packageManager.js.map +1 -1
  83. package/dist/src/capabilities/skill/discover.js +97 -11
  84. package/dist/src/capabilities/skill/discover.js.map +1 -1
  85. package/dist/src/capabilities/skill/render.js +2 -2
  86. package/dist/src/capabilities/skill/render.js.map +1 -1
  87. package/dist/src/capabilities/sources/installSkills.js +145 -3
  88. package/dist/src/capabilities/sources/installSkills.js.map +1 -1
  89. package/dist/src/capabilities/sources/runUpdate.js +68 -2
  90. package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
  91. package/dist/src/capabilities/sources/update.verb.js +3 -3
  92. package/dist/src/capabilities/sources/update.verb.js.map +1 -1
  93. package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
  94. package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
  95. package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
  96. package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
  97. package/dist/src/constants.js +13 -2
  98. package/dist/src/constants.js.map +1 -1
  99. package/dist/src/kernel/index.js +11 -2
  100. package/dist/src/kernel/index.js.map +1 -1
  101. package/dist/src/kernel/interactivity.js +68 -0
  102. package/dist/src/kernel/interactivity.js.map +1 -0
  103. package/dist/src/kernel/packs/renderPack.js +3 -3
  104. package/dist/src/kernel/packs/renderPack.js.map +1 -1
  105. package/dist/src/kernel/packs/resolveEntity.js +63 -12
  106. package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
  107. package/dist/src/kernel/packs/runBodies.js +57 -4
  108. package/dist/src/kernel/packs/runBodies.js.map +1 -1
  109. package/dist/src/kernel/packs/schema.js +54 -2
  110. package/dist/src/kernel/packs/schema.js.map +1 -1
  111. package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
  112. package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
  113. package/dist/src/kernel/packs/sparql/buildLookupQuery.js +310 -43
  114. package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
  115. package/dist/src/kernel/packs/types.js.map +1 -1
  116. package/dist/src/kernel/project/cli/buildProgram.js +1 -1
  117. package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
  118. package/dist/src/kernel/project/cli/dispatch.js +46 -24
  119. package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
  120. package/dist/src/kernel/project/cli/globalFlags.js +4 -1
  121. package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
  122. package/dist/src/kernel/project/cli/helpFormat.js +1 -2
  123. package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
  124. package/dist/src/kernel/project/cli/rootHelp.js +42 -9
  125. package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
  126. package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
  127. package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
  128. package/dist/src/kernel/render/renderers.js +1 -1
  129. package/dist/src/kernel/render/style.js +29 -9
  130. package/dist/src/kernel/render/style.js.map +1 -1
  131. package/dist/src/kernel/render/vocabulary.js +174 -0
  132. package/dist/src/kernel/render/vocabulary.js.map +1 -0
  133. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +2 -3
  134. package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
  135. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
  136. package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
  137. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
  138. package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
  139. package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
  140. package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
  141. package/dist/src/kernel/runtime/graphpack/read.js +14 -9
  142. package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
  143. package/dist/src/kernel/runtime/loadSession.js +3 -1
  144. package/dist/src/kernel/runtime/loadSession.js.map +1 -1
  145. package/dist/src/kernel/runtime/probe.js +1 -1
  146. package/dist/src/kernel/runtime/probe.js.map +1 -1
  147. package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
  148. package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
  149. package/dist/src/kernel/runtime/refs/resolve.js +9 -3
  150. package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
  151. package/dist/src/kernel/spec/emitReference.js +1 -1
  152. package/dist/src/kernel/spec/emitReference.js.map +1 -1
  153. package/package.json +24 -20
  154. package/pragma.conf.ts +272 -34
  155. package/src/bin.ts +8 -1
  156. package/src/capabilities/block.parity.test.ts +25 -11
  157. package/src/capabilities/block.shipped.exec.test.ts +282 -0
  158. package/src/capabilities/block.tierRank.exec.test.ts +300 -0
  159. package/src/capabilities/capabilities/hints.ts +1 -1
  160. package/src/capabilities/colophon/colophon.render.ts +1 -2
  161. package/src/capabilities/colophon/colophon.test.ts +2 -2
  162. package/src/capabilities/config/collectConfigShow.test.ts +1 -0
  163. package/src/capabilities/create/byteEquality.test.ts +28 -1
  164. package/src/capabilities/create/create.test.ts +1 -0
  165. package/src/capabilities/create/create.verb.ts +2 -1
  166. package/src/capabilities/create/createGrammar.test.ts +5 -2
  167. package/src/capabilities/create/createSurface.generated.ts +23 -1
  168. package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
  169. package/src/capabilities/create/mount.ts +3 -3
  170. package/src/capabilities/distribution.parity.test.ts +4 -0
  171. package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
  172. package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
  173. package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
  174. package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
  175. package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
  176. package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
  177. package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
  178. package/src/capabilities/doctor/checks/index.ts +6 -6
  179. package/src/capabilities/doctor/checks/isSupportedNodeVersion.test.ts +111 -0
  180. package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
  181. package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
  182. package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
  183. package/src/capabilities/doctor/doctor.render.test.ts +108 -32
  184. package/src/capabilities/doctor/doctor.render.ts +56 -80
  185. package/src/capabilities/doctor/doctor.test.ts +247 -35
  186. package/src/capabilities/doctor/doctor.verb.ts +7 -2
  187. package/src/capabilities/doctor/runChecks.ts +14 -11
  188. package/src/capabilities/doctor/types.ts +15 -24
  189. package/src/capabilities/emptyRecovery.test.ts +10 -2
  190. package/src/capabilities/graph/query.render.ts +2 -2
  191. package/src/capabilities/info/collectInfo.ts +8 -1
  192. package/src/capabilities/info/info.render.ts +6 -2
  193. package/src/capabilities/info/info.test.ts +37 -1
  194. package/src/capabilities/info/types.ts +10 -2
  195. package/src/capabilities/lazy.test.ts +26 -2
  196. package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
  197. package/src/capabilities/prompt/prompt.render.ts +2 -2
  198. package/src/capabilities/renderVocabularySync.test.ts +82 -0
  199. package/src/capabilities/resources/resources.test.ts +38 -2
  200. package/src/capabilities/scopeTypeSync.test.ts +29 -15
  201. package/src/capabilities/setup/buildPlan.ts +45 -41
  202. package/src/capabilities/setup/index.ts +1 -1
  203. package/src/capabilities/setup/mode.ts +2 -2
  204. package/src/capabilities/setup/operations/index.ts +6 -1
  205. package/src/capabilities/setup/operations/setupCompletions.ts +9 -2
  206. package/src/capabilities/setup/operations/setupConfig.ts +7 -2
  207. package/src/capabilities/setup/operations/setupGenerator.ts +227 -84
  208. package/src/capabilities/setup/operations/setupLsp.ts +203 -38
  209. package/src/capabilities/setup/operations/setupMcp.ts +139 -62
  210. package/src/capabilities/setup/operations/setupSkills.ts +399 -60
  211. package/src/capabilities/setup/plan.render.ts +171 -76
  212. package/src/capabilities/setup/plan.ts +17 -12
  213. package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
  214. package/src/capabilities/setup/setup.render.test.ts +50 -36
  215. package/src/capabilities/setup/setup.render.ts +1 -1
  216. package/src/capabilities/setup/setup.test.ts +1126 -50
  217. package/src/capabilities/setup/setup.verb.ts +174 -68
  218. package/src/capabilities/setup/targets.ts +138 -60
  219. package/src/capabilities/setup/types.ts +10 -18
  220. package/src/capabilities/shared/index.ts +18 -6
  221. package/src/capabilities/shared/packageManager.test.ts +421 -6
  222. package/src/capabilities/shared/packageManager.ts +394 -59
  223. package/src/capabilities/skill/bundledSkills.test.ts +347 -0
  224. package/src/capabilities/skill/discover.ts +100 -11
  225. package/src/capabilities/skill/render.ts +2 -2
  226. package/src/capabilities/skill/skill.test.ts +17 -4
  227. package/src/capabilities/sources/installSkills.ts +181 -3
  228. package/src/capabilities/sources/runUpdate.ts +78 -2
  229. package/src/capabilities/sources/sources.test.ts +228 -0
  230. package/src/capabilities/sources/update.verb.ts +3 -3
  231. package/src/capabilities/surface.test.ts +14 -6
  232. package/src/capabilities/upgrade/runUpgrade.ts +45 -6
  233. package/src/capabilities/upgrade/types.ts +10 -2
  234. package/src/capabilities/upgrade/upgrade.render.ts +9 -2
  235. package/src/capabilities/upgrade/upgrade.test.ts +72 -0
  236. package/src/constants.ts +14 -1
  237. package/src/identity.test.ts +18 -5
  238. package/src/kernel/completion/entitySource.test.ts +15 -2
  239. package/src/kernel/completion/safety.test.ts +16 -9
  240. package/src/kernel/config/defaults.test.ts +19 -3
  241. package/src/kernel/config/readConfig.test.ts +1 -1
  242. package/src/kernel/index.ts +11 -2
  243. package/src/kernel/interactivity.test.ts +76 -0
  244. package/src/kernel/interactivity.ts +69 -0
  245. package/src/kernel/packs/compile.test.ts +92 -0
  246. package/src/kernel/packs/renderPack.ts +3 -3
  247. package/src/kernel/packs/resolveEntity.test.ts +75 -14
  248. package/src/kernel/packs/resolveEntity.ts +88 -24
  249. package/src/kernel/packs/runBodies.ts +84 -19
  250. package/src/kernel/packs/schema.ts +60 -4
  251. package/src/kernel/packs/sparql/applyFilters.ts +125 -4
  252. package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
  253. package/src/kernel/packs/sparql/buildLookupQuery.ts +327 -45
  254. package/src/kernel/packs/toolDescription.test.ts +35 -1
  255. package/src/kernel/packs/types.ts +125 -2
  256. package/src/kernel/project/cli/buildProgram.test.ts +73 -2
  257. package/src/kernel/project/cli/buildProgram.ts +8 -1
  258. package/src/kernel/project/cli/dispatch.test.ts +5 -4
  259. package/src/kernel/project/cli/dispatch.ts +48 -25
  260. package/src/kernel/project/cli/globalFlags.ts +4 -1
  261. package/src/kernel/project/cli/helpFormat.ts +1 -2
  262. package/src/kernel/project/cli/rootHelp.ts +44 -8
  263. package/src/kernel/project/mcp/registerVerb.ts +20 -1
  264. package/src/kernel/render/prefixes.test.ts +2 -0
  265. package/src/kernel/render/renderers.ts +1 -1
  266. package/src/kernel/render/style.ts +32 -9
  267. package/src/kernel/render/vocabulary.ts +276 -0
  268. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +2 -3
  269. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
  270. package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
  271. package/src/kernel/runtime/graphpack/embedded.ts +14 -6
  272. package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
  273. package/src/kernel/runtime/graphpack/read.ts +14 -12
  274. package/src/kernel/runtime/loadSession.ts +3 -1
  275. package/src/kernel/runtime/probe.ts +1 -1
  276. package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
  277. package/src/kernel/runtime/refs/parseRef.ts +25 -1
  278. package/src/kernel/runtime/refs/resolve.ts +9 -3
  279. package/src/kernel/runtime/types.ts +17 -0
  280. package/src/kernel/spec/emitReference.ts +1 -1
  281. package/src/kernel/spec/types.ts +35 -6
  282. package/src/testing/PARITY_GAPS.ts +9 -3
  283. package/src/testing/behavioral/docExamples.test.ts +4 -2
  284. package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
  285. package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
  286. package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
  287. package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
  288. package/src/testing/eval/cases/readNouns.ts +245 -9
  289. package/src/testing/fixtures/blockGraph.ts +28 -13
  290. package/src/testing/fixtures/graph/canonical.ts +95 -12
  291. package/src/testing/helpers/runCli.test.ts +44 -0
  292. package/src/testing/helpers/runCli.ts +27 -3
  293. package/dist/src/capabilities/shared/bands.js +0 -16
  294. package/dist/src/capabilities/shared/bands.js.map +0 -1
  295. package/src/capabilities/shared/bands.ts +0 -16
@@ -13,6 +13,11 @@
13
13
  * by `assertExecOk` as an actionable UNSUPPORTED (exit 1) — the interpreter
14
14
  * RESOLVES on a nonzero exit, so the check is the consumer's job.
15
15
  * - offline / already-latest carry NO exec — a real run just returns the status.
16
+ * - a non-global install (linked / ephemeral / workspace / unknown) also
17
+ * carries NO exec even when an update exists: there is no command pragma may
18
+ * run for it, so the task reports the delta plus the state's guidance and
19
+ * mutates nothing. `pmUpdateCommand` only accepts the global arm, so this is
20
+ * enforced by the types, not by convention.
16
21
  *
17
22
  * The whole registry read is preview-safe, so no `mutation.preview` gating is
18
23
  * needed here (unlike `sources update`, whose heavy resolve must be gated).
@@ -29,6 +34,7 @@ import {
29
34
  guardMissingBinary,
30
35
  PRAGMA_PACKAGE,
31
36
  pmUpdateCommand,
37
+ updateGuidance,
32
38
  } from "../shared/index.js";
33
39
  import type { UpgradeData } from "./types.js";
34
40
 
@@ -46,8 +52,15 @@ export async function runUpgrade(
46
52
  ): Promise<Task<UpgradeData>> {
47
53
  const { channel } = (await rt.loadConfig()).config;
48
54
  const install = detectInstallSource();
49
- const command = pmUpdateCommand(install.pm, PRAGMA_PACKAGE);
55
+ // Only a GLOBAL install has a sanctioned update command — the type of
56
+ // `pmUpdateCommand` enforces the narrowing. A linked checkout in particular
57
+ // must never be handed `npm i -g`: it would overwrite the development link.
58
+ const command =
59
+ install.kind === "global"
60
+ ? pmUpdateCommand(install, PRAGMA_PACKAGE)
61
+ : undefined;
50
62
  const pm = install.label;
63
+ const kind = install.kind;
51
64
  const current = rt.version;
52
65
 
53
66
  const registry = await checkRegistryVersion(PRAGMA_PACKAGE, channel);
@@ -55,9 +68,10 @@ export async function runUpgrade(
55
68
  if (registry === undefined) {
56
69
  const data: UpgradeData = {
57
70
  pm,
71
+ kind,
58
72
  current,
59
73
  latest: undefined,
60
- command,
74
+ ...(command !== undefined ? { command } : {}),
61
75
  offline: true,
62
76
  alreadyLatest: false,
63
77
  executed: false,
@@ -71,9 +85,10 @@ export async function runUpgrade(
71
85
  if (registry.latest === current) {
72
86
  const data: UpgradeData = {
73
87
  pm,
88
+ kind,
74
89
  current,
75
90
  latest: registry.latest,
76
- command,
91
+ ...(command !== undefined ? { command } : {}),
77
92
  offline: false,
78
93
  alreadyLatest: true,
79
94
  executed: false,
@@ -84,16 +99,40 @@ export async function runUpgrade(
84
99
  });
85
100
  }
86
101
 
102
+ if (install.kind !== "global") {
103
+ // An update exists, but this install has no command pragma may run for
104
+ // it — say so honestly and run NOTHING. (The discriminant narrowing is
105
+ // what lets `updateGuidance` accept `install` here.)
106
+ const guidance = updateGuidance(install);
107
+ const data: UpgradeData = {
108
+ pm,
109
+ kind,
110
+ current,
111
+ latest: registry.latest,
112
+ guidance,
113
+ offline: false,
114
+ alreadyLatest: false,
115
+ executed: false,
116
+ };
117
+ return gen(function* () {
118
+ yield* $(info(`${current} → ${registry.latest}`));
119
+ yield* $(warn(`No automatic upgrade for this install: ${guidance}`));
120
+ return data;
121
+ });
122
+ }
123
+
124
+ const updateCommand = pmUpdateCommand(install, PRAGMA_PACKAGE);
87
125
  const data: UpgradeData = {
88
126
  pm,
127
+ kind,
89
128
  current,
90
129
  latest: registry.latest,
91
- command,
130
+ command: updateCommand,
92
131
  offline: false,
93
132
  alreadyLatest: false,
94
133
  executed: true,
95
134
  };
96
- const parts = command.split(" ");
135
+ const parts = updateCommand.split(" ");
97
136
  const bin = parts[0] ?? "npm";
98
137
  const args = parts.slice(1);
99
138
  // Guard the spawn: an absent package manager (`bin` not on PATH) REJECTS the
@@ -112,7 +151,7 @@ export async function runUpgrade(
112
151
  // so inspect the result: a failed install (e.g. EACCES on a global
113
152
  // `npm i -g`) must fail loudly, not report a silent success.
114
153
  const result = yield* $(exec(bin, args, rt.cwd));
115
- assertExecOk(command, result);
154
+ assertExecOk(updateCommand, result);
116
155
  return data;
117
156
  }),
118
157
  );
@@ -2,16 +2,24 @@
2
2
  * Data shape for `pragma upgrade`.
3
3
  */
4
4
 
5
+ import type { InstallKind } from "../../kernel/render/vocabulary.js";
6
+
5
7
  /** The outcome of an upgrade run (or its plan). */
6
8
  export interface UpgradeData {
7
9
  /** The install-source label (e.g. `bun (global)`). */
8
10
  readonly pm: string;
11
+ /** The install-source state behind the label. */
12
+ readonly kind: InstallKind;
9
13
  /** The currently-installed version. */
10
14
  readonly current: string;
11
15
  /** The latest published version, or `undefined` when offline. */
12
16
  readonly latest: string | undefined;
13
- /** The package-manager command that applies the update. */
14
- readonly command: string;
17
+ /** The package-manager command that applies the update — only for a GLOBAL
18
+ * install; every other state carries {@link guidance} instead. */
19
+ readonly command?: string;
20
+ /** The honest sentence for an install with no sanctioned update command
21
+ * (linked / ephemeral / workspace / unknown). */
22
+ readonly guidance?: string;
15
23
  /** True when the registry could not be reached. */
16
24
  readonly offline: boolean;
17
25
  /** True when the installed version is already the latest. */
@@ -33,8 +33,12 @@ export const upgradeFormatters: Formatters<UpgradeData> = {
33
33
  "",
34
34
  `Updated to ${data.latest}.`,
35
35
  );
36
- } else {
36
+ } else if (data.command) {
37
37
  lines.push(`Run: ${chalk.cyan(data.command)}`);
38
+ } else {
39
+ // No sanctioned command for this install (linked / ephemeral /
40
+ // workspace / unknown) — the guidance sentence is the whole story.
41
+ lines.push(`No automatic upgrade for this install: ${data.guidance}`);
38
42
  }
39
43
  return lines.join("\n");
40
44
  },
@@ -45,7 +49,10 @@ export const upgradeFormatters: Formatters<UpgradeData> = {
45
49
  return `Already at the latest version (${data.current}).`;
46
50
  }
47
51
  if (data.executed) return `Upgraded: ${data.current} → ${data.latest}`;
48
- return `Update available: ${data.current} → ${data.latest} (\`${data.command}\`)`;
52
+ if (data.command) {
53
+ return `Update available: ${data.current} → ${data.latest} (\`${data.command}\`)`;
54
+ }
55
+ return `Update available: ${data.current} → ${data.latest} — ${data.guidance}`;
49
56
  },
50
57
 
51
58
  json(data) {
@@ -28,6 +28,7 @@ import {
28
28
  guardMissingBinary,
29
29
  isMissingBinaryError,
30
30
  } from "../shared/assertExecOk.js";
31
+ import type { InstallSource } from "../shared/index.js";
31
32
  import {
32
33
  checkRegistryVersion,
33
34
  REGISTRY_TIMEOUT_MS,
@@ -36,6 +37,22 @@ import { runUpgrade } from "./runUpgrade.js";
36
37
  import type { UpgradeData } from "./types.js";
37
38
  import { upgradeModule } from "./upgrade.verb.js";
38
39
 
40
+ // Pin the install-source detection: the real detector reads THIS process
41
+ // (whose entry is a source checkout — an honest `unknown` with no command,
42
+ // which would starve every update-needed case of its exec). Detection itself
43
+ // is covered by `shared/packageManager.test.ts`; here it is a fixture.
44
+ const detection = vi.hoisted(() => ({ install: undefined as unknown }));
45
+ vi.mock("../shared/index.js", async (importOriginal) => ({
46
+ ...(await importOriginal<object>()),
47
+ detectInstallSource: () => detection.install,
48
+ }));
49
+
50
+ const GLOBAL_NPM: InstallSource = {
51
+ kind: "global",
52
+ pm: "npm",
53
+ label: "npm (global)",
54
+ };
55
+
39
56
  const upgradeVerb = upgradeModule.verbs[0] as VerbSpec;
40
57
  const FLAGS_JSON: GlobalFlags = {
41
58
  llm: false,
@@ -71,6 +88,7 @@ let prevXdg: string | undefined;
71
88
  beforeEach(() => {
72
89
  prevXdg = process.env.XDG_CONFIG_HOME;
73
90
  process.env.XDG_CONFIG_HOME = tmpCwd();
91
+ detection.install = GLOBAL_NPM;
74
92
  });
75
93
  afterEach(() => {
76
94
  process.env.XDG_CONFIG_HOME = prevXdg;
@@ -181,6 +199,60 @@ describe("upgrade — update needed", () => {
181
199
  });
182
200
  });
183
201
 
202
+ describe("upgrade — a non-global install NEVER gets an exec (the headline)", () => {
203
+ const LINKED: InstallSource = {
204
+ kind: "linked",
205
+ pm: "npm",
206
+ target: "/home/u/code/pragma/packages/cli/pragma",
207
+ label: "npm link (development checkout)",
208
+ };
209
+
210
+ it("linked + update available: no command, no exec — guidance instead", async () => {
211
+ // The case that must never regress: `npm i -g` against an npm-linked
212
+ // install would OVERWRITE the symlink to the development worktree.
213
+ detection.install = LINKED;
214
+ stubRegistry("99.0.0");
215
+ const task = await runUpgrade(bootRuntime(FLAGS_JSON, tmpCwd()));
216
+ const { value, effects } = dryRun(task);
217
+ expect(value.executed).toBe(false);
218
+ expect(value.command).toBeUndefined();
219
+ expect(value.kind).toBe("linked");
220
+ expect(value.guidance).toMatch(/development checkout/i);
221
+ expect(effects.some((e) => e._tag === "Exec")).toBe(false);
222
+ });
223
+
224
+ it("--dry-run through dispatch plans NO Execute line for a linked install", async () => {
225
+ detection.install = LINKED;
226
+ stubRegistry("99.0.0");
227
+ const outcome = await executeVerb(
228
+ upgradeVerb,
229
+ {},
230
+ DRY,
231
+ bootRuntime(FLAGS_JSON, tmpCwd()),
232
+ );
233
+ const plan = JSON.parse(outcome.stdout as string).data.plan as string[];
234
+ expect(plan.some((line) => line.startsWith("Execute:"))).toBe(false);
235
+ expect(plan.some((line) => line.includes("No automatic upgrade"))).toBe(
236
+ true,
237
+ );
238
+ });
239
+
240
+ it("an ephemeral run reports its state with no command either", async () => {
241
+ detection.install = {
242
+ kind: "ephemeral",
243
+ runner: "npx",
244
+ label: "npx (ephemeral)",
245
+ } satisfies InstallSource;
246
+ stubRegistry("99.0.0");
247
+ const { value, effects } = dryRun(
248
+ await runUpgrade(bootRuntime(FLAGS_JSON, tmpCwd())),
249
+ );
250
+ expect(value.executed).toBe(false);
251
+ expect(value.command).toBeUndefined();
252
+ expect(effects.some((e) => e._tag === "Exec")).toBe(false);
253
+ });
254
+ });
255
+
184
256
  describe("assertExecOk — the exec-exitCode guard", () => {
185
257
  it("returns on a zero exit and throws an actionable UNSUPPORTED (with stderr) on nonzero", () => {
186
258
  expect(() =>
package/src/constants.ts CHANGED
@@ -41,6 +41,13 @@ const ISSUES_URL = identity.issuesUrl;
41
41
  /** Semver version string read from package.json. */
42
42
  const VERSION: string = pkg.version;
43
43
 
44
+ /**
45
+ * The Node.js range this distribution supports, read from `engines.node` in
46
+ * package.json so the manifest stays the single statement of the floor. The
47
+ * doctor check quotes it, and `isSupportedNodeVersion` is pinned to it by test.
48
+ */
49
+ const SUPPORTED_NODE_RANGE: string = pkg.engines.node;
50
+
44
51
  /**
45
52
  * The project config filename the walker looks for and every surface that
46
53
  * quotes it — diagnostics, the onboarding note, the generated reference, the
@@ -76,7 +83,12 @@ type OutputFormat = (typeof OUTPUT_FORMATS)[number];
76
83
  const DEFAULT_DETAIL_LEVEL: DetailLevel = "standard";
77
84
 
78
85
  /**
79
- * The wordmark `--help` opens with, as raw ASCII-art lines.
86
+ * The wordmark `--help` opens with FOR A HUMAN READER, as raw ASCII-art lines.
87
+ *
88
+ * Whether it prints is decided at the one emit site (`rootHelp.ts`) from the
89
+ * invocation's global flags: a captured run (`--format llm`/`json`, including
90
+ * the `llm` a non-interactive stdout infers) gets the bare header instead. This
91
+ * constant is the ART, not the policy.
80
92
  *
81
93
  * PROJECTED from the distribution config, exactly like {@link BIN_NAME} and
82
94
  * {@link PROGRAM_DESCRIPTION} — a wordmark spells a name, so it is identity, and
@@ -111,5 +123,6 @@ export {
111
123
  PROGRAM_LOGO,
112
124
  PROJECT_CONFIG_FILENAME,
113
125
  RECOVERY_CLI_PREFIX,
126
+ SUPPORTED_NODE_RANGE,
114
127
  VERSION,
115
128
  };
@@ -123,6 +123,9 @@ describe("identity projection — a fork changes values, not code (PROTECTED)",
123
123
  ],
124
124
  "9.9.9",
125
125
  "https://recipes.test/issues",
126
+ // A human at a terminal: the fork's own art (it declares none) would
127
+ // print here, so the assertions below see the widest page.
128
+ { llm: false, format: "plain", verbose: false },
126
129
  );
127
130
 
128
131
  expect(help).not.toMatch(THIS_DISTRIBUTION);
@@ -214,15 +217,25 @@ describe("identity projection — a fork changes values, not code (PROTECTED)",
214
217
  // `$XDG_DATA_HOME/pragma/skills` and one `<cwd>/.pragma/skills`, so a fork
215
218
  // read the other's skills and could not install its own without collision.
216
219
  process.env.XDG_DATA_HOME = join(tmpdir(), "identity-data");
217
- const { skillRoots, installedSkillsDir } = await import(
220
+ const { skillRoots, installedSkillsDir, bundledSkillsDir } = await import(
218
221
  "./capabilities/skill/discover.js"
219
222
  );
220
223
  expect(installedSkillsDir().endsWith(join("recipes", "skills"))).toBe(true);
221
- expect(skillRoots("/work")).toEqual([
224
+ // The two roots that live on SHARED ground — the user's data home and the
225
+ // user's repository — are the ones a fork must namespace.
226
+ const namespaced = [
222
227
  join("/work", ".recipes", "skills"),
223
228
  installedSkillsDir(),
224
- ]);
225
- for (const root of skillRoots("/work")) {
229
+ ];
230
+ // The bundled snapshot is the third, and it is deliberately NOT namespaced
231
+ // by the bin name: it is the distribution's OWN package directory, which is
232
+ // already private to the install. A fork is a different package and ships
233
+ // its own `bundled-skills/`, so there is no shared ground to collide on —
234
+ // and it is exempt from the check below for exactly that reason, since the
235
+ // path this repository's package sits at necessarily says "pragma".
236
+ expect(bundledSkillsDir()).toBeDefined();
237
+ expect(skillRoots("/work")).toEqual([...namespaced, bundledSkillsDir()]);
238
+ for (const root of namespaced) {
226
239
  expect(root).not.toMatch(THIS_DISTRIBUTION);
227
240
  }
228
241
  });
@@ -259,7 +272,7 @@ describe("identity projection — a fork changes values, not code (PROTECTED)",
259
272
  args: ["mcp", "serve"],
260
273
  cwd: "/work",
261
274
  });
262
- // The global band omits `cwd` (a per-user server is not project-pinned)
275
+ // The global scope omits `cwd` (a per-user server is not project-pinned)
263
276
  // but still runs the fork's own binary.
264
277
  expect(pragmaMcpEntry("/work", "global")).toEqual({
265
278
  command: "recipes",
@@ -2,6 +2,10 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import {
6
+ bundledSkillsDir,
7
+ discoverSkillsFrom,
8
+ } from "../../capabilities/skill/discover.js";
5
9
  import { readConfig } from "../config/readConfig.js";
6
10
  import { embeddedManifest } from "../runtime/graphpack/embedded.js";
7
11
  import { activePackPath, packDir } from "../runtime/paths.js";
@@ -121,10 +125,19 @@ describe("indexCompletionEnv — multi-source names(ref)", () => {
121
125
  join(skillDir, "SKILL.md"),
122
126
  "---\nname: docx\ndescription: Word docs.\n---\n",
123
127
  );
128
+ // The BUNDLED snapshot ships inside the package, so it is a discovery root
129
+ // on every machine — including this one. The project root's skill is what
130
+ // this cell is about; the expectation is derived from the shipped artifact
131
+ // rather than pinned to a list, so a release that changes which skills the
132
+ // packs provide does not rewrite this test.
133
+ const bundled = discoverSkillsFrom([bundledSkillsDir() as string]).map(
134
+ (skill) => skill.name,
135
+ );
136
+ const expected = [...new Set(["docx", ...bundled])].sort();
124
137
  const env = indexCompletionEnv(cwd);
125
- expect(await env.names({ from: "skills" })).toEqual(["docx"]);
138
+ expect(await env.names({ from: "skills" })).toEqual(expected);
126
139
  // A second read returns the same list (one filesystem walk per env).
127
- expect(await env.names({ from: "skills" })).toEqual(["docx"]);
140
+ expect(await env.names({ from: "skills" })).toEqual(expected);
128
141
  });
129
142
 
130
143
  it("a type nothing matches yields [] (prefixes still list the default map)", async () => {
@@ -331,15 +331,22 @@ describe("storeless guarantee (PROTECTED)", () => {
331
331
  runComplete(["tier", "lookup", "ap"], capabilities, env),
332
332
  ).resolves.toContain("ds:apps");
333
333
 
334
- // skills (no skills root here) and prompt labels (this graph carries no
335
- // prompt entities) have nothing to offer, and the honest storeless answer
336
- // is an empty list rather than a store boot.
337
- for (const words of [
338
- ["skill", "lookup", "do"],
339
- ["prompt", "lookup", "bu"],
340
- ]) {
341
- await expect(runComplete(words, capabilities, env)).resolves.toEqual([]);
342
- }
334
+ // skills: the BUNDLED snapshot ships inside the package, so even this
335
+ // fresh cwd with an empty XDG data home completes REAL skill names — and
336
+ // does it storelessly, which is the whole point of the cell. (It used to
337
+ // assert `[]` here, which was true only because a fresh machine had no
338
+ // skills at all; that is the defect the snapshot fixes.) The names are not
339
+ // spelled out: an empty prefix asks for all of them.
340
+ await expect(
341
+ runComplete(["skill", "lookup", ""], capabilities, env),
342
+ ).resolves.not.toHaveLength(0);
343
+
344
+ // prompt labels: this graph carries no prompt entities and no shipped skill
345
+ // declares `prompt: true`, so the honest storeless answer is still an empty
346
+ // list rather than a store boot.
347
+ await expect(
348
+ runComplete(["prompt", "lookup", "bu"], capabilities, env),
349
+ ).resolves.toEqual([]);
343
350
 
344
351
  expect(vi.mocked(createStore)).not.toHaveBeenCalled();
345
352
  });
@@ -44,7 +44,12 @@ describe("defaults — the validated distribution config (pragma.conf.ts)", () =
44
44
  expect(defaults.colophon?.summary?.length).toBeLessThan(400);
45
45
  });
46
46
 
47
- it("ships the four canonical default packs (git+https sources)", () => {
47
+ // Five packs, and ONE carries a subdirectory. That is load-bearing, not
48
+ // decoration: `@canonical/token-ontology` lives inside a monorepo, and a
49
+ // git source names a repository — without `:packages/token-ontology` the
50
+ // clone would succeed against a root that has no `definitions/` or `data/`
51
+ // and the pack would contribute nothing, silently.
52
+ it("ships the five canonical default packs (all git+https)", () => {
48
53
  expect(
49
54
  defaults.packs?.map((pack) =>
50
55
  typeof pack === "string"
@@ -60,9 +65,15 @@ describe("defaults — the validated distribution config (pragma.conf.ts)", () =
60
65
  name: "@canonical/anatomy-dsl",
61
66
  source: "git+https://github.com/canonical/anatomy-dsl.git#main",
62
67
  },
68
+ {
69
+ name: "@canonical/token-ontology",
70
+ source:
71
+ "git+https://github.com/canonical/design-tokens.git#main:packages/token-ontology",
72
+ },
63
73
  {
64
74
  name: "@canonical/code-standards",
65
- source: "git+https://github.com/canonical/web-code-standards.git#main",
75
+ source:
76
+ "git+https://github.com/canonical/web-code-standards.git#v0.1.5",
66
77
  },
67
78
  {
68
79
  name: "@canonical/ds-implementations",
@@ -78,7 +89,7 @@ describe("defaults — the validated distribution config (pragma.conf.ts)", () =
78
89
  const storyCounts = defaults.packs?.map((pack) =>
79
90
  typeof pack === "string" ? 0 : (pack.stories?.length ?? 0),
80
91
  );
81
- expect(storyCounts).toEqual([5, 0, 1, 1]);
92
+ expect(storyCounts).toEqual([5, 0, 0, 1, 1]);
82
93
  });
83
94
 
84
95
  it("declares no removed field — the validator would refuse to load one", () => {
@@ -97,6 +108,11 @@ describe("defaults — the validated distribution config (pragma.conf.ts)", () =
97
108
  // can expand — `cs:` is here for that reader.
98
109
  expect(defaults.prefixes).toEqual({
99
110
  ds: "https://ds.canonical.com/",
111
+ dt: "https://dt.canonical.com/",
112
+ // Nested under `dt:`, and bound separately because `compactUri` takes the
113
+ // longest match — with only the parent bound, every term of this
114
+ // vocabulary would render as `dt:w3c-tokens/…`.
115
+ "w3c-tokens": "https://dt.canonical.com/w3c-tokens/",
100
116
  cs: "http://pragma.canonical.com/codestandards#",
101
117
  });
102
118
  });
@@ -59,7 +59,7 @@ describe("readConfig — layering + provenance", () => {
59
59
  expect(config.tier).toBe("core");
60
60
  expect(config.channel).toBe("experimental");
61
61
  expect(config.detail).toBe("detailed");
62
- expect(config.packs).toHaveLength(4); // from defaults
62
+ expect(config.packs).toHaveLength(5); // from defaults
63
63
 
64
64
  expect(origins).toMatchObject({
65
65
  tier: "project",
@@ -10,8 +10,16 @@
10
10
  * is high: it must be a declaration the kernel makes about itself rather than
11
11
  * a step in any one pipeline.
12
12
  *
13
- * Today that is the declared vocabulary — the distribution's mapping from the
14
- * roles the CLI knows (a prompt, a resource) onto the RDF terms that actually
13
+ * Today that is the declared vocabulary and the interactivity vocabulary. The
14
+ * latter (`interactivity.ts`) names the questions about attended streams — is
15
+ * stdout captured, may we prompt, may we color (that third one lives with the
16
+ * styler, for the dependency reason its docblock records) — which render,
17
+ * dispatch and flag parsing each used to answer with their own inline probe.
18
+ * They are different questions over different streams, and the names are what
19
+ * keeps them from being flattened into one flag.
20
+ *
21
+ * The declared vocabulary is the distribution's mapping from the roles the
22
+ * CLI knows (a prompt, a resource) onto the RDF terms that actually
15
23
  * carry them in the active graph. It is parsed once at module load and read
16
24
  * from the prompt capability, the MCP prompt source, and the pack index
17
25
  * builder, none of which owns it and any of which would otherwise hardcode
@@ -22,5 +30,6 @@
22
30
  * exporting it here would advertise a runtime API the gate does not have.
23
31
  */
24
32
 
33
+ export { canPrompt, stdoutIsCaptured } from "./interactivity.js";
25
34
  export type { DeclaredVocabulary } from "./vocabulary.js";
26
35
  export { VOCABULARY } from "./vocabulary.js";
@@ -0,0 +1,76 @@
1
+ import chalk from "chalk";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { canPrompt, stdoutIsCaptured } from "./interactivity.js";
4
+ import { canColor } from "./render/style.js";
5
+
6
+ /** Force a stream's `isTTY`, as only a test may. */
7
+ const setTTY = (
8
+ stream: NodeJS.ReadStream | NodeJS.WriteStream,
9
+ value: boolean | undefined,
10
+ ): void => {
11
+ (stream as { isTTY?: boolean }).isTTY = value;
12
+ };
13
+
14
+ const savedIn = process.stdin.isTTY;
15
+ const savedOut = process.stdout.isTTY;
16
+ const savedErr = process.stderr.isTTY;
17
+ const savedLevel = chalk.level;
18
+ afterEach(() => {
19
+ setTTY(process.stdin, savedIn);
20
+ setTTY(process.stdout, savedOut);
21
+ setTTY(process.stderr, savedErr);
22
+ chalk.level = savedLevel;
23
+ });
24
+
25
+ describe("the interactivity vocabulary asks THREE questions, not one", () => {
26
+ it("a captured stdout with an attended terminal is still promptable", () => {
27
+ // The case that makes flattening these wrong: an agent runs a mutating
28
+ // verb with `| cat` while a terminal is still attached. Answering the
29
+ // prompting question with stdout's probe would refuse a human; answering
30
+ // the output-shape question with stdin's would hand an agent a page of
31
+ // decoration it cannot use.
32
+ setTTY(process.stdout, false);
33
+ setTTY(process.stdin, true);
34
+ setTTY(process.stderr, true);
35
+ expect(stdoutIsCaptured()).toBe(true);
36
+ expect(canPrompt()).toBe(true);
37
+ });
38
+
39
+ it("a redirected stderr is NOT promptable, however attended stdout is", () => {
40
+ // `<verb> 2>/dev/null`: the step sequence renders to stderr, so gating on
41
+ // stdout would mount an invisible render that then blocks on stdin.
42
+ setTTY(process.stdout, true);
43
+ setTTY(process.stdin, true);
44
+ setTTY(process.stderr, false);
45
+ expect(stdoutIsCaptured()).toBe(false);
46
+ expect(canPrompt()).toBe(false);
47
+ });
48
+
49
+ it("stdin alone does not make a run promptable", () => {
50
+ setTTY(process.stdin, false);
51
+ setTTY(process.stderr, true);
52
+ expect(canPrompt()).toBe(false);
53
+ });
54
+
55
+ it("colour needs the terminal AND a usable chalk level", () => {
56
+ setTTY(process.stdout, true);
57
+ chalk.level = 1;
58
+ expect(canColor()).toBe(true);
59
+ // Off in CI-shaped environments where `supports-color` fires off a TTY…
60
+ setTTY(process.stdout, false);
61
+ expect(canColor()).toBe(false);
62
+ // …and off whenever the level is zeroed (what `NO_COLOR` does at load).
63
+ setTTY(process.stdout, true);
64
+ chalk.level = 0;
65
+ expect(canColor()).toBe(false);
66
+ });
67
+
68
+ it("treats an absent isTTY as captured / unpromptable, never as attended", () => {
69
+ setTTY(process.stdout, undefined);
70
+ setTTY(process.stdin, undefined);
71
+ setTTY(process.stderr, undefined);
72
+ expect(stdoutIsCaptured()).toBe(true);
73
+ expect(canPrompt()).toBe(false);
74
+ expect(canColor()).toBe(false);
75
+ });
76
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The interactivity vocabulary — three named questions over three streams.
3
+ *
4
+ * Every one of these used to be spelled inline as a bare `process.<stream>
5
+ * .isTTY === true` at the site that needed it, in four places, and the
6
+ * spellings did not agree — because they are NOT the same question. Naming them
7
+ * is the point: the names are what stop the next reader from "simplifying"
8
+ * three probes into one flag.
9
+ *
10
+ * - {@link stdoutIsCaptured} reads **stdout**. It governs OUTPUT SHAPE: whether
11
+ * a consumer is piping the data stream somewhere (the shape an agent
12
+ * captures), which is what AUTO-INFERS the condensed `llm` default. Capture
13
+ * is the inference, not the gate: what drops the root-help wordmark is the
14
+ * resulting shape — `llm` or `json` — so an explicit `--format plain` through
15
+ * a pipe is captured and keeps the art, while `--format llm` on an attended
16
+ * terminal is not captured and drops it. Explicit format beats inference, in
17
+ * both directions.
18
+ * - {@link canPrompt} reads **stdin AND stderr** — never stdout. The step
19
+ * sequence renders to stderr and reads stdin, so `<verb> 2>/dev/null` is
20
+ * non-interactive while `<verb> | cat` is not. Gating this one on stdout
21
+ * would mount an invisible render that then blocks on stdin.
22
+ * - `canColor` (`render/style.ts`) reads **stdout** plus chalk's own color
23
+ * level, so piped, redirected and MCP output stays byte-stable even where
24
+ * `supports-color` fires off a TTY. It is the one question of the three that
25
+ * lives with the styler rather than here, because it needs `chalk` and this
26
+ * module is imported by global-flag parsing — which runs before `--version`
27
+ * answers, on a path that must load nothing it does not use. It still asks
28
+ * its stdout half through {@link stdoutIsCaptured}, so the three stay one
29
+ * vocabulary.
30
+ *
31
+ * The distinction is not academic: an agent running a mutating verb with its
32
+ * stdout captured but a terminal still attached is CAPTURED and PROMPTABLE at
33
+ * the same time. Answering either question with the other's probe would either
34
+ * hang a pipeline or refuse a human.
35
+ *
36
+ * These are the only sanctioned readers of `process.*.isTTY` outside a test.
37
+ * Each is a plain function rather than a module-load constant because a test
38
+ * assigns the streams' `isTTY` per case, and a constant would freeze the first
39
+ * value the process ever saw.
40
+ */
41
+
42
+ /**
43
+ * Whether stdout is going somewhere other than an attended terminal — piped,
44
+ * redirected, or read by a parent process.
45
+ *
46
+ * The OUTPUT-SHAPE question, and stdout's alone: it says nothing about whether
47
+ * a human is available to answer a prompt (see {@link canPrompt}).
48
+ *
49
+ * @returns True when stdout is not a TTY.
50
+ * @note Impure — reads `process.stdout.isTTY`.
51
+ */
52
+ export function stdoutIsCaptured(): boolean {
53
+ return process.stdout.isTTY !== true;
54
+ }
55
+
56
+ /**
57
+ * Whether this invocation may ask the user a question: stdin AND stderr are
58
+ * both attended terminals.
59
+ *
60
+ * THE interactivity gate (H3). One exported fact, so the kernel's interaction
61
+ * context and a mounted subtree's own decision can never disagree about what
62
+ * "a TTY" means. Deliberately blind to stdout — see the module docblock.
63
+ *
64
+ * @returns True when a prompt can be both drawn and answered.
65
+ * @note Impure — reads `process.stdin.isTTY` and `process.stderr.isTTY`.
66
+ */
67
+ export function canPrompt(): boolean {
68
+ return process.stdin.isTTY === true && process.stderr.isTTY === true;
69
+ }