@canonical/pragma-cli 0.32.0 → 0.33.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 (140) hide show
  1. package/README.md +72 -0
  2. package/dist/pragma +0 -0
  3. package/package.json +14 -13
  4. package/src/bin.ts +2 -2
  5. package/src/capabilities/block/blockList.test.ts +79 -0
  6. package/src/capabilities/block/blockList.verb.ts +18 -5
  7. package/src/capabilities/block/pack.ts +12 -1
  8. package/src/capabilities/block/parity.test.ts +9 -2
  9. package/src/capabilities/capabilities/capabilities.test.ts +10 -1
  10. package/src/capabilities/capabilities/capabilities.verb.ts +1 -1
  11. package/src/capabilities/capabilities/catalog.ts +7 -3
  12. package/src/capabilities/capabilities/hints.ts +8 -17
  13. package/src/capabilities/capabilities/types.ts +2 -0
  14. package/src/capabilities/colophon/colophon.render.test.ts +31 -4
  15. package/src/capabilities/colophon/colophon.render.ts +13 -4
  16. package/src/capabilities/colophon/colophon.test.ts +3 -3
  17. package/src/capabilities/colophon/colophon.verb.ts +4 -1
  18. package/src/capabilities/colophon/markdownTerminal.ts +33 -14
  19. package/src/capabilities/colophon/pragmaColophon.ts +4 -4
  20. package/src/capabilities/colophon/types.ts +1 -1
  21. package/src/capabilities/config/fields.ts +21 -92
  22. package/src/capabilities/config/set.test.ts +6 -6
  23. package/src/capabilities/config/set.verb.ts +5 -5
  24. package/src/capabilities/config/show.render.test.ts +65 -0
  25. package/src/capabilities/config/show.render.ts +56 -10
  26. package/src/capabilities/config/show.verb.ts +5 -5
  27. package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
  28. package/src/capabilities/create/byteEquality.test.ts +7 -4
  29. package/src/capabilities/create/compiledCreate.subprocess.test.ts +1 -1
  30. package/src/capabilities/create/create.test.ts +32 -3
  31. package/src/capabilities/create/create.verb.ts +69 -11
  32. package/src/capabilities/doctor/checks/checkConfigFile.ts +1 -1
  33. package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
  34. package/src/capabilities/doctor/checks/checkMcpConfigured.ts +10 -4
  35. package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
  36. package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
  37. package/src/capabilities/doctor/doctor.render.test.ts +170 -0
  38. package/src/capabilities/doctor/doctor.render.ts +116 -34
  39. package/src/capabilities/doctor/doctor.test.ts +31 -1
  40. package/src/capabilities/doctor/runChecks.ts +28 -4
  41. package/src/capabilities/doctor/types.ts +15 -0
  42. package/src/capabilities/ontology/index.ts +10 -3
  43. package/src/capabilities/ontology/ontology.test.ts +157 -5
  44. package/src/capabilities/ontology/verbs.ts +192 -93
  45. package/src/capabilities/prompt/prompt.render.test.ts +94 -0
  46. package/src/capabilities/prompt/prompt.render.ts +23 -6
  47. package/src/capabilities/prompt/prompt.test.ts +61 -0
  48. package/src/capabilities/reference.test.ts +32 -0
  49. package/src/capabilities/resources/provider.ts +28 -14
  50. package/src/capabilities/resources/resources.test.ts +99 -0
  51. package/src/capabilities/scopeTypeSync.test.ts +37 -0
  52. package/src/capabilities/setup/index.ts +7 -1
  53. package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
  54. package/src/capabilities/setup/operations/setupGenerator.ts +157 -61
  55. package/src/capabilities/setup/operations/setupLsp.ts +56 -12
  56. package/src/capabilities/setup/operations/setupMcp.ts +216 -39
  57. package/src/capabilities/setup/operations/setupSkills.ts +7 -1
  58. package/src/capabilities/setup/setup.render.test.ts +109 -0
  59. package/src/capabilities/setup/setup.render.ts +61 -14
  60. package/src/capabilities/setup/setup.test.ts +352 -1
  61. package/src/capabilities/setup/setup.verb.ts +57 -5
  62. package/src/capabilities/setup/types.ts +59 -2
  63. package/src/capabilities/shared/bands.ts +16 -0
  64. package/src/capabilities/sources/collectStatus.ts +19 -4
  65. package/src/capabilities/sources/runUpdate.ts +58 -1
  66. package/src/capabilities/sources/sources.test.ts +187 -1
  67. package/src/capabilities/sources/status.render.test.ts +65 -0
  68. package/src/capabilities/sources/status.render.ts +50 -23
  69. package/src/capabilities/surface.test.ts +34 -9
  70. package/src/capabilities/tier/lookup.render.test.ts +72 -0
  71. package/src/capabilities/tier/lookup.render.ts +33 -8
  72. package/src/capabilities/tier/lookup.test.ts +15 -0
  73. package/src/capabilities/tier/runLookup.ts +28 -0
  74. package/src/constants.test.ts +1 -1
  75. package/src/constants.ts +2 -2
  76. package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +13 -14
  77. package/src/kernel/completion/bashDrive.test.ts +3 -3
  78. package/src/kernel/completion/complete.test.ts +3 -5
  79. package/src/kernel/completion/entitySource.ts +14 -6
  80. package/src/kernel/completion/model.test.ts +2 -2
  81. package/src/kernel/completion/model.ts +0 -1
  82. package/src/kernel/completion/parse.test.ts +2 -4
  83. package/src/kernel/error/PragmaError.test.ts +4 -4
  84. package/src/kernel/error/renderError.ts +1 -1
  85. package/src/kernel/packs/compile.ts +1 -1
  86. package/src/kernel/project/cli/buildProgram.test.ts +51 -7
  87. package/src/kernel/project/cli/buildProgram.ts +11 -0
  88. package/src/kernel/project/cli/globalFlags.test.ts +12 -4
  89. package/src/kernel/project/cli/globalFlags.ts +14 -14
  90. package/src/kernel/project/cli/rootHelp.ts +4 -2
  91. package/src/kernel/project/mcp/instructions.ts +1 -1
  92. package/src/kernel/project/mcp/mcpError.ts +52 -0
  93. package/src/kernel/project/mcp/prompts/prompts.test.ts +8 -0
  94. package/src/kernel/project/mcp/prompts/provider.ts +36 -6
  95. package/src/kernel/render/formatters.ts +5 -4
  96. package/src/kernel/render/renderers.test.ts +34 -0
  97. package/src/kernel/render/renderers.ts +25 -7
  98. package/src/kernel/render/style.test.ts +31 -0
  99. package/src/kernel/render/style.ts +74 -0
  100. package/src/kernel/runtime/graphpack/build.ts +28 -0
  101. package/src/kernel/runtime/graphpack/buildIndex.test.ts +103 -0
  102. package/src/kernel/runtime/graphpack/buildIndex.ts +124 -36
  103. package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +3 -6
  104. package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -2
  105. package/src/kernel/runtime/graphpack/graphpack.test.ts +69 -1
  106. package/src/kernel/runtime/graphpack/read.ts +28 -13
  107. package/src/kernel/runtime/graphpack/types.ts +14 -0
  108. package/src/kernel/runtime/loadSession.ts +4 -12
  109. package/src/kernel/runtime/refs/resolve.ts +63 -3
  110. package/src/kernel/runtime/storeReadiness.ts +61 -0
  111. package/src/kernel/runtime/types.ts +1 -1
  112. package/src/kernel/runtime/wasmEmbed.test.ts +3 -1
  113. package/src/kernel/spec/emitReference.test.ts +196 -0
  114. package/src/kernel/spec/emitReference.ts +491 -0
  115. package/src/kernel/spec/emitSurface.ts +4 -2
  116. package/src/kernel/spec/index.ts +2 -0
  117. package/src/kernel/spec/surfaceConformance.test.ts +2 -2
  118. package/src/testing/PARITY_GAPS.ts +1 -1
  119. package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
  120. package/src/testing/behavioral/completion.test.ts +2 -2
  121. package/src/testing/behavioral/disclosure.test.ts +12 -0
  122. package/src/testing/behavioral/docExamples.test.ts +283 -0
  123. package/src/testing/behavioral/errorMatrix.storeless.test.ts +2 -1
  124. package/src/testing/behavioral/journeys.cli.test.ts +3 -1
  125. package/src/testing/behavioral/journeys.defaultPack.test.ts +571 -0
  126. package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
  127. package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
  128. package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +176 -0
  129. package/src/testing/behavioral/mcpSurface.test.ts +1 -1
  130. package/src/testing/behavioral/staleCommands.test.ts +76 -0
  131. package/src/testing/eval/__snapshots__/eval.test.ts.snap +2 -2
  132. package/src/testing/eval/cases/assembly.ts +3 -3
  133. package/src/testing/eval/cases/effectful.ts +6 -3
  134. package/src/testing/eval/cases/readNouns.ts +4 -1
  135. package/src/testing/fixtures/fixtureCapability.ts +156 -0
  136. package/src/testing/fixtures/graph/defaultPack.ts +178 -0
  137. package/src/testing/fixtures/graph/owlExport.ts +95 -0
  138. package/src/testing/helpers/extractSnippets.ts +29 -0
  139. package/src/testing/helpers/golden.ts +1 -1
  140. package/src/capabilities/config/field.test.ts +0 -188
@@ -7,11 +7,16 @@
7
7
  * degrades to a `pragma sources update` hint (never a live re-index).
8
8
  */
9
9
 
10
+ import { mkdtempSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
10
14
  import { afterAll, beforeAll, describe, expect, it } from "vitest";
11
15
  import { readPackIndex } from "../../kernel/completion/entitySource.js";
12
16
  import { verbKey } from "../../kernel/packs/uniqueness.js";
13
17
  import { bootRuntime } from "../../kernel/runtime/boot.js";
14
18
  import type { PackIndex } from "../../kernel/runtime/graphpack/types.js";
19
+ import { writeLock } from "../../kernel/runtime/lock.js";
15
20
  import type { InspectResult } from "../../kernel/runtime/readEntity.js";
16
21
  import type { VerbSpec } from "../../kernel/spec/types.js";
17
22
  import { TEST_FLAGS } from "../../testing/helpers/projectCli.js";
@@ -44,6 +49,48 @@ describe("resource listing (storeless, over the pack index)", () => {
44
49
  expect(componentIdx).toBeLessThan(buttonIdx);
45
50
  });
46
51
 
52
+ it("emits ONE resource per URI for an OWL-punned subject (A8)", () => {
53
+ // A punned subject (a class IRI also asserted as an individual) is indexed
54
+ // as TWO facets — tbox + abox — sharing one prefixed URI. The listing must
55
+ // dedup them so the MCP resource surface carries no duplicate URI. The abox
56
+ // facet is listed first here to prove the dedup relies on the schema-first
57
+ // SORT, not input order.
58
+ const punned: PackIndex = {
59
+ version: 2,
60
+ contentHash: "test",
61
+ prefixes: {},
62
+ entities: [
63
+ {
64
+ name: "ex:Slider",
65
+ type: "ex:Category",
66
+ uri: "https://ex.test/#Slider",
67
+ prefixed: "ex:Slider",
68
+ types: ["owl:Class", "ex:Category"],
69
+ label: "Slider",
70
+ box: "abox",
71
+ description: null,
72
+ },
73
+ {
74
+ name: "ex:Slider",
75
+ type: "owl:Class",
76
+ uri: "https://ex.test/#Slider",
77
+ prefixed: "ex:Slider",
78
+ types: ["owl:Class", "ex:Category"],
79
+ label: "Slider",
80
+ box: "tbox",
81
+ description: null,
82
+ },
83
+ ],
84
+ instanceCountByType: {},
85
+ };
86
+ const slider = buildResourceList(punned).filter(
87
+ (r) => r.uri === "pragma:ex:Slider",
88
+ );
89
+ expect(slider).toHaveLength(1);
90
+ // The retained facet is the schema (tbox) one — surfaced above individuals.
91
+ expect(slider.at(0)?._meta?.["pragma/box"]).toBe("tbox");
92
+ });
93
+
47
94
  it("enriches each entry with the _meta taxonomy (pragma/box + priority)", () => {
48
95
  const resources = buildResourceList(readPackIndex(process.cwd()));
49
96
  const component = resources.find((r) => r.uri === "pragma:ex:Component");
@@ -111,4 +158,56 @@ describe("resource surface over the server (embedded pack)", () => {
111
158
  expect(fromResource).toEqual(fromCli);
112
159
  expect(fromResource.uri).toBe("https://pragma.canonical.com/sample#Button");
113
160
  });
161
+
162
+ it("rejects a read of an absent entity as InvalidParams carrying ENTITY_NOT_FOUND", async () => {
163
+ // The warm embedded pack resolves the `ex:` prefix but holds no `Nonexistent`
164
+ // subject, so the read fails ENTITY_NOT_FOUND — the caller's fault, which
165
+ // `mcpErrorFrom` maps to JSON-RPC InvalidParams, NOT the InternalError a cold
166
+ // store (STORE_UNAVAILABLE) earns.
167
+ const error = await harness.readResource("pragma:ex:Nonexistent").then(
168
+ () => undefined,
169
+ (caught: unknown) => caught,
170
+ );
171
+ expect(error, "the read must throw, not swallow to content").toBeDefined();
172
+ expect((error as Error).message).toMatch(/not found/i);
173
+ const failure = error as { code?: number; data?: { code?: string } };
174
+ expect(failure.data?.code).toBe("ENTITY_NOT_FOUND");
175
+ expect(failure.code).toBe(ErrorCode.InvalidParams);
176
+ expect(failure.code).not.toBe(ErrorCode.InternalError);
177
+ });
178
+ });
179
+
180
+ /**
181
+ * D3 — a resource-read failure surfaces as a JSON-RPC error (the read analogue of
182
+ * a tool result's `isError`) carrying the recovery, NOT swallowed into a
183
+ * `text/plain` "success" body that drops the recovery and reads as though the
184
+ * entity itself were malformed. A lock pointing at an evicted pack is the cold
185
+ * store; the read boots the store, so it must refuse with STORE_UNAVAILABLE.
186
+ */
187
+ describe("resource read — cold-store failure surfaces isError + recovery (D3)", () => {
188
+ let harness: Awaited<ReturnType<typeof projectMcp>>;
189
+ beforeAll(async () => {
190
+ const cwd = mkdtempSync(join(tmpdir(), "pragma-resource-cold-"));
191
+ writeLock(cwd, { version: 1, contentHash: "b".repeat(64), packs: [] });
192
+ harness = await projectMcp([graphModule], cwd);
193
+ });
194
+ afterAll(async () => {
195
+ await harness.cleanup();
196
+ });
197
+
198
+ it("rejects (does not return text/plain content) with the STORE_UNAVAILABLE recovery", async () => {
199
+ const error = await harness.readResource("pragma:ex:Button").then(
200
+ () => undefined,
201
+ (caught: unknown) => caught,
202
+ );
203
+ expect(error, "the read must throw, not swallow to content").toBeDefined();
204
+ expect((error as Error).message).toMatch(/store unavailable/i);
205
+ const data = (
206
+ error as {
207
+ data?: { code?: string; recovery?: { mcp?: { tool?: string } } };
208
+ }
209
+ ).data;
210
+ expect(data?.code).toBe("STORE_UNAVAILABLE");
211
+ expect(data?.recovery?.mcp?.tool).toBe("sources_update");
212
+ });
114
213
  });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Type-sync pin (PROTECTED): the CLI's redeclared `ScopeBand`/`ScopeSelection`
3
+ * mirrors must stay structurally EQUAL to the `@canonical/harnesses` types.
4
+ *
5
+ * The CLI redeclares these in `doctor/types.ts` and `setup/types.ts` on purpose
6
+ * — so those statically-reachable type modules never pull the harnesses runtime
7
+ * onto the fast-path module graph (the lazy-dispatch invariant). That freedom is
8
+ * only safe if the mirrors do not DRIFT from the source of truth, which this
9
+ * `expectTypeOf` pin enforces at `tsc` time.
10
+ *
11
+ * Every harnesses import here is `import type` (type-only, erased at emit), so
12
+ * this file loads no harnesses runtime — the `lazy.test` no-runtime-import guard
13
+ * stays green (it also never reaches this test file: it walks the graph from
14
+ * `capabilities/index`, which imports no test).
15
+ */
16
+
17
+ import type {
18
+ ScopeBand as HarnessScopeBand,
19
+ ScopeSelection as HarnessScopeSelection,
20
+ } from "@canonical/harnesses";
21
+ import { describe, expectTypeOf, it } from "vitest";
22
+ import type { ScopeBand as DoctorScopeBand } from "./doctor/types.js";
23
+ import type {
24
+ ScopeBand as SetupScopeBand,
25
+ ScopeSelection as SetupScopeSelection,
26
+ } from "./setup/types.js";
27
+
28
+ describe("scope type-sync — CLI mirrors == @canonical/harnesses", () => {
29
+ it("both ScopeBand mirrors equal the harnesses ScopeBand", () => {
30
+ expectTypeOf<DoctorScopeBand>().toEqualTypeOf<HarnessScopeBand>();
31
+ expectTypeOf<SetupScopeBand>().toEqualTypeOf<HarnessScopeBand>();
32
+ });
33
+
34
+ it("the setup ScopeSelection mirror equals the harnesses ScopeSelection", () => {
35
+ expectTypeOf<SetupScopeSelection>().toEqualTypeOf<HarnessScopeSelection>();
36
+ });
37
+ });
@@ -3,4 +3,10 @@
3
3
  */
4
4
 
5
5
  export { setupModule } from "./setup.verb.js";
6
- export type { SetupResult, SetupSkillsResult, SymlinkAction } from "./types.js";
6
+ export type {
7
+ ConfiguredTarget,
8
+ ScopeSelection,
9
+ SetupResult,
10
+ SetupSkillsResult,
11
+ SymlinkAction,
12
+ } from "./types.js";
@@ -10,6 +10,7 @@
10
10
  * `setup completions`").
11
11
  */
12
12
 
13
+ import { existsSync, readFileSync } from "node:fs";
13
14
  import { dirname } from "node:path";
14
15
  import {
15
16
  deleteFile,
@@ -26,15 +27,39 @@ import {
26
27
  detectShell,
27
28
  type ShellId,
28
29
  } from "../shell.js";
30
+ import type { CompletionsState } from "../types.js";
29
31
 
30
32
  /**
31
33
  * The detected completion install target: the shell, the absolute script path,
32
- * and the emitted script body — all `null` when no shell could be detected.
34
+ * the emitted script body — all `null` when no shell could be detected — and the
35
+ * prior on-disk {@link CompletionsState} (whether an up-to-date/stale script is
36
+ * already installed), read up front so the recap and a `--dry-run` are accurate
37
+ * and an identical rewrite is skipped.
33
38
  */
34
39
  export interface CompletionsDetection {
35
40
  readonly shell: ShellId | null;
36
41
  readonly path: string | null;
37
42
  readonly script: string | null;
43
+ readonly state: CompletionsState;
44
+ }
45
+
46
+ /**
47
+ * Classify the completion script already at `path` against the body we would
48
+ * write: `installed` when byte-identical (a re-run skips it), `stale` when a
49
+ * different script is present, `absent` when no file exists.
50
+ *
51
+ * @param path - The install path.
52
+ * @param script - The body a write would emit.
53
+ * @returns The prior {@link CompletionsState}.
54
+ * @note Impure — reads the install path.
55
+ */
56
+ function classifyCompletions(path: string, script: string): CompletionsState {
57
+ if (!existsSync(path)) return "absent";
58
+ try {
59
+ return readFileSync(path, "utf8") === script ? "installed" : "stale";
60
+ } catch {
61
+ return "stale";
62
+ }
38
63
  }
39
64
 
40
65
  /**
@@ -52,7 +77,7 @@ export async function detectCompletions(
52
77
  cwd: string,
53
78
  ): Promise<CompletionsDetection> {
54
79
  const shell = detectShell();
55
- if (!shell) return { shell: null, path: null, script: null };
80
+ if (!shell) return { shell: null, path: null, script: null, state: "absent" };
56
81
 
57
82
  const [{ capabilities }, { emitScripts }, { readConfig }] = await Promise.all(
58
83
  [
@@ -68,18 +93,16 @@ export async function detectCompletions(
68
93
  .filter(([, enabled]) => enabled === false)
69
94
  .map(([family]) => family)
70
95
  : undefined;
71
- return {
72
- shell,
73
- path: completionScriptPath(shell),
74
- script: emitScripts(capabilities, {
75
- ...(completion?.minChars !== undefined
76
- ? { minChars: completion.minChars }
77
- : {}),
78
- ...(disabledFamilies && disabledFamilies.length > 0
79
- ? { disabledFamilies }
80
- : {}),
81
- })[shell],
82
- };
96
+ const path = completionScriptPath(shell);
97
+ const script = emitScripts(capabilities, {
98
+ ...(completion?.minChars !== undefined
99
+ ? { minChars: completion.minChars }
100
+ : {}),
101
+ ...(disabledFamilies && disabledFamilies.length > 0
102
+ ? { disabledFamilies }
103
+ : {}),
104
+ })[shell];
105
+ return { shell, path, script, state: classifyCompletions(path, script) };
83
106
  }
84
107
 
85
108
  /**
@@ -89,6 +112,12 @@ export async function detectCompletions(
89
112
  * interprets a generator's task TWICE — once for the confirm-gate preview and
90
113
  * once to perform it — so the composed task must survive a second drive.
91
114
  *
115
+ * The prior `state` drives the MESSAGE only ("already up to date" / "Updating" /
116
+ * "Installing"), NOT whether the write happens: the (idempotent) write is always
117
+ * composed so it carries its `undo` — `--undo` reverses the SAME task and must
118
+ * find the reversible effect, and `execute` interprets the task twice. A
119
+ * byte-identical rewrite in the `installed` case is harmless.
120
+ *
92
121
  * @param d - The detection gathered up front.
93
122
  * @returns A Task that writes the script (with an undo), or warns if no shell.
94
123
  */
@@ -98,9 +127,13 @@ export function composeCompletions(d: CompletionsDetection): Task<void> {
98
127
  "Could not detect your shell — set $SHELL to zsh, bash, or fish.",
99
128
  );
100
129
  }
101
- const { path, script, shell } = d;
130
+ const { path, script, shell, state } = d;
131
+ const opening =
132
+ state === "installed"
133
+ ? `${shell} completions already up to date at ${path}.`
134
+ : `${state === "stale" ? "Updating" : "Installing"} ${shell} completions to ${path}...`;
102
135
  return sequence_([
103
- info(`Installing ${shell} completions to ${path}...`),
136
+ info(opening),
104
137
  mkdir(dirname(path), true),
105
138
  writeFile(path, script, { undo: deleteFile(path) }),
106
139
  info(activationHint(shell)),