@canonical/pragma-cli 0.29.0 → 0.30.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 (241) hide show
  1. package/README.md +67 -15
  2. package/dist/pragma +0 -0
  3. package/package.json +13 -13
  4. package/src/config/defaultConfig.test.ts +33 -0
  5. package/src/config/defaultConfig.ts +59 -0
  6. package/src/config/defaults.json +17 -0
  7. package/src/config/findProjectConfigPath.test.ts +53 -0
  8. package/src/config/findProjectConfigPath.ts +36 -0
  9. package/src/config/index.ts +17 -2
  10. package/src/config/mergeConfigFileUpdate.ts +58 -0
  11. package/src/config/parseConfigValues.ts +215 -0
  12. package/src/config/readConfig.test.ts +104 -13
  13. package/src/config/readConfig.ts +12 -153
  14. package/src/config/readConfigLayers.test.ts +107 -0
  15. package/src/config/readConfigLayers.ts +115 -0
  16. package/src/config/resolveGlobalConfigPath.ts +13 -0
  17. package/src/config/resolveWriteConfigPath.test.ts +53 -0
  18. package/src/config/resolveWriteConfigPath.ts +31 -0
  19. package/src/config/types.ts +76 -1
  20. package/src/config/writeConfig.ts +25 -55
  21. package/src/domains/block/commands/list.ts +5 -90
  22. package/src/domains/block/commands/lookup.ts +8 -167
  23. package/src/domains/block/mcp/specs.ts +16 -196
  24. package/src/domains/block/orchestration/resolveBlockLookup.ts +2 -1
  25. package/src/domains/block/stories.test.ts +28 -0
  26. package/src/domains/block/stories.ts +254 -0
  27. package/src/domains/config/commands/channel.test.ts +14 -3
  28. package/src/domains/config/commands/channel.ts +11 -7
  29. package/src/domains/config/commands/configScope.ts +46 -0
  30. package/src/domains/config/commands/framework.test.ts +18 -1
  31. package/src/domains/config/commands/framework.ts +13 -7
  32. package/src/domains/config/commands/show.ts +5 -45
  33. package/src/domains/config/commands/tier.test.ts +13 -2
  34. package/src/domains/config/commands/tier.ts +11 -7
  35. package/src/domains/config/commands/trace.test.ts +19 -2
  36. package/src/domains/config/commands/trace.ts +6 -3
  37. package/src/domains/config/formatters/channel.test.ts +20 -6
  38. package/src/domains/config/formatters/channel.ts +14 -11
  39. package/src/domains/config/formatters/framework.ts +14 -11
  40. package/src/domains/config/formatters/show.test.ts +27 -0
  41. package/src/domains/config/formatters/show.ts +27 -4
  42. package/src/domains/config/formatters/tier.test.ts +26 -10
  43. package/src/domains/config/formatters/tier.ts +19 -11
  44. package/src/domains/config/formatters/trace.ts +7 -7
  45. package/src/domains/config/mcp/specs.ts +33 -53
  46. package/src/domains/config/operations/show.test.ts +18 -0
  47. package/src/domains/config/operations/show.ts +7 -1
  48. package/src/domains/config/operations/types.ts +7 -0
  49. package/src/domains/config/stories.ts +42 -0
  50. package/src/domains/config/tasks/index.ts +1 -0
  51. package/src/domains/config/tasks/setChannel.ts +13 -28
  52. package/src/domains/config/tasks/setFramework.ts +11 -30
  53. package/src/domains/config/tasks/setTier.ts +10 -29
  54. package/src/domains/config/tasks/setTrace.ts +10 -28
  55. package/src/domains/config/tasks/writeConfigField.ts +41 -0
  56. package/src/domains/create/commands/component.ts +5 -5
  57. package/src/domains/doctor/operations/checks/checkConfigFile.ts +19 -5
  58. package/src/domains/doctor/operations/checks/checkMcpCommands.ts +130 -0
  59. package/src/domains/doctor/operations/checks/checkMcpConfigured.ts +1 -1
  60. package/src/domains/doctor/operations/checks/checkPackageRefs.ts +2 -14
  61. package/src/domains/doctor/operations/checks/checkSkillsSymlinked.ts +1 -1
  62. package/src/domains/doctor/operations/checks/index.ts +1 -0
  63. package/src/domains/doctor/operations/checks.test.ts +158 -7
  64. package/src/domains/doctor/operations/runChecks.test.ts +15 -6
  65. package/src/domains/doctor/operations/runChecks.ts +2 -0
  66. package/src/domains/graph/helpers/resolveUri.test.ts +50 -0
  67. package/src/domains/graph/helpers/resolveUri.ts +28 -21
  68. package/src/domains/graphql/commands/build.test.ts +5 -3
  69. package/src/domains/graphql/commands/build.ts +8 -16
  70. package/src/domains/graphql/commands/check.test.ts +5 -3
  71. package/src/domains/graphql/commands/check.ts +7 -13
  72. package/src/domains/graphql/commands/index.ts +1 -0
  73. package/src/domains/graphql/commands/serve.test.ts +40 -0
  74. package/src/domains/graphql/commands/serve.ts +131 -0
  75. package/src/domains/graphql/constants.ts +10 -0
  76. package/src/domains/graphql/helpers/gatherSources.test.ts +92 -0
  77. package/src/domains/graphql/helpers/gatherSources.ts +58 -0
  78. package/src/domains/graphql/helpers/index.ts +2 -0
  79. package/src/domains/graphql/helpers/routeGraphql.test.ts +43 -0
  80. package/src/domains/graphql/helpers/routeGraphql.ts +28 -0
  81. package/src/domains/graphql/index.ts +9 -6
  82. package/src/domains/graphql/operations/compileSchema.test.ts +21 -45
  83. package/src/domains/graphql/operations/compileSchema.ts +23 -25
  84. package/src/domains/graphql/operations/createSchemaServer.test.ts +50 -0
  85. package/src/domains/graphql/operations/createSchemaServer.ts +75 -0
  86. package/src/domains/graphql/operations/index.ts +5 -0
  87. package/src/domains/info/operations/collectInfo.test.ts +14 -3
  88. package/src/domains/info/operations/collectInfo.ts +12 -15
  89. package/src/domains/llm/capabilities.test.ts +4 -4
  90. package/src/domains/llm/data/toolCatalog.ts +6 -0
  91. package/src/domains/modifier/commands/list.ts +5 -38
  92. package/src/domains/modifier/commands/lookup.ts +8 -79
  93. package/src/domains/modifier/mcp/specs.ts +15 -94
  94. package/src/domains/modifier/orchestration/resolveModifierLookup.ts +2 -1
  95. package/src/domains/modifier/stories.test.ts +20 -0
  96. package/src/domains/modifier/stories.ts +77 -0
  97. package/src/domains/ontology/commands/list.ts +5 -58
  98. package/src/domains/ontology/commands/show.ts +6 -58
  99. package/src/domains/ontology/formatters/show.ts +7 -1
  100. package/src/domains/ontology/mcp/specs.ts +7 -84
  101. package/src/domains/ontology/stories.test.ts +36 -0
  102. package/src/domains/ontology/stories.ts +108 -0
  103. package/src/domains/refs/operations/updateRefs.ts +2 -37
  104. package/src/domains/setup/commands/skills.test.ts +6 -2
  105. package/src/domains/setup/commands/skills.ts +6 -2
  106. package/src/domains/setup/helpers/runSetupTask.test.ts +148 -150
  107. package/src/domains/setup/helpers/runSetupTask.ts +62 -70
  108. package/src/domains/setup/operations/setupMcp.test.ts +6 -1
  109. package/src/domains/shared/bootStore.ts +6 -3
  110. package/src/domains/shared/compactUri.test.ts +11 -0
  111. package/src/domains/shared/compactUri.ts +10 -5
  112. package/src/domains/shared/context.ts +2 -2
  113. package/src/domains/shared/contracts.ts +11 -0
  114. package/src/domains/shared/createInteractivePromptSession.test.ts +439 -0
  115. package/src/domains/shared/createInteractivePromptSession.ts +236 -0
  116. package/src/domains/shared/loaders/bundledLoader.test.ts +248 -0
  117. package/src/domains/shared/loaders/bundledLoader.ts +110 -12
  118. package/src/domains/shared/loaders/gitLoader.ts +2 -2
  119. package/src/domains/shared/loaders/index.ts +4 -1
  120. package/src/domains/shared/loaders/localLoader.ts +18 -4
  121. package/src/domains/shared/loaders/readPackageDir.ts +36 -1
  122. package/src/domains/shared/loaders/storyAssetsPlugin.test.ts +84 -0
  123. package/src/domains/shared/loaders/storyAssetsPlugin.ts +46 -0
  124. package/src/domains/shared/lookupMany.test.ts +120 -0
  125. package/src/domains/shared/lookupMany.ts +34 -2
  126. package/src/domains/shared/mergeAndParseRefs.test.ts +89 -0
  127. package/src/domains/shared/mergeAndParseRefs.ts +44 -0
  128. package/src/domains/shared/packages.ts +10 -17
  129. package/src/domains/shared/resolveConfiguredGraphs.test.ts +27 -0
  130. package/src/domains/shared/resolveConfiguredGraphs.ts +41 -0
  131. package/src/domains/shared/runtime.ts +4 -63
  132. package/src/domains/shared/semanticPackage.ts +17 -1
  133. package/src/domains/shared/stories/compileLookupCommand.test.ts +145 -0
  134. package/src/domains/shared/stories/compileLookupCommand.ts +100 -0
  135. package/src/domains/shared/stories/compileLookupTool.test.ts +105 -0
  136. package/src/domains/shared/stories/compileLookupTool.ts +96 -0
  137. package/src/domains/shared/stories/compileReadCommand.test.ts +121 -0
  138. package/src/domains/shared/stories/compileReadCommand.ts +48 -0
  139. package/src/domains/shared/stories/compileReadTool.test.ts +64 -0
  140. package/src/domains/shared/stories/compileReadTool.ts +38 -0
  141. package/src/domains/shared/stories/condense.test.ts +18 -0
  142. package/src/domains/shared/stories/condense.ts +15 -0
  143. package/src/domains/shared/stories/index.ts +20 -0
  144. package/src/domains/shared/stories/normalizeNames.test.ts +20 -0
  145. package/src/domains/shared/stories/normalizeNames.ts +22 -0
  146. package/src/domains/shared/stories/pack/applyPackFilters.test.ts +102 -0
  147. package/src/domains/shared/stories/pack/applyPackFilters.ts +76 -0
  148. package/src/domains/shared/stories/pack/buildLookupQuery.test.ts +51 -0
  149. package/src/domains/shared/stories/pack/buildLookupQuery.ts +80 -0
  150. package/src/domains/shared/stories/pack/collectPackStories.test.ts +108 -0
  151. package/src/domains/shared/stories/pack/collectPackStories.ts +76 -0
  152. package/src/domains/shared/stories/pack/compilePackCommands.ts +40 -0
  153. package/src/domains/shared/stories/pack/compilePackStories.test.ts +146 -0
  154. package/src/domains/shared/stories/pack/compilePackStories.ts +253 -0
  155. package/src/domains/shared/stories/pack/compilePackToolSpecs.ts +43 -0
  156. package/src/domains/shared/stories/pack/index.ts +28 -0
  157. package/src/domains/shared/stories/pack/runSelectQuery.ts +31 -0
  158. package/src/domains/shared/stories/pack/standardParity.test.ts +308 -0
  159. package/src/domains/shared/stories/pack/types.ts +104 -0
  160. package/src/domains/shared/stories/pack/validateStoryPackDefinition.test.ts +240 -0
  161. package/src/domains/shared/stories/pack/validateStoryPackDefinition.ts +408 -0
  162. package/src/domains/shared/stories/projectCliParameters.test.ts +59 -0
  163. package/src/domains/shared/stories/projectCliParameters.ts +57 -0
  164. package/src/domains/shared/stories/projectToolParams.test.ts +50 -0
  165. package/src/domains/shared/stories/projectToolParams.ts +32 -0
  166. package/src/domains/shared/stories/requirePragmaContext.test.ts +23 -0
  167. package/src/domains/shared/stories/requirePragmaContext.ts +23 -0
  168. package/src/domains/shared/stories/resolveLookupDetailed.test.ts +57 -0
  169. package/src/domains/shared/stories/resolveLookupDetailed.ts +27 -0
  170. package/src/domains/shared/stories/types.ts +171 -0
  171. package/src/domains/shared/suggestions/expandGlob.ts +1 -0
  172. package/src/domains/shared/suggestions/suggestNames.ts +43 -10
  173. package/src/domains/skill/commands/index.ts +3 -1
  174. package/src/domains/skill/commands/list.test.ts +6 -2
  175. package/src/domains/skill/commands/list.ts +5 -63
  176. package/src/domains/skill/commands/lookup.test.ts +118 -0
  177. package/src/domains/skill/commands/lookup.ts +14 -0
  178. package/src/domains/skill/formatters/index.ts +1 -0
  179. package/src/domains/skill/formatters/lookup.test.ts +46 -0
  180. package/src/domains/skill/formatters/lookup.ts +57 -0
  181. package/src/domains/skill/helpers/resolveSkillSources.ts +0 -1
  182. package/src/domains/skill/index.ts +7 -3
  183. package/src/domains/skill/mcp/specs.ts +10 -29
  184. package/src/domains/skill/operations/discover.test.ts +61 -30
  185. package/src/domains/skill/operations/discover.ts +28 -37
  186. package/src/domains/skill/operations/index.ts +2 -1
  187. package/src/domains/skill/operations/list.test.ts +15 -7
  188. package/src/domains/skill/operations/lookup.test.ts +91 -0
  189. package/src/domains/skill/operations/lookup.ts +70 -0
  190. package/src/domains/skill/stories.test.ts +43 -0
  191. package/src/domains/skill/stories.ts +103 -0
  192. package/src/domains/skill/types.ts +8 -0
  193. package/src/domains/standard/commands/list.ts +5 -84
  194. package/src/domains/standard/commands/lookup.ts +8 -93
  195. package/src/domains/standard/mcp/specs.ts +16 -171
  196. package/src/domains/standard/orchestration/resolveStandardLookup.ts +2 -1
  197. package/src/domains/standard/stories.test.ts +24 -0
  198. package/src/domains/standard/stories.ts +151 -0
  199. package/src/domains/tier/commands/list.ts +5 -55
  200. package/src/domains/tier/mcp/specs.ts +5 -31
  201. package/src/domains/tier/stories.test.ts +22 -0
  202. package/src/domains/tier/stories.ts +52 -0
  203. package/src/domains/token/mcp/specs.ts +5 -1
  204. package/src/domains/token/orchestration/resolveTokenLookup.ts +2 -1
  205. package/src/domains/trace/tracePlugin.test.ts +1 -1
  206. package/src/mcp/registerResources.test.ts +382 -74
  207. package/src/mcp/registerResources.ts +5 -348
  208. package/src/mcp/resources/buildGraphIndex.ts +167 -0
  209. package/src/mcp/resources/buildResourceList.test.ts +97 -0
  210. package/src/mcp/resources/buildResourceList.ts +176 -0
  211. package/src/mcp/resources/classifyEntity.test.ts +40 -0
  212. package/src/mcp/resources/classifyEntity.ts +27 -0
  213. package/src/mcp/resources/constants.ts +105 -0
  214. package/src/mcp/resources/displayPredicates.ts +62 -0
  215. package/src/mcp/resources/pickFirstValue.test.ts +29 -0
  216. package/src/mcp/resources/pickFirstValue.ts +26 -0
  217. package/src/mcp/resources/pickPrimaryType.test.ts +41 -0
  218. package/src/mcp/resources/pickPrimaryType.ts +26 -0
  219. package/src/mcp/resources/rankUriCompletions.test.ts +52 -0
  220. package/src/mcp/resources/rankUriCompletions.ts +64 -0
  221. package/src/mcp/resources/readEntity.ts +169 -0
  222. package/src/mcp/resources/registerResources.ts +126 -0
  223. package/src/mcp/resources/types.ts +128 -0
  224. package/src/mcp/tools/index.ts +9 -0
  225. package/src/mcp/tools/registerTools.test.ts +65 -8
  226. package/src/mcp/types.ts +4 -0
  227. package/src/pipeline/collectCommands.ts +8 -1
  228. package/src/pipeline/runCli.ts +40 -6
  229. package/src/testing/createTestMcpClient.ts +4 -1
  230. package/src/testing/index.ts +10 -0
  231. package/src/testing/integration/__snapshots__/executor-golden.test.ts.snap +1207 -0
  232. package/src/testing/integration/cross-binary-byte-equality.test.ts +129 -0
  233. package/src/testing/integration/executor-execution.test.ts +101 -0
  234. package/src/testing/integration/executor-golden.test.ts +145 -0
  235. package/src/testing/integration/mcp-surface.test.ts +1 -1
  236. package/src/testing/integration/parity.test.ts +17 -12
  237. package/src/testing/regression/0001-skill-discovery-granularity.test.ts +71 -0
  238. package/src/testing/setupXdgIsolation.ts +15 -0
  239. package/src/testing/standardParityFixtures.ts +92 -0
  240. package/src/testing/storyFixtures.ts +63 -0
  241. package/src/pipeline/runInteractiveCommand.ts +0 -198
@@ -1,38 +1,20 @@
1
- import { $, gen, type Task, writeFile } from "@canonical/task";
2
- import { readConfig } from "../../../config/index.js";
3
- import resolveConfigPath from "../../../config/resolveConfigPath.js";
1
+ import type { Task } from "@canonical/task";
2
+ import type { ConfigScope } from "../../../config/index.js";
3
+ import writeConfigField from "./writeConfigField.js";
4
4
 
5
5
  /**
6
6
  * Build a config-write task for enabling or disabling query tracing.
7
7
  *
8
- * @param cwd - Working directory containing pragma.config.json.
8
+ * @param cwd - Working directory the target config layer is resolved from.
9
9
  * @param trace - `true` to enable, `false` to disable.
10
- * @returns Task yielding the persisted field/value payload.
10
+ * @param scope - Optional explicit target layer (`"global"` | `"local"`).
11
+ * @returns Task yielding the persisted field/value payload and target path.
12
+ * @note - Impure — reads and writes the target config file.
11
13
  */
12
14
  export default function setTrace(
13
15
  cwd: string,
14
16
  trace: boolean,
15
- ): Task<{ field: "trace"; value: boolean }> {
16
- return gen(function* () {
17
- // Merge onto the full existing config so other fields (e.g. `packages`)
18
- // survive — rebuilding from a fixed field list silently drops everything
19
- // not listed, and `packages` is runtime-consumed, so dropping it is data
20
- // loss. `readConfig` normalises defaults (tier: undefined, channel:
21
- // "normal"); strip those so we keep the file minimal, matching the
22
- // existing convention of not persisting unset defaults.
23
- const current = readConfig(cwd);
24
- const { tier, channel, ...rest } = current;
25
- const next = {
26
- ...rest,
27
- ...(tier !== undefined ? { tier } : {}),
28
- ...(channel !== "normal" ? { channel } : {}),
29
- trace,
30
- };
31
-
32
- yield* $(
33
- writeFile(resolveConfigPath(cwd), `${JSON.stringify(next, null, 2)}\n`),
34
- );
35
-
36
- return { field: "trace", value: trace };
37
- });
17
+ scope?: ConfigScope,
18
+ ): Task<{ field: "trace"; value: boolean | undefined; path: string }> {
19
+ return writeConfigField(cwd, "trace", trace, scope);
38
20
  }
@@ -0,0 +1,41 @@
1
+ import { $, gen, type Task, writeFile } from "@canonical/task";
2
+ import {
3
+ type ConfigScope,
4
+ type ConfigUpdate,
5
+ mergeConfigFileUpdate,
6
+ resolveWriteConfigPath,
7
+ } from "../../../config/index.js";
8
+
9
+ /**
10
+ * Build a config-write task for one field.
11
+ *
12
+ * Resolves the target layer with the global-first write rule (explicit
13
+ * scope wins, else the nearest existing project file, else the global XDG
14
+ * file), merges the update into that file's own contents — never copying
15
+ * values from another layer — and writes through the tracked `writeFile`
16
+ * effect so the change participates in dry-run and undo. `undefined`
17
+ * removes the field from the target file.
18
+ *
19
+ * @param cwd - Working directory the project layer is resolved from.
20
+ * @param field - Config field to set or remove.
21
+ * @param value - New value, or `undefined` to remove the field.
22
+ * @param scope - Optional explicit target layer.
23
+ * @returns Task yielding the persisted field/value payload and the path written.
24
+ * @note Impure — resolves and reads the target file, writes via effect.
25
+ */
26
+ export default function writeConfigField<F extends keyof ConfigUpdate>(
27
+ cwd: string,
28
+ field: F,
29
+ value: ConfigUpdate[F],
30
+ scope?: ConfigScope,
31
+ ): Task<{ field: F; value: ConfigUpdate[F]; path: string }> {
32
+ return gen(function* () {
33
+ const path = resolveWriteConfigPath(cwd, scope);
34
+ const update = { [field]: value } as ConfigUpdate;
35
+ const content = mergeConfigFileUpdate(path, update);
36
+
37
+ yield* $(writeFile(path, content));
38
+
39
+ return { field, value, path };
40
+ });
41
+ }
@@ -90,7 +90,7 @@ export default function buildComponentCommand(): CommandDefinition {
90
90
  validOptions: ["react", "svelte", "lit"],
91
91
  recovery: {
92
92
  message: "Provide a framework for component generation.",
93
- cli: "pragma create component react --component-path src/components/Button",
93
+ cli: "pragma create component react src/components/Button",
94
94
  },
95
95
  });
96
96
  }
@@ -109,10 +109,10 @@ export default function buildComponentCommand(): CommandDefinition {
109
109
  },
110
110
  meta: {
111
111
  examples: [
112
- "pragma create component react --component-path src/components/Button",
113
- "pragma create component svelte --component-path src/lib/components/Toggle",
114
- "pragma create component lit --component-path src/lib/Accordion",
115
- "pragma create component react --component-path src/components/Button --dry-run",
112
+ "pragma create component react src/components/Button",
113
+ "pragma create component svelte src/lib/components/Toggle",
114
+ "pragma create component lit src/lib/Accordion",
115
+ "pragma create component react src/components/Button --dry-run",
116
116
  ],
117
117
  },
118
118
  parameterGroups: {
@@ -1,21 +1,35 @@
1
- import { configExists } from "#config";
1
+ import { readConfigLayers } from "#config";
2
2
  import type { CheckContext, CheckResult } from "../types.js";
3
3
 
4
4
  /**
5
- * Check that `pragma.config.json` exists in the working directory.
5
+ * Check which config layer is active for the working directory.
6
+ *
7
+ * Passes when a project `pragma.config.json` is found up the tree, or —
8
+ * pragma is global-first — when only the global XDG config exists. Fails
9
+ * only when neither layer is configured.
6
10
  *
7
11
  * @param ctx - Check context with the working directory.
8
- * @returns A CheckResult indicating pass or fail.
12
+ * @returns A CheckResult indicating pass or fail with the active layer.
9
13
  * @note Impure
10
14
  */
11
15
  export default async function checkConfigFile(
12
16
  ctx: CheckContext,
13
17
  ): Promise<CheckResult> {
14
- if (configExists(ctx.cwd)) {
18
+ const layers = readConfigLayers(ctx.cwd);
19
+
20
+ if (layers.project.exists) {
21
+ return {
22
+ name: "pragma.config.json",
23
+ status: "pass",
24
+ detail: `found (${layers.project.path})`,
25
+ };
26
+ }
27
+
28
+ if (layers.global.exists) {
15
29
  return {
16
30
  name: "pragma.config.json",
17
31
  status: "pass",
18
- detail: "found",
32
+ detail: `no project config — global config active (${layers.global.path})`,
19
33
  };
20
34
  }
21
35
 
@@ -0,0 +1,130 @@
1
+ import { existsSync } from "node:fs";
2
+ import { delimiter, isAbsolute, join } from "node:path";
3
+ import type { DetectedHarness } from "@canonical/harnesses";
4
+ import { detectHarnesses, readMcpConfig } from "@canonical/harnesses";
5
+ import { runTask } from "@canonical/task/node";
6
+ import type { CheckContext, CheckResult } from "../types.js";
7
+
8
+ const NAME = "MCP commands";
9
+
10
+ /**
11
+ * Extract the stdio command from an MCP server config entry, if any.
12
+ * HTTP/SSE entries (e.g. `{ "type": "http", "url": ... }`) have no command
13
+ * and are not checked.
14
+ *
15
+ * @param entry - Raw server entry from a harness MCP config.
16
+ * @returns The command string, or `undefined` when the entry is not
17
+ * command-based.
18
+ */
19
+ function commandOf(entry: unknown): string | undefined {
20
+ if (typeof entry !== "object" || entry === null) return undefined;
21
+ const command = (entry as { command?: unknown }).command;
22
+ return typeof command === "string" && command.length > 0
23
+ ? command
24
+ : undefined;
25
+ }
26
+
27
+ /**
28
+ * Resolve a command the way a shell would: entries containing a path
29
+ * separator are checked as file paths (relative to the project root),
30
+ * bare names are searched across `PATH`.
31
+ *
32
+ * @param command - Command string from an MCP server entry.
33
+ * @param cwd - Project root for resolving relative paths.
34
+ * @returns Whether the command resolves to an existing file.
35
+ * @note Impure — reads `process.env.PATH` and the filesystem.
36
+ */
37
+ function commandResolves(command: string, cwd: string): boolean {
38
+ if (command.includes("/") || command.includes("\\")) {
39
+ return existsSync(isAbsolute(command) ? command : join(cwd, command));
40
+ }
41
+
42
+ return (process.env.PATH ?? "")
43
+ .split(delimiter)
44
+ .filter((dir) => dir.length > 0)
45
+ .some((dir) => existsSync(join(dir, command)));
46
+ }
47
+
48
+ /**
49
+ * Check that every command-based MCP server entry in detected harness
50
+ * configs resolves to an executable. A dead entry (e.g. a leftover server
51
+ * whose binary was never installed) makes every agent session try and fail
52
+ * to boot it, so it is flagged with the offending config path.
53
+ *
54
+ * @param ctx - Check context with the working directory.
55
+ * @returns A CheckResult: pass when all commands resolve, fail listing the
56
+ * broken entries, or skip when there is nothing to check.
57
+ * @note Impure
58
+ */
59
+ export default async function checkMcpCommands(
60
+ ctx: CheckContext,
61
+ ): Promise<CheckResult> {
62
+ let detected: DetectedHarness[];
63
+ try {
64
+ detected = await runTask(detectHarnesses(ctx.cwd));
65
+ } catch {
66
+ return {
67
+ name: NAME,
68
+ status: "skip",
69
+ detail: "harness detection failed",
70
+ };
71
+ }
72
+
73
+ const withConfig = detected.filter((d) => d.configExists);
74
+ if (withConfig.length === 0) {
75
+ return {
76
+ name: NAME,
77
+ status: "skip",
78
+ detail: "no MCP configs found",
79
+ };
80
+ }
81
+
82
+ const broken: string[] = [];
83
+ let checked = 0;
84
+
85
+ for (const d of withConfig) {
86
+ let servers: Record<string, unknown>;
87
+ try {
88
+ servers = await runTask(readMcpConfig(d.harness, ctx.cwd));
89
+ } catch {
90
+ // Config unreadable — the "MCP configured" check reports on this.
91
+ continue;
92
+ }
93
+
94
+ for (const [serverName, entry] of Object.entries(servers)) {
95
+ const command = commandOf(entry);
96
+ if (!command) continue;
97
+
98
+ checked += 1;
99
+ if (!commandResolves(command, ctx.cwd)) {
100
+ broken.push(
101
+ `"${serverName}" ("${command}" not found, ${d.configPath})`,
102
+ );
103
+ }
104
+ }
105
+ }
106
+
107
+ if (checked === 0) {
108
+ return {
109
+ name: NAME,
110
+ status: "skip",
111
+ detail: "no command-based MCP servers configured",
112
+ };
113
+ }
114
+
115
+ if (broken.length === 0) {
116
+ return {
117
+ name: NAME,
118
+ status: "pass",
119
+ detail: `${checked} command${checked === 1 ? "" : "s"} resolve on PATH`,
120
+ };
121
+ }
122
+
123
+ return {
124
+ name: NAME,
125
+ status: "fail",
126
+ detail: `unresolvable: ${broken.join("; ")}`,
127
+ remedy:
128
+ "Install the missing command or remove the entry from the MCP config — every agent session tries and fails to boot it.",
129
+ };
130
+ }
@@ -1,6 +1,6 @@
1
1
  import type { DetectedHarness } from "@canonical/harnesses";
2
2
  import { detectHarnesses, readMcpConfig } from "@canonical/harnesses";
3
- import { runTask } from "@canonical/task";
3
+ import { runTask } from "@canonical/task/node";
4
4
  import type { CheckContext, CheckResult } from "../types.js";
5
5
 
6
6
  /**
@@ -6,14 +6,12 @@
6
6
  */
7
7
 
8
8
  import { readConfig } from "#config";
9
- import { parsePackageEntry } from "../../../refs/operations/parseRef.js";
10
- import readGlobalRefs from "../../../refs/operations/readGlobalRefs.js";
11
9
  import {
12
10
  createBundledLoader,
13
11
  createGitLoader,
14
12
  createLocalLoader,
15
13
  } from "../../../shared/loaders/index.js";
16
- import { DEFAULT_PACKAGES } from "../../../shared/packages.js";
14
+ import { mergeAndParseRefs } from "../../../shared/mergeAndParseRefs.js";
17
15
  import { resolveSemanticPackages } from "../../../shared/semanticPackage.js";
18
16
  import type { CheckContext, CheckResult } from "../types.js";
19
17
 
@@ -21,17 +19,7 @@ export default async function checkPackageRefs(
21
19
  ctx: CheckContext,
22
20
  ): Promise<CheckResult> {
23
21
  const config = readConfig(ctx.cwd);
24
- const globalEntries = readGlobalRefs();
25
-
26
- const hasProjectRefs = config.packages && config.packages.length > 0;
27
-
28
- const rawEntries = hasProjectRefs
29
- ? config.packages!
30
- : globalEntries.length > 0
31
- ? globalEntries
32
- : DEFAULT_PACKAGES;
33
-
34
- const refs = [...rawEntries].map(parsePackageEntry);
22
+ const refs = mergeAndParseRefs(config.packages);
35
23
  const loaders = [
36
24
  createLocalLoader(),
37
25
  createGitLoader(),
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import type { DetectedHarness } from "@canonical/harnesses";
3
3
  import { detectHarnesses } from "@canonical/harnesses";
4
- import { runTask } from "@canonical/task";
4
+ import { runTask } from "@canonical/task/node";
5
5
  import type { CheckContext, CheckResult } from "../types.js";
6
6
 
7
7
  /**
@@ -1,6 +1,7 @@
1
1
  /** @module Individual environment health checks invoked by runChecks. */
2
2
  export { default as checkConfigFile } from "./checkConfigFile.js";
3
3
  export { default as checkKeStore } from "./checkKeStore.js";
4
+ export { default as checkMcpCommands } from "./checkMcpCommands.js";
4
5
  export { default as checkMcpConfigured } from "./checkMcpConfigured.js";
5
6
  export { default as checkNodeVersion } from "./checkNodeVersion.js";
6
7
  export { default as checkPackageRefs } from "./checkPackageRefs.js";
@@ -1,8 +1,12 @@
1
- import { beforeEach, describe, expect, it, vi } from "vitest";
1
+ import { mkdtempSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
5
 
3
6
  vi.mock("#config", async (importOriginal) => ({
4
7
  ...(await importOriginal<typeof import("#config")>()),
5
8
  configExists: vi.fn(),
9
+ readConfigLayers: vi.fn(),
6
10
  }));
7
11
  vi.mock("../../shared/bootStore.js", () => ({
8
12
  bootStore: vi.fn(),
@@ -14,17 +18,18 @@ vi.mock("@canonical/harnesses", () => ({
14
18
  detectHarnesses: vi.fn(),
15
19
  readMcpConfig: vi.fn(),
16
20
  }));
17
- vi.mock("@canonical/task", () => ({
21
+ vi.mock("@canonical/task/node", () => ({
18
22
  runTask: vi.fn((task: unknown) => task),
19
23
  }));
20
24
 
21
25
  import { detectHarnesses, readMcpConfig } from "@canonical/harnesses";
22
- import { configExists } from "#config";
26
+ import { configExists, readConfigLayers } from "#config";
23
27
  import { collectStoreSummary } from "../../info/operations/index.js";
24
28
  import { bootStore } from "../../shared/bootStore.js";
25
29
  import {
26
30
  checkConfigFile,
27
31
  checkKeStore,
32
+ checkMcpCommands,
28
33
  checkMcpConfigured,
29
34
  checkNodeVersion,
30
35
  checkPragmaVersion,
@@ -55,14 +60,55 @@ describe("checkPragmaVersion", () => {
55
60
  });
56
61
 
57
62
  describe("checkConfigFile", () => {
58
- it("passes when config exists", async () => {
59
- vi.mocked(configExists).mockReturnValue(true);
63
+ it("passes when a project config exists", async () => {
64
+ vi.mocked(readConfigLayers).mockReturnValue({
65
+ config: { tier: undefined, channel: "normal" },
66
+ origins: {
67
+ tier: "default",
68
+ channel: "default",
69
+ packages: "default",
70
+ trace: "default",
71
+ framework: "default",
72
+ },
73
+ global: { path: "/home/u/.config/pragma/config.json", exists: false },
74
+ project: { path: "/repo/pragma.config.json", exists: true },
75
+ });
76
+ const result = await checkConfigFile(ctx);
77
+ expect(result.status).toBe("pass");
78
+ expect(result.detail).toContain("/repo/pragma.config.json");
79
+ });
80
+
81
+ it("passes when only the global config exists", async () => {
82
+ vi.mocked(readConfigLayers).mockReturnValue({
83
+ config: { tier: undefined, channel: "normal" },
84
+ origins: {
85
+ tier: "default",
86
+ channel: "default",
87
+ packages: "default",
88
+ trace: "default",
89
+ framework: "default",
90
+ },
91
+ global: { path: "/home/u/.config/pragma/config.json", exists: true },
92
+ project: { path: "/repo/pragma.config.json", exists: false },
93
+ });
60
94
  const result = await checkConfigFile(ctx);
61
95
  expect(result.status).toBe("pass");
96
+ expect(result.detail).toContain("global config active");
62
97
  });
63
98
 
64
- it("fails with remedy when config missing", async () => {
65
- vi.mocked(configExists).mockReturnValue(false);
99
+ it("fails with remedy when neither layer exists", async () => {
100
+ vi.mocked(readConfigLayers).mockReturnValue({
101
+ config: { tier: undefined, channel: "normal" },
102
+ origins: {
103
+ tier: "default",
104
+ channel: "default",
105
+ packages: "default",
106
+ trace: "default",
107
+ framework: "default",
108
+ },
109
+ global: { path: "/home/u/.config/pragma/config.json", exists: false },
110
+ project: { path: "/repo/pragma.config.json", exists: false },
111
+ });
66
112
  const result = await checkConfigFile(ctx);
67
113
  expect(result.status).toBe("fail");
68
114
  expect(result.remedy).toBeDefined();
@@ -145,6 +191,111 @@ describe("checkMcpConfigured", () => {
145
191
  });
146
192
  });
147
193
 
194
+ describe("checkMcpCommands", () => {
195
+ const harness = {
196
+ id: "claude-code",
197
+ name: "Claude Code",
198
+ configPath: () => "/test/.mcp.json",
199
+ skillsPath: () => "/test/.claude/skills",
200
+ detect: [],
201
+ version: "*",
202
+ configFormat: "json" as const,
203
+ mcpKey: "mcpServers",
204
+ };
205
+ const detected = {
206
+ harness,
207
+ confidence: "high" as const,
208
+ configExists: true,
209
+ configPath: "/test/.mcp.json",
210
+ };
211
+
212
+ afterEach(() => {
213
+ vi.unstubAllEnvs();
214
+ });
215
+
216
+ it("skips when no harnesses detected", async () => {
217
+ vi.mocked(detectHarnesses).mockReturnValue(Promise.resolve([]) as never);
218
+ const result = await checkMcpCommands(ctx);
219
+ expect(result.status).toBe("skip");
220
+ });
221
+
222
+ it("skips when only URL-based servers are configured", async () => {
223
+ vi.mocked(detectHarnesses).mockReturnValue(
224
+ Promise.resolve([detected]) as never,
225
+ );
226
+ vi.mocked(readMcpConfig).mockReturnValue(
227
+ Promise.resolve({
228
+ figma: { type: "http", url: "https://mcp.figma.com/mcp" },
229
+ }) as never,
230
+ );
231
+
232
+ const result = await checkMcpCommands(ctx);
233
+ expect(result.status).toBe("skip");
234
+ expect(result.detail).toContain("no command-based");
235
+ });
236
+
237
+ it("passes when every configured command resolves on PATH", async () => {
238
+ const binDir = mkdtempSync(join(tmpdir(), "pragma-doctor-"));
239
+ writeFileSync(join(binDir, "pragma"), "#!/bin/sh\n", { mode: 0o755 });
240
+ vi.stubEnv("PATH", binDir);
241
+
242
+ vi.mocked(detectHarnesses).mockReturnValue(
243
+ Promise.resolve([detected]) as never,
244
+ );
245
+ vi.mocked(readMcpConfig).mockReturnValue(
246
+ Promise.resolve({
247
+ pragma: { command: "pragma", args: ["mcp"] },
248
+ }) as never,
249
+ );
250
+
251
+ const result = await checkMcpCommands(ctx);
252
+ expect(result.status).toBe("pass");
253
+ expect(result.detail).toContain("1 command");
254
+ });
255
+
256
+ it("fails and names the entry whose command does not resolve", async () => {
257
+ const emptyDir = mkdtempSync(join(tmpdir(), "pragma-doctor-empty-"));
258
+ vi.stubEnv("PATH", emptyDir);
259
+
260
+ vi.mocked(detectHarnesses).mockReturnValue(
261
+ Promise.resolve([detected]) as never,
262
+ );
263
+ vi.mocked(readMcpConfig).mockReturnValue(
264
+ Promise.resolve({
265
+ sem: { command: "sem", args: ["mcp"] },
266
+ }) as never,
267
+ );
268
+
269
+ const result = await checkMcpCommands(ctx);
270
+ expect(result.status).toBe("fail");
271
+ expect(result.detail).toContain('"sem"');
272
+ expect(result.detail).toContain("/test/.mcp.json");
273
+ expect(result.remedy).toBeDefined();
274
+ });
275
+
276
+ it("flags only the broken entry among several", async () => {
277
+ const binDir = mkdtempSync(join(tmpdir(), "pragma-doctor-mixed-"));
278
+ writeFileSync(join(binDir, "pragma"), "#!/bin/sh\n", { mode: 0o755 });
279
+ vi.stubEnv("PATH", binDir);
280
+
281
+ vi.mocked(detectHarnesses).mockReturnValue(
282
+ Promise.resolve([detected]) as never,
283
+ );
284
+ vi.mocked(readMcpConfig).mockReturnValue(
285
+ Promise.resolve({
286
+ pragma: { command: "pragma", args: ["mcp"] },
287
+ sem: { command: "sem", args: ["mcp"] },
288
+ figma: { type: "http", url: "https://mcp.figma.com/mcp" },
289
+ }) as never,
290
+ );
291
+
292
+ const result = await checkMcpCommands(ctx);
293
+ expect(result.status).toBe("fail");
294
+ expect(result.detail).toContain('"sem"');
295
+ expect(result.detail).not.toContain('"pragma"');
296
+ });
297
+ });
298
+
148
299
  describe("checkSkillsSymlinked", () => {
149
300
  it("skips when no harnesses detected", async () => {
150
301
  vi.mocked(detectHarnesses).mockReturnValue(Promise.resolve([]) as never);
@@ -8,12 +8,14 @@ vi.mock("./checks/index.js", () => ({
8
8
  checkKeStore: vi.fn(),
9
9
  checkShellCompletions: vi.fn(),
10
10
  checkMcpConfigured: vi.fn(),
11
+ checkMcpCommands: vi.fn(),
11
12
  checkSkillsSymlinked: vi.fn(),
12
13
  }));
13
14
 
14
15
  import {
15
16
  checkConfigFile,
16
17
  checkKeStore,
18
+ checkMcpCommands,
17
19
  checkMcpConfigured,
18
20
  checkNodeVersion,
19
21
  checkPackageRefs,
@@ -50,13 +52,14 @@ describe("runChecks", () => {
50
52
  vi.mocked(checkKeStore).mockResolvedValue(pass("store"));
51
53
  vi.mocked(checkShellCompletions).mockResolvedValue(pass("completions"));
52
54
  vi.mocked(checkMcpConfigured).mockResolvedValue(pass("mcp"));
55
+ vi.mocked(checkMcpCommands).mockResolvedValue(pass("mcp commands"));
53
56
  vi.mocked(checkSkillsSymlinked).mockResolvedValue(pass("skills"));
54
57
 
55
58
  const data = await runChecks({ cwd: "/test" });
56
- expect(data.passed).toBe(8);
59
+ expect(data.passed).toBe(9);
57
60
  expect(data.failed).toBe(0);
58
61
  expect(data.skipped).toBe(0);
59
- expect(data.checks).toHaveLength(8);
62
+ expect(data.checks).toHaveLength(9);
60
63
  });
61
64
 
62
65
  it("counts failures and skips correctly", async () => {
@@ -67,10 +70,11 @@ describe("runChecks", () => {
67
70
  vi.mocked(checkKeStore).mockResolvedValue(fail("store"));
68
71
  vi.mocked(checkShellCompletions).mockResolvedValue(fail("completions"));
69
72
  vi.mocked(checkMcpConfigured).mockResolvedValue(pass("mcp"));
73
+ vi.mocked(checkMcpCommands).mockResolvedValue(pass("mcp commands"));
70
74
  vi.mocked(checkSkillsSymlinked).mockResolvedValue(skip("skills"));
71
75
 
72
76
  const data = await runChecks({ cwd: "/test" });
73
- expect(data.passed).toBe(4);
77
+ expect(data.passed).toBe(5);
74
78
  expect(data.failed).toBe(3);
75
79
  expect(data.skipped).toBe(1);
76
80
  });
@@ -83,6 +87,7 @@ describe("runChecks", () => {
83
87
  vi.mocked(checkKeStore).mockResolvedValue(pass("store"));
84
88
  vi.mocked(checkShellCompletions).mockResolvedValue(pass("completions"));
85
89
  vi.mocked(checkMcpConfigured).mockResolvedValue(pass("mcp"));
90
+ vi.mocked(checkMcpCommands).mockResolvedValue(pass("mcp commands"));
86
91
  vi.mocked(checkSkillsSymlinked).mockResolvedValue(pass("skills"));
87
92
 
88
93
  const data = await runChecks({ cwd: "/test" });
@@ -91,7 +96,7 @@ describe("runChecks", () => {
91
96
  expect(data.checks[2].name).toBe("config");
92
97
  expect(data.checks[3].name).toBe("refs");
93
98
  expect(data.checks[4].name).toBe("store");
94
- expect(data.checks[7].name).toBe("skills");
99
+ expect(data.checks[8].name).toBe("skills");
95
100
  });
96
101
 
97
102
  it("preserves order even when checks resolve out of order", async () => {
@@ -109,6 +114,7 @@ describe("runChecks", () => {
109
114
  vi.mocked(checkKeStore).mockResolvedValue(pass("store"));
110
115
  vi.mocked(checkShellCompletions).mockResolvedValue(pass("completions"));
111
116
  vi.mocked(checkMcpConfigured).mockResolvedValue(pass("mcp"));
117
+ vi.mocked(checkMcpCommands).mockResolvedValue(pass("mcp commands"));
112
118
  vi.mocked(checkSkillsSymlinked).mockReturnValue(delayed(pass("skills"), 0));
113
119
 
114
120
  const data = await runChecks({ cwd: "/test" });
@@ -120,6 +126,7 @@ describe("runChecks", () => {
120
126
  "store",
121
127
  "completions",
122
128
  "mcp",
129
+ "mcp commands",
123
130
  "skills",
124
131
  ]);
125
132
  });
@@ -132,13 +139,14 @@ describe("runChecks", () => {
132
139
  vi.mocked(checkKeStore).mockResolvedValue(pass("store"));
133
140
  vi.mocked(checkShellCompletions).mockResolvedValue(pass("completions"));
134
141
  vi.mocked(checkMcpConfigured).mockResolvedValue(pass("mcp"));
142
+ vi.mocked(checkMcpCommands).mockResolvedValue(pass("mcp commands"));
135
143
  vi.mocked(checkSkillsSymlinked).mockResolvedValue(pass("skills"));
136
144
 
137
145
  const data = await runChecks({ cwd: "/test" });
138
146
 
139
147
  // The whole run still completes; the thrown check becomes a fail in place.
140
- expect(data.checks).toHaveLength(8);
141
- expect(data.passed).toBe(7);
148
+ expect(data.checks).toHaveLength(9);
149
+ expect(data.passed).toBe(8);
142
150
  expect(data.failed).toBe(1);
143
151
  const failed = data.checks[2];
144
152
  expect(failed.status).toBe("fail");
@@ -157,6 +165,7 @@ describe("runChecks", () => {
157
165
  vi.mocked(checkKeStore).mockRejectedValue("string failure");
158
166
  vi.mocked(checkShellCompletions).mockResolvedValue(pass("completions"));
159
167
  vi.mocked(checkMcpConfigured).mockResolvedValue(pass("mcp"));
168
+ vi.mocked(checkMcpCommands).mockResolvedValue(pass("mcp commands"));
160
169
  vi.mocked(checkSkillsSymlinked).mockResolvedValue(pass("skills"));
161
170
 
162
171
  const data = await runChecks({ cwd: "/test" });
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  checkConfigFile,
3
3
  checkKeStore,
4
+ checkMcpCommands,
4
5
  checkMcpConfigured,
5
6
  checkNodeVersion,
6
7
  checkPackageRefs,
@@ -28,6 +29,7 @@ function buildChecks(
28
29
  ["ke store", checkKeStore(ctx)],
29
30
  ["Shell completions", checkShellCompletions()],
30
31
  ["MCP configured", checkMcpConfigured(ctx)],
32
+ ["MCP commands", checkMcpCommands(ctx)],
31
33
  ["Skills symlinked", checkSkillsSymlinked(ctx)],
32
34
  ];
33
35
  }