@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
@@ -185,7 +185,11 @@ describe("setup mcp — recap gate", () => {
185
185
  mkdirSync(join(cwd, ".cursor"), { recursive: true });
186
186
  const configPath = join(cwd, ".cursor", "mcp.json");
187
187
 
188
- const { generator } = await buildSetupPlan(bootRuntime(FLAGS, cwd), "mcp");
188
+ const { generator } = await buildSetupPlan(
189
+ bootRuntime(FLAGS, cwd),
190
+ "mcp",
191
+ "both",
192
+ );
189
193
  // A handler that declines EVERYTHING (incl. execute's "Proceed?" gate) must
190
194
  // fail the run cleanly — the GENERATOR_CANCELLED code the boundary renders
191
195
  // as a plain "Cancelled." — and write nothing.
@@ -197,6 +201,207 @@ describe("setup mcp — recap gate", () => {
197
201
  });
198
202
  });
199
203
 
204
+ describe("setup mcp — scope & dedup", () => {
205
+ // Isolate PATH so a `claude`/`codex` on the host PATH can't inject harnesses
206
+ // via a `process` signal — detection is driven only by the dirs each test makes.
207
+ let prevPath: string | undefined;
208
+ beforeEach(() => {
209
+ prevPath = process.env.PATH;
210
+ process.env.PATH = tmp("pragma-scope-path-");
211
+ });
212
+ afterEach(() => {
213
+ process.env.PATH = prevPath;
214
+ });
215
+
216
+ it("writes both VS Code and Cline keys into one .vscode/mcp.json (7a dedup)", async () => {
217
+ const cwd = tmp("pragma-setup-proj-");
218
+ mkdirSync(join(cwd, ".vscode"), { recursive: true }); // VS Code (dir signal)
219
+ // Cline is detected by its extension (NOT the .vscode dir), so install a fake
220
+ // one under the isolated HOME — both harnesses then resolve to the SAME
221
+ // .vscode/mcp.json, which is what the two-level dedup write must handle. The
222
+ // extension is matched by its `package.json` manifest, so write one.
223
+ const clineExt = join(
224
+ process.env.HOME ?? "",
225
+ ".vscode",
226
+ "extensions",
227
+ "saoudrizwan.claude-dev-1.0.0",
228
+ );
229
+ mkdirSync(clineExt, { recursive: true });
230
+ writeFileSync(join(clineExt, "package.json"), "{}");
231
+ const outcome = await executeVerb(
232
+ verbOf("mcp"),
233
+ {},
234
+ YES,
235
+ bootRuntime(FLAGS, cwd),
236
+ );
237
+ expect(outcome.exitCode).toBe(0);
238
+ const config = JSON.parse(
239
+ readFileSync(join(cwd, ".vscode", "mcp.json"), "utf-8"),
240
+ );
241
+ // VS Code writes under `servers`, Cline under `mcpServers` — the two-level
242
+ // dedup makes two writes to one file, each preserving the other's key.
243
+ expect(config.servers?.pragma?.command).toBe("pragma");
244
+ expect(config.mcpServers?.pragma?.command).toBe("pragma");
245
+ });
246
+
247
+ it("--local writes the project config and skips a global-only harness", async () => {
248
+ const cwd = tmp("pragma-setup-proj-");
249
+ const home = process.env.HOME ?? "";
250
+ mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project scope
251
+ mkdirSync(join(cwd, ".windsurf"), { recursive: true }); // global scope
252
+ await executeVerb(
253
+ verbOf("mcp"),
254
+ { local: true },
255
+ YES,
256
+ bootRuntime(FLAGS, cwd),
257
+ );
258
+ expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(true);
259
+ expect(
260
+ existsSync(join(home, ".codeium", "windsurf", "mcp_config.json")),
261
+ ).toBe(false);
262
+ });
263
+
264
+ it("--global writes a global harness's home config, skipping project harnesses", async () => {
265
+ const cwd = tmp("pragma-setup-proj-");
266
+ const home = process.env.HOME ?? "";
267
+ mkdirSync(join(cwd, ".windsurf"), { recursive: true }); // global scope
268
+ mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project scope
269
+ await executeVerb(
270
+ verbOf("mcp"),
271
+ { global: true },
272
+ YES,
273
+ bootRuntime(FLAGS, cwd),
274
+ );
275
+ const windsurfHome = JSON.parse(
276
+ readFileSync(
277
+ join(home, ".codeium", "windsurf", "mcp_config.json"),
278
+ "utf-8",
279
+ ),
280
+ );
281
+ expect(windsurfHome.mcpServers?.pragma?.command).toBe("pragma");
282
+ expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(false);
283
+ });
284
+
285
+ // The `--scope <enum>` values (project/global/both) resolve independently of
286
+ // the `--global`/`--local` boolean sugars above — a distinct resolveScope arm.
287
+ it("--scope project (the enum) writes the project config, skipping a global harness", async () => {
288
+ const cwd = tmp("pragma-setup-proj-");
289
+ const home = process.env.HOME ?? "";
290
+ mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project scope
291
+ mkdirSync(join(cwd, ".windsurf"), { recursive: true }); // global scope
292
+ await executeVerb(
293
+ verbOf("mcp"),
294
+ { scope: "project" },
295
+ YES,
296
+ bootRuntime(FLAGS, cwd),
297
+ );
298
+ expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(true);
299
+ expect(
300
+ existsSync(join(home, ".codeium", "windsurf", "mcp_config.json")),
301
+ ).toBe(false);
302
+ });
303
+
304
+ it("--scope global (the enum) writes the home config, skipping a project harness", async () => {
305
+ const cwd = tmp("pragma-setup-proj-");
306
+ const home = process.env.HOME ?? "";
307
+ mkdirSync(join(cwd, ".windsurf"), { recursive: true }); // global scope
308
+ mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project scope
309
+ await executeVerb(
310
+ verbOf("mcp"),
311
+ { scope: "global" },
312
+ YES,
313
+ bootRuntime(FLAGS, cwd),
314
+ );
315
+ expect(
316
+ existsSync(join(home, ".codeium", "windsurf", "mcp_config.json")),
317
+ ).toBe(true);
318
+ expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(false);
319
+ });
320
+ });
321
+
322
+ describe("setup mcp — customize opt-in gate (Item 6)", () => {
323
+ it("gates the per-file multiselect behind an explicit customize=true", async () => {
324
+ const cwd = tmp("pragma-setup-proj-");
325
+ mkdirSync(join(cwd, ".cursor"), { recursive: true }); // ⇒ one MCP target group
326
+ const { generator } = await buildSetupPlan(
327
+ bootRuntime(FLAGS, cwd),
328
+ "mcp",
329
+ "both",
330
+ );
331
+ const customize = generator.prompts.find((p) => p.name === "customize");
332
+ const targets = generator.prompts.find((p) => p.name === "mcpTargets");
333
+ // The gate is an opt-in confirm that defaults to NOT customizing.
334
+ expect(customize?.type).toBe("confirm");
335
+ expect(customize?.default).toBe(false);
336
+ // The per-file multiselect only surfaces after an explicit yes — so the
337
+ // "all" default configures every deduped file without an extra question.
338
+ expect(targets?.when?.({})).toBe(false);
339
+ expect(targets?.when?.({ customize: false })).toBe(false);
340
+ expect(targets?.when?.({ customize: true })).toBe(true);
341
+ });
342
+ });
343
+
344
+ describe("setup (run-all wizard) — scope threading", () => {
345
+ // Isolate PATH so an ambient `claude`/`codex` can't inject a harness via a
346
+ // `process` signal — detection is driven only by the dirs each test makes.
347
+ let prevPath: string | undefined;
348
+ beforeEach(() => {
349
+ prevPath = process.env.PATH;
350
+ process.env.PATH = tmp("pragma-runall-path-");
351
+ });
352
+ afterEach(() => {
353
+ process.env.PATH = prevPath;
354
+ });
355
+
356
+ /** Seed a discoverable skill so the project-band skills step is offerable. */
357
+ const seedSkill = (cwd: string): void => {
358
+ const dir = join(cwd, ".pragma", "skills", "s");
359
+ mkdirSync(dir, { recursive: true });
360
+ writeFileSync(
361
+ join(dir, "SKILL.md"),
362
+ "---\nname: s\ndescription: A skill.\n---\n",
363
+ );
364
+ };
365
+
366
+ it("--local omits the global-band completions + lsp steps, keeping project steps", async () => {
367
+ const cwd = tmp("pragma-setup-proj-");
368
+ mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-band MCP target
369
+ seedSkill(cwd); // project-band skills step
370
+ const outcome = await executeVerb(
371
+ setupSelfVerb,
372
+ { local: true },
373
+ DRY,
374
+ bootRuntime(FLAGS, cwd),
375
+ );
376
+ expect(outcome.exitCode).toBe(0);
377
+ const plan = outcome.stdout ?? "";
378
+ // The global-band steps are gone under --local (the bug: they used to run).
379
+ expect(plan).not.toContain(completionScriptPath("zsh"));
380
+ expect(plan).not.toContain("terrazzo-lsp-extension");
381
+ // The project-band steps remain: MCP into .cursor, skills into .agents/skills.
382
+ expect(plan).toContain("mcp.json");
383
+ expect(plan).toContain(".agents/skills");
384
+ });
385
+
386
+ it("--global omits the project-band skills step, keeping global steps", async () => {
387
+ const cwd = tmp("pragma-setup-proj-");
388
+ seedSkill(cwd); // WOULD be offered under the default `both`
389
+ const outcome = await executeVerb(
390
+ setupSelfVerb,
391
+ { global: true },
392
+ DRY,
393
+ bootRuntime(FLAGS, cwd),
394
+ );
395
+ expect(outcome.exitCode).toBe(0);
396
+ const plan = outcome.stdout ?? "";
397
+ // The global-band steps are present under --global.
398
+ expect(plan).toContain(completionScriptPath("zsh"));
399
+ expect(plan).toContain("terrazzo-lsp-extension");
400
+ // The project-band skills step is gone (the bug: it used to run under --global).
401
+ expect(plan).not.toContain(".agents/skills");
402
+ });
403
+ });
404
+
200
405
  describe("setup skills", () => {
201
406
  it("empty skills raise EMPTY_RESULTS on the direct sub-verb", async () => {
202
407
  const cwd = tmp("pragma-setup-proj-");
@@ -261,6 +466,152 @@ describe("setup lsp — missing-binary guard (bunx absent)", () => {
261
466
  });
262
467
  });
263
468
 
469
+ describe("setup — idempotent detection of already-present config", () => {
470
+ // Isolate PATH so an ambient harness can't inject via a `process` signal.
471
+ let prevPath: string | undefined;
472
+ beforeEach(() => {
473
+ prevPath = process.env.PATH;
474
+ process.env.PATH = tmp("pragma-idem-path-");
475
+ });
476
+ afterEach(() => {
477
+ process.env.PATH = prevPath;
478
+ });
479
+
480
+ it("mcp: a second run detects the already-configured file (byte-identical rewrite)", async () => {
481
+ const cwd = tmp("pragma-setup-proj-");
482
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
483
+ const configPath = join(cwd, ".cursor", "mcp.json");
484
+
485
+ // First run writes the pragma entry.
486
+ await executeVerb(verbOf("mcp"), {}, YES, bootRuntime(FLAGS, cwd));
487
+ const firstBody = readFileSync(configPath, "utf-8");
488
+
489
+ // Detection now classifies the group `configured`.
490
+ const { toResult } = await buildSetupPlan(
491
+ bootRuntime(FLAGS, cwd),
492
+ "mcp",
493
+ "both",
494
+ );
495
+ const result = toResult({});
496
+ expect(result.kind).toBe("mcp");
497
+ if (result.kind === "mcp") {
498
+ expect(result.targets.at(0)?.state).toBe("configured");
499
+ }
500
+
501
+ // The wizard default-DESELECTS a configured target (so a plain re-run offers
502
+ // it unchecked) — proven on the generated multiselect's default.
503
+ const { generator } = await buildSetupPlan(
504
+ bootRuntime(FLAGS, cwd),
505
+ "mcp",
506
+ "both",
507
+ );
508
+ const multiselect = generator.prompts.find((p) => p.name === "mcpTargets");
509
+ expect(multiselect?.default).toEqual([]); // the only file is configured
510
+ expect(
511
+ (multiselect?.choices ?? []).some((c) =>
512
+ typeof c === "object" && "label" in c
513
+ ? c.label.includes("already configured")
514
+ : false,
515
+ ),
516
+ ).toBe(true);
517
+
518
+ // A real second run is idempotent: the file stays byte-identical (the
519
+ // read-modify-write re-merges the SAME pragma entry).
520
+ await executeVerb(verbOf("mcp"), {}, YES, bootRuntime(FLAGS, cwd));
521
+ expect(readFileSync(configPath, "utf-8")).toBe(firstBody);
522
+ });
523
+
524
+ it("mcp: a drifted pragma entry (wrong cwd) reads as `drifted` and is updated", async () => {
525
+ const cwd = tmp("pragma-setup-proj-");
526
+ mkdirSync(join(cwd, ".cursor"), { recursive: true });
527
+ const configPath = join(cwd, ".cursor", "mcp.json");
528
+ // Seed a stale pragma entry pointing at a different cwd.
529
+ writeFileSync(
530
+ configPath,
531
+ `${JSON.stringify(
532
+ {
533
+ mcpServers: {
534
+ pragma: { command: "pragma", args: ["mcp"], cwd: "/old" },
535
+ },
536
+ },
537
+ null,
538
+ 2,
539
+ )}\n`,
540
+ );
541
+
542
+ const { toResult, generator } = await buildSetupPlan(
543
+ bootRuntime(FLAGS, cwd),
544
+ "mcp",
545
+ "both",
546
+ );
547
+ const result = toResult({});
548
+ if (result.kind === "mcp") {
549
+ expect(result.targets.at(0)?.state).toBe("drifted");
550
+ }
551
+ // A drifted target stays SELECTED by default (it needs the update).
552
+ const multiselect = generator.prompts.find((p) => p.name === "mcpTargets");
553
+ expect(multiselect?.default).toEqual([configPath]);
554
+
555
+ await executeVerb(verbOf("mcp"), {}, YES, bootRuntime(FLAGS, cwd));
556
+ const config = JSON.parse(readFileSync(configPath, "utf-8"));
557
+ expect(config.mcpServers.pragma.cwd).toBe(cwd); // updated to the real cwd
558
+ });
559
+
560
+ it("completions: a second run detects the up-to-date script (state=installed)", async () => {
561
+ const cwd = tmp("pragma-setup-proj-");
562
+ const path = completionScriptPath("zsh");
563
+ // First install.
564
+ await executeVerb(completionsVerb, {}, YES, bootRuntime(FLAGS, cwd));
565
+ const firstBody = readFileSync(path, "utf-8");
566
+
567
+ // Detection now classifies the installed script `installed`.
568
+ const { toResult } = await buildSetupPlan(
569
+ bootRuntime(FLAGS, cwd),
570
+ "completions",
571
+ "both",
572
+ );
573
+ const result = toResult({});
574
+ if (result.kind === "completions") {
575
+ expect(result.state).toBe("installed");
576
+ }
577
+
578
+ // A real second run is idempotent — the byte-identical script survives.
579
+ await executeVerb(completionsVerb, {}, YES, bootRuntime(FLAGS, cwd));
580
+ expect(readFileSync(path, "utf-8")).toBe(firstBody);
581
+ });
582
+
583
+ it("completions: a stale script (different body) reads as `stale`", async () => {
584
+ const cwd = tmp("pragma-setup-proj-");
585
+ const path = completionScriptPath("zsh");
586
+ mkdirSync(join(path, ".."), { recursive: true });
587
+ writeFileSync(path, "# stale hand-edited completion\n");
588
+ const { toResult } = await buildSetupPlan(
589
+ bootRuntime(FLAGS, cwd),
590
+ "completions",
591
+ "both",
592
+ );
593
+ const result = toResult({});
594
+ if (result.kind === "completions") {
595
+ expect(result.state).toBe("stale");
596
+ }
597
+ });
598
+
599
+ it("lsp: reports `unknown` when the `code` CLI is absent from PATH", async () => {
600
+ // PATH is the isolated empty dir (beforeEach), so `code` is unresolvable:
601
+ // detection cannot enumerate and reports `unknown` (installer still runs).
602
+ const { toResult } = await buildSetupPlan(
603
+ bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
604
+ "lsp",
605
+ "both",
606
+ );
607
+ const result = toResult({});
608
+ expect(result.kind).toBe("lsp");
609
+ if (result.kind === "lsp") {
610
+ expect(result.state).toBe("unknown");
611
+ }
612
+ });
613
+ });
614
+
264
615
  describe("setup (run-all wizard)", () => {
265
616
  it("--dry-run previews every DETECTED step (completions + lsp + mcp), writing nothing", async () => {
266
617
  const cwd = tmp("pragma-setup-proj-");
@@ -23,9 +23,52 @@ import type { GeneratorResult } from "@canonical/summon-core";
23
23
  import { $, gen, type Task } from "@canonical/task";
24
24
  import type { PragmaRuntime } from "../../kernel/runtime/types.js";
25
25
  import { asVerb } from "../../kernel/spec/asVerb.js";
26
- import type { CapabilityModule, VerbSpec } from "../../kernel/spec/types.js";
26
+ import type {
27
+ CapabilityModule,
28
+ ParamSpec,
29
+ VerbSpec,
30
+ } from "../../kernel/spec/types.js";
27
31
  import { setupFormatters } from "./setup.render.js";
28
- import type { SetupMode, SetupResult } from "./types.js";
32
+ import type { ScopeSelection, SetupMode, SetupResult } from "./types.js";
33
+
34
+ /**
35
+ * The `--scope` flag + its `--global`/`--local` boolean sugars, shared by the
36
+ * band-aware verbs (`setup`/`mcp`/`skills`). `completions`/`lsp` never carry
37
+ * them — they are single-band installers.
38
+ */
39
+ const SCOPE_PARAMS: readonly ParamSpec[] = [
40
+ {
41
+ kind: "enum",
42
+ name: "scope",
43
+ doc: "Which config band(s) to configure: project, global, or both.",
44
+ values: ["project", "global", "both"],
45
+ default: "both",
46
+ },
47
+ {
48
+ kind: "boolean",
49
+ name: "global",
50
+ doc: "Shorthand for --scope global (configure the user/home band).",
51
+ },
52
+ {
53
+ kind: "boolean",
54
+ name: "local",
55
+ doc: "Shorthand for --scope project (configure the per-project band).",
56
+ },
57
+ ];
58
+
59
+ /**
60
+ * Resolve the `--scope`/`--global`/`--local` params into a single selection.
61
+ * The boolean sugars win over `--scope`; `--global` wins over `--local`.
62
+ *
63
+ * @param params - The coerced param bag.
64
+ * @returns The resolved scope selection (defaults to `both`).
65
+ */
66
+ function resolveScope(params: Record<string, unknown>): ScopeSelection {
67
+ if (params.global === true) return "global";
68
+ if (params.local === true) return "project";
69
+ const scope = params.scope;
70
+ return scope === "global" || scope === "project" ? scope : "both";
71
+ }
29
72
 
30
73
  /** The exposed-to-MCP capability the run-all self-verb declares. */
31
74
  const SELF_CAPABILITY = {
@@ -76,6 +119,7 @@ const SUB_CAPABILITY = {
76
119
  async function runSetup(
77
120
  mode: SetupMode,
78
121
  rt: PragmaRuntime,
122
+ scope: ScopeSelection,
79
123
  ): Promise<Task<SetupResult>> {
80
124
  // Lazy dynamic imports (R1 lazy-React discipline): summon-core's barrel is
81
125
  // React-free, and the non-TTY branch picks autoPrompt/mcpPrompt (never mounts
@@ -85,7 +129,7 @@ async function runSetup(
85
129
  import("./operations/setupGenerator.js"),
86
130
  ]);
87
131
 
88
- const { generator, toResult } = await ops.buildSetupPlan(rt, mode);
132
+ const { generator, toResult } = await ops.buildSetupPlan(rt, mode, scope);
89
133
 
90
134
  const { isTTY, transport, yes, signal, abort } = rt.interaction ?? {
91
135
  isTTY: false,
@@ -149,7 +193,8 @@ function setupVerb(
149
193
  output: { formatters: setupFormatters },
150
194
  capability,
151
195
  ...extras,
152
- run: (_params, rt) => runSetup(mode, rt) as unknown as Task<SetupResult>,
196
+ run: (params, rt) =>
197
+ runSetup(mode, rt, resolveScope(params)) as unknown as Task<SetupResult>,
153
198
  };
154
199
  }
155
200
 
@@ -159,10 +204,15 @@ const setupAllVerb = setupVerb(
159
204
  "all",
160
205
  SELF_CAPABILITY,
161
206
  {
162
- doc: "Runs the shell-completions, LSP, MCP, and skills installers as a single wizard: pick the steps, review the recap, then apply.",
207
+ params: SCOPE_PARAMS,
208
+ doc: "Runs the shell-completions, LSP, MCP, and skills installers as a single wizard: pick the steps, review the recap, then apply. The scope option targets the project band, the user/home band, or both.",
163
209
  examples: [
164
210
  { cmd: "pragma setup" },
165
211
  { cmd: "pragma setup --dry-run", note: "preview every step's effects" },
212
+ {
213
+ cmd: "pragma setup --global",
214
+ note: "configure only the user/home band",
215
+ },
166
216
  { cmd: "pragma setup mcp", note: "just the MCP server registration" },
167
217
  ],
168
218
  },
@@ -173,6 +223,7 @@ const mcpVerb = setupVerb(
173
223
  "Register the pragma MCP server in detected AI harnesses.",
174
224
  "mcp",
175
225
  SUB_CAPABILITY,
226
+ { params: SCOPE_PARAMS },
176
227
  );
177
228
 
178
229
  const completionsVerb = setupVerb(
@@ -187,6 +238,7 @@ const skillsVerb = setupVerb(
187
238
  "Symlink discovered skills into each AI harness.",
188
239
  "skills",
189
240
  SUB_CAPABILITY,
241
+ { params: SCOPE_PARAMS },
190
242
  );
191
243
 
192
244
  const lspVerb = setupVerb(
@@ -11,6 +11,58 @@ import type { ShellId } from "./shell.js";
11
11
  */
12
12
  export type SetupMode = "all" | "completions" | "lsp" | "mcp" | "skills";
13
13
 
14
+ /**
15
+ * One of the two config bands: the per-user/home `global` band or the per-repo
16
+ * `project` band. Mirrors `@canonical/harnesses`' `ScopeBand` structurally, but
17
+ * is redeclared here so this statically-reachable type module never pulls the
18
+ * harnesses runtime into the fast-path module graph (the lazy-dispatch invariant).
19
+ */
20
+ export type ScopeBand = "project" | "global";
21
+
22
+ /**
23
+ * The resolved `--scope` selection: which config band(s) a run touches. `both`
24
+ * (the default) runs each detected harness's default band; `global`/`project`
25
+ * run only that band. Structurally mirrors the harnesses `ScopeSelection`.
26
+ */
27
+ export type ScopeSelection = "project" | "global" | "both";
28
+
29
+ /**
30
+ * The prior state of an MCP target file, read up front by `detectMcp`:
31
+ * `absent` (no pragma entry yet), `configured` (a matching pragma entry already
32
+ * present in every write — a re-run skips it), or `drifted` (a pragma entry
33
+ * exists but differs, so a write updates it). Mirrors the skills step's
34
+ * created/skipped/replaced idempotency at the file grain.
35
+ */
36
+ export type McpTargetState = "absent" | "configured" | "drifted";
37
+
38
+ /**
39
+ * One configured MCP target in a result: the file that was written, which band
40
+ * it belongs to, the harness name(s) that share it, and its prior
41
+ * {@link McpTargetState} (so the recap can report new vs updated vs unchanged).
42
+ */
43
+ export interface ConfiguredTarget {
44
+ readonly name: string;
45
+ readonly band: ScopeBand;
46
+ readonly path: string;
47
+ readonly state: McpTargetState;
48
+ }
49
+
50
+ /**
51
+ * The prior on-disk state of the shell-completion script, read up front by
52
+ * `detectCompletions`: `absent` (no script), `installed` (a byte-identical
53
+ * script is already present — a re-run skips it), or `stale` (a different
54
+ * script is present, so a write updates it).
55
+ */
56
+ export type CompletionsState = "absent" | "installed" | "stale";
57
+
58
+ /**
59
+ * The detected state of the Terrazzo LSP VS Code extension, probed up front by
60
+ * `detectLsp`: `installed` (already present — a re-run skips it), `absent` (not
61
+ * present, so the installer runs), or `unknown` (the `code` CLI is not on PATH,
62
+ * so we cannot enumerate and the installer runs regardless).
63
+ */
64
+ export type LspState = "installed" | "absent" | "unknown";
65
+
14
66
  /** One symlink create/skip/replace action performed during skill setup. */
15
67
  export interface SymlinkAction {
16
68
  readonly skillName: string;
@@ -35,8 +87,13 @@ export type SetupResult =
35
87
  readonly shell: ShellId | null;
36
88
  readonly path: string | null;
37
89
  readonly installed: boolean;
90
+ readonly state: CompletionsState;
91
+ }
92
+ | { readonly kind: "lsp"; readonly state: LspState }
93
+ | {
94
+ readonly kind: "mcp";
95
+ readonly configured: readonly string[];
96
+ readonly targets: readonly ConfiguredTarget[];
38
97
  }
39
- | { readonly kind: "lsp" }
40
- | { readonly kind: "mcp"; readonly configured: readonly string[] }
41
98
  | { readonly kind: "skills"; readonly result: SetupSkillsResult }
42
99
  | { readonly kind: "all"; readonly steps: readonly string[] };
@@ -0,0 +1,16 @@
1
+ /**
2
+ * User-facing labels for the two config bands, shared by the setup recap
3
+ * ({@link ../setup/setup.render}) and the doctor report
4
+ * ({@link ../doctor/doctor.render}) so ONE consistent vocabulary appears across
5
+ * both. The pair matches the CLI grammar users already meet elsewhere: the
6
+ * `--global`/`--local` flags, the `--scope project|global|both` enum, config
7
+ * show's `[global]`/`[project]` origin markers, and the `setup` manifest's
8
+ * `[global]`/`[project]` lines. (Earlier these two surfaces said MACHINE/PROJECT
9
+ * — a third term for the same bands; this is the single source that unifies it.)
10
+ */
11
+
12
+ /** The user-facing display label for each of the two config bands. */
13
+ export const BAND_LABELS: Record<"project" | "global", string> = {
14
+ global: "Global",
15
+ project: "Project",
16
+ };
@@ -28,15 +28,27 @@ const entryName = (entry: PackageEntry): string =>
28
28
  const entrySource = (entry: PackageEntry): string =>
29
29
  typeof entry === "string" ? entry : (entry.source ?? entry.name);
30
30
 
31
- /** The indexed entity count from a cached pack's index.json (storeless). */
31
+ /**
32
+ * The distinct-abox entity count from a cached pack's index.json (storeless).
33
+ *
34
+ * Only reached for LEGACY packs whose manifest predates the persisted
35
+ * `entityCount` (A10) — a current pack's count is read straight from the
36
+ * manifest, no index parse. Counts distinct abox subjects so the figure matches
37
+ * both the manifest count and `info`'s `entityTotal` (A1).
38
+ */
32
39
  function indexEntityCount(dir: string): number | null {
33
40
  const path = join(dir, INDEX_FILE);
34
41
  if (!existsSync(path)) return null;
35
42
  try {
36
43
  const parsed = JSON.parse(readFileSync(path, "utf-8")) as {
37
- entities?: unknown[];
44
+ entities?: { box?: string; uri?: string; name?: string }[];
38
45
  };
39
- return Array.isArray(parsed.entities) ? parsed.entities.length : null;
46
+ if (!Array.isArray(parsed.entities)) return null;
47
+ const subjects = new Set<string>();
48
+ for (const entity of parsed.entities) {
49
+ if (entity.box === "abox") subjects.add(entity.uri ?? entity.name ?? "");
50
+ }
51
+ return subjects.size;
40
52
  } catch {
41
53
  return null;
42
54
  }
@@ -81,7 +93,10 @@ export async function collectStatus(
81
93
  contentHash: lock?.contentHash ?? null,
82
94
  cached,
83
95
  builtAt: manifest?.createdAt ?? null,
84
- entityCount: dir && cached ? indexEntityCount(dir) : null,
96
+ // Prefer the manifest's persisted count (no index parse); a legacy pack
97
+ // without it falls back to counting the index's abox subjects (A10).
98
+ entityCount:
99
+ dir && cached ? (manifest?.entityCount ?? indexEntityCount(dir)) : null,
85
100
  sources,
86
101
  };
87
102
  }