@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.
- package/README.md +72 -0
- package/dist/pragma +0 -0
- package/package.json +14 -13
- package/src/bin.ts +2 -2
- package/src/capabilities/block/blockList.test.ts +79 -0
- package/src/capabilities/block/blockList.verb.ts +18 -5
- package/src/capabilities/block/pack.ts +12 -1
- package/src/capabilities/block/parity.test.ts +9 -2
- package/src/capabilities/capabilities/capabilities.test.ts +10 -1
- package/src/capabilities/capabilities/capabilities.verb.ts +1 -1
- package/src/capabilities/capabilities/catalog.ts +7 -3
- package/src/capabilities/capabilities/hints.ts +8 -17
- package/src/capabilities/capabilities/types.ts +2 -0
- package/src/capabilities/colophon/colophon.render.test.ts +31 -4
- package/src/capabilities/colophon/colophon.render.ts +13 -4
- package/src/capabilities/colophon/colophon.test.ts +3 -3
- package/src/capabilities/colophon/colophon.verb.ts +4 -1
- package/src/capabilities/colophon/markdownTerminal.ts +33 -14
- package/src/capabilities/colophon/pragmaColophon.ts +4 -4
- package/src/capabilities/colophon/types.ts +1 -1
- package/src/capabilities/config/fields.ts +21 -92
- package/src/capabilities/config/set.test.ts +6 -6
- package/src/capabilities/config/set.verb.ts +5 -5
- package/src/capabilities/config/show.render.test.ts +65 -0
- package/src/capabilities/config/show.render.ts +56 -10
- package/src/capabilities/config/show.verb.ts +5 -5
- package/src/capabilities/create/byteEquality.subprocess.test.ts +1 -1
- package/src/capabilities/create/byteEquality.test.ts +7 -4
- package/src/capabilities/create/compiledCreate.subprocess.test.ts +1 -1
- package/src/capabilities/create/create.test.ts +32 -3
- package/src/capabilities/create/create.verb.ts +69 -11
- package/src/capabilities/doctor/checks/checkConfigFile.ts +1 -1
- package/src/capabilities/doctor/checks/checkMcpCommands.ts +6 -0
- package/src/capabilities/doctor/checks/checkMcpConfigured.ts +10 -4
- package/src/capabilities/doctor/checks/deriveBand.test.ts +42 -0
- package/src/capabilities/doctor/checks/deriveBand.ts +28 -0
- package/src/capabilities/doctor/doctor.render.test.ts +170 -0
- package/src/capabilities/doctor/doctor.render.ts +116 -34
- package/src/capabilities/doctor/doctor.test.ts +31 -1
- package/src/capabilities/doctor/runChecks.ts +28 -4
- package/src/capabilities/doctor/types.ts +15 -0
- package/src/capabilities/ontology/index.ts +10 -3
- package/src/capabilities/ontology/ontology.test.ts +157 -5
- package/src/capabilities/ontology/verbs.ts +192 -93
- package/src/capabilities/prompt/prompt.render.test.ts +94 -0
- package/src/capabilities/prompt/prompt.render.ts +23 -6
- package/src/capabilities/prompt/prompt.test.ts +61 -0
- package/src/capabilities/reference.test.ts +32 -0
- package/src/capabilities/resources/provider.ts +28 -14
- package/src/capabilities/resources/resources.test.ts +99 -0
- package/src/capabilities/scopeTypeSync.test.ts +37 -0
- package/src/capabilities/setup/index.ts +7 -1
- package/src/capabilities/setup/operations/setupCompletions.ts +49 -16
- package/src/capabilities/setup/operations/setupGenerator.ts +157 -61
- package/src/capabilities/setup/operations/setupLsp.ts +56 -12
- package/src/capabilities/setup/operations/setupMcp.ts +216 -39
- package/src/capabilities/setup/operations/setupSkills.ts +7 -1
- package/src/capabilities/setup/setup.render.test.ts +109 -0
- package/src/capabilities/setup/setup.render.ts +61 -14
- package/src/capabilities/setup/setup.test.ts +352 -1
- package/src/capabilities/setup/setup.verb.ts +57 -5
- package/src/capabilities/setup/types.ts +59 -2
- package/src/capabilities/shared/bands.ts +16 -0
- package/src/capabilities/sources/collectStatus.ts +19 -4
- package/src/capabilities/sources/runUpdate.ts +58 -1
- package/src/capabilities/sources/sources.test.ts +187 -1
- package/src/capabilities/sources/status.render.test.ts +65 -0
- package/src/capabilities/sources/status.render.ts +50 -23
- package/src/capabilities/surface.test.ts +34 -9
- package/src/capabilities/tier/lookup.render.test.ts +72 -0
- package/src/capabilities/tier/lookup.render.ts +33 -8
- package/src/capabilities/tier/lookup.test.ts +15 -0
- package/src/capabilities/tier/runLookup.ts +28 -0
- package/src/constants.test.ts +1 -1
- package/src/constants.ts +2 -2
- package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +13 -14
- package/src/kernel/completion/bashDrive.test.ts +3 -3
- package/src/kernel/completion/complete.test.ts +3 -5
- package/src/kernel/completion/entitySource.ts +14 -6
- package/src/kernel/completion/model.test.ts +2 -2
- package/src/kernel/completion/model.ts +0 -1
- package/src/kernel/completion/parse.test.ts +2 -4
- package/src/kernel/error/PragmaError.test.ts +4 -4
- package/src/kernel/error/renderError.ts +1 -1
- package/src/kernel/packs/compile.ts +1 -1
- package/src/kernel/project/cli/buildProgram.test.ts +51 -7
- package/src/kernel/project/cli/buildProgram.ts +11 -0
- package/src/kernel/project/cli/globalFlags.test.ts +12 -4
- package/src/kernel/project/cli/globalFlags.ts +14 -14
- package/src/kernel/project/cli/rootHelp.ts +4 -2
- package/src/kernel/project/mcp/instructions.ts +1 -1
- package/src/kernel/project/mcp/mcpError.ts +52 -0
- package/src/kernel/project/mcp/prompts/prompts.test.ts +8 -0
- package/src/kernel/project/mcp/prompts/provider.ts +36 -6
- package/src/kernel/render/formatters.ts +5 -4
- package/src/kernel/render/renderers.test.ts +34 -0
- package/src/kernel/render/renderers.ts +25 -7
- package/src/kernel/render/style.test.ts +31 -0
- package/src/kernel/render/style.ts +74 -0
- package/src/kernel/runtime/graphpack/build.ts +28 -0
- package/src/kernel/runtime/graphpack/buildIndex.test.ts +103 -0
- package/src/kernel/runtime/graphpack/buildIndex.ts +124 -36
- package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +3 -6
- package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -2
- package/src/kernel/runtime/graphpack/graphpack.test.ts +69 -1
- package/src/kernel/runtime/graphpack/read.ts +28 -13
- package/src/kernel/runtime/graphpack/types.ts +14 -0
- package/src/kernel/runtime/loadSession.ts +4 -12
- package/src/kernel/runtime/refs/resolve.ts +63 -3
- package/src/kernel/runtime/storeReadiness.ts +61 -0
- package/src/kernel/runtime/types.ts +1 -1
- package/src/kernel/runtime/wasmEmbed.test.ts +3 -1
- package/src/kernel/spec/emitReference.test.ts +196 -0
- package/src/kernel/spec/emitReference.ts +491 -0
- package/src/kernel/spec/emitSurface.ts +4 -2
- package/src/kernel/spec/index.ts +2 -0
- package/src/kernel/spec/surfaceConformance.test.ts +2 -2
- package/src/testing/PARITY_GAPS.ts +1 -1
- package/src/testing/behavioral/autoLlm.e2e.test.ts +12 -2
- package/src/testing/behavioral/completion.test.ts +2 -2
- package/src/testing/behavioral/disclosure.test.ts +12 -0
- package/src/testing/behavioral/docExamples.test.ts +283 -0
- package/src/testing/behavioral/errorMatrix.storeless.test.ts +2 -1
- package/src/testing/behavioral/journeys.cli.test.ts +3 -1
- package/src/testing/behavioral/journeys.defaultPack.test.ts +571 -0
- package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +66 -0
- package/src/testing/behavioral/journeys.livePack.e2e.test.ts +129 -0
- package/src/testing/behavioral/journeys.mcpLifecycle.mcp.test.ts +176 -0
- package/src/testing/behavioral/mcpSurface.test.ts +1 -1
- package/src/testing/behavioral/staleCommands.test.ts +76 -0
- package/src/testing/eval/__snapshots__/eval.test.ts.snap +2 -2
- package/src/testing/eval/cases/assembly.ts +3 -3
- package/src/testing/eval/cases/effectful.ts +6 -3
- package/src/testing/eval/cases/readNouns.ts +4 -1
- package/src/testing/fixtures/fixtureCapability.ts +156 -0
- package/src/testing/fixtures/graph/defaultPack.ts +178 -0
- package/src/testing/fixtures/graph/owlExport.ts +95 -0
- package/src/testing/helpers/extractSnippets.ts +29 -0
- package/src/testing/helpers/golden.ts +1 -1
- 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(
|
|
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 {
|
|
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: (
|
|
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
|
-
|
|
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
|
-
/**
|
|
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?:
|
|
44
|
+
entities?: { box?: string; uri?: string; name?: string }[];
|
|
38
45
|
};
|
|
39
|
-
|
|
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
|
-
|
|
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
|
}
|