@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
@@ -0,0 +1,107 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { PragmaError } from "../error/PragmaError.js";
6
+ import readConfigLayers from "./readConfigLayers.js";
7
+
8
+ describe("readConfigLayers", () => {
9
+ let xdgDir: string;
10
+ let projectDir: string;
11
+ let originalXdg: string | undefined;
12
+
13
+ beforeEach(() => {
14
+ originalXdg = process.env.XDG_CONFIG_HOME;
15
+ xdgDir = mkdtempSync(join(tmpdir(), "pragma-layers-xdg-"));
16
+ process.env.XDG_CONFIG_HOME = xdgDir;
17
+ projectDir = mkdtempSync(join(tmpdir(), "pragma-layers-project-"));
18
+ mkdirSync(join(projectDir, ".git"));
19
+ });
20
+
21
+ afterEach(() => {
22
+ process.env.XDG_CONFIG_HOME = originalXdg;
23
+ rmSync(xdgDir, { recursive: true, force: true });
24
+ rmSync(projectDir, { recursive: true, force: true });
25
+ });
26
+
27
+ function writeGlobal(content: string): void {
28
+ mkdirSync(join(xdgDir, "pragma"), { recursive: true });
29
+ writeFileSync(join(xdgDir, "pragma/config.json"), content);
30
+ }
31
+
32
+ function writeProject(content: string): void {
33
+ writeFileSync(join(projectDir, "pragma.config.json"), content);
34
+ }
35
+
36
+ it("returns defaults with default origins when no layer exists", () => {
37
+ const layers = readConfigLayers(projectDir);
38
+ expect(layers.config).toEqual({ tier: undefined, channel: "normal" });
39
+ expect(layers.origins).toEqual({
40
+ tier: "default",
41
+ channel: "default",
42
+ packages: "default",
43
+ trace: "default",
44
+ framework: "default",
45
+ stories: "default",
46
+ prefixes: "default",
47
+ });
48
+ expect(layers.global.exists).toBe(false);
49
+ expect(layers.project.exists).toBe(false);
50
+ expect(layers.project.path).toBe(join(projectDir, "pragma.config.json"));
51
+ });
52
+
53
+ it("reads the global layer when no project file exists", () => {
54
+ writeGlobal('{"channel":"experimental","tier":"global"}');
55
+ const layers = readConfigLayers(projectDir);
56
+ expect(layers.config.channel).toBe("experimental");
57
+ expect(layers.config.tier).toBe("global");
58
+ expect(layers.origins.channel).toBe("global");
59
+ expect(layers.origins.tier).toBe("global");
60
+ expect(layers.global.exists).toBe(true);
61
+ });
62
+
63
+ it("merges per field — project wins only the fields it sets", () => {
64
+ writeGlobal('{"channel":"experimental","trace":true}');
65
+ writeProject('{"tier":"apps/lxd"}');
66
+ const layers = readConfigLayers(projectDir);
67
+ expect(layers.config).toEqual({
68
+ tier: "apps/lxd",
69
+ channel: "experimental",
70
+ trace: true,
71
+ });
72
+ expect(layers.origins).toEqual({
73
+ tier: "project",
74
+ channel: "global",
75
+ packages: "default",
76
+ trace: "global",
77
+ framework: "default",
78
+ stories: "default",
79
+ prefixes: "default",
80
+ });
81
+ });
82
+
83
+ it("lets the project override a global field wholesale", () => {
84
+ writeGlobal('{"packages":["@canonical/a"],"channel":"experimental"}');
85
+ writeProject('{"packages":["@canonical/b"],"channel":"normal"}');
86
+ const layers = readConfigLayers(projectDir);
87
+ expect(layers.config.packages).toEqual(["@canonical/b"]);
88
+ expect(layers.config.channel).toBe("normal");
89
+ expect(layers.origins.packages).toBe("project");
90
+ expect(layers.origins.channel).toBe("project");
91
+ });
92
+
93
+ it("finds the project layer from a subdirectory", () => {
94
+ writeProject('{"tier":"apps/lxd"}');
95
+ const sub = join(projectDir, "packages/app");
96
+ mkdirSync(sub, { recursive: true });
97
+ const layers = readConfigLayers(sub);
98
+ expect(layers.config.tier).toBe("apps/lxd");
99
+ expect(layers.project.path).toBe(join(projectDir, "pragma.config.json"));
100
+ expect(layers.project.exists).toBe(true);
101
+ });
102
+
103
+ it("throws on invalid JSON in the global layer", () => {
104
+ writeGlobal("{not json");
105
+ expect(() => readConfigLayers(projectDir)).toThrow(PragmaError);
106
+ });
107
+ });
@@ -0,0 +1,115 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { DEFAULT_CONFIG } from "./defaultConfig.js";
3
+ import findProjectConfigPath from "./findProjectConfigPath.js";
4
+ import parseConfigValues from "./parseConfigValues.js";
5
+ import resolveConfigPath from "./resolveConfigPath.js";
6
+ import resolveGlobalConfigPath from "./resolveGlobalConfigPath.js";
7
+ import type {
8
+ ConfigFileValues,
9
+ ConfigLayers,
10
+ ConfigOrigin,
11
+ PragmaConfig,
12
+ } from "./types.js";
13
+
14
+ /**
15
+ * Fields the built-in defaults document contributes to the layer merge.
16
+ *
17
+ * `packages` is deliberately absent: it reaches the ref merge through
18
+ * `DEFAULT_PACKAGES` instead, so global `refs.json` entries keep merging
19
+ * over the default packages by name. Surfacing it here would make the
20
+ * defaults look user-declared and replace the merged set wholesale.
21
+ */
22
+ const DEFAULT_LAYER: ConfigFileValues = {
23
+ channel: DEFAULT_CONFIG.channel,
24
+ };
25
+
26
+ /**
27
+ * Resolve the layered configuration with per-field provenance.
28
+ *
29
+ * Layer order, least to most specific: built-in defaults, the global XDG
30
+ * file (`~/.config/pragma/config.json`), and the nearest project
31
+ * `pragma.config.json` up the tree. Merging is per field — a field set by
32
+ * a more specific layer wins wholesale (`packages` replaces as a whole,
33
+ * preserving the established project-pins-exactly semantics).
34
+ *
35
+ * @param cwd - Directory the project layer is resolved from.
36
+ * @returns The effective config, per-field origins, and both layers.
37
+ * @throws PragmaError with code `CONFIG_ERROR` when either file is invalid.
38
+ * @note Impure — reads config files from the filesystem.
39
+ */
40
+ export default function readConfigLayers(
41
+ cwd: string = process.cwd(),
42
+ ): ConfigLayers {
43
+ const globalPath = resolveGlobalConfigPath();
44
+ const projectPath = findProjectConfigPath(cwd) ?? resolveConfigPath(cwd);
45
+
46
+ const globalFile = readConfigFile(globalPath);
47
+ const projectFile = readConfigFile(projectPath);
48
+
49
+ const pick = <K extends keyof ConfigFileValues>(
50
+ field: K,
51
+ ): { value: ConfigFileValues[K] | undefined; origin: ConfigOrigin } => {
52
+ if (field in projectFile.values) {
53
+ return { value: projectFile.values[field], origin: "project" };
54
+ }
55
+ if (field in globalFile.values) {
56
+ return { value: globalFile.values[field], origin: "global" };
57
+ }
58
+ if (field in DEFAULT_LAYER) {
59
+ return { value: DEFAULT_LAYER[field], origin: "default" };
60
+ }
61
+ return { value: undefined, origin: "default" };
62
+ };
63
+
64
+ const tier = pick("tier");
65
+ const channel = pick("channel");
66
+ const packages = pick("packages");
67
+ const trace = pick("trace");
68
+ const framework = pick("framework");
69
+ const stories = pick("stories");
70
+ const prefixes = pick("prefixes");
71
+
72
+ const config: PragmaConfig = {
73
+ tier: tier.value,
74
+ channel: channel.value ?? DEFAULT_CONFIG.channel,
75
+ ...(packages.value ? { packages: packages.value } : {}),
76
+ ...(trace.value !== undefined ? { trace: trace.value } : {}),
77
+ ...(framework.value !== undefined ? { framework: framework.value } : {}),
78
+ ...(stories.value ? { stories: stories.value } : {}),
79
+ ...(prefixes.value ? { prefixes: prefixes.value } : {}),
80
+ };
81
+
82
+ return {
83
+ config,
84
+ origins: {
85
+ tier: tier.origin,
86
+ channel: channel.origin,
87
+ packages: packages.origin,
88
+ trace: trace.origin,
89
+ framework: framework.origin,
90
+ stories: stories.origin,
91
+ prefixes: prefixes.origin,
92
+ },
93
+ global: { path: globalPath, exists: globalFile.exists },
94
+ project: { path: projectPath, exists: projectFile.exists },
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Read and parse one config file layer.
100
+ *
101
+ * A missing or unreadable file contributes no values — the layer simply
102
+ * does not participate in the merge.
103
+ */
104
+ function readConfigFile(path: string): {
105
+ values: ConfigFileValues;
106
+ exists: boolean;
107
+ } {
108
+ let raw: string;
109
+ try {
110
+ raw = readFileSync(path, "utf-8");
111
+ } catch {
112
+ return { values: {}, exists: false };
113
+ }
114
+ return { values: parseConfigValues(raw, path), exists: true };
115
+ }
@@ -0,0 +1,13 @@
1
+ import { join } from "node:path";
2
+ import { globalConfigDir } from "../domains/refs/operations/paths.js";
3
+
4
+ /**
5
+ * Resolve the absolute path of the global config file.
6
+ *
7
+ * Lives next to `refs.json` in the pragma XDG config directory:
8
+ * `$XDG_CONFIG_HOME/pragma/config.json`, defaulting to
9
+ * `~/.config/pragma/config.json`.
10
+ */
11
+ export default function resolveGlobalConfigPath(): string {
12
+ return join(globalConfigDir(), "config.json");
13
+ }
@@ -0,0 +1,53 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import resolveWriteConfigPath from "./resolveWriteConfigPath.js";
6
+
7
+ describe("resolveWriteConfigPath", () => {
8
+ let xdgDir: string;
9
+ let projectDir: string;
10
+ let originalXdg: string | undefined;
11
+
12
+ beforeEach(() => {
13
+ originalXdg = process.env.XDG_CONFIG_HOME;
14
+ xdgDir = mkdtempSync(join(tmpdir(), "pragma-write-xdg-"));
15
+ process.env.XDG_CONFIG_HOME = xdgDir;
16
+ projectDir = mkdtempSync(join(tmpdir(), "pragma-write-project-"));
17
+ mkdirSync(join(projectDir, ".git"));
18
+ });
19
+
20
+ afterEach(() => {
21
+ process.env.XDG_CONFIG_HOME = originalXdg;
22
+ rmSync(xdgDir, { recursive: true, force: true });
23
+ rmSync(projectDir, { recursive: true, force: true });
24
+ });
25
+
26
+ it("targets the nearest existing project file", () => {
27
+ writeFileSync(join(projectDir, "pragma.config.json"), "{}");
28
+ const sub = join(projectDir, "packages/app");
29
+ mkdirSync(sub, { recursive: true });
30
+ expect(resolveWriteConfigPath(sub)).toBe(
31
+ join(projectDir, "pragma.config.json"),
32
+ );
33
+ });
34
+
35
+ it("falls back to the global file when no project file exists", () => {
36
+ expect(resolveWriteConfigPath(projectDir)).toBe(
37
+ join(xdgDir, "pragma/config.json"),
38
+ );
39
+ });
40
+
41
+ it("honors an explicit local scope even without an existing file", () => {
42
+ expect(resolveWriteConfigPath(projectDir, "local")).toBe(
43
+ join(projectDir, "pragma.config.json"),
44
+ );
45
+ });
46
+
47
+ it("honors an explicit global scope over an existing project file", () => {
48
+ writeFileSync(join(projectDir, "pragma.config.json"), "{}");
49
+ expect(resolveWriteConfigPath(projectDir, "global")).toBe(
50
+ join(xdgDir, "pragma/config.json"),
51
+ );
52
+ });
53
+ });
@@ -0,0 +1,31 @@
1
+ import findProjectConfigPath from "./findProjectConfigPath.js";
2
+ import resolveConfigPath from "./resolveConfigPath.js";
3
+ import resolveGlobalConfigPath from "./resolveGlobalConfigPath.js";
4
+ import type { ConfigScope } from "./types.js";
5
+
6
+ /**
7
+ * Resolve which config file a write should target.
8
+ *
9
+ * An explicit scope wins: `"local"` targets `<cwd>/pragma.config.json`,
10
+ * `"global"` targets the XDG file. Otherwise the nearest existing project
11
+ * file up the tree is updated in place, and when none exists the write
12
+ * goes to the global file — pragma is global-first: an unconfigured
13
+ * directory should not sprout a project file as a side effect.
14
+ *
15
+ * @param cwd - Directory the project layer is resolved from.
16
+ * @param scope - Optional explicit target layer.
17
+ * @returns Absolute path of the config file to write.
18
+ * @note Impure — probes the filesystem for the nearest project file.
19
+ */
20
+ export default function resolveWriteConfigPath(
21
+ cwd: string,
22
+ scope?: ConfigScope,
23
+ ): string {
24
+ if (scope === "local") {
25
+ return resolveConfigPath(cwd);
26
+ }
27
+ if (scope === "global") {
28
+ return resolveGlobalConfigPath();
29
+ }
30
+ return findProjectConfigPath(cwd) ?? resolveGlobalConfigPath();
31
+ }
@@ -7,6 +7,7 @@
7
7
 
8
8
  import type { Channel, Framework } from "../constants.js";
9
9
  import type { RawPackageEntry } from "../domains/refs/operations/parseRef.js";
10
+ import type { StoryPackDefinition } from "../domains/shared/stories/pack/types.js";
10
11
 
11
12
  /** Parsed contents of pragma.config.json. */
12
13
  interface PragmaConfig {
@@ -27,6 +28,17 @@ interface PragmaConfig {
27
28
  * only — no behaviour reads it yet; reserved for future framework defaulting.
28
29
  */
29
30
  framework?: Framework | undefined;
31
+ /**
32
+ * Declarative read stories compiled into CLI commands and MCP tools at
33
+ * boot.
34
+ * @experimental Story packs (v0) are experimental — the format may change.
35
+ */
36
+ stories?: ReadonlyArray<StoryPackDefinition> | undefined;
37
+ /**
38
+ * Additional namespace prefixes merged over the built-in prefix map —
39
+ * registered on the store (usable in queries) and used for display.
40
+ */
41
+ prefixes?: Readonly<Record<string, string>> | undefined;
30
42
  }
31
43
 
32
44
  /** Partial update payload for writing config changes. */
@@ -43,4 +55,67 @@ interface ConfigUpdate {
43
55
  framework?: Framework | undefined;
44
56
  }
45
57
 
46
- export type { ConfigUpdate, PragmaConfig };
58
+ /**
59
+ * Values a single config file declares. A key is present only when the
60
+ * file sets it — presence drives which layer wins during merging.
61
+ */
62
+ interface ConfigFileValues {
63
+ readonly tier?: string;
64
+ readonly channel?: Channel;
65
+ readonly packages?: ReadonlyArray<RawPackageEntry>;
66
+ readonly trace?: boolean;
67
+ readonly framework?: Framework;
68
+ readonly stories?: ReadonlyArray<StoryPackDefinition>;
69
+ readonly prefixes?: Readonly<Record<string, string>>;
70
+ }
71
+
72
+ /** Which config layer supplied an effective field value. */
73
+ type ConfigOrigin = "default" | "global" | "project";
74
+
75
+ /** Per-field provenance for the effective merged config. */
76
+ interface ConfigOrigins {
77
+ readonly tier: ConfigOrigin;
78
+ readonly channel: ConfigOrigin;
79
+ readonly packages: ConfigOrigin;
80
+ readonly trace: ConfigOrigin;
81
+ readonly framework: ConfigOrigin;
82
+ readonly stories: ConfigOrigin;
83
+ readonly prefixes: ConfigOrigin;
84
+ }
85
+
86
+ /** A resolved config file layer. */
87
+ interface ConfigLayer {
88
+ /** Absolute path of the layer's config file (existing or would-be). */
89
+ readonly path: string;
90
+ /** Whether the file exists and was readable. */
91
+ readonly exists: boolean;
92
+ }
93
+
94
+ /** Layered config resolution result with per-field provenance. */
95
+ interface ConfigLayers {
96
+ /** The effective merged configuration (defaults < global < project). */
97
+ readonly config: PragmaConfig;
98
+ /** Which layer supplied each effective field. */
99
+ readonly origins: ConfigOrigins;
100
+ /** The global XDG layer (`$XDG_CONFIG_HOME/pragma/config.json`). */
101
+ readonly global: ConfigLayer;
102
+ /**
103
+ * The project layer: the nearest `pragma.config.json` up the tree, or
104
+ * the would-be path at `cwd` when none exists.
105
+ */
106
+ readonly project: ConfigLayer;
107
+ }
108
+
109
+ /** Target layer selector for config writes. */
110
+ type ConfigScope = "global" | "local";
111
+
112
+ export type {
113
+ ConfigFileValues,
114
+ ConfigLayer,
115
+ ConfigLayers,
116
+ ConfigOrigin,
117
+ ConfigOrigins,
118
+ ConfigScope,
119
+ ConfigUpdate,
120
+ PragmaConfig,
121
+ };
@@ -1,67 +1,37 @@
1
- import { readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import resolveConfigPath from "./resolveConfigPath.js";
3
- import type { ConfigUpdate } from "./types.js";
1
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import mergeConfigFileUpdate from "./mergeConfigFileUpdate.js";
4
+ import resolveWriteConfigPath from "./resolveWriteConfigPath.js";
5
+ import type { ConfigScope, ConfigUpdate } from "./types.js";
4
6
 
5
7
  /**
6
- * Write config updates to pragma.config.json.
8
+ * Write config updates to the resolved target layer.
7
9
  *
8
- * Merges updates into existing JSON. A field set to `undefined` removes it.
9
- * Creates the file if it doesn't exist.
10
+ * The target follows the global-first write rule (see
11
+ * `resolveWriteConfigPath`): an explicit scope wins, else the nearest
12
+ * existing project file, else the global XDG file. Merges updates into
13
+ * that file's own contents — a field set to `undefined` removes it —
14
+ * creating the file (and its directory) if needed, atomically via a
15
+ * temp file rename.
10
16
  *
11
- * @param cwd - Directory containing (or to contain) pragma.config.json.
17
+ * @param cwd - Directory the project layer is resolved from.
12
18
  * @param update - Fields to set or remove.
19
+ * @param scope - Optional explicit target layer (`"global"` | `"local"`).
20
+ * @returns Absolute path of the file that was written.
13
21
  *
14
22
  * @note Impure — writes to the filesystem.
15
23
  */
16
- export default function writeConfig(cwd: string, update: ConfigUpdate): void {
17
- const configPath = resolveConfigPath(cwd);
24
+ export default function writeConfig(
25
+ cwd: string,
26
+ update: ConfigUpdate,
27
+ scope?: ConfigScope,
28
+ ): string {
29
+ const configPath = resolveWriteConfigPath(cwd, scope);
18
30
  const tempPath = `${configPath}.tmp`;
31
+ const content = mergeConfigFileUpdate(configPath, update);
19
32
 
20
- let existing: Record<string, unknown> = {};
21
- try {
22
- const raw = readFileSync(configPath, "utf-8");
23
- existing = JSON.parse(raw) as Record<string, unknown>;
24
- } catch (err: unknown) {
25
- // Only swallow "file not found" — surface parse or permission errors.
26
- const isNotFound =
27
- err instanceof Error &&
28
- "code" in err &&
29
- (err as NodeJS.ErrnoException).code === "ENOENT";
30
- if (!isNotFound) {
31
- throw err;
32
- }
33
- }
34
-
35
- if (update.tier !== undefined) {
36
- existing.tier = update.tier;
37
- } else if ("tier" in update) {
38
- delete existing.tier;
39
- }
40
-
41
- if (update.channel !== undefined) {
42
- existing.channel = update.channel;
43
- } else if ("channel" in update) {
44
- delete existing.channel;
45
- }
46
-
47
- if (update.packages !== undefined) {
48
- existing.packages = update.packages;
49
- } else if ("packages" in update) {
50
- delete existing.packages;
51
- }
52
-
53
- if (update.trace !== undefined) {
54
- existing.trace = update.trace;
55
- } else if ("trace" in update) {
56
- delete existing.trace;
57
- }
58
-
59
- if (update.framework !== undefined) {
60
- existing.framework = update.framework;
61
- } else if ("framework" in update) {
62
- delete existing.framework;
63
- }
64
-
65
- writeFileSync(tempPath, `${JSON.stringify(existing, null, 2)}\n`);
33
+ mkdirSync(dirname(configPath), { recursive: true });
34
+ writeFileSync(tempPath, content);
66
35
  renameSync(tempPath, configPath);
36
+ return configPath;
67
37
  }
@@ -1,96 +1,11 @@
1
- /**
2
- * Wires the `pragma block list` CLI command.
3
- *
4
- * Lists all blocks visible under the current tier and channel filters.
5
- * Supports progressive disclosure via `--digest` and `--detailed` flags,
6
- * and an `--all-tiers` flag to widen the search.
7
- */
8
-
9
- import {
10
- type CommandDefinition,
11
- type CommandResult,
12
- createOutputResult,
13
- } from "@canonical/cli-core";
14
- import { createListView } from "#tui";
1
+ import type { CommandDefinition } from "@canonical/cli-core";
15
2
  import type { PragmaContext } from "../../shared/context.js";
16
- import type { ColumnDef } from "../../shared/contracts.js";
17
- import { selectFormatter } from "../../shared/formatters.js";
18
- import { blockConfig } from "../blockConfig.js";
19
- import { listFormatters } from "../formatters/index.js";
20
- import { resolveBlockList } from "../orchestration/index.js";
3
+ import { compileReadCommand } from "../../shared/stories/index.js";
4
+ import { blockListStory } from "../stories.js";
21
5
 
6
+ /** Wire the `pragma block list` CLI command from the block list story. */
22
7
  export default function buildListCommand(
23
8
  ctx: PragmaContext,
24
9
  ): CommandDefinition {
25
- return {
26
- path: ["block", "list"],
27
- description: "List blocks in the design system",
28
- parameters: [
29
- {
30
- name: "allTiers",
31
- description: "Show blocks from all tiers",
32
- type: "boolean",
33
- default: false,
34
- },
35
- {
36
- name: "digest",
37
- description: "Show enriched summary with implementations",
38
- type: "boolean",
39
- default: false,
40
- },
41
- {
42
- name: "detailed",
43
- description: "Show full details for each block",
44
- type: "boolean",
45
- default: false,
46
- },
47
- ],
48
- meta: {
49
- examples: [
50
- "pragma block list",
51
- "pragma block list --all-tiers",
52
- "pragma block list --digest",
53
- "pragma block list --detailed",
54
- "pragma block list --llm",
55
- "pragma block list --format json",
56
- ],
57
- },
58
- execute: async (
59
- params: Record<string, unknown>,
60
- ): Promise<CommandResult> => {
61
- const contract = await resolveBlockList(ctx, {
62
- allTiers: params.allTiers === true,
63
- digest: params.digest === true,
64
- detailed: params.detailed === true,
65
- });
66
-
67
- return createOutputResult(contract.result.items, {
68
- plain: selectFormatter(ctx, listFormatters),
69
- ink: (data) =>
70
- createListView({
71
- heading: "Blocks",
72
- domain: "block",
73
- items: data,
74
- columns: reorderColumns(blockConfig.listColumns, [
75
- "name",
76
- "type",
77
- "tier",
78
- "modifiers",
79
- "uri",
80
- ]),
81
- }),
82
- });
83
- },
84
- };
85
- }
86
-
87
- function reorderColumns<T>(
88
- columns: readonly ColumnDef<T>[],
89
- order: readonly string[],
90
- ): readonly ColumnDef<T>[] {
91
- const byKey = new Map(columns.map((col) => [col.key, col]));
92
- return order.flatMap((key) => {
93
- const col = byKey.get(key as keyof T & string);
94
- return col ? [col] : [];
95
- });
10
+ return compileReadCommand(ctx, blockListStory);
96
11
  }