@canonical/pragma-cli 0.35.0 → 0.36.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 +2 -2
- package/bundled-skills/add-standard/SKILL.md +338 -0
- package/bundled-skills/adoption-a1-styles/SKILL.md +269 -0
- package/bundled-skills/adoption-a2-components/SKILL.md +291 -0
- package/bundled-skills/adoption-a3-forms/SKILL.md +260 -0
- package/bundled-skills/anatomy-author/ANATOMY_DSL_SPEC.md +406 -0
- package/bundled-skills/anatomy-author/SKILL.md +1226 -0
- package/bundled-skills/design-auditor/SKILL.md +455 -0
- package/bundled-skills/implement-component/SKILL.md +333 -0
- package/bundled-skills/specify-component/SKILL.md +331 -0
- package/bundled-skills/specify-pattern/SKILL.md +292 -0
- package/dist/package.json +16 -15
- package/dist/pragma.conf.js +236 -34
- package/dist/pragma.conf.js.map +1 -1
- package/dist/src/bin.js +1 -1
- package/dist/src/bin.js.map +1 -1
- package/dist/src/capabilities/colophon/colophon.render.js +1 -1
- package/dist/src/capabilities/colophon/colophon.render.js.map +1 -1
- package/dist/src/capabilities/create/mount.js +4 -3
- package/dist/src/capabilities/create/mount.js.map +1 -1
- package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +60 -4
- package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -1
- package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +8 -8
- package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -1
- package/dist/src/capabilities/doctor/checks/harnessInventory.js +195 -0
- package/dist/src/capabilities/doctor/checks/harnessInventory.js.map +1 -0
- package/dist/src/capabilities/doctor/checks/index.js +6 -6
- package/dist/src/capabilities/doctor/checks/targetHealth.js +194 -50
- package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -1
- package/dist/src/capabilities/doctor/doctor.render.js +43 -64
- package/dist/src/capabilities/doctor/doctor.render.js.map +1 -1
- package/dist/src/capabilities/doctor/doctor.verb.js +6 -2
- package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -1
- package/dist/src/capabilities/doctor/runChecks.js +14 -11
- package/dist/src/capabilities/doctor/runChecks.js.map +1 -1
- package/dist/src/capabilities/graph/query.render.js +2 -2
- package/dist/src/capabilities/graph/query.render.js.map +1 -1
- package/dist/src/capabilities/info/collectInfo.js +8 -2
- package/dist/src/capabilities/info/collectInfo.js.map +1 -1
- package/dist/src/capabilities/info/info.render.js +6 -2
- package/dist/src/capabilities/info/info.render.js.map +1 -1
- package/dist/src/capabilities/prompt/prompt.render.js +2 -2
- package/dist/src/capabilities/prompt/prompt.render.js.map +1 -1
- package/dist/src/capabilities/setup/buildPlan.js +43 -39
- package/dist/src/capabilities/setup/buildPlan.js.map +1 -1
- package/dist/src/capabilities/setup/mode.js +2 -2
- package/dist/src/capabilities/setup/operations/index.js +4 -4
- package/dist/src/capabilities/setup/operations/index.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupGenerator.js +92 -45
- package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupLsp.js +88 -36
- package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupMcp.js +97 -51
- package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupSkills.js +325 -47
- package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -1
- package/dist/src/capabilities/setup/plan.js.map +1 -1
- package/dist/src/capabilities/setup/plan.render.js +144 -66
- package/dist/src/capabilities/setup/plan.render.js.map +1 -1
- package/dist/src/capabilities/setup/setup.render.js +1 -1
- package/dist/src/capabilities/setup/setup.verb.js +86 -33
- package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
- package/dist/src/capabilities/setup/targets.js +110 -45
- package/dist/src/capabilities/setup/targets.js.map +1 -1
- package/dist/src/capabilities/shared/index.js +6 -5
- package/dist/src/capabilities/shared/index.js.map +1 -1
- package/dist/src/capabilities/shared/packageManager.js +300 -54
- package/dist/src/capabilities/shared/packageManager.js.map +1 -1
- package/dist/src/capabilities/skill/discover.js +97 -11
- package/dist/src/capabilities/skill/discover.js.map +1 -1
- package/dist/src/capabilities/skill/render.js +2 -2
- package/dist/src/capabilities/skill/render.js.map +1 -1
- package/dist/src/capabilities/sources/installSkills.js +145 -3
- package/dist/src/capabilities/sources/installSkills.js.map +1 -1
- package/dist/src/capabilities/sources/runUpdate.js +68 -2
- package/dist/src/capabilities/sources/runUpdate.js.map +1 -1
- package/dist/src/capabilities/sources/update.verb.js +3 -3
- package/dist/src/capabilities/sources/update.verb.js.map +1 -1
- package/dist/src/capabilities/upgrade/runUpgrade.js +43 -7
- package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -1
- package/dist/src/capabilities/upgrade/upgrade.render.js +10 -2
- package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -1
- package/dist/src/constants.js +6 -1
- package/dist/src/constants.js.map +1 -1
- package/dist/src/kernel/index.js +11 -2
- package/dist/src/kernel/index.js.map +1 -1
- package/dist/src/kernel/interactivity.js +68 -0
- package/dist/src/kernel/interactivity.js.map +1 -0
- package/dist/src/kernel/packs/renderPack.js +3 -3
- package/dist/src/kernel/packs/renderPack.js.map +1 -1
- package/dist/src/kernel/packs/resolveEntity.js +63 -12
- package/dist/src/kernel/packs/resolveEntity.js.map +1 -1
- package/dist/src/kernel/packs/runBodies.js +57 -4
- package/dist/src/kernel/packs/runBodies.js.map +1 -1
- package/dist/src/kernel/packs/schema.js +54 -2
- package/dist/src/kernel/packs/schema.js.map +1 -1
- package/dist/src/kernel/packs/sparql/applyFilters.js +104 -5
- package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -1
- package/dist/src/kernel/packs/sparql/buildLookupQuery.js +306 -43
- package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -1
- package/dist/src/kernel/packs/types.js.map +1 -1
- package/dist/src/kernel/project/cli/buildProgram.js +1 -1
- package/dist/src/kernel/project/cli/buildProgram.js.map +1 -1
- package/dist/src/kernel/project/cli/dispatch.js +23 -23
- package/dist/src/kernel/project/cli/dispatch.js.map +1 -1
- package/dist/src/kernel/project/cli/globalFlags.js +4 -1
- package/dist/src/kernel/project/cli/globalFlags.js.map +1 -1
- package/dist/src/kernel/project/cli/helpFormat.js +1 -2
- package/dist/src/kernel/project/cli/helpFormat.js.map +1 -1
- package/dist/src/kernel/project/cli/rootHelp.js +34 -4
- package/dist/src/kernel/project/cli/rootHelp.js.map +1 -1
- package/dist/src/kernel/project/mcp/registerVerb.js +16 -1
- package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -1
- package/dist/src/kernel/render/renderers.js +1 -1
- package/dist/src/kernel/render/style.js +29 -9
- package/dist/src/kernel/render/style.js.map +1 -1
- package/dist/src/kernel/render/vocabulary.js +174 -0
- package/dist/src/kernel/render/vocabulary.js.map +1 -0
- package/dist/src/kernel/spec/emitReference.js +1 -1
- package/dist/src/kernel/spec/emitReference.js.map +1 -1
- package/package.json +16 -15
- package/pragma.conf.ts +241 -34
- package/src/bin.ts +8 -1
- package/src/capabilities/block.parity.test.ts +25 -11
- package/src/capabilities/block.shipped.exec.test.ts +282 -0
- package/src/capabilities/block.tierRank.exec.test.ts +300 -0
- package/src/capabilities/colophon/colophon.render.ts +1 -2
- package/src/capabilities/colophon/colophon.test.ts +2 -2
- package/src/capabilities/create/createGrammar.test.ts +1 -1
- package/src/capabilities/create/mount.ts +3 -3
- package/src/capabilities/distribution.parity.test.ts +4 -0
- package/src/capabilities/doctor/checks/checkPackageRefs.ts +69 -4
- package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +6 -4
- package/src/capabilities/doctor/checks/checkShellCompletions.ts +8 -8
- package/src/capabilities/doctor/checks/harnessInventory.test.ts +219 -0
- package/src/capabilities/doctor/checks/harnessInventory.ts +298 -0
- package/src/capabilities/doctor/checks/index.ts +6 -6
- package/src/capabilities/doctor/checks/targetHealth.ts +229 -59
- package/src/capabilities/doctor/doctor.render.test.ts +108 -32
- package/src/capabilities/doctor/doctor.render.ts +56 -80
- package/src/capabilities/doctor/doctor.test.ts +247 -35
- package/src/capabilities/doctor/doctor.verb.ts +7 -2
- package/src/capabilities/doctor/runChecks.ts +14 -11
- package/src/capabilities/doctor/types.ts +15 -24
- package/src/capabilities/emptyRecovery.test.ts +10 -2
- package/src/capabilities/graph/query.render.ts +2 -2
- package/src/capabilities/info/collectInfo.ts +8 -1
- package/src/capabilities/info/info.render.ts +6 -2
- package/src/capabilities/info/info.test.ts +37 -1
- package/src/capabilities/info/types.ts +10 -2
- package/src/capabilities/prompt/prompt.render.ts +2 -2
- package/src/capabilities/renderVocabularySync.test.ts +82 -0
- package/src/capabilities/scopeTypeSync.test.ts +29 -15
- package/src/capabilities/setup/buildPlan.ts +45 -41
- package/src/capabilities/setup/index.ts +1 -1
- package/src/capabilities/setup/mode.ts +2 -2
- package/src/capabilities/setup/operations/index.ts +6 -1
- package/src/capabilities/setup/operations/setupGenerator.ts +144 -70
- package/src/capabilities/setup/operations/setupLsp.ts +129 -37
- package/src/capabilities/setup/operations/setupMcp.ts +132 -61
- package/src/capabilities/setup/operations/setupSkills.ts +392 -58
- package/src/capabilities/setup/plan.render.ts +171 -76
- package/src/capabilities/setup/plan.ts +17 -12
- package/src/capabilities/setup/setup.mcp.correctness.test.ts +9 -9
- package/src/capabilities/setup/setup.render.test.ts +50 -36
- package/src/capabilities/setup/setup.render.ts +1 -1
- package/src/capabilities/setup/setup.test.ts +881 -50
- package/src/capabilities/setup/setup.verb.ts +91 -33
- package/src/capabilities/setup/targets.ts +120 -52
- package/src/capabilities/setup/types.ts +10 -18
- package/src/capabilities/shared/index.ts +17 -6
- package/src/capabilities/shared/packageManager.test.ts +421 -6
- package/src/capabilities/shared/packageManager.ts +394 -59
- package/src/capabilities/skill/bundledSkills.test.ts +347 -0
- package/src/capabilities/skill/discover.ts +100 -11
- package/src/capabilities/skill/render.ts +2 -2
- package/src/capabilities/skill/skill.test.ts +17 -4
- package/src/capabilities/sources/installSkills.ts +181 -3
- package/src/capabilities/sources/runUpdate.ts +78 -2
- package/src/capabilities/sources/sources.test.ts +228 -0
- package/src/capabilities/sources/update.verb.ts +3 -3
- package/src/capabilities/surface.test.ts +2 -2
- package/src/capabilities/upgrade/runUpgrade.ts +45 -6
- package/src/capabilities/upgrade/types.ts +10 -2
- package/src/capabilities/upgrade/upgrade.render.ts +9 -2
- package/src/capabilities/upgrade/upgrade.test.ts +72 -0
- package/src/constants.ts +6 -1
- package/src/identity.test.ts +18 -5
- package/src/kernel/completion/entitySource.test.ts +15 -2
- package/src/kernel/completion/safety.test.ts +16 -9
- package/src/kernel/config/defaults.test.ts +2 -1
- package/src/kernel/index.ts +11 -2
- package/src/kernel/interactivity.test.ts +76 -0
- package/src/kernel/interactivity.ts +69 -0
- package/src/kernel/packs/compile.test.ts +92 -0
- package/src/kernel/packs/renderPack.ts +3 -3
- package/src/kernel/packs/resolveEntity.test.ts +75 -14
- package/src/kernel/packs/resolveEntity.ts +88 -24
- package/src/kernel/packs/runBodies.ts +84 -19
- package/src/kernel/packs/schema.ts +60 -4
- package/src/kernel/packs/sparql/applyFilters.ts +125 -4
- package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
- package/src/kernel/packs/sparql/buildLookupQuery.ts +323 -45
- package/src/kernel/packs/toolDescription.test.ts +35 -1
- package/src/kernel/packs/types.ts +125 -2
- package/src/kernel/project/cli/buildProgram.test.ts +73 -2
- package/src/kernel/project/cli/buildProgram.ts +8 -1
- package/src/kernel/project/cli/dispatch.test.ts +5 -4
- package/src/kernel/project/cli/dispatch.ts +25 -24
- package/src/kernel/project/cli/globalFlags.ts +4 -1
- package/src/kernel/project/cli/helpFormat.ts +1 -2
- package/src/kernel/project/cli/rootHelp.ts +36 -3
- package/src/kernel/project/mcp/registerVerb.ts +20 -1
- package/src/kernel/render/renderers.ts +1 -1
- package/src/kernel/render/style.ts +32 -9
- package/src/kernel/render/vocabulary.ts +276 -0
- package/src/kernel/spec/emitReference.ts +1 -1
- package/src/kernel/spec/types.ts +35 -6
- package/src/testing/PARITY_GAPS.ts +9 -3
- package/src/testing/behavioral/docExamples.test.ts +4 -2
- package/src/testing/behavioral/errorMatrix.mcp.test.ts +110 -7
- package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
- package/src/testing/behavioral/journeys.packageStories.test.ts +6 -3
- package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
- package/src/testing/eval/cases/readNouns.ts +245 -9
- package/src/testing/fixtures/blockGraph.ts +28 -13
- package/src/testing/fixtures/graph/canonical.ts +95 -12
- package/dist/src/capabilities/shared/bands.js +0 -16
- package/dist/src/capabilities/shared/bands.js.map +0 -1
- package/src/capabilities/shared/bands.ts +0 -16
|
@@ -26,9 +26,9 @@ import {
|
|
|
26
26
|
writeFileSync,
|
|
27
27
|
} from "node:fs";
|
|
28
28
|
import { tmpdir } from "node:os";
|
|
29
|
-
import { join, relative } from "node:path";
|
|
29
|
+
import { join, relative, sep } from "node:path";
|
|
30
30
|
import { execute } from "@canonical/summon-core";
|
|
31
|
-
import { dryRun, type Effect, type Task } from "@canonical/task";
|
|
31
|
+
import { collectUndos, dryRun, type Effect, type Task } from "@canonical/task";
|
|
32
32
|
import { runTask, runUndo } from "@canonical/task/node";
|
|
33
33
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
34
34
|
import { BIN_NAME } from "../../constants.js";
|
|
@@ -42,13 +42,16 @@ import { projectCli } from "../../testing/helpers/projectCli.js";
|
|
|
42
42
|
import { projectMcp } from "../../testing/helpers/projectMcp.js";
|
|
43
43
|
import { capabilities } from "../index.js";
|
|
44
44
|
import { detectCompletions } from "./operations/setupCompletions.js";
|
|
45
|
-
import { buildSetupRun } from "./operations/setupGenerator.js";
|
|
45
|
+
import { buildSetupRun, type RowEvent } from "./operations/setupGenerator.js";
|
|
46
46
|
import {
|
|
47
47
|
composeSkills,
|
|
48
48
|
composeSkillsRemoval,
|
|
49
49
|
detectSkills,
|
|
50
50
|
ownedSkillLinks,
|
|
51
|
+
skillsSkipReason,
|
|
52
|
+
skillsSkipRemedy,
|
|
51
53
|
} from "./operations/setupSkills.js";
|
|
54
|
+
import { DRY_RUN_HINT, PREVIEW_HINT } from "./setup.render.js";
|
|
52
55
|
import { setupModule } from "./setup.verb.js";
|
|
53
56
|
import { completionScriptPath, detectShell, type ShellId } from "./shell.js";
|
|
54
57
|
|
|
@@ -204,7 +207,7 @@ describe("setup completions", () => {
|
|
|
204
207
|
// The dry-run renders the PLAN through the verb's formatPlan seam, not the
|
|
205
208
|
// kernel's raw effect dump: one row, the shell it detected, and the script
|
|
206
209
|
// path rendered against the header's roots instead of repeated absolutely.
|
|
207
|
-
expect(outcome.stdout).toContain("Setup plan — global
|
|
210
|
+
expect(outcome.stdout).toContain("Setup plan — global");
|
|
208
211
|
expect(outcome.stdout).toMatch(
|
|
209
212
|
new RegExp(`completions\\s+install\\s+${SHELL} →`),
|
|
210
213
|
);
|
|
@@ -249,7 +252,7 @@ describe("setup mcp — recap gate", () => {
|
|
|
249
252
|
mkdirSync(join(cwd, ".cursor"), { recursive: true }); // makes Cursor detected
|
|
250
253
|
const configPath = join(cwd, ".cursor", "mcp.json");
|
|
251
254
|
|
|
252
|
-
// `--local`: the project
|
|
255
|
+
// `--local`: the project scope is opt-in now, so a bare `setup mcp` would
|
|
253
256
|
// write the HOME config and leave this repository untouched.
|
|
254
257
|
const outcome = await executeVerb(
|
|
255
258
|
verbOf("mcp"),
|
|
@@ -405,7 +408,7 @@ describe("setup mcp — scope & dedup", () => {
|
|
|
405
408
|
// A per-user server must not be pinned to whatever directory `setup mcp
|
|
406
409
|
// --global` happened to run from (a registration made from ~/Downloads
|
|
407
410
|
// used to serve ~/Downloads forever, and re-running from repo B flipped
|
|
408
|
-
// the machine
|
|
411
|
+
// the machine scope to repo B).
|
|
409
412
|
const home = process.env.HOME ?? "";
|
|
410
413
|
const configPath = join(home, ".codeium", "windsurf", "mcp_config.json");
|
|
411
414
|
const register = async (cwd: string): Promise<string> => {
|
|
@@ -461,9 +464,9 @@ describe("setup mcp — scope & dedup", () => {
|
|
|
461
464
|
expect(
|
|
462
465
|
JSON.parse(readFileSync(configPath, "utf-8")).mcpServers.pragma,
|
|
463
466
|
).toEqual({ command: "pragma", args: ["mcp", "serve"] });
|
|
464
|
-
// ...and it stays `
|
|
467
|
+
// ...and it stays `registered` afterwards (no churn on every run).
|
|
465
468
|
const after = await detectMcp(bootRuntime(FLAGS, cwd), "global");
|
|
466
|
-
expect(mcpGroupState(after, configPath)).toBe("
|
|
469
|
+
expect(mcpGroupState(after, configPath)).toBe("registered");
|
|
467
470
|
});
|
|
468
471
|
|
|
469
472
|
it("a PROJECT entry still records the project root as cwd", async () => {
|
|
@@ -482,25 +485,32 @@ describe("setup mcp — scope & dedup", () => {
|
|
|
482
485
|
});
|
|
483
486
|
});
|
|
484
487
|
|
|
485
|
-
describe("setup mcp —
|
|
486
|
-
it("
|
|
488
|
+
describe("setup mcp — the per-file question, asked directly", () => {
|
|
489
|
+
it("asks the per-file multiselect with NO `customize` meta-question in front", async () => {
|
|
490
|
+
// `answers.customize` was read in exactly one place in the repo — this
|
|
491
|
+
// prompt's `when` — and by nothing else: not `generate`, not `applied`, not
|
|
492
|
+
// any plan, recap, renderer or JSON projection. Answering no just meant the
|
|
493
|
+
// child prompt went unasked, its key was absent, and the compose bodies
|
|
494
|
+
// read that as "all" — so everything detected was configured anyway.
|
|
487
495
|
const cwd = tmp("pragma-setup-proj-");
|
|
488
|
-
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
496
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
497
|
+
mkdirSync(join(cwd, ".gemini"), { recursive: true }); // 2 children ⇒ narrowable
|
|
489
498
|
const { generator } = await buildSetupRun(
|
|
490
499
|
bootRuntime(FLAGS, cwd),
|
|
491
500
|
"mcp",
|
|
492
|
-
"
|
|
501
|
+
"project",
|
|
493
502
|
);
|
|
494
|
-
|
|
503
|
+
expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
|
|
495
504
|
const targets = generator.prompts.find((p) => p.name === "mcpTargets");
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
505
|
+
expect(targets).toBeDefined();
|
|
506
|
+
// It is asked whenever its row is in the run — the row's own selection is
|
|
507
|
+
// the only gate, and the choices carry the detected state per file.
|
|
508
|
+
expect(targets?.when?.({})).toBe(true);
|
|
509
|
+
expect(
|
|
510
|
+
(targets?.choices as { label: string }[]).every((c) =>
|
|
511
|
+
/— (add|update|unchanged)$/.test(c.label),
|
|
512
|
+
),
|
|
513
|
+
).toBe(true);
|
|
504
514
|
});
|
|
505
515
|
|
|
506
516
|
it("recaps the files the run KEPT, not the ones it offered", async () => {
|
|
@@ -517,7 +527,6 @@ describe("setup mcp — customize opt-in gate (Item 6)", () => {
|
|
|
517
527
|
|
|
518
528
|
const applied = run.applied({
|
|
519
529
|
targets: ["project:mcp"],
|
|
520
|
-
customize: true,
|
|
521
530
|
mcpTargets: [kept],
|
|
522
531
|
});
|
|
523
532
|
expect(applied.rows.find((r) => r.target === "mcp")?.outcome?.note).toBe(
|
|
@@ -526,6 +535,67 @@ describe("setup mcp — customize opt-in gate (Item 6)", () => {
|
|
|
526
535
|
});
|
|
527
536
|
});
|
|
528
537
|
|
|
538
|
+
describe("setup — live row events (the wizard's step feed)", () => {
|
|
539
|
+
it("brackets each row with start/done, in the recap's own sentence", async () => {
|
|
540
|
+
// The wizard's live progress renders the PLAN's rows, not the effect
|
|
541
|
+
// transcript — one `start` as a row's body is entered, one `done` carrying
|
|
542
|
+
// the same `target detail — note` sentence the recap will print, so the
|
|
543
|
+
// watcher and the recap reader see one dialect.
|
|
544
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
545
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
546
|
+
const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
|
|
547
|
+
const events: RowEvent[] = [];
|
|
548
|
+
run.setRowListener((event) => events.push(event));
|
|
549
|
+
await runTask(run.generator.generate({}), { onLog: () => {} });
|
|
550
|
+
expect(events.map((event) => event.status)).toEqual(["start", "done"]);
|
|
551
|
+
expect(events[0]?.key).toBe("project:mcp");
|
|
552
|
+
// The start label is the row without an outcome; the done label appends
|
|
553
|
+
// the SAME note `applied()` puts on the recap row.
|
|
554
|
+
const applied = run.applied({});
|
|
555
|
+
const note = applied.rows.find((r) => r.target === "mcp")?.outcome?.note;
|
|
556
|
+
expect(note).toBe("1 added");
|
|
557
|
+
expect(events[1]?.label).toBe(`${events[0]?.label} — ${note}`);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("no listener registered costs nothing — the run is unchanged", async () => {
|
|
561
|
+
// `--yes`, `--dry-run` and MCP never register one; the bracket emits into
|
|
562
|
+
// the void and the composed effects are identical.
|
|
563
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
564
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
565
|
+
const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
|
|
566
|
+
await runTask(run.generator.generate({}), { onLog: () => {} });
|
|
567
|
+
expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(true);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("a failing row reports `failed` with its cause, and its siblings still bracket", async () => {
|
|
571
|
+
// Same arrangement as the S1-1 isolation test: an editor on PATH with no
|
|
572
|
+
// `bun`, so the LSP row's compose fails. The event stream must name the
|
|
573
|
+
// failure — the wizard paints that row ✗ live — while the run continues.
|
|
574
|
+
const prevPath = process.env.PATH;
|
|
575
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
576
|
+
process.env.XDG_DATA_HOME = tmp("pragma-rowevents-data-");
|
|
577
|
+
const dir = stubPath();
|
|
578
|
+
writeFileSync(join(dir, "code"), "");
|
|
579
|
+
process.env.PATH = dir;
|
|
580
|
+
try {
|
|
581
|
+
const run = await buildSetupRun(
|
|
582
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
583
|
+
"lsp",
|
|
584
|
+
"global",
|
|
585
|
+
);
|
|
586
|
+
const events: RowEvent[] = [];
|
|
587
|
+
run.setRowListener((event) => events.push(event));
|
|
588
|
+
await runTask(run.generator.generate({}), { onLog: () => {} });
|
|
589
|
+
expect(events.map((event) => event.status)).toEqual(["start", "failed"]);
|
|
590
|
+
expect(events[1]?.label).toContain("bun");
|
|
591
|
+
} finally {
|
|
592
|
+
process.env.PATH = prevPath;
|
|
593
|
+
if (prevData === undefined) delete process.env.XDG_DATA_HOME;
|
|
594
|
+
else process.env.XDG_DATA_HOME = prevData;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
529
599
|
describe("setup lsp — per-editor multiselect (child rows)", () => {
|
|
530
600
|
let prevPath: string | undefined;
|
|
531
601
|
let stubDir = "";
|
|
@@ -573,20 +643,18 @@ describe("setup lsp — per-editor multiselect (child rows)", () => {
|
|
|
573
643
|
expect(children?.find((c) => c.key === "codium")?.action).toBe("add");
|
|
574
644
|
});
|
|
575
645
|
|
|
576
|
-
it("
|
|
646
|
+
it("asks the per-editor multiselect directly, with both forks pre-selected", async () => {
|
|
577
647
|
const { generator } = await buildSetupRun(
|
|
578
648
|
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
579
649
|
"lsp",
|
|
580
650
|
"global",
|
|
581
651
|
);
|
|
582
|
-
|
|
652
|
+
expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
|
|
583
653
|
const editors = generator.prompts.find((p) => p.name === "lspEditors");
|
|
584
|
-
|
|
585
|
-
//
|
|
586
|
-
// every fork without an extra question.
|
|
654
|
+
// Both pending editors are pre-selected, so pressing enter installs into
|
|
655
|
+
// every fork — the same default the meta-question used to stand for.
|
|
587
656
|
expect(editors?.default).toEqual(["code", "codium"]);
|
|
588
|
-
expect(editors?.when?.({
|
|
589
|
-
expect(editors?.when?.({ customize: true })).toBe(true);
|
|
657
|
+
expect(editors?.when?.({})).toBe(true);
|
|
590
658
|
});
|
|
591
659
|
|
|
592
660
|
it("sideloads only into the editors the user kept", async () => {
|
|
@@ -608,6 +676,202 @@ describe("setup lsp — per-editor multiselect (child rows)", () => {
|
|
|
608
676
|
const { effects } = dryRun(composeLsp(detected, []));
|
|
609
677
|
expect(effects.some((e) => e._tag === "Exec")).toBe(false);
|
|
610
678
|
});
|
|
679
|
+
|
|
680
|
+
it("the removal is `none` when no editor carries the extension", async () => {
|
|
681
|
+
// Both forks are on PATH, neither has a copy — there is nothing this
|
|
682
|
+
// command owns, so a removal has honestly nothing to do.
|
|
683
|
+
const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
|
|
684
|
+
"./operations/setupLsp.js"
|
|
685
|
+
);
|
|
686
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
687
|
+
expect(ownedLspEditors(detected)).toEqual([]);
|
|
688
|
+
expect(collectUndos(composeLspRemoval(detected))).toEqual([]);
|
|
689
|
+
|
|
690
|
+
const { plan } = await buildSetupRun(
|
|
691
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
692
|
+
"lsp",
|
|
693
|
+
"global",
|
|
694
|
+
true,
|
|
695
|
+
);
|
|
696
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
697
|
+
expect(row?.action).toBe("none");
|
|
698
|
+
expect(row?.detail).toBe("no editor carries the extension");
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
describe("setup lsp — the removal contract the other four rows implement", () => {
|
|
703
|
+
let prevPath: string | undefined;
|
|
704
|
+
let stubDir = "";
|
|
705
|
+
beforeEach(() => {
|
|
706
|
+
prevPath = process.env.PATH;
|
|
707
|
+
stubDir = stubPath();
|
|
708
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
709
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
710
|
+
process.env.PATH = stubDir;
|
|
711
|
+
});
|
|
712
|
+
afterEach(() => {
|
|
713
|
+
process.env.PATH = prevPath;
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
/** Seed a versioned extension copy in an editor's extensions dir. */
|
|
717
|
+
const seedExtension = (dir: string, version: string): void => {
|
|
718
|
+
mkdirSync(
|
|
719
|
+
join(
|
|
720
|
+
process.env.HOME ?? "",
|
|
721
|
+
dir,
|
|
722
|
+
"extensions",
|
|
723
|
+
`canonical.terrazzo-lsp-extension-${version}`,
|
|
724
|
+
),
|
|
725
|
+
{ recursive: true },
|
|
726
|
+
);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
it("carries one `--uninstall-extension` exec per OWNED editor as its undo", async () => {
|
|
730
|
+
// The row hardcoded a skip behind a docblock claiming "an `exec` carries no
|
|
731
|
+
// reversal" — factually wrong: `Exec` has an `undo` slot, and the sibling
|
|
732
|
+
// `composeMcpRemoval` already uses this exact carrier shape.
|
|
733
|
+
seedExtension(".vscode", "1.2.3");
|
|
734
|
+
const { detectLsp, composeLspRemoval } = await import(
|
|
735
|
+
"./operations/setupLsp.js"
|
|
736
|
+
);
|
|
737
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
738
|
+
|
|
739
|
+
// The FORWARD side must be walkable with every effect mocked: it is a
|
|
740
|
+
// `mkdir` of the editor's own extensions dir, which reads nothing.
|
|
741
|
+
const forward = dryRun(composeLspRemoval(detected));
|
|
742
|
+
expect(forward.effects.map((e) => e._tag)).toEqual(["MakeDir"]);
|
|
743
|
+
expect(forward.effects.some((e) => e._tag === "Exec")).toBe(false);
|
|
744
|
+
|
|
745
|
+
const undos = collectUndos(composeLspRemoval(detected));
|
|
746
|
+
expect(undos).toHaveLength(1);
|
|
747
|
+
const execs = undos
|
|
748
|
+
.flatMap((u) => dryRun(u).effects)
|
|
749
|
+
.filter((e) => e._tag === "Exec") as (Effect & {
|
|
750
|
+
_tag: "Exec";
|
|
751
|
+
command: string;
|
|
752
|
+
args: string[];
|
|
753
|
+
})[];
|
|
754
|
+
expect(execs.map((e) => e.command)).toEqual(["code"]);
|
|
755
|
+
expect(execs[0].args).toEqual([
|
|
756
|
+
"--uninstall-extension",
|
|
757
|
+
"canonical.terrazzo-lsp-extension",
|
|
758
|
+
]);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("REGRESSION: a pre-0.8.3 copy still plans `install` forward but `remove` back", async () => {
|
|
762
|
+
// The conflation that would have caused a silent wrong-skip.
|
|
763
|
+
// `extensionInstalled` was version-gated, so an editor carrying a dead
|
|
764
|
+
// pre-0.8.3 copy — one this command itself installed — reported
|
|
765
|
+
// `installed: false`, and a removal keyed on it would have walked past the
|
|
766
|
+
// dead extension while reporting success.
|
|
767
|
+
seedExtension(".vscode", "0.8.1");
|
|
768
|
+
const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
|
|
769
|
+
"./operations/setupLsp.js"
|
|
770
|
+
);
|
|
771
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
772
|
+
const code = detected.editors.find((e) => e.editor.cli === "code");
|
|
773
|
+
// Present, but not current — the two facts stay separate.
|
|
774
|
+
expect(code?.present).toBe(true);
|
|
775
|
+
expect(code?.installed).toBe(false);
|
|
776
|
+
|
|
777
|
+
// Forward is UNCHANGED by this fix: the dead copy is still "not installed".
|
|
778
|
+
const forward = await buildSetupRun(
|
|
779
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
780
|
+
"lsp",
|
|
781
|
+
"global",
|
|
782
|
+
);
|
|
783
|
+
expect(forward.plan.rows.find((r) => r.target === "lsp")?.action).toBe(
|
|
784
|
+
"install",
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
// Removal keys on ownership, so the dead copy IS removed.
|
|
788
|
+
expect(ownedLspEditors(detected).map((e) => e.editor.cli)).toEqual([
|
|
789
|
+
"code",
|
|
790
|
+
]);
|
|
791
|
+
const { plan } = await buildSetupRun(
|
|
792
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
793
|
+
"lsp",
|
|
794
|
+
"global",
|
|
795
|
+
true,
|
|
796
|
+
);
|
|
797
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
798
|
+
expect(row?.action).toBe("remove");
|
|
799
|
+
expect(row?.children?.map((c) => c.key)).toEqual(["code"]);
|
|
800
|
+
expect(
|
|
801
|
+
collectUndos(composeLspRemoval(detected))
|
|
802
|
+
.flatMap((u) => dryRun(u).effects)
|
|
803
|
+
.filter((e) => e._tag === "Exec"),
|
|
804
|
+
).toHaveLength(1);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it("the remedy names an OWNING editor, not the first one on PATH", async () => {
|
|
808
|
+
// With Cursor (or here: `code`) installed and the extension only in
|
|
809
|
+
// VSCodium, the printed remedy named the wrong editor — a command that
|
|
810
|
+
// would report success having uninstalled nothing.
|
|
811
|
+
seedExtension(".vscode-oss", "1.2.3"); // codium's extensions dir
|
|
812
|
+
const { detectLsp, lspUninstallRemedy } = await import(
|
|
813
|
+
"./operations/setupLsp.js"
|
|
814
|
+
);
|
|
815
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
816
|
+
expect(detected.editors[0]?.editor.cli).toBe("code"); // first on PATH
|
|
817
|
+
expect(lspUninstallRemedy(detected)).toBe(
|
|
818
|
+
"codium --uninstall-extension canonical.terrazzo-lsp-extension",
|
|
819
|
+
);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("still SKIPS with the named reason when no editor CLI is on PATH", async () => {
|
|
823
|
+
process.env.PATH = stubPath(); // no editor CLI at all
|
|
824
|
+
const { plan } = await buildSetupRun(
|
|
825
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
826
|
+
"lsp",
|
|
827
|
+
"global",
|
|
828
|
+
true,
|
|
829
|
+
);
|
|
830
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
831
|
+
expect(row?.action).toBe("skip");
|
|
832
|
+
expect(row?.reason).toContain("no VS Code-family editor CLI on PATH");
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("end to end: `setup lsp --undo` reports `Undid 1 step(s)` against a STUB cli", async () => {
|
|
836
|
+
// The reported symptom was `Undid 0 step(s).` at exit 0. The stub is a
|
|
837
|
+
// marker-writing script — never a real editor: an undo test must not be
|
|
838
|
+
// able to mutate the machine running it.
|
|
839
|
+
const marker = join(tmp("pragma-lsp-undo-"), "uninstalled");
|
|
840
|
+
const dir = stubPath();
|
|
841
|
+
writeFileSync(
|
|
842
|
+
join(dir, "code"),
|
|
843
|
+
`#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\n`,
|
|
844
|
+
{ mode: 0o755 },
|
|
845
|
+
);
|
|
846
|
+
process.env.PATH = dir;
|
|
847
|
+
seedExtension(".vscode", "1.2.3");
|
|
848
|
+
|
|
849
|
+
const outcome = await executeVerb(
|
|
850
|
+
verbOf("lsp"),
|
|
851
|
+
{},
|
|
852
|
+
UNDO,
|
|
853
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
854
|
+
);
|
|
855
|
+
expect(outcome.exitCode).toBe(0);
|
|
856
|
+
expect(outcome.stdout).toBe("Undid 1 step(s).\n");
|
|
857
|
+
expect(readFileSync(marker, "utf-8")).toBe(
|
|
858
|
+
"--uninstall-extension\ncanonical.terrazzo-lsp-extension\n",
|
|
859
|
+
);
|
|
860
|
+
// NON-GOAL guard: the staging dir is a cache, not this removal's business.
|
|
861
|
+
// Nothing here deletes it, and nothing here touches the VSIX.
|
|
862
|
+
const { detectLsp, composeLspRemoval } = await import(
|
|
863
|
+
"./operations/setupLsp.js"
|
|
864
|
+
);
|
|
865
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
866
|
+
const all = [
|
|
867
|
+
...dryRun(composeLspRemoval(detected)).effects,
|
|
868
|
+
...collectUndos(composeLspRemoval(detected)).flatMap(
|
|
869
|
+
(u) => dryRun(u).effects,
|
|
870
|
+
),
|
|
871
|
+
];
|
|
872
|
+
expect(all.some((e) => e._tag === "DeleteFile")).toBe(false);
|
|
873
|
+
expect(all.some((e) => e._tag === "DeleteDirectory")).toBe(false);
|
|
874
|
+
});
|
|
611
875
|
});
|
|
612
876
|
|
|
613
877
|
describe("setup (run-all wizard) — scope threading", () => {
|
|
@@ -630,7 +894,7 @@ describe("setup (run-all wizard) — scope threading", () => {
|
|
|
630
894
|
writeFileSync(join(editorPath, "code"), "");
|
|
631
895
|
};
|
|
632
896
|
|
|
633
|
-
/** Seed a discoverable skill so the project-
|
|
897
|
+
/** Seed a discoverable skill so the project-scope skills step is offerable. */
|
|
634
898
|
const seedSkill = (cwd: string): void => {
|
|
635
899
|
const dir = join(cwd, ".pragma", "skills", "s");
|
|
636
900
|
mkdirSync(dir, { recursive: true });
|
|
@@ -640,10 +904,10 @@ describe("setup (run-all wizard) — scope threading", () => {
|
|
|
640
904
|
);
|
|
641
905
|
};
|
|
642
906
|
|
|
643
|
-
it("--local omits the global-
|
|
907
|
+
it("--local omits the global-scope completions + lsp steps, keeping project steps", async () => {
|
|
644
908
|
const cwd = tmp("pragma-setup-proj-");
|
|
645
|
-
mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-
|
|
646
|
-
seedSkill(cwd); // project-
|
|
909
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-scope MCP target
|
|
910
|
+
seedSkill(cwd); // project-scope skills step
|
|
647
911
|
const outcome = await executeVerb(
|
|
648
912
|
setupSelfVerb,
|
|
649
913
|
{ local: true },
|
|
@@ -652,17 +916,19 @@ describe("setup (run-all wizard) — scope threading", () => {
|
|
|
652
916
|
);
|
|
653
917
|
expect(outcome.exitCode).toBe(0);
|
|
654
918
|
const plan = outcome.stdout ?? "";
|
|
655
|
-
// A
|
|
656
|
-
// rows saying why they are not in this run, so nothing is silently absent
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
919
|
+
// A scope FILTERS the run-all: the out-of-scope targets are still named, as
|
|
920
|
+
// rows saying why they are not in this run, so nothing is silently absent —
|
|
921
|
+
// and the reason names the flag that WOULD reach them, so the row is not a
|
|
922
|
+
// dead end either.
|
|
923
|
+
expect(plan).toContain("completions nothing to do");
|
|
924
|
+
expect(plan).toContain("this one is global only — run it with `--global`");
|
|
925
|
+
// The per-project steps remain: MCP into .cursor, skills into .agents/skills.
|
|
660
926
|
expect(plan).toContain("mcp.json");
|
|
661
927
|
expect(plan).toContain(".agents/skills");
|
|
662
928
|
});
|
|
663
929
|
|
|
664
930
|
withShell(
|
|
665
|
-
"--global omits the project-
|
|
931
|
+
"--global omits the project-scope skills step, keeping global steps",
|
|
666
932
|
async () => {
|
|
667
933
|
const cwd = tmp("pragma-setup-proj-");
|
|
668
934
|
seedSkill(cwd); // WOULD be offered under the default `both`
|
|
@@ -675,13 +941,20 @@ describe("setup (run-all wizard) — scope threading", () => {
|
|
|
675
941
|
);
|
|
676
942
|
expect(outcome.exitCode).toBe(0);
|
|
677
943
|
const plan = outcome.stdout ?? "";
|
|
678
|
-
// The global-
|
|
944
|
+
// The global-scope steps are present under --global. Paths render against
|
|
679
945
|
// the header's roots, so the row shows `~/.zfunc/_pragma`, not an absolute
|
|
680
946
|
// prefix repeated on every line.
|
|
681
947
|
expect(plan).toMatch(new RegExp(`completions\\s+install\\s+${SHELL} →`));
|
|
682
948
|
expect(plan).toContain("lsp");
|
|
683
|
-
// The project-
|
|
684
|
-
|
|
949
|
+
// The project-scope skills step is gone (the bug: it used to run under
|
|
950
|
+
// --global). `shortenPath` renders a project path as `./…` and a global
|
|
951
|
+
// one as `~/…`, and the two scopes' cross-client directories differ only
|
|
952
|
+
// by that marker — so the covenant is asserted on the PROJECT spelling.
|
|
953
|
+
// A bare "not .agents/skills" no longer separates them: the global scope
|
|
954
|
+
// legitimately links its own `~/.agents/skills` now that the bundled
|
|
955
|
+
// snapshot gives it skills on a machine that has run nothing.
|
|
956
|
+
expect(plan).not.toContain(`.${sep}.agents${sep}skills`);
|
|
957
|
+
expect(plan).toContain(`~${sep}.agents${sep}skills`);
|
|
685
958
|
},
|
|
686
959
|
);
|
|
687
960
|
});
|
|
@@ -701,7 +974,101 @@ describe("setup skills", () => {
|
|
|
701
974
|
);
|
|
702
975
|
expect(outcome.exitCode).toBe(0);
|
|
703
976
|
expect(outcome.stdout).toContain("skills");
|
|
704
|
-
expect(outcome.stdout).toContain("
|
|
977
|
+
expect(outcome.stdout).toContain("this project holds no skills");
|
|
978
|
+
// The skip is no longer a dead end: it names the action that settles it on
|
|
979
|
+
// THIS machine. The reason does NOT invert — the row still skips.
|
|
980
|
+
expect(outcome.stdout).toContain("SKILL.md, then run this again");
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
it("an EXISTING but empty project skills root is not reported as absent", async () => {
|
|
984
|
+
// `available` is false for an absent root and for an empty one alike, so
|
|
985
|
+
// the shared reason line said "is absent" about a directory the user is
|
|
986
|
+
// looking at. `rootExists` is the flag that already separates those two
|
|
987
|
+
// states everywhere else in the module; the wording now honours it.
|
|
988
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
989
|
+
mkdirSync(join(cwd, ".pragma", "skills"), { recursive: true });
|
|
990
|
+
|
|
991
|
+
const { plan } = await buildSetupRun(
|
|
992
|
+
bootRuntime(FLAGS, cwd),
|
|
993
|
+
"skills",
|
|
994
|
+
"project",
|
|
995
|
+
);
|
|
996
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
997
|
+
expect(row?.action).toBe("skip"); // still nothing to do...
|
|
998
|
+
expect(row?.reason).toContain("is empty"); // ...for the honest reason
|
|
999
|
+
expect(row?.reason).not.toContain("does not exist");
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("a genuinely MISSING project skills root still reports absent", async () => {
|
|
1003
|
+
// The other half of the distinction: the empty-root wording must not have
|
|
1004
|
+
// been bought by making every skip vague.
|
|
1005
|
+
const { plan } = await buildSetupRun(
|
|
1006
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1007
|
+
"skills",
|
|
1008
|
+
"project",
|
|
1009
|
+
);
|
|
1010
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
1011
|
+
expect(row?.action).toBe("skip");
|
|
1012
|
+
expect(row?.reason).toContain("does not exist");
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it("the GLOBAL scope OFFERS skills on a machine that has run nothing", async () => {
|
|
1016
|
+
// THE BEHAVIOUR CHANGE, asserted at the scope that used to be empty. The
|
|
1017
|
+
// global scope's only source was the installed root, so an empty
|
|
1018
|
+
// `XDG_DATA_HOME` — a fresh install — planned `skip` and told the user to
|
|
1019
|
+
// go and run `sources update` first. The bundled snapshot is the scope's
|
|
1020
|
+
// second root, so the row is actionable with no network and no prior
|
|
1021
|
+
// command. The empty-root WORDING this replaces is still asserted, on the
|
|
1022
|
+
// pure reason/remedy pair, in the cell below.
|
|
1023
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
1024
|
+
process.env.XDG_DATA_HOME = tmp("pragma-skills-data-");
|
|
1025
|
+
try {
|
|
1026
|
+
const { plan } = await buildSetupRun(
|
|
1027
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1028
|
+
"skills",
|
|
1029
|
+
"global",
|
|
1030
|
+
);
|
|
1031
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
1032
|
+
expect(row?.action).toBe("link");
|
|
1033
|
+
expect(row?.detail).toMatch(/\d+ skills? →/);
|
|
1034
|
+
|
|
1035
|
+
// End to end, inside the same jail (an ambient data root that happened to
|
|
1036
|
+
// hold a skill would stop this exercising the fresh-machine condition).
|
|
1037
|
+
const outcome = await executeVerb(
|
|
1038
|
+
verbOf("skills"),
|
|
1039
|
+
{},
|
|
1040
|
+
YES,
|
|
1041
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1042
|
+
);
|
|
1043
|
+
expect(outcome.exitCode).toBe(0);
|
|
1044
|
+
expect(outcome.stdout).not.toContain("no skills installed");
|
|
1045
|
+
} finally {
|
|
1046
|
+
// `process.env.X = undefined` STORES THE STRING "undefined"; it does not
|
|
1047
|
+
// unset. The variable is always set here (`setupXdgIsolation.ts` gives
|
|
1048
|
+
// every test file its own XDG root), so this restores rather than
|
|
1049
|
+
// deletes in practice — but a test must not leave a poisoned path behind
|
|
1050
|
+
// for the files that run after it if that ever stops being true.
|
|
1051
|
+
if (prevData === undefined) delete process.env.XDG_DATA_HOME;
|
|
1052
|
+
else process.env.XDG_DATA_HOME = prevData;
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it("the GLOBAL skip still names `sources update` when it does happen", async () => {
|
|
1057
|
+
// The remedy the cell above used to assert end-to-end. That state is no
|
|
1058
|
+
// longer reachable with a snapshot in the package — it takes a build whose
|
|
1059
|
+
// `bundled-skills/` is missing AND an empty installed root — so it is
|
|
1060
|
+
// asserted where it actually lives: on the pure pair that both the setup row
|
|
1061
|
+
// and the doctor row read, which is what stops the two surfaces wording one
|
|
1062
|
+
// finding differently. A skip with no next step is the defect.
|
|
1063
|
+
expect(
|
|
1064
|
+
skillsSkipReason("~/.local/share/pragma/skills", "global", true),
|
|
1065
|
+
).toBe(
|
|
1066
|
+
"nothing to link — no skills installed yet; they arrive with the packs " +
|
|
1067
|
+
`\`${BIN_NAME} sources update\` builds`,
|
|
1068
|
+
);
|
|
1069
|
+
expect(
|
|
1070
|
+
skillsSkipRemedy("~/.local/share/pragma/skills", "global"),
|
|
1071
|
+
).toContain(`${BIN_NAME} sources update`);
|
|
705
1072
|
});
|
|
706
1073
|
|
|
707
1074
|
it("detects a created action and composes a symlink carrying an undo", async () => {
|
|
@@ -727,6 +1094,77 @@ describe("setup skills", () => {
|
|
|
727
1094
|
expect(existsSync(join(cwd, ".agents", "skills", "my-skill"))).toBe(false);
|
|
728
1095
|
});
|
|
729
1096
|
|
|
1097
|
+
it("NEVER deletes a user's own symlink that collides with a shipped skill (REGRESSION)", async () => {
|
|
1098
|
+
// Bundled skills ship on every install, so a folder-name collision with a
|
|
1099
|
+
// user's own link is reachable with NO user action — where previously an
|
|
1100
|
+
// empty skill set meant no action was ever composed for that path.
|
|
1101
|
+
// `composeSkills` composes every non-skipped row, so classifying a
|
|
1102
|
+
// resolving foreign link as `replaced` deletes the user's link.
|
|
1103
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1104
|
+
const skillDir = join(cwd, ".pragma", "skills", "my-skill");
|
|
1105
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1106
|
+
writeFileSync(
|
|
1107
|
+
join(skillDir, "SKILL.md"),
|
|
1108
|
+
"---\nname: my-skill\ndescription: A test skill.\n---\n",
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
// Somewhere entirely outside every pragma root, and it RESOLVES.
|
|
1112
|
+
const mine = join(cwd, "my-own-work", "my-skill");
|
|
1113
|
+
mkdirSync(mine, { recursive: true });
|
|
1114
|
+
writeFileSync(join(mine, "SKILL.md"), "---\nname: mine\n---\n");
|
|
1115
|
+
|
|
1116
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1117
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1118
|
+
const linkPath = join(linkDir, "my-skill");
|
|
1119
|
+
symlinkSync(mine, linkPath);
|
|
1120
|
+
|
|
1121
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1122
|
+
const action = detected.actions.find((a) => a.linkPath === linkPath);
|
|
1123
|
+
expect(action?.action).toBe("skipped");
|
|
1124
|
+
expect(action?.owned).toBe(false);
|
|
1125
|
+
|
|
1126
|
+
// Neither the preview nor the real run may touch it.
|
|
1127
|
+
const { effects } = dryRun(composeSkills(detected));
|
|
1128
|
+
expect(
|
|
1129
|
+
effects.filter(
|
|
1130
|
+
(e) =>
|
|
1131
|
+
e._tag === "DeleteFile" && (e as { path?: string }).path === linkPath,
|
|
1132
|
+
),
|
|
1133
|
+
).toHaveLength(0);
|
|
1134
|
+
|
|
1135
|
+
await runTask(composeSkills(detected));
|
|
1136
|
+
expect(readlinkSync(linkPath)).toBe(mine);
|
|
1137
|
+
expect(existsSync(join(linkPath, "SKILL.md"))).toBe(true);
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
it("sweeps a link into a REMOVED package directory once its skill is gone (REGRESSION)", async () => {
|
|
1141
|
+
// The upgrade shape under a version-stamped layout (pnpm, npx, volta): the
|
|
1142
|
+
// link points into `…@0.34.0/bundled-skills/<name>`, a directory the
|
|
1143
|
+
// upgrade replaced. Ownership by residence in a CURRENT root cannot see it,
|
|
1144
|
+
// and once the release drops that skill no per-skill action covers the path
|
|
1145
|
+
// either — so the dangling link would survive every reconcile, invisible to
|
|
1146
|
+
// setup and to doctor alike.
|
|
1147
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1148
|
+
const skillDir = join(cwd, ".pragma", "skills", "still-here");
|
|
1149
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1150
|
+
writeFileSync(
|
|
1151
|
+
join(skillDir, "SKILL.md"),
|
|
1152
|
+
"---\nname: still-here\ndescription: A test skill.\n---\n",
|
|
1153
|
+
);
|
|
1154
|
+
|
|
1155
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1156
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1157
|
+
// Never created: this is the point — the old package directory is gone.
|
|
1158
|
+
const retired = join(cwd, "pragma-cli@0.34.0", "bundled-skills", "retired");
|
|
1159
|
+
const linkPath = join(linkDir, "retired");
|
|
1160
|
+
symlinkSync(retired, linkPath);
|
|
1161
|
+
|
|
1162
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1163
|
+
const orphan = detected.orphans.find((o) => o.linkPath === linkPath);
|
|
1164
|
+
expect(orphan).toBeDefined();
|
|
1165
|
+
expect(orphan?.owned).toBe(true);
|
|
1166
|
+
});
|
|
1167
|
+
|
|
730
1168
|
it("classifies a DANGLING symlink as replaced and repairs it (S1-2)", async () => {
|
|
731
1169
|
// `existsSync` FOLLOWS a symlink, so a dangling link used to read as
|
|
732
1170
|
// "absent" → plan `created` → the real symlink() crashed EEXIST labeled
|
|
@@ -877,17 +1315,168 @@ describe("setup skills", () => {
|
|
|
877
1315
|
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
878
1316
|
const action = detected.actions.find((a) => a.linkPath === linkPath);
|
|
879
1317
|
expect(action?.owned).toBe(true);
|
|
1318
|
+
// A relative link that RESOLVES to the skill is already correct. Comparing
|
|
1319
|
+
// the raw `readlink` string to an absolute path classified it `replaced`,
|
|
1320
|
+
// so it churned on every run and doctor called it "points elsewhere".
|
|
1321
|
+
expect(action?.action).toBe("skipped");
|
|
1322
|
+
expect(dryRun(composeSkills(detected)).effects).toEqual([]);
|
|
880
1323
|
|
|
881
1324
|
await runUndo(composeSkillsRemoval(detected));
|
|
882
1325
|
expect(existsSync(linkPath)).toBe(false);
|
|
883
1326
|
});
|
|
884
1327
|
});
|
|
885
1328
|
|
|
1329
|
+
describe("setup skills — the forward pass is a RECONCILE", () => {
|
|
1330
|
+
/** Seed one project skill and return its source dir. */
|
|
1331
|
+
const seed = (cwd: string, name: string): string => {
|
|
1332
|
+
const dir = join(cwd, ".pragma", "skills", name);
|
|
1333
|
+
mkdirSync(dir, { recursive: true });
|
|
1334
|
+
writeFileSync(
|
|
1335
|
+
join(dir, "SKILL.md"),
|
|
1336
|
+
`---\nname: ${name}\ndescription: A skill.\n---\n`,
|
|
1337
|
+
);
|
|
1338
|
+
return dir;
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
it("removes the link a dropped skill left behind, without --undo", async () => {
|
|
1342
|
+
// The reported bug. `d.orphans` was computed and read ONLY by `--undo`,
|
|
1343
|
+
// which removes every owned link rather than the stale ones — so no command
|
|
1344
|
+
// reconciled, and a skill dropped upstream kept its link forever.
|
|
1345
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1346
|
+
const kept = seed(cwd, "kept");
|
|
1347
|
+
const dropped = seed(cwd, "dropped");
|
|
1348
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1349
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1350
|
+
symlinkSync(kept, join(linkDir, "kept"));
|
|
1351
|
+
symlinkSync(dropped, join(linkDir, "dropped"));
|
|
1352
|
+
rmSync(dropped, { recursive: true, force: true }); // retired upstream
|
|
1353
|
+
|
|
1354
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1355
|
+
expect(detected.orphans.map((o) => o.skillName)).toEqual(["dropped"]);
|
|
1356
|
+
|
|
1357
|
+
await runTask(composeSkills(detected));
|
|
1358
|
+
// `existsSync` FOLLOWS a link, so a dangling one false-negatives — lstat.
|
|
1359
|
+
expect(() => lstatSync(join(linkDir, "dropped"))).toThrow();
|
|
1360
|
+
expect(readlinkSync(join(linkDir, "kept"))).toBe(kept);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
it("plans the sweep as an ACTIONABLE row, not a deselected `none`", async () => {
|
|
1364
|
+
// An orphan-only tree planned `none` → `defaultSelected(none)` is false →
|
|
1365
|
+
// the row was never composed, so even the fixed reconcile would not run.
|
|
1366
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1367
|
+
const dropped = seed(cwd, "dropped");
|
|
1368
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1369
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1370
|
+
symlinkSync(dropped, join(linkDir, "dropped"));
|
|
1371
|
+
rmSync(dropped, { recursive: true, force: true });
|
|
1372
|
+
|
|
1373
|
+
const { plan } = await buildSetupRun(
|
|
1374
|
+
bootRuntime(FLAGS, cwd),
|
|
1375
|
+
"skills",
|
|
1376
|
+
"project",
|
|
1377
|
+
);
|
|
1378
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
1379
|
+
expect(row?.action).toBe("update");
|
|
1380
|
+
expect(row?.selected).toBe(true);
|
|
1381
|
+
expect(row?.detail).toContain("1 stale link to remove");
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
it("the sweep is reversible — undo puts the stale link back", async () => {
|
|
1385
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1386
|
+
const dropped = seed(cwd, "dropped");
|
|
1387
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1388
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1389
|
+
const linkPath = join(linkDir, "dropped");
|
|
1390
|
+
symlinkSync(dropped, linkPath);
|
|
1391
|
+
rmSync(dropped, { recursive: true, force: true });
|
|
1392
|
+
|
|
1393
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1394
|
+
const { undoCount } = await runUndo(composeSkills(detected));
|
|
1395
|
+
expect(undoCount).toBeGreaterThan(0);
|
|
1396
|
+
expect(readlinkSync(linkPath)).toBe(dropped);
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it("is idempotent — a second pass composes zero effects", async () => {
|
|
1400
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1401
|
+
const dropped = seed(cwd, "dropped");
|
|
1402
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1403
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1404
|
+
symlinkSync(dropped, join(linkDir, "dropped"));
|
|
1405
|
+
rmSync(dropped, { recursive: true, force: true });
|
|
1406
|
+
|
|
1407
|
+
await runTask(
|
|
1408
|
+
composeSkills(await detectSkills(bootRuntime(FLAGS, cwd), "project")),
|
|
1409
|
+
);
|
|
1410
|
+
const second = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1411
|
+
expect(dryRun(composeSkills(second)).effects).toEqual([]);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
it("SAFETY: an ABSENT source root means no opinion — nothing is swept", async () => {
|
|
1415
|
+
// `available` conflated "the root is empty" with "the root does not exist".
|
|
1416
|
+
// Sweeping on that alone would wipe every pragma link out of the harness
|
|
1417
|
+
// dirs on any machine where XDG_DATA_HOME moved or sources update never ran.
|
|
1418
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1419
|
+
const skillDir = seed(cwd, "gone");
|
|
1420
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1421
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1422
|
+
const linkPath = join(linkDir, "gone");
|
|
1423
|
+
symlinkSync(skillDir, linkPath);
|
|
1424
|
+
// The whole source ROOT disappears, not just one skill.
|
|
1425
|
+
rmSync(join(cwd, ".pragma", "skills"), { recursive: true, force: true });
|
|
1426
|
+
|
|
1427
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1428
|
+
expect(detected.rootExists).toBe(false);
|
|
1429
|
+
expect(detected.available).toBe(false);
|
|
1430
|
+
expect(detected.orphans).toEqual([]);
|
|
1431
|
+
expect(dryRun(composeSkills(detected)).effects).toEqual([]);
|
|
1432
|
+
|
|
1433
|
+
await runTask(composeSkills(detected));
|
|
1434
|
+
expect(readlinkSync(linkPath)).toBe(skillDir);
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
it("SAFETY: a real dir, a resolving user link and a `-backup` sibling all survive", async () => {
|
|
1438
|
+
// The three shapes the sweep must never touch, in one tree: a hand-placed
|
|
1439
|
+
// directory, a link into someone's own checkout, and a link into a SIBLING
|
|
1440
|
+
// whose name merely EXTENDS the root's (the old string-prefix bug).
|
|
1441
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1442
|
+
const root = join(cwd, ".pragma", "skills");
|
|
1443
|
+
mkdirSync(root, { recursive: true }); // exists, but holds no skill
|
|
1444
|
+
const backup = join(cwd, ".pragma", "skills-backup", "sibling");
|
|
1445
|
+
mkdirSync(backup, { recursive: true });
|
|
1446
|
+
writeFileSync(join(backup, "SKILL.md"), "the user's own copy\n");
|
|
1447
|
+
const elsewhere = join(cwd, "elsewhere", "mine");
|
|
1448
|
+
mkdirSync(elsewhere, { recursive: true });
|
|
1449
|
+
|
|
1450
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1451
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1452
|
+
const realDir = join(linkDir, "hand-placed");
|
|
1453
|
+
mkdirSync(realDir, { recursive: true });
|
|
1454
|
+
writeFileSync(join(realDir, "SKILL.md"), "hand-placed\n");
|
|
1455
|
+
symlinkSync(elsewhere, join(linkDir, "mine"));
|
|
1456
|
+
symlinkSync(backup, join(linkDir, "sibling"));
|
|
1457
|
+
|
|
1458
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1459
|
+
expect(detected.rootExists).toBe(true); // the sweep IS entitled to act
|
|
1460
|
+
expect(detected.orphans).toEqual([]); // ...and finds nothing of its own
|
|
1461
|
+
expect(dryRun(composeSkills(detected)).effects).toEqual([]);
|
|
1462
|
+
|
|
1463
|
+
await runTask(composeSkills(detected));
|
|
1464
|
+
expect(readFileSync(join(realDir, "SKILL.md"), "utf-8")).toBe(
|
|
1465
|
+
"hand-placed\n",
|
|
1466
|
+
);
|
|
1467
|
+
expect(readlinkSync(join(linkDir, "mine"))).toBe(elsewhere);
|
|
1468
|
+
expect(readlinkSync(join(linkDir, "sibling"))).toBe(backup);
|
|
1469
|
+
expect(readFileSync(join(backup, "SKILL.md"), "utf-8")).toBe(
|
|
1470
|
+
"the user's own copy\n",
|
|
1471
|
+
);
|
|
1472
|
+
});
|
|
1473
|
+
});
|
|
1474
|
+
|
|
886
1475
|
describe("setup — detections settle independently", () => {
|
|
887
1476
|
it("gives a rejecting detection its own failed row, leaving the rest planned", async () => {
|
|
888
1477
|
// The targets are independent, and one `Promise.all` made them share a
|
|
889
1478
|
// fate: a single rejecting detection prevented a run being built at all,
|
|
890
|
-
// so NONE of the other targets ran and doctor rendered no
|
|
1479
|
+
// so NONE of the other targets ran and doctor rendered no scoped rows.
|
|
891
1480
|
const { TARGETS } = await import("./targets.js");
|
|
892
1481
|
const lsp = TARGETS.find((t) => t.id === "lsp") as (typeof TARGETS)[number];
|
|
893
1482
|
const spy = vi
|
|
@@ -1364,8 +1953,8 @@ describe("setup (run-all wizard)", () => {
|
|
|
1364
1953
|
let thrown: unknown;
|
|
1365
1954
|
try {
|
|
1366
1955
|
// `--scope both`: the failing target (lsp) is global-only, and the two
|
|
1367
|
-
// that must survive it write the project
|
|
1368
|
-
// both. A bare run-all is the GLOBAL
|
|
1956
|
+
// that must survive it write the project scope, so the run has to cover
|
|
1957
|
+
// both. A bare run-all is the GLOBAL scope now and would not touch this
|
|
1369
1958
|
// repository at all.
|
|
1370
1959
|
await executeVerb(
|
|
1371
1960
|
setupSelfVerb,
|
|
@@ -1392,11 +1981,17 @@ describe("setup (run-all wizard)", () => {
|
|
|
1392
1981
|
});
|
|
1393
1982
|
|
|
1394
1983
|
it("omits skills gracefully when none are discovered (no mid-wizard EMPTY_RESULTS)", async () => {
|
|
1395
|
-
// A run-all in a
|
|
1396
|
-
//
|
|
1984
|
+
// A run-all in a scope with no skills must NOT throw — it just doesn't offer
|
|
1985
|
+
// the skills step. Reaching a clean plan proves the graceful degrade.
|
|
1986
|
+
//
|
|
1987
|
+
// POINTED AT THE PROJECT BAND, which is where an empty skills root is still
|
|
1988
|
+
// an ordinary condition: the global scope now always has the bundled
|
|
1989
|
+
// snapshot behind it, so a run-all there can no longer produce the empty
|
|
1990
|
+
// row this cell exists to survive. The condition is the same one; only the
|
|
1991
|
+
// scope that can still reach it has changed.
|
|
1397
1992
|
const outcome = await executeVerb(
|
|
1398
1993
|
setupSelfVerb,
|
|
1399
|
-
{},
|
|
1994
|
+
{ local: true },
|
|
1400
1995
|
DRY,
|
|
1401
1996
|
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1402
1997
|
);
|
|
@@ -1404,9 +1999,245 @@ describe("setup (run-all wizard)", () => {
|
|
|
1404
1999
|
// The skills row is PRESENT and says why it cannot act, rather than being
|
|
1405
2000
|
// dropped from a plan that then claims completeness.
|
|
1406
2001
|
expect(outcome.stdout).toContain("skills");
|
|
1407
|
-
expect(outcome.stdout).toContain("no skills
|
|
2002
|
+
expect(outcome.stdout).toContain("this project holds no skills");
|
|
1408
2003
|
expect(outcome.stdout).toContain("lsp");
|
|
1409
2004
|
});
|
|
2005
|
+
|
|
2006
|
+
it("the GLOBAL run-all offers the skills row on a fresh machine", async () => {
|
|
2007
|
+
// The other side of the same graceful-degrade question, and the one a fresh
|
|
2008
|
+
// install actually meets: a bare run-all is the global scope, whose skills
|
|
2009
|
+
// row is now offerable from the bundled snapshot rather than skipped with a
|
|
2010
|
+
// remedy the user has to go away and run.
|
|
2011
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
2012
|
+
process.env.XDG_DATA_HOME = tmp("pragma-runall-data-");
|
|
2013
|
+
try {
|
|
2014
|
+
const outcome = await executeVerb(
|
|
2015
|
+
setupSelfVerb,
|
|
2016
|
+
{},
|
|
2017
|
+
DRY,
|
|
2018
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2019
|
+
);
|
|
2020
|
+
expect(outcome.exitCode).toBe(0);
|
|
2021
|
+
expect(outcome.stdout).toContain("skills");
|
|
2022
|
+
expect(outcome.stdout).not.toContain("no skills installed");
|
|
2023
|
+
} finally {
|
|
2024
|
+
if (prevData === undefined) delete process.env.XDG_DATA_HOME;
|
|
2025
|
+
else process.env.XDG_DATA_HOME = prevData;
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
describe("setup — a converged machine is reported, never asked", () => {
|
|
2031
|
+
// A converged run composes ZERO effects, and the confirm gate then mounted
|
|
2032
|
+
// Ink to render summon-core's "No operations planned." above "Proceed?".
|
|
2033
|
+
// On a sub-verb, whose prompt list is empty, that contentless gate was the
|
|
2034
|
+
// first and only thing the user ever saw.
|
|
2035
|
+
let prevPath: string | undefined;
|
|
2036
|
+
beforeEach(() => {
|
|
2037
|
+
prevPath = process.env.PATH;
|
|
2038
|
+
process.env.PATH = stubPath(); // no editor CLI ⇒ lsp skips
|
|
2039
|
+
});
|
|
2040
|
+
afterEach(() => {
|
|
2041
|
+
process.env.PATH = prevPath;
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
const NO = { dryRun: false, undo: false, yes: false };
|
|
2045
|
+
|
|
2046
|
+
it("non-interactive + no --yes: the recap, NOT the `run again with --yes` hint", async () => {
|
|
2047
|
+
// The compounding defect: a converged plan printed "Nothing was applied.
|
|
2048
|
+
// Run again with --yes to apply." over a machine where there was nothing
|
|
2049
|
+
// to apply in the first place.
|
|
2050
|
+
const outcome = await executeVerb(
|
|
2051
|
+
verbOf("lsp"),
|
|
2052
|
+
{},
|
|
2053
|
+
NO,
|
|
2054
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2055
|
+
);
|
|
2056
|
+
expect(outcome.exitCode).toBe(0);
|
|
2057
|
+
expect(outcome.stdout).not.toContain("Run again with --yes");
|
|
2058
|
+
expect(outcome.stdout).not.toContain("Setup plan");
|
|
2059
|
+
// It says what was detected and what happened to it — doctor's house shape.
|
|
2060
|
+
expect(outcome.stdout).toContain("skipped");
|
|
2061
|
+
expect(outcome.stdout).toContain("no VS Code-family editor CLI on PATH");
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
it("a converged TTY run without --yes mounts NO Ink and prints the recap", async () => {
|
|
2065
|
+
// The acceptance case. Faking a terminal on both streams is what makes
|
|
2066
|
+
// `cliIsTTY()` true, which is the ONLY gate between this run and the wizard.
|
|
2067
|
+
const prevIn = process.stdin.isTTY;
|
|
2068
|
+
const prevErr = process.stderr.isTTY;
|
|
2069
|
+
(process.stdin as { isTTY?: boolean }).isTTY = true;
|
|
2070
|
+
(process.stderr as { isTTY?: boolean }).isTTY = true;
|
|
2071
|
+
let outcome: Awaited<ReturnType<typeof executeVerb>>;
|
|
2072
|
+
try {
|
|
2073
|
+
outcome = await executeVerb(
|
|
2074
|
+
verbOf("lsp"),
|
|
2075
|
+
{},
|
|
2076
|
+
NO,
|
|
2077
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2078
|
+
);
|
|
2079
|
+
} finally {
|
|
2080
|
+
(process.stdin as { isTTY?: boolean }).isTTY = prevIn;
|
|
2081
|
+
(process.stderr as { isTTY?: boolean }).isTTY = prevErr;
|
|
2082
|
+
}
|
|
2083
|
+
expect(outcome.exitCode).toBe(0);
|
|
2084
|
+
expect(outcome.stdout).not.toContain("Proceed");
|
|
2085
|
+
expect(outcome.stdout).toContain("skipped");
|
|
2086
|
+
// The same lazy-React probe the PROTECTED guard below uses: had the
|
|
2087
|
+
// contentless gate mounted, Ink would be in the module cache.
|
|
2088
|
+
const isReactPkg = (k: string) =>
|
|
2089
|
+
/[\\/](react|react-dom|ink)@\d/.test(k) ||
|
|
2090
|
+
/[\\/]node_modules[\\/](react|react-dom|ink)[\\/]/.test(k);
|
|
2091
|
+
expect(Object.keys(require.cache ?? {}).filter(isReactPkg)).toEqual([]);
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
it("--dry-run is untouched — it still ends with the dry-run hint", async () => {
|
|
2095
|
+
// The guard sits AFTER the `previewing` computation precisely so a preview
|
|
2096
|
+
// keeps saying it was a preview.
|
|
2097
|
+
const outcome = await executeVerb(
|
|
2098
|
+
verbOf("lsp"),
|
|
2099
|
+
{},
|
|
2100
|
+
DRY,
|
|
2101
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2102
|
+
);
|
|
2103
|
+
expect(outcome.exitCode).toBe(0);
|
|
2104
|
+
expect(outcome.stdout).toContain("Setup plan");
|
|
2105
|
+
expect(outcome.stdout).toContain(DRY_RUN_HINT);
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
it("a PARTIALLY converged plan is NOT short-circuited", async () => {
|
|
2109
|
+
// One actionable row is enough: the run goes through `execute` as before,
|
|
2110
|
+
// so the wizard is still reachable on a machine with something to do.
|
|
2111
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2112
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2113
|
+
const outcome = await executeVerb(
|
|
2114
|
+
verbOf("mcp"),
|
|
2115
|
+
{ local: true },
|
|
2116
|
+
NO,
|
|
2117
|
+
bootRuntime(FLAGS, cwd),
|
|
2118
|
+
);
|
|
2119
|
+
expect(outcome.exitCode).toBe(0);
|
|
2120
|
+
// Non-interactive without --yes ⇒ still the preview, hint and all.
|
|
2121
|
+
expect(outcome.stdout).toContain("Setup plan");
|
|
2122
|
+
expect(outcome.stdout).toContain(PREVIEW_HINT);
|
|
2123
|
+
});
|
|
2124
|
+
|
|
2125
|
+
it("the run-all on a fully converged machine recaps every row at exit 0", async () => {
|
|
2126
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2127
|
+
// First pass configures whatever this machine can hold...
|
|
2128
|
+
await executeVerb(setupSelfVerb, {}, YES, bootRuntime(FLAGS, cwd));
|
|
2129
|
+
// ...the second has nothing left to do.
|
|
2130
|
+
const outcome = await executeVerb(
|
|
2131
|
+
setupSelfVerb,
|
|
2132
|
+
{},
|
|
2133
|
+
NO,
|
|
2134
|
+
bootRuntime(FLAGS, cwd),
|
|
2135
|
+
);
|
|
2136
|
+
expect(outcome.exitCode).toBe(0);
|
|
2137
|
+
expect(outcome.stdout).not.toContain("Run again with --yes");
|
|
2138
|
+
expect(outcome.stdout).toContain("targets configured");
|
|
2139
|
+
});
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
describe("setup — the detection summary", () => {
|
|
2143
|
+
let prevPath: string | undefined;
|
|
2144
|
+
beforeEach(() => {
|
|
2145
|
+
prevPath = process.env.PATH;
|
|
2146
|
+
process.env.PATH = stubPath();
|
|
2147
|
+
});
|
|
2148
|
+
afterEach(() => {
|
|
2149
|
+
process.env.PATH = prevPath;
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* Drive a run, capturing STDERR — which is where the report seam writes, and
|
|
2154
|
+
* the same stream the Ink frame would use. The dispatcher builds its own
|
|
2155
|
+
* runtime for a mutation, so an `rt.report` set on the boot runtime is
|
|
2156
|
+
* replaced; capturing the stream is what actually pins the behaviour.
|
|
2157
|
+
*/
|
|
2158
|
+
const onStderr = async (
|
|
2159
|
+
verb: VerbSpec,
|
|
2160
|
+
params: Record<string, unknown>,
|
|
2161
|
+
flags: GlobalFlags,
|
|
2162
|
+
cwd: string,
|
|
2163
|
+
mutation = YES,
|
|
2164
|
+
): Promise<string[]> => {
|
|
2165
|
+
const chunks: string[] = [];
|
|
2166
|
+
const spy = vi
|
|
2167
|
+
.spyOn(process.stderr, "write")
|
|
2168
|
+
.mockImplementation((chunk: unknown): boolean => {
|
|
2169
|
+
chunks.push(String(chunk));
|
|
2170
|
+
return true;
|
|
2171
|
+
});
|
|
2172
|
+
try {
|
|
2173
|
+
await executeVerb(verb, params, mutation, bootRuntime(flags, cwd));
|
|
2174
|
+
} finally {
|
|
2175
|
+
spy.mockRestore();
|
|
2176
|
+
}
|
|
2177
|
+
return chunks;
|
|
2178
|
+
};
|
|
2179
|
+
|
|
2180
|
+
/** The one write the report seam made for the summary block, if any. */
|
|
2181
|
+
const summaryOf = (chunks: readonly string[]): string =>
|
|
2182
|
+
chunks.find((c) => c.startsWith("Detected —")) ?? "";
|
|
2183
|
+
|
|
2184
|
+
it("names what was detected BEFORE anything is asked, detected rows only", async () => {
|
|
2185
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2186
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2187
|
+
const chunks = await onStderr(setupSelfVerb, { local: true }, FLAGS, cwd);
|
|
2188
|
+
// The report seam writes the block as ONE write, before the generator is
|
|
2189
|
+
// driven — so it is also the first thing on the stream.
|
|
2190
|
+
expect(chunks[0]?.startsWith("Detected —")).toBe(true);
|
|
2191
|
+
const summary = summaryOf(chunks);
|
|
2192
|
+
// FIRST — the Ink frame and this line both go to stderr, so only a line
|
|
2193
|
+
// written before `execute` lands above the frame in scrollback.
|
|
2194
|
+
expect(summary).toContain("Detected —");
|
|
2195
|
+
expect(summary).toContain("mcp");
|
|
2196
|
+
// A skip is "nothing detected here", so it is out of the default view...
|
|
2197
|
+
expect(summary).not.toContain("user-level only");
|
|
2198
|
+
expect(summary).toContain("--verbose lists them");
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
it("--verbose widens it to every row, with no `not detected` footer", async () => {
|
|
2202
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2203
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2204
|
+
const summary = summaryOf(
|
|
2205
|
+
await onStderr(
|
|
2206
|
+
setupSelfVerb,
|
|
2207
|
+
{ local: true },
|
|
2208
|
+
{ ...FLAGS, verbose: true },
|
|
2209
|
+
cwd,
|
|
2210
|
+
),
|
|
2211
|
+
);
|
|
2212
|
+
// ...and back in under `--verbose`, which consumes the EXISTING global flag
|
|
2213
|
+
// rather than adding a signal.
|
|
2214
|
+
expect(summary).toContain("this one is global only");
|
|
2215
|
+
expect(summary).not.toContain("--verbose lists them");
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
it("says nothing on a --dry-run — the plan table IS that run's output", async () => {
|
|
2219
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2220
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2221
|
+
const chunks = await onStderr(
|
|
2222
|
+
setupSelfVerb,
|
|
2223
|
+
{ local: true },
|
|
2224
|
+
FLAGS,
|
|
2225
|
+
cwd,
|
|
2226
|
+
DRY,
|
|
2227
|
+
);
|
|
2228
|
+
expect(summaryOf(chunks)).toBe("");
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
it("is silent over MCP — `rt.report` is a no-op there, so no Ink and no noise", async () => {
|
|
2232
|
+
// The seam is the point: a plain-string report, not an Ink mount, is what
|
|
2233
|
+
// keeps this off the React graph AND out of the MCP data stream.
|
|
2234
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2235
|
+
const mcp = await projectMcp([setupModule], cwd);
|
|
2236
|
+
const result = await mcp.callTool("setup", { confirm: true });
|
|
2237
|
+
await mcp.cleanup();
|
|
2238
|
+
expect(result.ok).toBe(true);
|
|
2239
|
+
expect(JSON.stringify(result.data)).not.toContain("Detected —");
|
|
2240
|
+
});
|
|
1410
2241
|
});
|
|
1411
2242
|
|
|
1412
2243
|
describe("lazy-React discipline (PROTECTED)", () => {
|