@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
@@ -2,84 +2,261 @@
2
2
  * `setup mcp` — register the pragma MCP server in detected AI harnesses.
3
3
  *
4
4
  * Split into `detectMcp` (harness detection runs FOR REAL up front, via
5
- * `runTask` over safe reads, so the wizard recap and a `--dry-run` describe
6
- * writes against the TRUE harness set) and `composeMcp` (a pure, re-runnable
7
- * write body driven by the harnesses the user SELECTED — the per-harness confirm
8
- * is gone; selection now comes from the wizard's multiselect). The covenant
9
- * sub-verb carries no force-flags, so harness selection is detection-only.
5
+ * `runTask` over safe reads, and the resolved config targets are deduplicated
6
+ * into per-file {@link TargetGroup}s for the chosen `--scope`) and `composeMcp`
7
+ * (a pure, re-runnable write body driven by the SELECTED groups). Each write is
8
+ * a distinct `(path, mcpKey)` so two harnesses sharing a file (VS Code + Cline)
9
+ * each preserve the other; every group emits a band-prefixed `info()` manifest
10
+ * line, so the recap and a `--dry-run` show which band each file belongs to.
11
+ *
12
+ * Detection ALSO reads each group's existing config (via `readMcpConfigFrom`,
13
+ * FOR REAL up front) and classifies every group as `absent` (no pragma entry
14
+ * yet), `configured` (the pragma entry already matches what we'd write), or
15
+ * `drifted` (a pragma entry exists but differs). The wizard shows that prior
16
+ * state in each row and DEFAULT-DESELECTS the already-`configured` files; the
17
+ * write itself stays idempotent (a re-merge of the identical entry is
18
+ * byte-for-byte), so a re-run is a no-op the recap reports as "unchanged" — the
19
+ * same state-awareness `setup skills` has always had.
10
20
  */
11
21
 
12
- import type { DetectedHarness } from "@canonical/harnesses";
22
+ import type {
23
+ McpServerConfig,
24
+ PlatformEnv,
25
+ TargetGroup,
26
+ } from "@canonical/harnesses";
13
27
  import { info, sequence_, type Task, warn } from "@canonical/task";
14
28
  import { MCP_SERVER_NAME } from "../../../constants.js";
15
29
  import type { PragmaRuntime } from "../../../kernel/runtime/types.js";
30
+ import type {
31
+ ConfiguredTarget,
32
+ McpTargetState,
33
+ ScopeSelection,
34
+ } from "../types.js";
16
35
 
17
- /** The `writeMcpConfig` Task-builder, captured from the dynamic harness import. */
18
- type WriteMcpConfig = typeof import("@canonical/harnesses").writeMcpConfig;
36
+ /** The `writeMcpConfigTargets` builder, captured from the dynamic harness import. */
37
+ type WriteMcpConfigTargets =
38
+ typeof import("@canonical/harnesses").writeMcpConfigTargets;
19
39
 
20
40
  /**
21
- * The detected MCP state: the harnesses found, the project root the config is
22
- * written against, and the (dynamically imported) config-writer the pure
23
- * `composeMcp` needs synchronously.
41
+ * The detected MCP state: the per-file target groups (already scoped to the
42
+ * requested `--scope`), a by-path map of each group's prior
43
+ * {@link McpTargetState} (read up front), the project root, and the
44
+ * (dynamically imported) target-based writer the pure `composeMcp` needs
45
+ * synchronously. Keeping `groups` a plain {@link TargetGroup}[] leaves every
46
+ * existing `.groups` consumer (path/harnessNames/scope) unchanged; the state
47
+ * rides alongside, keyed by the group's `path`.
24
48
  */
25
49
  export interface McpDetection {
26
- readonly harnesses: readonly DetectedHarness[];
50
+ readonly groups: readonly TargetGroup[];
51
+ readonly stateByPath: ReadonlyMap<string, McpTargetState>;
27
52
  readonly cwd: string;
28
- readonly writeMcpConfig: WriteMcpConfig;
53
+ readonly platform: PlatformEnv;
54
+ readonly writeMcpConfigTargets: WriteMcpConfigTargets;
55
+ }
56
+
57
+ /**
58
+ * The pragma MCP server entry we register — the SINGLE source of truth both the
59
+ * classifier (does the existing entry match this?) and the writer (`composeMcp`)
60
+ * consume, so "already configured" means byte-for-byte what a write would emit.
61
+ *
62
+ * @param cwd - The project root recorded on the entry.
63
+ * @returns The pragma {@link McpServerConfig}.
64
+ */
65
+ export function pragmaMcpEntry(cwd: string): McpServerConfig {
66
+ return { command: "pragma", args: ["mcp"], cwd };
67
+ }
68
+
69
+ /**
70
+ * Whether an existing MCP entry equals the pragma entry we would write, over the
71
+ * fields we control (`command`/`args`/`cwd`). A shallow structural compare — a
72
+ * harness that carries extra keys (e.g. `env`) still reads as `configured` as
73
+ * long as our three fields match, so we never churn a file we did not author.
74
+ */
75
+ function entryMatches(
76
+ existing: McpServerConfig,
77
+ want: McpServerConfig,
78
+ ): boolean {
79
+ const sameArgs =
80
+ (existing.args ?? []).length === (want.args ?? []).length &&
81
+ (want.args ?? []).every((a, i) => existing.args?.[i] === a);
82
+ return (
83
+ existing.command === want.command && existing.cwd === want.cwd && sameArgs
84
+ );
85
+ }
86
+
87
+ /**
88
+ * Classify one target group against its on-disk config: `absent` when no pragma
89
+ * entry exists in ANY of the group's writes, `configured` when EVERY write
90
+ * already carries a matching pragma entry, `drifted` otherwise (present in at
91
+ * least one write but not matching everywhere). Reads each write's file for real
92
+ * via `readMcpConfigFrom`.
93
+ *
94
+ * @param group - The target group whose writes to inspect.
95
+ * @param want - The pragma entry a write would emit.
96
+ * @param readMcpConfigFrom - The harness reader (dynamically imported).
97
+ * @param runTask - The node Task interpreter.
98
+ * @returns The group's {@link McpTargetState}.
99
+ * @note Impure — reads each write's config file.
100
+ */
101
+ async function classifyGroup(
102
+ group: TargetGroup,
103
+ want: McpServerConfig,
104
+ readMcpConfigFrom: typeof import("@canonical/harnesses").readMcpConfigFrom,
105
+ runTask: typeof import("@canonical/task/node").runTask,
106
+ ): Promise<McpTargetState> {
107
+ let present = 0;
108
+ let matching = 0;
109
+ for (const write of group.writes) {
110
+ const servers = await runTask(readMcpConfigFrom(write));
111
+ const existing = servers[MCP_SERVER_NAME];
112
+ if (existing === undefined) continue;
113
+ present += 1;
114
+ if (entryMatches(existing, want)) matching += 1;
115
+ }
116
+ if (present === 0) return "absent";
117
+ if (matching === group.writes.length) return "configured";
118
+ return "drifted";
29
119
  }
30
120
 
31
121
  /**
32
- * Detect the AI harnesses present in the project (real reads, up front).
122
+ * Detect the AI harnesses present in the project (real reads, up front),
123
+ * resolve+dedup their config targets for the chosen scope, AND read each
124
+ * group's existing config to classify it `absent`/`configured`/`drifted` — so
125
+ * the wizard can show prior state and default-deselect already-`configured`
126
+ * files.
33
127
  *
34
128
  * @param rt - The per-invocation runtime.
35
- * @returns The detected harnesses + the writer used to compose config writes.
36
- * @note Impure — reads the filesystem via `detectHarnesses`.
129
+ * @param scope - The resolved `--scope` selection.
130
+ * @returns The scoped target groups, their prior states, + the config writer.
131
+ * @note Impure — reads the filesystem via `detectHarnesses` + `readMcpConfigFrom`.
37
132
  */
38
- export async function detectMcp(rt: PragmaRuntime): Promise<McpDetection> {
133
+ export async function detectMcp(
134
+ rt: PragmaRuntime,
135
+ scope: ScopeSelection,
136
+ ): Promise<McpDetection> {
39
137
  const cwd = rt.cwd;
40
- const [{ detectHarnesses, writeMcpConfig }, { runTask }] = await Promise.all([
138
+ const [
139
+ {
140
+ detectHarnesses,
141
+ groupTargetsForScope,
142
+ readMcpConfigFrom,
143
+ readPlatformEnv,
144
+ writeMcpConfigTargets,
145
+ },
146
+ { runTask },
147
+ ] = await Promise.all([
41
148
  import("@canonical/harnesses"),
42
149
  import("@canonical/task/node"),
43
150
  ]);
44
- const harnesses = await runTask(detectHarnesses(cwd));
45
- return { harnesses, cwd, writeMcpConfig };
151
+ const platform = readPlatformEnv();
152
+ const detected = await runTask(detectHarnesses(cwd, platform));
153
+ const groups = groupTargetsForScope(detected, cwd, scope, platform);
154
+
155
+ // Read each group's existing config (for real, up front) so the recap/preview
156
+ // and the default selection reflect true prior state — the same discipline
157
+ // `detectSkills` uses for its per-link create/skip/replace decision.
158
+ const want = pragmaMcpEntry(cwd);
159
+ const stateByPath = new Map<string, McpTargetState>();
160
+ await Promise.all(
161
+ groups.map(async (group) => {
162
+ const state = await classifyGroup(
163
+ group,
164
+ want,
165
+ readMcpConfigFrom,
166
+ runTask,
167
+ );
168
+ stateByPath.set(group.path, state);
169
+ }),
170
+ );
171
+
172
+ return { groups, stateByPath, cwd, platform, writeMcpConfigTargets };
173
+ }
174
+
175
+ /**
176
+ * The prior state of a group, defaulting to `absent` for an unknown path (so a
177
+ * caller never has to guard the map lookup).
178
+ */
179
+ export function mcpGroupState(d: McpDetection, path: string): McpTargetState {
180
+ return d.stateByPath.get(path) ?? "absent";
181
+ }
182
+
183
+ /** The target groups the user selected (by path), or all when none recorded. */
184
+ export function selectedGroups(
185
+ d: McpDetection,
186
+ selectedPaths: readonly string[],
187
+ ): TargetGroup[] {
188
+ return d.groups.filter((g) => selectedPaths.includes(g.path));
46
189
  }
47
190
 
48
191
  /**
49
- * Compose the per-harness config writes for the SELECTED harnesses (pure —
50
- * re-runnable; builds ABSOLUTE config paths itself).
192
+ * Compose the per-target config writes for the SELECTED groups (pure —
193
+ * re-runnable; builds ABSOLUTE config paths itself). The prior
194
+ * {@link McpTargetState} drives the manifest MESSAGE (already-configured /
195
+ * updated / added) but NOT whether the write runs: the idempotent
196
+ * read-modify-write is always composed so it carries its `undo` and a re-run is
197
+ * byte-identical when already `configured`. The wizard already default-DESELECTS
198
+ * `configured` files (so they are usually not in `groups` at all); a `configured`
199
+ * file that IS selected is still rewritten idempotently.
51
200
  *
52
201
  * @param d - The detection gathered up front.
53
- * @param selectedIds - Harness ids the user chose (a subset of `d.harnesses`).
54
- * @returns A Task that writes the pragma MCP config into each chosen harness.
202
+ * @param groups - The target groups the user chose (a subset of `d.groups`).
203
+ * @returns A Task that writes the pragma MCP config into each chosen file.
55
204
  */
56
205
  export function composeMcp(
57
206
  d: McpDetection,
58
- selectedIds: readonly string[],
207
+ groups: readonly TargetGroup[],
59
208
  ): Task<void> {
60
- const chosen = d.harnesses.filter((h) => selectedIds.includes(h.harness.id));
61
- if (chosen.length === 0) {
209
+ if (groups.length === 0) {
62
210
  return warn("No AI harnesses selected — nothing to configure.");
63
211
  }
64
- const pragmaMcpConfig = { command: "pragma", args: ["mcp"], cwd: d.cwd };
212
+ const pragmaMcpConfig = pragmaMcpEntry(d.cwd);
65
213
  // Re-runnable combinators (NOT a single-use `gen`): `execute` interprets the
66
- // task twice (preview + perform).
214
+ // task twice (preview + perform). One combined write per file keeps a shared
215
+ // file (VS Code + Cline) a single read-modify-write — dry-run safe.
67
216
  const tasks: Task<unknown>[] = [];
68
- for (const h of chosen) {
217
+ for (const group of groups) {
218
+ const state = mcpGroupState(d, group.path);
219
+ const names = group.harnessNames.join(", ");
220
+ tasks.push(
221
+ d.writeMcpConfigTargets(group.writes, MCP_SERVER_NAME, pragmaMcpConfig),
222
+ );
223
+ const verb =
224
+ state === "configured"
225
+ ? "already configured →"
226
+ : state === "drifted"
227
+ ? "updated →"
228
+ : "→";
69
229
  tasks.push(
70
- d.writeMcpConfig(h.harness, d.cwd, MCP_SERVER_NAME, pragmaMcpConfig),
230
+ info(
231
+ `[${group.scope}] pragma MCP server ${verb} ${group.path} (${names})`,
232
+ ),
71
233
  );
72
- tasks.push(info(`✓ pragma MCP server added to ${h.configPath}`));
73
234
  }
74
235
  return sequence_(tasks);
75
236
  }
76
237
 
77
- /** The harness names configured for a given selection (for the result shape). */
78
- export function mcpConfigured(
238
+ /** The harness names configured across a selection (for the result summary). */
239
+ export function mcpConfigured(groups: readonly TargetGroup[]): string[] {
240
+ const names = new Set<string>();
241
+ for (const group of groups) {
242
+ for (const name of group.harnessNames) names.add(name);
243
+ }
244
+ return [...names].sort();
245
+ }
246
+
247
+ /**
248
+ * The per-file {@link ConfiguredTarget}s for a selection (for the result), each
249
+ * carrying its prior {@link McpTargetState} so the recap distinguishes newly
250
+ * added, updated, and already-configured targets.
251
+ */
252
+ export function mcpTargets(
79
253
  d: McpDetection,
80
- selectedIds: readonly string[],
81
- ): string[] {
82
- return d.harnesses
83
- .filter((h) => selectedIds.includes(h.harness.id))
84
- .map((h) => h.harness.name);
254
+ groups: readonly TargetGroup[],
255
+ ): ConfiguredTarget[] {
256
+ return groups.map((group) => ({
257
+ name: group.harnessNames.join(", "),
258
+ band: group.scope,
259
+ path: group.path,
260
+ state: mcpGroupState(d, group.path),
261
+ }));
85
262
  }
@@ -148,6 +148,10 @@ export async function detectSkills(
148
148
  * interprets the task twice (preview + perform). `created` links carry an undo;
149
149
  * a `replaced` link is delete-then-relink (idempotent); `skipped` is a no-op.
150
150
  *
151
+ * FOLLOW-UP(AV-284): global-band skills — skills only link into per-project
152
+ * harness dirs today; a `--scope global` machine-band skills location is
153
+ * deferred. The manifest line is `[project]`-prefixed to make that explicit.
154
+ *
151
155
  * @param d - The detection gathered up front.
152
156
  * @returns A Task that mkdirs each target and creates/replaces each symlink.
153
157
  */
@@ -168,7 +172,9 @@ export function composeSkills(d: SkillsDetection): Task<void> {
168
172
  }
169
173
  }
170
174
  tasks.push(
171
- info(`Linked ${d.skillCount} skill(s) into ${d.targets.length} dir(s).`),
175
+ info(
176
+ `[project] Linked ${d.skillCount} skill(s) into ${d.targets.length} dir(s).`,
177
+ ),
172
178
  );
173
179
  return sequence_(tasks);
174
180
  }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Render goldens for `pragma setup` — the MCP recap grouped by band.
3
+ *
4
+ * `summarizeMcpTargets` (the recap's band grouping) previously shipped
5
+ * executed-but-never-asserted; this pins the ACTUAL banded recap text for BOTH
6
+ * the plain and llm formatters, and locks the unified Global/Project vocabulary
7
+ * (the recap no longer says MACHINE/PROJECT). The setup formatters use no
8
+ * colour, so no chalk level dance is needed.
9
+ */
10
+
11
+ import { describe, expect, it } from "vitest";
12
+ import { setupFormatters } from "./setup.render.js";
13
+ import type { SetupResult } from "./types.js";
14
+
15
+ /**
16
+ * A two-band MCP result: a global (Windsurf) and a project (Cursor) target,
17
+ * both newly `absent`→added — the clean first-run recap (no state tally).
18
+ */
19
+ const BOTH_BANDS: SetupResult = {
20
+ kind: "mcp",
21
+ configured: ["Cursor", "Windsurf"],
22
+ targets: [
23
+ {
24
+ name: "Windsurf",
25
+ band: "global",
26
+ path: "/home/u/.codeium/windsurf/mcp_config.json",
27
+ state: "absent",
28
+ },
29
+ {
30
+ name: "Cursor",
31
+ band: "project",
32
+ path: "/proj/.cursor/mcp.json",
33
+ state: "absent",
34
+ },
35
+ ],
36
+ };
37
+
38
+ describe("setup render — MCP recap banded by Global/Project", () => {
39
+ it("plain lists the global band before the project band", () => {
40
+ expect(setupFormatters.plain(BOTH_BANDS)).toBe(
41
+ "Configured MCP — Global: Windsurf · Project: Cursor.",
42
+ );
43
+ });
44
+
45
+ it("llm is the same banded recap prefixed with a bullet", () => {
46
+ expect(setupFormatters.llm(BOTH_BANDS)).toBe(
47
+ "- Configured MCP — Global: Windsurf · Project: Cursor.",
48
+ );
49
+ });
50
+
51
+ it("uses the unified labels, never MACHINE/PROJECT", () => {
52
+ const out = setupFormatters.plain(BOTH_BANDS);
53
+ expect(out).not.toContain("MACHINE");
54
+ expect(out).not.toContain("PROJECT");
55
+ });
56
+
57
+ it("a single-band selection lists only that band", () => {
58
+ const projectOnly: SetupResult = {
59
+ kind: "mcp",
60
+ configured: ["Cursor"],
61
+ targets: [
62
+ {
63
+ name: "Cursor",
64
+ band: "project",
65
+ path: "/p/.cursor/mcp.json",
66
+ state: "absent",
67
+ },
68
+ ],
69
+ };
70
+ expect(setupFormatters.plain(projectOnly)).toBe(
71
+ "Configured MCP — Project: Cursor.",
72
+ );
73
+ });
74
+
75
+ it("appends the state tally when a target was already present (re-run)", () => {
76
+ // A mix of already-configured + drifted + newly-added surfaces the
77
+ // idempotency breakdown, so a re-run reads as a no-op rather than a rewrite.
78
+ const rerun: SetupResult = {
79
+ kind: "mcp",
80
+ configured: ["Cursor", "Windsurf"],
81
+ targets: [
82
+ {
83
+ name: "Windsurf",
84
+ band: "global",
85
+ path: "/home/u/.codeium/windsurf/mcp_config.json",
86
+ state: "configured",
87
+ },
88
+ {
89
+ name: "Cursor",
90
+ band: "project",
91
+ path: "/proj/.cursor/mcp.json",
92
+ state: "drifted",
93
+ },
94
+ ],
95
+ };
96
+ expect(setupFormatters.plain(rerun)).toBe(
97
+ "Configured MCP — Global: Windsurf · Project: Cursor (0 added, 1 updated, 1 unchanged).",
98
+ );
99
+ });
100
+
101
+ it("no targets → a plain 'nothing configured' line", () => {
102
+ const none: SetupResult = { kind: "mcp", configured: [], targets: [] };
103
+ expect(setupFormatters.plain(none)).toBe("No harnesses configured.");
104
+ });
105
+
106
+ it("json is the exact SetupResult round-trip", () => {
107
+ expect(JSON.parse(setupFormatters.json(BOTH_BANDS))).toEqual(BOTH_BANDS);
108
+ });
109
+ });
@@ -4,31 +4,78 @@
4
4
  */
5
5
 
6
6
  import type { Formatters } from "../../kernel/spec/types.js";
7
- import type { SetupResult, SetupSkillsResult } from "./types.js";
7
+ import { BAND_LABELS } from "../shared/bands.js";
8
+ import type {
9
+ ConfiguredTarget,
10
+ SetupResult,
11
+ SetupSkillsResult,
12
+ } from "./types.js";
8
13
 
9
14
  /** Summarize a skills result: created / skipped / replaced counts. */
10
- function skillsSummary(result: SetupSkillsResult): string {
15
+ function summarizeSkills(result: SetupSkillsResult): string {
11
16
  const created = result.actions.filter((a) => a.action === "created").length;
12
17
  const skipped = result.actions.filter((a) => a.action === "skipped").length;
13
18
  const replaced = result.actions.filter((a) => a.action === "replaced").length;
14
19
  return `${created} created, ${replaced} replaced, ${skipped} unchanged across ${result.harnessCount} harness(es)`;
15
20
  }
16
21
 
22
+ /**
23
+ * Summarize the MCP targets grouped by band — the global (user/home) band
24
+ * before the project (per-repo) band — so the recap shows which files at which
25
+ * level got the pragma server. The Global/Project labels match the doctor
26
+ * report and the CLI's `--global`/`--local` grammar (see {@link BAND_LABELS}).
27
+ *
28
+ * A trailing state tally is appended ONLY when something was already present
29
+ * (an `updated` or `unchanged` target) — a clean first run (every target newly
30
+ * `added`) keeps the plain `Configured MCP — …` line, so the idempotency detail
31
+ * surfaces exactly when it is informative (a re-run made clear it was a no-op).
32
+ */
33
+ function summarizeMcpTargets(targets: readonly ConfiguredTarget[]): string {
34
+ if (targets.length === 0) return "No harnesses configured.";
35
+ const section = (band: ConfiguredTarget["band"], label: string): string => {
36
+ const names = targets.filter((t) => t.band === band).map((t) => t.name);
37
+ return names.length > 0 ? `${label}: ${names.join(", ")}` : "";
38
+ };
39
+ const parts = [
40
+ section("global", BAND_LABELS.global),
41
+ section("project", BAND_LABELS.project),
42
+ ]
43
+ .filter((p) => p.length > 0)
44
+ .join(" · ");
45
+ const updated = targets.filter((t) => t.state === "drifted").length;
46
+ const unchanged = targets.filter((t) => t.state === "configured").length;
47
+ if (updated === 0 && unchanged === 0) {
48
+ // Clean first run — every target newly added; keep the original recap line.
49
+ return `Configured MCP — ${parts}.`;
50
+ }
51
+ const added = targets.filter((t) => t.state === "absent").length;
52
+ return `Configured MCP — ${parts} (${added} added, ${updated} updated, ${unchanged} unchanged).`;
53
+ }
54
+
17
55
  /** Render a setup result to a single human line (shared by plain/llm). */
18
- function line(data: SetupResult): string {
56
+ function renderSetupLine(data: SetupResult): string {
19
57
  switch (data.kind) {
20
- case "completions":
21
- return data.installed
22
- ? `Installed ${data.shell} completions at ${data.path}.`
23
- : "No shell detected — completions were not installed.";
58
+ case "completions": {
59
+ if (!data.installed) {
60
+ return "No shell detected — completions were not installed.";
61
+ }
62
+ switch (data.state) {
63
+ case "installed":
64
+ return `${data.shell} completions already up to date at ${data.path}.`;
65
+ case "stale":
66
+ return `Updated ${data.shell} completions at ${data.path}.`;
67
+ default:
68
+ return `Installed ${data.shell} completions at ${data.path}.`;
69
+ }
70
+ }
24
71
  case "lsp":
25
- return "Terrazzo LSP extension is installed (up to date).";
72
+ return data.state === "installed"
73
+ ? "Terrazzo LSP extension already installed (up to date)."
74
+ : "Terrazzo LSP extension is installed (up to date).";
26
75
  case "mcp":
27
- return data.configured.length > 0
28
- ? `Configured MCP for: ${data.configured.join(", ")}.`
29
- : "No harnesses configured.";
76
+ return summarizeMcpTargets(data.targets);
30
77
  case "skills":
31
- return `Skills: ${skillsSummary(data.result)}.`;
78
+ return `Skills: ${summarizeSkills(data.result)}.`;
32
79
  case "all":
33
80
  return data.steps.length > 0
34
81
  ? `Setup complete — ran: ${data.steps.join(", ")}.`
@@ -38,10 +85,10 @@ function line(data: SetupResult): string {
38
85
 
39
86
  export const setupFormatters: Formatters<SetupResult> = {
40
87
  plain(data) {
41
- return line(data);
88
+ return renderSetupLine(data);
42
89
  },
43
90
  llm(data) {
44
- return `- ${line(data)}`;
91
+ return `- ${renderSetupLine(data)}`;
45
92
  },
46
93
  json(data) {
47
94
  return JSON.stringify(data);