@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
@@ -11,6 +11,12 @@
11
11
  * it more than once (the confirm-gate preview + the build), so all real reads
12
12
  * belong in detection, never in `generate`.
13
13
  *
14
+ * The `--scope` selection is threaded through detection AND step-offering so
15
+ * `listAvailableSteps` only offers steps whose band the scope runs, and the MCP
16
+ * step targets exactly the deduped files for that scope. Per-file narrowing is
17
+ * opt-in (Item 6): the "all" default never springs a per-file question — it
18
+ * configures every deduped file.
19
+ *
14
20
  * This module carries NO static import of `@canonical/summon-core` VALUES or of
15
21
  * React/Ink — it only `import type`s the generator shape and builds plain object
16
22
  * literals over `@canonical/task`. It is itself reached only through the verb's
@@ -24,18 +30,26 @@ import type {
24
30
  import { sequence_, type Task, when } from "@canonical/task";
25
31
  import type { PragmaRuntime } from "../../../kernel/runtime/types.js";
26
32
  import { guardMissingBinary } from "../../shared/assertExecOk.js";
27
- import type { SetupMode, SetupResult } from "../types.js";
33
+ import type {
34
+ LspState,
35
+ ScopeSelection,
36
+ SetupMode,
37
+ SetupResult,
38
+ } from "../types.js";
28
39
  import {
29
40
  type CompletionsDetection,
30
41
  composeCompletions,
31
42
  detectCompletions,
32
43
  } from "./setupCompletions.js";
33
- import { composeLsp } from "./setupLsp.js";
44
+ import { composeLsp, detectLsp, type LspDetection } from "./setupLsp.js";
34
45
  import {
35
46
  composeMcp,
36
47
  detectMcp,
37
48
  type McpDetection,
38
49
  mcpConfigured,
50
+ mcpGroupState,
51
+ mcpTargets,
52
+ selectedGroups,
39
53
  } from "./setupMcp.js";
40
54
  import {
41
55
  composeSkills,
@@ -61,22 +75,29 @@ export interface SetupPlan {
61
75
  /** The union of every step's detection, gathered up front for the run-all. */
62
76
  interface SetupDetection {
63
77
  readonly completions: CompletionsDetection;
78
+ readonly lsp: LspDetection;
64
79
  readonly mcp: McpDetection;
65
80
  readonly skills: SkillsDetection;
66
81
  }
67
82
 
68
83
  /** Read `answers.steps` (the run-all multiselect) as a string array. */
69
- const stepsOf = (answers: Record<string, unknown>): string[] =>
84
+ const readSteps = (answers: Record<string, unknown>): string[] =>
70
85
  Array.isArray(answers.steps) ? (answers.steps as string[]) : [];
71
86
 
72
- /** Read `answers.mcpHarnesses`, falling back to ALL detected ids. */
73
- const mcpSelection = (
87
+ /** Read `answers.mcpTargets` (selected file paths), falling back to ALL files. */
88
+ const resolveMcpPaths = (
74
89
  d: McpDetection,
75
90
  answers: Record<string, unknown>,
76
91
  ): string[] =>
77
- Array.isArray(answers.mcpHarnesses)
78
- ? (answers.mcpHarnesses as string[])
79
- : d.harnesses.map((h) => h.harness.id);
92
+ Array.isArray(answers.mcpTargets)
93
+ ? (answers.mcpTargets as string[])
94
+ : d.groups.map((g) => g.path);
95
+
96
+ /** The groups the user chose (or all, under the "all" default). */
97
+ const selectChosenGroups = (
98
+ d: McpDetection,
99
+ answers: Record<string, unknown>,
100
+ ) => selectedGroups(d, resolveMcpPaths(d, answers));
80
101
 
81
102
  /**
82
103
  * The LSP-install step, guarded at its use site. An absent `bunx` (no Bun on
@@ -87,18 +108,18 @@ const mcpSelection = (
87
108
  * exec (no spawn) — and re-runnable (the guard is a `recover`, and `composeLsp`
88
109
  * is combinator-built), so it survives `execute`'s double interpretation.
89
110
  */
90
- const composeGuardedLsp = (rt: PragmaRuntime): Task<void> =>
111
+ const composeGuardedLsp = (rt: PragmaRuntime, state: LspState): Task<void> =>
91
112
  guardMissingBinary(
92
113
  "bunx",
93
114
  {
94
115
  message:
95
116
  "Install Bun (https://bun.sh) to provide `bunx`, then run this again.",
96
117
  },
97
- composeLsp(rt.cwd),
118
+ composeLsp(rt.cwd, state),
98
119
  );
99
120
 
100
121
  /** Build a generator's `meta` (no stamping — the version is just header text). */
101
- const metaFor = (
122
+ const buildMeta = (
102
123
  rt: PragmaRuntime,
103
124
  title: string,
104
125
  ): GeneratorDefinition["meta"] => ({
@@ -108,20 +129,50 @@ const metaFor = (
108
129
  version: rt.version,
109
130
  });
110
131
 
111
- /** The MCP-harness multiselect follow-up (offered only when harnesses exist). */
112
- const mcpHarnessesPrompt = (
132
+ /** The opt-in "customize which files" gate (Item 6) — defaults to false. */
133
+ const buildCustomizePrompt = (
134
+ when?: PromptDefinition["when"],
135
+ ): PromptDefinition => ({
136
+ name: "customize",
137
+ type: "confirm",
138
+ message: "Customize which files pragma configures?",
139
+ default: false,
140
+ when,
141
+ });
142
+
143
+ /** The suffix a group's prior state adds to its multiselect label. */
144
+ const mcpStateSuffix = (d: McpDetection, path: string): string => {
145
+ switch (mcpGroupState(d, path)) {
146
+ case "configured":
147
+ return " — already configured";
148
+ case "drifted":
149
+ return " — needs update";
150
+ default:
151
+ return "";
152
+ }
153
+ };
154
+
155
+ /**
156
+ * The per-file MCP multiselect — one row per deduped {@link TargetGroup} file.
157
+ * A row's label carries its prior state, and an already-`configured` file is
158
+ * DEFAULT-DESELECTED (so a re-run does not re-offer to rewrite what is already
159
+ * current); `absent`/`drifted` files stay selected by default.
160
+ */
161
+ const buildMcpTargetsPrompt = (
113
162
  d: McpDetection,
114
163
  when?: PromptDefinition["when"],
115
164
  ): PromptDefinition => ({
116
- name: "mcpHarnesses",
165
+ name: "mcpTargets",
117
166
  type: "multiselect",
118
- message: "Configure MCP for which harnesses?",
167
+ message: "Configure MCP for which files?",
119
168
  when,
120
- choices: d.harnesses.map((h) => ({
121
- label: h.harness.name,
122
- value: h.harness.id,
169
+ choices: d.groups.map((g) => ({
170
+ label: `${g.path} — ${g.harnessNames.join(", ")} [${g.scope}]${mcpStateSuffix(d, g.path)}`,
171
+ value: g.path,
123
172
  })),
124
- default: d.harnesses.map((h) => h.harness.id),
173
+ default: d.groups
174
+ .filter((g) => mcpGroupState(d, g.path) !== "configured")
175
+ .map((g) => g.path),
125
176
  });
126
177
 
127
178
  // =============================================================================
@@ -129,39 +180,55 @@ const mcpHarnessesPrompt = (
129
180
  // =============================================================================
130
181
 
131
182
  /** Gather EVERY step's detection up front (real reads), for the run-all. */
132
- async function gatherDetection(rt: PragmaRuntime): Promise<SetupDetection> {
133
- const [completions, mcp, skills] = await Promise.all([
183
+ async function gatherDetection(
184
+ rt: PragmaRuntime,
185
+ scope: ScopeSelection,
186
+ ): Promise<SetupDetection> {
187
+ const [completions, lsp, mcp, skills] = await Promise.all([
134
188
  detectCompletions(rt.cwd),
135
- detectMcp(rt),
189
+ detectLsp(rt.cwd),
190
+ detectMcp(rt, scope),
136
191
  detectSkills(rt),
137
192
  ]);
138
- return { completions, mcp, skills };
193
+ return { completions, lsp, mcp, skills };
139
194
  }
140
195
 
141
196
  /**
142
- * The steps worth OFFERING, given detection — undetectable steps are omitted so
143
- * the run-all degrades gracefully instead of throwing a mid-wizard
144
- * EMPTY_RESULTS (skills with no skills, mcp with no harnesses, completions with
145
- * no shell). LSP is always offered (its install is a self-contained exec).
197
+ * The steps worth OFFERING, given detection AND the scope selection. An
198
+ * undetectable step is omitted so the run-all degrades gracefully instead of
199
+ * throwing a mid-wizard EMPTY_RESULTS; a step whose band the scope does not run
200
+ * is omitted too (completions/lsp are global-band, skills are project-band, MCP
201
+ * spans both via its resolved groups). Threading `scope` here is what makes
202
+ * `setup --local` skip completions+lsp and `setup --global` skip skills — the
203
+ * MCP groups are already scoped by {@link detectMcp}.
204
+ *
205
+ * @param detected - Every step's up-front detection.
206
+ * @param scope - The resolved `--scope` selection (project/global/both).
207
+ * @returns The offerable steps, in composition/display order.
146
208
  */
147
- function availableSteps(
209
+ function listAvailableSteps(
148
210
  detected: SetupDetection,
211
+ scope: ScopeSelection,
149
212
  ): { label: string; value: StepId }[] {
213
+ const hasProject = scope !== "global";
214
+ const hasGlobal = scope !== "project";
150
215
  const choices: { label: string; value: StepId }[] = [];
151
- if (detected.completions.shell) {
216
+ if (detected.completions.shell && hasGlobal) {
152
217
  choices.push({
153
218
  label: `Shell completions (${detected.completions.shell})`,
154
219
  value: "completions",
155
220
  });
156
221
  }
157
- choices.push({ label: "Terrazzo LSP extension", value: "lsp" });
158
- if (detected.mcp.harnesses.length > 0) {
222
+ if (hasGlobal) {
223
+ choices.push({ label: "Terrazzo LSP extension", value: "lsp" });
224
+ }
225
+ if (detected.mcp.groups.length > 0) {
159
226
  choices.push({
160
- label: `MCP config (${detected.mcp.harnesses.length} harness(es))`,
227
+ label: `MCP config (${detected.mcp.groups.length} file(s))`,
161
228
  value: "mcp",
162
229
  });
163
230
  }
164
- if (detected.skills.available) {
231
+ if (detected.skills.available && hasProject) {
165
232
  choices.push({
166
233
  label: `Link skills (${detected.skills.skillCount})`,
167
234
  value: "skills",
@@ -174,9 +241,18 @@ function availableSteps(
174
241
  // Plan builders
175
242
  // =============================================================================
176
243
 
177
- /** The run-all self-verb: a step multiselect + per-step composition. */
178
- function runAllPlan(rt: PragmaRuntime, detected: SetupDetection): SetupPlan {
179
- const steps = availableSteps(detected);
244
+ /**
245
+ * The run-all self-verb: a step multiselect + per-step composition. The resolved
246
+ * `scope` narrows BOTH the offered steps (via {@link listAvailableSteps}) and the
247
+ * MCP target groups (already scoped by {@link detectMcp}), so `--local` omits the
248
+ * global-band completions+lsp and `--global` omits the project-band skills.
249
+ */
250
+ function buildRunAllPlan(
251
+ rt: PragmaRuntime,
252
+ detected: SetupDetection,
253
+ scope: ScopeSelection,
254
+ ): SetupPlan {
255
+ const steps = listAvailableSteps(detected, scope);
180
256
  const prompts: PromptDefinition[] = [
181
257
  {
182
258
  name: "steps",
@@ -186,11 +262,16 @@ function runAllPlan(rt: PragmaRuntime, detected: SetupDetection): SetupPlan {
186
262
  default: steps.map((c) => c.value), // --yes / non-TTY ⇒ every step
187
263
  },
188
264
  ];
189
- if (detected.mcp.harnesses.length > 0) {
265
+ // Item 6: per-file narrowing is opt-in. The "customize?" gate (default false)
266
+ // only surfaces when MCP is chosen and there is more than one file; the
267
+ // per-file multiselect only surfaces after an explicit yes — so the "all"
268
+ // default configures every deduped file without an extra question.
269
+ if (detected.mcp.groups.length > 0) {
190
270
  prompts.push(
191
- mcpHarnessesPrompt(
271
+ buildCustomizePrompt((a) => readSteps(a).includes("mcp")),
272
+ buildMcpTargetsPrompt(
192
273
  detected.mcp,
193
- (a) => stepsOf(a).includes("mcp"), // only when mcp is chosen
274
+ (a) => a.customize === true && readSteps(a).includes("mcp"),
194
275
  ),
195
276
  );
196
277
  }
@@ -201,19 +282,19 @@ function runAllPlan(rt: PragmaRuntime, detected: SetupDetection): SetupPlan {
201
282
  // reads already happened up front). Composing an unchosen step is harmless:
202
283
  // `when(false, …)` discards the (side-effect-free) task it was handed.
203
284
  const generator: GeneratorDefinition = {
204
- meta: metaFor(rt, "pragma setup"),
285
+ meta: buildMeta(rt, "pragma setup"),
205
286
  prompts,
206
287
  generate: (answers) => {
207
- const chosen = stepsOf(answers);
288
+ const chosen = readSteps(answers);
208
289
  return sequence_([
209
290
  when(
210
291
  chosen.includes("completions"),
211
292
  composeCompletions(detected.completions),
212
293
  ),
213
- when(chosen.includes("lsp"), composeGuardedLsp(rt)),
294
+ when(chosen.includes("lsp"), composeGuardedLsp(rt, detected.lsp.state)),
214
295
  when(
215
296
  chosen.includes("mcp"),
216
- composeMcp(detected.mcp, mcpSelection(detected.mcp, answers)),
297
+ composeMcp(detected.mcp, selectChosenGroups(detected.mcp, answers)),
217
298
  ),
218
299
  when(chosen.includes("skills"), composeSkills(detected.skills)),
219
300
  ]);
@@ -222,18 +303,18 @@ function runAllPlan(rt: PragmaRuntime, detected: SetupDetection): SetupPlan {
222
303
 
223
304
  return {
224
305
  generator,
225
- toResult: (answers) => ({ kind: "all", steps: stepsOf(answers) }),
306
+ toResult: (answers) => ({ kind: "all", steps: readSteps(answers) }),
226
307
  };
227
308
  }
228
309
 
229
310
  /** A single-step generator (no run-all multiselect) for one sub-verb. */
230
- function singleStep(
311
+ function buildSingleStep(
231
312
  rt: PragmaRuntime,
232
313
  title: string,
233
314
  prompts: PromptDefinition[],
234
315
  generate: (answers: Record<string, unknown>) => Task<void>,
235
316
  ): GeneratorDefinition {
236
- return { meta: metaFor(rt, title), prompts, generate };
317
+ return { meta: buildMeta(rt, title), prompts, generate };
237
318
  }
238
319
 
239
320
  /**
@@ -244,6 +325,7 @@ function singleStep(
244
325
  *
245
326
  * @param rt - The per-invocation runtime.
246
327
  * @param mode - The entry point (`all` or one sub-verb).
328
+ * @param scope - The resolved `--scope` selection (project/global/both).
247
329
  * @returns The generator + result mapper.
248
330
  * @throws PragmaError EMPTY_RESULTS for a direct `setup skills` with no skills.
249
331
  * @note Impure — performs each step's real detection.
@@ -251,15 +333,16 @@ function singleStep(
251
333
  export async function buildSetupPlan(
252
334
  rt: PragmaRuntime,
253
335
  mode: SetupMode,
336
+ scope: ScopeSelection,
254
337
  ): Promise<SetupPlan> {
255
338
  switch (mode) {
256
339
  case "all":
257
- return runAllPlan(rt, await gatherDetection(rt));
340
+ return buildRunAllPlan(rt, await gatherDetection(rt, scope), scope);
258
341
 
259
342
  case "completions": {
260
343
  const d = await detectCompletions(rt.cwd);
261
344
  return {
262
- generator: singleStep(rt, "pragma setup completions", [], () =>
345
+ generator: buildSingleStep(rt, "pragma setup completions", [], () =>
263
346
  composeCompletions(d),
264
347
  ),
265
348
  toResult: () => ({
@@ -267,29 +350,42 @@ export async function buildSetupPlan(
267
350
  shell: d.shell,
268
351
  path: d.path,
269
352
  installed: d.shell !== null,
353
+ state: d.state,
270
354
  }),
271
355
  };
272
356
  }
273
357
 
274
- case "lsp":
358
+ case "lsp": {
359
+ const d = await detectLsp(rt.cwd);
275
360
  return {
276
- generator: singleStep(rt, "pragma setup lsp", [], () =>
277
- composeGuardedLsp(rt),
361
+ generator: buildSingleStep(rt, "pragma setup lsp", [], () =>
362
+ composeGuardedLsp(rt, d.state),
278
363
  ),
279
- toResult: () => ({ kind: "lsp" }),
364
+ toResult: () => ({ kind: "lsp", state: d.state }),
280
365
  };
366
+ }
281
367
 
282
368
  case "mcp": {
283
- const d = await detectMcp(rt);
284
- const prompts = d.harnesses.length > 0 ? [mcpHarnessesPrompt(d)] : [];
369
+ const d = await detectMcp(rt, scope);
370
+ const prompts: PromptDefinition[] =
371
+ d.groups.length > 0
372
+ ? [
373
+ buildCustomizePrompt(),
374
+ buildMcpTargetsPrompt(d, (a) => a.customize === true),
375
+ ]
376
+ : [];
285
377
  return {
286
- generator: singleStep(rt, "pragma setup mcp", prompts, (answers) =>
287
- composeMcp(d, mcpSelection(d, answers)),
378
+ generator: buildSingleStep(rt, "pragma setup mcp", prompts, (answers) =>
379
+ composeMcp(d, selectChosenGroups(d, answers)),
288
380
  ),
289
- toResult: (answers) => ({
290
- kind: "mcp",
291
- configured: mcpConfigured(d, mcpSelection(d, answers)),
292
- }),
381
+ toResult: (answers) => {
382
+ const sel = selectChosenGroups(d, answers);
383
+ return {
384
+ kind: "mcp",
385
+ configured: mcpConfigured(sel),
386
+ targets: mcpTargets(d, sel),
387
+ };
388
+ },
293
389
  };
294
390
  }
295
391
 
@@ -297,7 +393,7 @@ export async function buildSetupPlan(
297
393
  const d = await detectSkills(rt);
298
394
  if (!d.available) throw skillsEmptyError();
299
395
  return {
300
- generator: singleStep(rt, "pragma setup skills", [], () =>
396
+ generator: buildSingleStep(rt, "pragma setup skills", [], () =>
301
397
  composeSkills(d),
302
398
  ),
303
399
  toResult: () => ({ kind: "skills", result: toSkillsResult(d) }),
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * `setup lsp` — ensure the Terrazzo LSP VS Code extension is installed.
3
3
  *
4
- * There is nothing to detect (the installer is a single idempotent `exec`), so
5
- * `detectLsp` is a trivial marker and `composeLsp` holds the sole mutation —
6
- * mocked under `--dry-run`, run for real otherwise. A successful run reports it
7
- * installed (up to date), not a fresh install.
4
+ * `detectLsp` probes for the extension FOR REAL up front — it runs
5
+ * `code --list-extensions` (via the `exec` seam, over `runTask`) and matches the
6
+ * `terrazzo` extension id — so a re-run of an already-installed extension is a
7
+ * true no-op and the recap says so, rather than always shelling out. When the
8
+ * `code` CLI is absent from PATH the state is `unknown` (we cannot enumerate,
9
+ * so the installer still runs). `composeLsp` holds the sole mutation, mocked
10
+ * under `--dry-run` and skipped when detection already found the extension.
8
11
  */
9
12
 
10
13
  import {
@@ -17,18 +20,51 @@ import {
17
20
  type Task,
18
21
  } from "@canonical/task";
19
22
  import { assertExecOk } from "../../shared/assertExecOk.js";
23
+ import type { LspState } from "../types.js";
20
24
 
21
- /** LSP has no real detection — the install is one idempotent subprocess. */
25
+ /** The VS Code CLI queried for the installed-extensions list. */
26
+ const VSCODE_CLI = "code";
27
+
28
+ /** The substring an installed Terrazzo extension id contains (publisher-agnostic). */
29
+ const TERRAZZO_EXTENSION_MATCH = "terrazzo";
30
+
31
+ /**
32
+ * The detected LSP state: whether the Terrazzo extension is already `installed`,
33
+ * `absent`, or `unknown` (the `code` CLI is not on PATH, so we cannot enumerate).
34
+ */
22
35
  export interface LspDetection {
23
36
  readonly available: true;
37
+ readonly state: LspState;
24
38
  }
25
39
 
26
40
  /**
27
- * "Detect" the LSP step. Always available — kept for symmetry with the other
28
- * steps' detect phase (so the run-all can gather every step uniformly).
41
+ * Probe VS Code for the Terrazzo extension. Runs `code --list-extensions` and
42
+ * matches the `terrazzo` id; an absent `code` CLI (spawn ENOENT) or a nonzero
43
+ * exit yields `unknown` (we fall through to running the installer).
44
+ *
45
+ * @param cwd - The directory the probe runs in.
46
+ * @returns The detected {@link LspState}.
47
+ * @note Impure — spawns `code --list-extensions`.
29
48
  */
30
- export function detectLsp(): LspDetection {
31
- return { available: true };
49
+ export async function detectLsp(cwd: string): Promise<LspDetection> {
50
+ const { runTask } = await import("@canonical/task/node");
51
+ let state: LspState = "unknown";
52
+ try {
53
+ const result = (await runTask(
54
+ exec(VSCODE_CLI, ["--list-extensions"], cwd),
55
+ )) as ExecResult;
56
+ if (result.exitCode === 0) {
57
+ const installed = result.stdout
58
+ .toLowerCase()
59
+ .includes(TERRAZZO_EXTENSION_MATCH);
60
+ state = installed ? "installed" : "absent";
61
+ }
62
+ } catch {
63
+ // `code` absent from PATH (ENOENT) or any spawn failure → we cannot tell;
64
+ // leave `unknown` so the installer still runs.
65
+ state = "unknown";
66
+ }
67
+ return { available: true, state };
32
68
  }
33
69
 
34
70
  /**
@@ -37,12 +73,20 @@ export function detectLsp(): LspDetection {
37
73
  * Built from re-runnable combinators (NOT a single-use `gen`) because `execute`
38
74
  * interprets the task twice (preview + perform). Under the dry-run preview the
39
75
  * `exec` is MOCKED to `exitCode 0`, so `assertExecOk` passes there; a real run's
40
- * nonzero exit still fails loudly.
76
+ * nonzero exit still fails loudly. When detection already found the extension
77
+ * `installed`, the install is SKIPPED — a re-run is a true no-op.
41
78
  *
42
79
  * @param cwd - The directory to run the installer in.
43
- * @returns A Task that execs the extension installer, failing loudly on nonzero.
80
+ * @param state - The prior state from {@link detectLsp} (defaults to `unknown`).
81
+ * @returns A Task that execs the installer (or reports already-installed).
44
82
  */
45
- export function composeLsp(cwd: string): Task<void> {
83
+ export function composeLsp(
84
+ cwd: string,
85
+ state: LspState = "unknown",
86
+ ): Task<void> {
87
+ if (state === "installed") {
88
+ return info("Terrazzo LSP VS Code extension already installed — skipped.");
89
+ }
46
90
  return sequence_([
47
91
  info("Ensuring the Terrazzo LSP VS Code extension is installed..."),
48
92
  // The interpreter RESOLVES on a nonzero exit — a failed installer must fail