@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,57 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import resolveLookupDetailed from "./resolveLookupDetailed.js";
3
+ import type { LookupStory } from "./types.js";
4
+
5
+ type AnyLookupStory = LookupStory<unknown, unknown>;
6
+
7
+ const baseStory: Pick<
8
+ AnyLookupStory,
9
+ "noun" | "description" | "toolDescription" | "namesDescription"
10
+ > = {
11
+ noun: "thing",
12
+ description: "d",
13
+ toolDescription: "td",
14
+ namesDescription: "names",
15
+ };
16
+
17
+ function makeStory(overrides: Partial<AnyLookupStory>): AnyLookupStory {
18
+ return {
19
+ ...baseStory,
20
+ examples: [],
21
+ resolve: async () => ({ results: [], errors: [] }),
22
+ toFmtInput: (entity) => entity,
23
+ formatters: {
24
+ plain: () => "",
25
+ llm: () => "",
26
+ json: () => "",
27
+ },
28
+ ...overrides,
29
+ };
30
+ }
31
+
32
+ describe("resolveLookupDetailed", () => {
33
+ it("is never detailed when the story has no detailed param", () => {
34
+ const story = makeStory({});
35
+ expect(resolveLookupDetailed(story, "cli", { detailed: true })).toBe(false);
36
+ expect(resolveLookupDetailed(story, "mcp", {})).toBe(false);
37
+ });
38
+
39
+ it("defaults detailed to true on MCP and false on CLI", () => {
40
+ const story = makeStory({ detailedParam: { description: "d" } });
41
+ expect(resolveLookupDetailed(story, "mcp", {})).toBe(true);
42
+ expect(resolveLookupDetailed(story, "mcp", { detailed: false })).toBe(
43
+ false,
44
+ );
45
+ expect(resolveLookupDetailed(story, "cli", {})).toBe(false);
46
+ expect(resolveLookupDetailed(story, "cli", { detailed: true })).toBe(true);
47
+ });
48
+
49
+ it("lets a story override the surface defaults", () => {
50
+ const story = makeStory({
51
+ detailedParam: { description: "d" },
52
+ resolveDetailed: (surface) => surface === "cli",
53
+ });
54
+ expect(resolveLookupDetailed(story, "cli", {})).toBe(true);
55
+ expect(resolveLookupDetailed(story, "mcp", {})).toBe(false);
56
+ });
57
+ });
@@ -0,0 +1,27 @@
1
+ import type { LookupStory } from "./types.js";
2
+
3
+ /**
4
+ * Resolve the effective `detailed` flag for a lookup story on a surface.
5
+ *
6
+ * Defaults follow the ratified surface contract: the CLI opts in
7
+ * (`detailed` is false unless set) while MCP opts out (`detailed` is true
8
+ * unless set, since agents want full data by default). Stories without a
9
+ * `detailed` parameter are never detailed. A story's `resolveDetailed`
10
+ * override wins — block uses it to let aspect flags imply detail.
11
+ */
12
+ export default function resolveLookupDetailed<TDetailed, TFmtInput>(
13
+ story: LookupStory<TDetailed, TFmtInput>,
14
+ surface: "cli" | "mcp",
15
+ params: Record<string, unknown>,
16
+ ): boolean {
17
+ if (story.resolveDetailed) {
18
+ return story.resolveDetailed(surface, params);
19
+ }
20
+ if (!story.detailedParam) {
21
+ return false;
22
+ }
23
+ if (surface === "mcp") {
24
+ return ((params.detailed as boolean | undefined) ?? true) === true;
25
+ }
26
+ return params.detailed === true;
27
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Read-story spec types — one declaration per read story, many projections.
3
+ *
4
+ * A read story (list, show, or lookup) is declared once per domain and
5
+ * compiled into both surfaces: a cli-core `CommandDefinition` and a
6
+ * declarative MCP `ToolSpec`. The compilers in this folder own the shared
7
+ * execute skeletons — parameter projection, condensed rendering with token
8
+ * estimates, empty-result guards, and summary projection — so a domain
9
+ * supplies only its facts: descriptions, parameters, resolve function,
10
+ * formatters, and envelope mapping. This kills the per-domain duplication
11
+ * between `commands/*.ts` and `mcp/specs.ts` for read verbs and makes
12
+ * CLI/MCP parity structural instead of conventional.
13
+ */
14
+
15
+ import type { CommandContext } from "@canonical/cli-core";
16
+ import type { PragmaError } from "#error";
17
+ import type { LookupResult } from "../contracts.js";
18
+ import type { Formatters } from "../formatters.js";
19
+ import type { PragmaRuntime } from "../types/index.js";
20
+
21
+ /** Surfaces a story parameter is projected to (default: "both"). */
22
+ export type StorySurface = "both" | "cli" | "mcp";
23
+
24
+ /**
25
+ * Neutral parameter definition projected to a CLI flag and an MCP tool
26
+ * parameter. `string[]` becomes a CLI multiselect and an MCP string array.
27
+ */
28
+ export interface StoryParam {
29
+ readonly name: string;
30
+ readonly type: "string" | "boolean" | "string[]";
31
+ /** CLI help text; also the MCP description unless `toolDescription` is set. */
32
+ readonly description: string;
33
+ /** MCP-specific description override. */
34
+ readonly toolDescription?: string;
35
+ readonly required?: boolean;
36
+ /** CLI default value — MCP parameters carry no defaults. */
37
+ readonly default?: unknown;
38
+ /** Allowed values; projects to CLI select choices and an MCP enum. */
39
+ readonly enum?: readonly string[];
40
+ /** If true, the CLI accepts this parameter as a positional argument. */
41
+ readonly positional?: boolean;
42
+ /** Surfaces this parameter appears on (default: "both"). */
43
+ readonly surfaces?: StorySurface;
44
+ /** CLI tab-completion for this parameter's argument. */
45
+ readonly complete?: (
46
+ partial: string,
47
+ ctx: CommandContext,
48
+ ) => Promise<string[]>;
49
+ }
50
+
51
+ /** MCP envelope fragment produced by a story's `toEnvelope`. */
52
+ export interface StoryEnvelope {
53
+ readonly data: unknown;
54
+ readonly meta?: Record<string, unknown>;
55
+ }
56
+
57
+ /**
58
+ * A collection-or-singleton read story (`<noun> list`, `<noun> show`).
59
+ *
60
+ * `TData` is the domain's resolved payload; `TOutput` is the formatter and
61
+ * renderer input derived from it via `toOutput`.
62
+ */
63
+ export interface ReadStory<TData, TOutput> {
64
+ readonly noun: string;
65
+ /** Read verb this story projects to (e.g. "list", "show"). */
66
+ readonly verb: string;
67
+ /** CLI one-line help description. */
68
+ readonly description: string;
69
+ /** MCP tool description. */
70
+ readonly toolDescription: string;
71
+ readonly params: readonly StoryParam[];
72
+ /** Usage examples shown in CLI help. */
73
+ readonly examples: readonly string[];
74
+ /** Fetch the story's data. Shared verbatim by both surfaces. */
75
+ readonly resolve: (
76
+ rt: PragmaRuntime,
77
+ params: Record<string, unknown>,
78
+ ) => Promise<TData>;
79
+ /** Map resolved data to the formatter/renderer input. */
80
+ readonly toOutput: (data: TData, params: Record<string, unknown>) => TOutput;
81
+ readonly formatters: Formatters<TOutput>;
82
+ /** Map resolved data to the MCP `{ data, meta }` envelope. */
83
+ readonly toEnvelope: (data: TData) => StoryEnvelope;
84
+ /**
85
+ * CLI-only pre-resolve guard: return an error to reject invalid
86
+ * parameters (e.g. an empty required positional).
87
+ */
88
+ readonly guardParams?: (
89
+ params: Record<string, unknown>,
90
+ ) => PragmaError | undefined;
91
+ /**
92
+ * CLI-only post-resolve guard: return an error to reject an empty
93
+ * result set with a recovery hint.
94
+ */
95
+ readonly emptyError?: (
96
+ data: TData,
97
+ params: Record<string, unknown>,
98
+ ) => PragmaError | undefined;
99
+ /** Optional interactive (ink) renderer for the CLI. */
100
+ readonly renderInk?: (output: TOutput) => unknown;
101
+ }
102
+
103
+ /** Per-invocation view state passed to lookup story hooks. */
104
+ export interface LookupStoryView {
105
+ readonly surface: "cli" | "mcp";
106
+ /** Effective detail level after surface defaults and flag implications. */
107
+ readonly detailed: boolean;
108
+ readonly params: Record<string, unknown>;
109
+ }
110
+
111
+ /**
112
+ * A batch retrieval story (`<noun> lookup <names...>`).
113
+ *
114
+ * `TDetailed` is the looked-up entity; `TFmtInput` is the per-entity
115
+ * formatter input derived via `toFmtInput` (some domains pair the entity
116
+ * with view flags such as `detailed` or aspect selections).
117
+ */
118
+ export interface LookupStory<TDetailed, TFmtInput> {
119
+ readonly noun: string;
120
+ readonly description: string;
121
+ readonly toolDescription: string;
122
+ /** CLI description of the `names` positional parameter. */
123
+ readonly namesDescription: string;
124
+ /** MCP-specific `names` description override. */
125
+ readonly namesToolDescription?: string;
126
+ /** CLI tab-completion for names. */
127
+ readonly complete?: (
128
+ partial: string,
129
+ ctx: CommandContext,
130
+ ) => Promise<string[]>;
131
+ /**
132
+ * The `detailed` toggle, when the story supports one. MCP defaults
133
+ * `detailed` to true; the CLI defaults it to false.
134
+ */
135
+ readonly detailedParam?: {
136
+ readonly description: string;
137
+ readonly toolDescription?: string;
138
+ };
139
+ /** Additional story parameters beyond `names` and `detailed`. */
140
+ readonly params?: readonly StoryParam[];
141
+ /** Named groups for organizing CLI parameters in help output. */
142
+ readonly parameterGroups?: Readonly<Record<string, readonly string[]>>;
143
+ readonly examples: readonly string[];
144
+ /** Resolve all queries, collecting per-query failures. */
145
+ readonly resolve: (
146
+ rt: PragmaRuntime,
147
+ names: readonly string[],
148
+ params: Record<string, unknown>,
149
+ ) => Promise<LookupResult<TDetailed>>;
150
+ /**
151
+ * Effective `detailed` for a surface. Defaults to the plain flag value
152
+ * (CLI: false unless set; MCP: true unless set). Domains override this
153
+ * when other flags imply detail (e.g. block aspect filters).
154
+ */
155
+ readonly resolveDetailed?: (
156
+ surface: "cli" | "mcp",
157
+ params: Record<string, unknown>,
158
+ ) => boolean;
159
+ /** Map an entity to the per-entity formatter input. */
160
+ readonly toFmtInput: (entity: TDetailed, view: LookupStoryView) => TFmtInput;
161
+ readonly formatters: Formatters<TFmtInput>;
162
+ /** MCP summary projection applied when `detailed` is false. */
163
+ readonly project?: (entity: TDetailed) => unknown;
164
+ /** CLI-only error thrown when no names are supplied. */
165
+ readonly emptyNamesError?: () => PragmaError;
166
+ /** Optional interactive (ink) renderer for the CLI. */
167
+ readonly renderInk?: (
168
+ result: LookupResult<TDetailed>,
169
+ view: LookupStoryView,
170
+ ) => unknown;
171
+ }
@@ -41,6 +41,7 @@ function globToRegex(glob: string): RegExp {
41
41
  for (const char of glob) {
42
42
  if (char === "*") {
43
43
  pattern += ".*";
44
+ // biome-ignore lint/suspicious/noTemplateCurlyInString: regex meta-characters to escape, not a JS template
44
45
  } else if (".+^${}()|[]\\".includes(char)) {
45
46
  pattern += `\\${char}`;
46
47
  } else {
@@ -63,35 +63,68 @@ function damerauLevenshtein(a: string, b: string): number {
63
63
  if (lenA === 0) return lenB;
64
64
  if (lenB === 0) return lenA;
65
65
 
66
+ // Fully allocated (lenA + 1) × (lenB + 1) grid: every row/column index in
67
+ // [0, lenA] × [0, lenB] below is provably in bounds, so `row.at(j)` never
68
+ // yields undefined for the indices we read.
66
69
  const matrix: number[][] = Array.from({ length: lenA + 1 }, () =>
67
70
  Array.from<number>({ length: lenB + 1 }).fill(0),
68
71
  );
69
72
 
70
73
  for (let i = 0; i <= lenA; i++) {
71
- matrix[i]![0] = i;
74
+ getRow(matrix, i)[0] = i;
72
75
  }
76
+ const firstRow = getRow(matrix, 0);
73
77
  for (let j = 0; j <= lenB; j++) {
74
- matrix[0]![j] = j;
78
+ firstRow[j] = j;
75
79
  }
76
80
 
77
81
  for (let i = 1; i <= lenA; i++) {
82
+ const prevRow = getRow(matrix, i - 1);
83
+ const row = getRow(matrix, i);
78
84
  for (let j = 1; j <= lenB; j++) {
79
85
  const cost = a[i - 1] === b[j - 1] ? 0 : 1;
80
86
 
81
- matrix[i]![j] = Math.min(
82
- matrix[i - 1]![j]! + 1, // deletion
83
- matrix[i]![j - 1]! + 1, // insertion
84
- matrix[i - 1]![j - 1]! + cost, // substitution
87
+ row[j] = Math.min(
88
+ getCell(prevRow, j) + 1, // deletion
89
+ getCell(row, j - 1) + 1, // insertion
90
+ getCell(prevRow, j - 1) + cost, // substitution
85
91
  );
86
92
 
87
93
  if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
88
- matrix[i]![j] = Math.min(
89
- matrix[i]![j]!,
90
- matrix[i - 2]![j - 2]! + cost, // transposition
94
+ const prevPrevRow = getRow(matrix, i - 2);
95
+ row[j] = Math.min(
96
+ getCell(row, j),
97
+ getCell(prevPrevRow, j - 2) + cost, // transposition
91
98
  );
92
99
  }
93
100
  }
94
101
  }
95
102
 
96
- return matrix[lenA]![lenB]!;
103
+ return getCell(getRow(matrix, lenA), lenB);
104
+ }
105
+
106
+ /**
107
+ * Read a matrix row that is structurally guaranteed to exist.
108
+ *
109
+ * @throws if the row is missing, signalling a broken allocation invariant.
110
+ */
111
+ function getRow(matrix: number[][], index: number): number[] {
112
+ const row = matrix.at(index);
113
+ if (row === undefined) {
114
+ throw new Error(`matrix row ${index} is out of bounds`);
115
+ }
116
+ return row;
117
+ }
118
+
119
+ /**
120
+ * Read a matrix cell that is structurally guaranteed to exist.
121
+ *
122
+ * @throws if the cell is missing, signalling a broken allocation invariant.
123
+ */
124
+ function getCell(row: number[], index: number): number {
125
+ const cell = row.at(index);
126
+ if (cell === undefined) {
127
+ throw new Error(`matrix cell ${index} is out of bounds`);
128
+ }
129
+ return cell;
97
130
  }
@@ -1,2 +1,4 @@
1
- /** @module Skill command definitions for `pragma skill`. */
1
+ /** @module Re-exports skill command builders (`list`, `lookup`). */
2
+
2
3
  export { default as listCommand } from "./list.js";
4
+ export { default as lookupCommand } from "./lookup.js";
@@ -9,9 +9,13 @@ import buildListCommand from "./list.js";
9
9
 
10
10
  const TMP_ROOT = join(tmpdir(), `pragma-skill-cmd-${Date.now()}`);
11
11
 
12
+ /** One source per skill directory, matching the loader granularity. */
12
13
  const TEST_SOURCES = [
13
14
  {
14
- dir: join(TMP_ROOT, "node_modules/@canonical/design-system/skills"),
15
+ dir: join(
16
+ TMP_ROOT,
17
+ "node_modules/@canonical/design-system/skills/design-audit",
18
+ ),
15
19
  packageName: "@canonical/design-system",
16
20
  },
17
21
  ];
@@ -23,7 +27,7 @@ vi.mock("../helpers/resolveSkillSources.js", () => ({
23
27
  beforeAll(() => {
24
28
  mkdirSync(TMP_ROOT, { recursive: true });
25
29
 
26
- const dir = join(TEST_SOURCES[0].dir, "design-audit");
30
+ const dir = TEST_SOURCES.at(0)?.dir ?? "";
27
31
  mkdirSync(dir, { recursive: true });
28
32
  writeFileSync(
29
33
  join(dir, "SKILL.md"),
@@ -1,69 +1,11 @@
1
- import {
2
- type CommandDefinition,
3
- type CommandResult,
4
- createOutputResult,
5
- } from "@canonical/cli-core";
6
- import { PragmaError } from "#error";
1
+ import type { CommandDefinition } from "@canonical/cli-core";
7
2
  import type { PragmaContext } from "../../shared/context.js";
8
- import { selectFormatter } from "../../shared/formatters.js";
9
- import { listFormatters } from "../formatters/index.js";
10
- import type { SkillListInput } from "../formatters/types.js";
11
- import { listSkills } from "../operations/index.js";
3
+ import { compileReadCommand } from "../../shared/stories/index.js";
4
+ import { skillListStory } from "../stories.js";
12
5
 
13
- /**
14
- * Build the `pragma skill list` command definition.
15
- *
16
- * Discovers skills from installed packages and formats the result.
17
- * Throws when no skills are found, with a recovery hint.
18
- *
19
- * @param ctx - Pragma context providing cwd and formatter selection.
20
- * @returns The command definition for `pragma skill list`.
21
- * @note Impure
22
- */
6
+ /** Wire the `pragma skill list` CLI command from the skill list story. */
23
7
  export default function buildListCommand(
24
8
  ctx: PragmaContext,
25
9
  ): CommandDefinition {
26
- return {
27
- path: ["skill", "list"],
28
- description: "List available agent skills from design system packages",
29
- parameters: [
30
- {
31
- name: "detailed",
32
- description: "Show full metadata for each skill",
33
- type: "boolean",
34
- default: false,
35
- },
36
- ],
37
- meta: {
38
- examples: [
39
- "pragma skill list",
40
- "pragma skill list --detailed",
41
- "pragma skill list --llm",
42
- "pragma skill list --format json",
43
- ],
44
- },
45
- execute: async (
46
- params: Record<string, unknown>,
47
- ): Promise<CommandResult> => {
48
- const detailed = params.detailed === true;
49
- const { skills, sources } = await listSkills(ctx.cwd);
50
-
51
- if (skills.length === 0) {
52
- const allUnavailable = sources.every((s) => !s.available);
53
- throw PragmaError.emptyResults("skill", {
54
- recovery: {
55
- message: allUnavailable
56
- ? "Install @canonical packages first"
57
- : "No SKILL.md files found in source packages",
58
- },
59
- });
60
- }
61
-
62
- const input: SkillListInput = { skills, sources, detailed };
63
-
64
- return createOutputResult(input, {
65
- plain: selectFormatter(ctx, listFormatters),
66
- });
67
- },
68
- };
10
+ return compileReadCommand(ctx, skillListStory);
69
11
  }
@@ -0,0 +1,118 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { CommandDefinition } from "@canonical/cli-core";
5
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
6
+ import { PragmaError } from "#error";
7
+ import type { PragmaContext } from "../../shared/context.js";
8
+ import buildLookupCommand from "./lookup.js";
9
+
10
+ const TMP_ROOT = join(tmpdir(), `pragma-skill-lookup-cmd-${Date.now()}`);
11
+
12
+ /** One source per skill directory, matching the loader granularity. */
13
+ const TEST_SOURCES = [
14
+ {
15
+ dir: join(
16
+ TMP_ROOT,
17
+ "node_modules/@canonical/design-system/skills/design-auditor",
18
+ ),
19
+ packageName: "@canonical/design-system",
20
+ },
21
+ ];
22
+
23
+ vi.mock("../helpers/resolveSkillSources.js", () => ({
24
+ default: () => TEST_SOURCES,
25
+ }));
26
+
27
+ beforeAll(() => {
28
+ const dir = TEST_SOURCES.at(0)?.dir ?? "";
29
+ mkdirSync(dir, { recursive: true });
30
+ writeFileSync(
31
+ join(dir, "SKILL.md"),
32
+ `---
33
+ name: design-auditor
34
+ description: Audit design system coverage
35
+ ---
36
+
37
+ # Design Auditor
38
+ `,
39
+ );
40
+ });
41
+
42
+ afterAll(() => {
43
+ rmSync(TMP_ROOT, { recursive: true, force: true });
44
+ });
45
+
46
+ function makeCtx(overrides: Partial<PragmaContext> = {}): PragmaContext {
47
+ return {
48
+ cwd: TMP_ROOT,
49
+ globalFlags: { llm: false, format: "text" as const, verbose: false },
50
+ store: {} as PragmaContext["store"],
51
+ config: { tier: undefined, channel: "normal" },
52
+ ...overrides,
53
+ } as PragmaContext;
54
+ }
55
+
56
+ async function executeText(
57
+ cmd: CommandDefinition,
58
+ params: Record<string, unknown>,
59
+ ctx: PragmaContext,
60
+ ): Promise<string> {
61
+ const result = await cmd.execute(params, ctx);
62
+ expect(result.tag).toBe("output");
63
+ if (result.tag !== "output") throw new Error("Expected output result");
64
+ return result.render.plain(result.value);
65
+ }
66
+
67
+ describe("buildLookupCommand", () => {
68
+ it("serves the full SKILL.md content", async () => {
69
+ const ctx = makeCtx();
70
+ const text = await executeText(
71
+ buildLookupCommand(ctx),
72
+ { names: ["design-auditor"] },
73
+ ctx,
74
+ );
75
+ expect(text).toContain("design-auditor");
76
+ expect(text).toContain("# Design Auditor");
77
+ });
78
+
79
+ it("renders JSON with content under --format json", async () => {
80
+ const ctx = makeCtx({
81
+ globalFlags: { llm: false, format: "json", verbose: false },
82
+ });
83
+ const text = await executeText(
84
+ buildLookupCommand(ctx),
85
+ { names: ["design-auditor"] },
86
+ ctx,
87
+ );
88
+ const data = JSON.parse(text) as { name: string; content: string };
89
+ expect(data.name).toBe("design-auditor");
90
+ expect(data.content).toContain("# Design Auditor");
91
+ });
92
+
93
+ it("reports unknown skills in the errors section", async () => {
94
+ const ctx = makeCtx();
95
+ const text = await executeText(
96
+ buildLookupCommand(ctx),
97
+ { names: ["nope"] },
98
+ ctx,
99
+ );
100
+ expect(text).toContain("Errors:");
101
+ expect(text).toContain('skill "nope" not found.');
102
+ });
103
+
104
+ it("rejects an empty names list with a recovery hint", async () => {
105
+ const ctx = makeCtx();
106
+ const cmd = buildLookupCommand(ctx);
107
+ await expect(cmd.execute({ names: [] }, ctx)).rejects.toThrow(PragmaError);
108
+ });
109
+
110
+ it("completes skill names from discovery", async () => {
111
+ const ctx = makeCtx();
112
+ const cmd = buildLookupCommand(ctx);
113
+ const names = cmd.parameters.at(0);
114
+ await expect(names?.complete?.("des", ctx)).resolves.toEqual([
115
+ "design-auditor",
116
+ ]);
117
+ });
118
+ });
@@ -0,0 +1,14 @@
1
+ import type { CommandDefinition } from "@canonical/cli-core";
2
+ import type { PragmaContext } from "../../shared/context.js";
3
+ import { compileLookupCommand } from "../../shared/stories/index.js";
4
+ import { skillLookupStory } from "../stories.js";
5
+
6
+ /**
7
+ * Wire the `pragma skill lookup <name...>` CLI command from the skill
8
+ * lookup story.
9
+ */
10
+ export default function buildLookupCommand(
11
+ ctx: PragmaContext,
12
+ ): CommandDefinition {
13
+ return compileLookupCommand(ctx, skillLookupStory);
14
+ }
@@ -1,2 +1,3 @@
1
1
  /** @module Formatter sets for skill domain output. */
2
2
  export { default as listFormatters } from "./list.js";
3
+ export { default as lookupFormatters } from "./lookup.js";
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { SkillDetailed } from "../types.js";
3
+ import formatters from "./lookup.js";
4
+
5
+ const skill: SkillDetailed = {
6
+ name: "design-auditor",
7
+ description: "Audit design system coverage",
8
+ sourcePath: "/pkgs/design-system/skills/design-auditor",
9
+ sourcePackage: "@canonical/design-system",
10
+ folderName: "design-auditor",
11
+ frontmatter: {
12
+ name: "design-auditor",
13
+ description: "Audit design system coverage",
14
+ },
15
+ content: "---\nname: design-auditor\n---\n\n# Design Auditor\n",
16
+ files: ["AUDIT_SPEC.md"],
17
+ };
18
+
19
+ describe("skill lookup formatters", () => {
20
+ it("plain renders a header and the full content", () => {
21
+ const text = formatters.plain(skill);
22
+ expect(text).toContain("design-auditor");
23
+ expect(text).toContain("@canonical/design-system");
24
+ expect(text).toContain("Companion files: AUDIT_SPEC.md");
25
+ expect(text).toContain("# Design Auditor");
26
+ });
27
+
28
+ it("llm renders a markdown header above the content", () => {
29
+ const text = formatters.llm(skill);
30
+ expect(text).toContain("## Skill: design-auditor");
31
+ expect(text).toContain("**Companion files:** AUDIT_SPEC.md");
32
+ expect(text).toContain("# Design Auditor");
33
+ });
34
+
35
+ it("llm omits the companion files line when there are none", () => {
36
+ const text = formatters.llm({ ...skill, files: [] });
37
+ expect(text).not.toContain("Companion files");
38
+ });
39
+
40
+ it("json serializes the skill with content and files", () => {
41
+ const data = JSON.parse(formatters.json(skill)) as Record<string, unknown>;
42
+ expect(data.name).toBe("design-auditor");
43
+ expect(data.files).toEqual(["AUDIT_SPEC.md"]);
44
+ expect(data.content).toContain("# Design Auditor");
45
+ });
46
+ });