@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
|
@@ -12,9 +12,10 @@
|
|
|
12
12
|
* VSCodium/Cursor/Windsurf/Antigravity machines unable to install at all.
|
|
13
13
|
*
|
|
14
14
|
* `detectLsp` probes FOR REAL up front, and spawns NOTHING: an editor is
|
|
15
|
-
*
|
|
16
|
-
* them),
|
|
17
|
-
* `canonical.terrazzo-lsp-extension-<version>/` entry
|
|
15
|
+
* detected when its CLI resolves on PATH (the `editorClis` registry names
|
|
16
|
+
* them), carries the extension when its extensions dir holds a
|
|
17
|
+
* `canonical.terrazzo-lsp-extension-<version>/` entry, and is "installed" when
|
|
18
|
+
* that entry is also new enough to work. Running
|
|
18
19
|
* `--list-extensions` instead would be the natural probe, but Cursor's Linux
|
|
19
20
|
* launcher OPENS THE EDITOR on that flag — a detection step must never launch
|
|
20
21
|
* an app, so the fs is the source of truth. The full extension id is matched
|
|
@@ -50,10 +51,31 @@ const EXTENSION_ID = "canonical.terrazzo-lsp-extension";
|
|
|
50
51
|
/** The npm package whose tarball bundles the VSIX. */
|
|
51
52
|
const LSP_PACKAGE = "@canonical/terrazzo-lsp-extension";
|
|
52
53
|
|
|
53
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* One detected editor: its registry row, its extensions directory, and the two
|
|
56
|
+
* DIFFERENT questions about the extension.
|
|
57
|
+
*
|
|
58
|
+
* `present` and `installed` are deliberately separate, and collapsing them is a
|
|
59
|
+
* silent wrong-removal. `installed` is version-gated — an entry older than
|
|
60
|
+
* {@link MIN_EXTENSION_VERSION} reports false, because the forward plan and
|
|
61
|
+
* doctor must treat a copy that cannot start as not installed. But a REMOVAL
|
|
62
|
+
* keyed on that same boolean would walk past a dead pre-0.8.3 copy that pragma
|
|
63
|
+
* itself put there, report "nothing to remove", and leave it behind forever.
|
|
64
|
+
* So removal keys on `present` (any matching entry at all) and everything else
|
|
65
|
+
* keys on `installed` (present AND new enough to work).
|
|
66
|
+
*/
|
|
54
67
|
export interface DetectedEditor {
|
|
55
68
|
readonly editor: EditorCliDefinition;
|
|
69
|
+
/** Any `canonical.terrazzo-lsp-extension-*` entry, whatever its version. */
|
|
70
|
+
readonly present: boolean;
|
|
71
|
+
/** Present AND at least {@link MIN_EXTENSION_VERSION} — i.e. it works. */
|
|
56
72
|
readonly installed: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* The editor's extensions directory. Kept because the removal's forward
|
|
75
|
+
* carrier needs a path that DEMONSTRABLY already exists (see
|
|
76
|
+
* {@link composeLspRemoval}); when `present` is true, this is that path.
|
|
77
|
+
*/
|
|
78
|
+
readonly extensionsDir: string;
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
/**
|
|
@@ -144,37 +166,44 @@ const compareVersions = (left: number[], right: number[]): number => {
|
|
|
144
166
|
};
|
|
145
167
|
|
|
146
168
|
/**
|
|
147
|
-
*
|
|
169
|
+
* The two facts an editor's extensions dir answers: is a copy THERE, and is it
|
|
170
|
+
* new enough to WORK.
|
|
148
171
|
*
|
|
149
|
-
*
|
|
150
|
-
* entry meant a machine carrying a build
|
|
151
|
-
* `installed`, so `setup lsp` skipped it and
|
|
152
|
-
* a wrong skip that reads as correct, which is
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
172
|
+
* `current` is version-aware on purpose. Matching any
|
|
173
|
+
* `canonical.terrazzo-lsp-extension-*` entry meant a machine carrying a build
|
|
174
|
+
* whose server cannot start reported `installed`, so `setup lsp` skipped it and
|
|
175
|
+
* `doctor` called it healthy — a wrong skip that reads as correct, which is
|
|
176
|
+
* worse than a wrong failure. Everyone who ran `setup lsp` before
|
|
177
|
+
* {@link MIN_EXTENSION_VERSION} would have kept a dead language server forever,
|
|
178
|
+
* with nothing telling them why. An unparseable suffix counts as too old: a
|
|
179
|
+
* directory this function cannot read the version of is one it cannot vouch for.
|
|
156
180
|
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
181
|
+
* `present` is the same probe WITHOUT the version gate, and it exists because
|
|
182
|
+
* removal asks a different question. A pre-0.8.3 copy is not `current`, but it
|
|
183
|
+
* is unquestionably there, and it is a copy this command installed — so a
|
|
184
|
+
* removal keyed on `current` would leave the dead extension on disk while
|
|
185
|
+
* reporting success. See {@link DetectedEditor}.
|
|
159
186
|
*/
|
|
160
|
-
const
|
|
187
|
+
const extensionState = (
|
|
161
188
|
editor: EditorCliDefinition,
|
|
162
189
|
platform: PlatformEnv,
|
|
163
|
-
): boolean => {
|
|
190
|
+
): { present: boolean; current: boolean } => {
|
|
164
191
|
let entries: string[];
|
|
165
192
|
try {
|
|
166
193
|
entries = readdirSync(editor.extensionsDir(platform));
|
|
167
194
|
} catch {
|
|
168
|
-
return false; // No
|
|
195
|
+
return { present: false, current: false }; // No dir — nothing installed.
|
|
169
196
|
}
|
|
170
197
|
const prefix = `${EXTENSION_ID}-`;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
198
|
+
const matching = entries.filter((entry) =>
|
|
199
|
+
entry.toLowerCase().startsWith(prefix),
|
|
200
|
+
);
|
|
201
|
+
const current = matching.some((entry) => {
|
|
202
|
+
const version = parseVersion(entry.toLowerCase().slice(prefix.length));
|
|
175
203
|
if (version === undefined) return false; // Unreadable: cannot vouch for it.
|
|
176
204
|
return compareVersions(version, MINIMUM) >= 0;
|
|
177
205
|
});
|
|
206
|
+
return { present: matching.length > 0, current };
|
|
178
207
|
};
|
|
179
208
|
|
|
180
209
|
/**
|
|
@@ -191,10 +220,15 @@ export async function detectLsp(_cwd: string): Promise<LspDetection> {
|
|
|
191
220
|
const platform = readPlatformEnv();
|
|
192
221
|
const editors: DetectedEditor[] = editorClis
|
|
193
222
|
.filter((editor) => cliOnPath(executableCandidates(editor.cli, platform)))
|
|
194
|
-
.map((editor) =>
|
|
195
|
-
editor,
|
|
196
|
-
|
|
197
|
-
|
|
223
|
+
.map((editor) => {
|
|
224
|
+
const { present, current } = extensionState(editor, platform);
|
|
225
|
+
return {
|
|
226
|
+
editor,
|
|
227
|
+
present,
|
|
228
|
+
installed: current,
|
|
229
|
+
extensionsDir: editor.extensionsDir(platform),
|
|
230
|
+
};
|
|
231
|
+
});
|
|
198
232
|
const state: LspState =
|
|
199
233
|
editors.length === 0
|
|
200
234
|
? "unknown"
|
|
@@ -327,29 +361,87 @@ export function composeLsp(
|
|
|
327
361
|
}
|
|
328
362
|
|
|
329
363
|
/**
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
* answer is the plan row's, which names the editor CLI that CAN uninstall it
|
|
334
|
-
* (see {@link lspUninstallRemedy}) or states that this machine has none.
|
|
364
|
+
* The editors this command OWNS an extension copy in right now — every detected
|
|
365
|
+
* editor whose extensions dir holds a `canonical.terrazzo-lsp-extension-*`
|
|
366
|
+
* entry, version-gate deliberately NOT applied (see {@link DetectedEditor}).
|
|
335
367
|
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
368
|
+
* Named and shaped like `ownedMcpGroups` and `ownedSkillLinks`, and for the
|
|
369
|
+
* same reason those exist: a reversal is composed from what detection says this
|
|
370
|
+
* command owns, never from what a fresh forward plan would create.
|
|
371
|
+
*
|
|
372
|
+
* @param d - The detection gathered up front.
|
|
373
|
+
* @returns The editors carrying a copy.
|
|
338
374
|
*/
|
|
339
|
-
export
|
|
340
|
-
|
|
375
|
+
export const ownedLspEditors = (d: LspDetection): readonly DetectedEditor[] =>
|
|
376
|
+
d.editors.filter((e) => e.present);
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Compose the removal: one `<editor cli> --uninstall-extension <id>` per owned
|
|
380
|
+
* editor, carried as the `undo` of a forward no-op.
|
|
381
|
+
*
|
|
382
|
+
* The docblock this replaces claimed "an `exec` carries no reversal". That was
|
|
383
|
+
* factually wrong about the effect model — `Exec` has an `undo` slot and `exec`
|
|
384
|
+
* takes `UndoOptions`. What is true is that `exec` has no DEFAULT undo; the
|
|
385
|
+
* caller supplies one, exactly as `composeMcpRemoval` does for its `mkdir`.
|
|
386
|
+
*
|
|
387
|
+
* The carrier is copied from `composeMcpRemoval`, and its trap is the reason
|
|
388
|
+
* for the shape: `runUndo` collects undos by walking the FORWARD task with
|
|
389
|
+
* effects MOCKED, so the forward side must be walkable without reading or
|
|
390
|
+
* mutating anything. A `mkdir` of the editor's own extensions directory — which
|
|
391
|
+
* `present` proves already exists — reads nothing and is a genuine no-op. Phase
|
|
392
|
+
* two then runs the uninstall against the real editor.
|
|
393
|
+
*
|
|
394
|
+
* @param d - The detection gathered up front.
|
|
395
|
+
* @returns A Task whose undo uninstalls the extension from each owned editor.
|
|
396
|
+
*/
|
|
397
|
+
export function composeLspRemoval(d: LspDetection): Task<void> {
|
|
398
|
+
const owned = ownedLspEditors(d);
|
|
399
|
+
// Nothing on this machine carries a copy — there is no honest work to model,
|
|
400
|
+
// and the plan row says so. Same shape as the other rows' empty removals.
|
|
401
|
+
if (owned.length === 0) return sequence_([]);
|
|
402
|
+
return sequence_(
|
|
403
|
+
owned.map(({ editor, extensionsDir }) => {
|
|
404
|
+
const command = `${editor.cli} --uninstall-extension ${EXTENSION_ID}`;
|
|
405
|
+
return mkdir(extensionsDir, true, {
|
|
406
|
+
undo: guardMissingBinary(
|
|
407
|
+
editor.cli,
|
|
408
|
+
{
|
|
409
|
+
message: `The \`${editor.cli}\` CLI disappeared from PATH mid-run — restore it, then run \`${BIN_NAME} setup lsp --undo\` again.`,
|
|
410
|
+
},
|
|
411
|
+
flatMap(
|
|
412
|
+
exec(
|
|
413
|
+
editor.cli,
|
|
414
|
+
["--uninstall-extension", EXTENSION_ID],
|
|
415
|
+
extensionsDir,
|
|
416
|
+
),
|
|
417
|
+
(result) =>
|
|
418
|
+
checkExecOk(command, result as ExecResult, {
|
|
419
|
+
message:
|
|
420
|
+
`${editor.name} refused the uninstall (its output is above). ` +
|
|
421
|
+
`Remove it by hand with \`${command}\`.`,
|
|
422
|
+
}),
|
|
423
|
+
),
|
|
424
|
+
),
|
|
425
|
+
});
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
341
428
|
}
|
|
342
429
|
|
|
343
430
|
/**
|
|
344
431
|
* The uninstall instruction for a detection — a command that runs on THIS
|
|
345
|
-
* machine, or `undefined` when
|
|
346
|
-
* nothing honest to print.
|
|
432
|
+
* machine, or `undefined` when nothing here carries the extension and there is
|
|
433
|
+
* therefore nothing honest to print.
|
|
434
|
+
*
|
|
435
|
+
* It names an OWNING editor. Reading `d.editors.at(0)` named the first editor
|
|
436
|
+
* on PATH instead, so a machine with Cursor installed and the extension only in
|
|
437
|
+
* VSCodium printed a command against the wrong editor — one that would report
|
|
438
|
+
* success having uninstalled nothing.
|
|
347
439
|
*
|
|
348
440
|
* @param d - The detection gathered up front.
|
|
349
441
|
* @returns The uninstall command, or undefined.
|
|
350
442
|
*/
|
|
351
443
|
export function lspUninstallRemedy(d: LspDetection): string | undefined {
|
|
352
|
-
const cli = d.
|
|
444
|
+
const cli = ownedLspEditors(d).at(0)?.editor.cli;
|
|
353
445
|
return cli === undefined
|
|
354
446
|
? undefined
|
|
355
447
|
: `${cli} --uninstall-extension ${EXTENSION_ID}`;
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
*
|
|
12
12
|
* Detection ALSO reads each group's existing config (via `readMcpConfigFrom`,
|
|
13
13
|
* FOR REAL up front) and classifies every group as `absent` (no pragma entry
|
|
14
|
-
* yet), `
|
|
14
|
+
* yet), `registered` (the pragma entry already matches what we'd write), or
|
|
15
15
|
* `drifted` (a pragma entry exists but differs). The wizard shows that prior
|
|
16
|
-
* state in each row and DEFAULT-DESELECTS the already-`
|
|
16
|
+
* state in each row and DEFAULT-DESELECTS the already-`registered` files; the
|
|
17
17
|
* write itself stays idempotent (a re-merge of the identical entry is
|
|
18
18
|
* byte-for-byte), so a re-run is a no-op the recap reports as "unchanged" — the
|
|
19
19
|
* same state-awareness `setup skills` has always had.
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
|
|
22
22
|
import { dirname } from "node:path";
|
|
23
23
|
import type {
|
|
24
|
+
ConfigTarget,
|
|
24
25
|
McpServerConfig,
|
|
25
26
|
PlatformEnv,
|
|
26
27
|
TargetGroup,
|
|
@@ -28,7 +29,7 @@ import type {
|
|
|
28
29
|
import { mkdir, sequence_, type Task } from "@canonical/task";
|
|
29
30
|
import { MCP_SERVER_NAME } from "../../../constants.js";
|
|
30
31
|
import type { PragmaRuntime } from "../../../kernel/runtime/index.js";
|
|
31
|
-
import type { McpTargetState,
|
|
32
|
+
import type { McpTargetState, Scope } from "../types.js";
|
|
32
33
|
|
|
33
34
|
/** The `writeMcpConfigTargets` builder, captured from the dynamic harness import. */
|
|
34
35
|
type WriteMcpConfigTargets =
|
|
@@ -41,15 +42,25 @@ type RemoveMcpConfigFrom =
|
|
|
41
42
|
/**
|
|
42
43
|
* The detected MCP state: the per-file target groups (already scoped to the
|
|
43
44
|
* requested `--scope`), a by-path map of each group's prior
|
|
44
|
-
* {@link McpTargetState} (read up front), the
|
|
45
|
-
* (dynamically imported) target-based
|
|
46
|
-
* synchronously. Keeping `groups` a plain
|
|
47
|
-
* existing `.groups` consumer
|
|
48
|
-
* rides alongside, keyed by the
|
|
45
|
+
* {@link McpTargetState} (read up front), the same states at PER-WRITE
|
|
46
|
+
* resolution, the project root, and the (dynamically imported) target-based
|
|
47
|
+
* writer the pure `composeMcp` needs synchronously. Keeping `groups` a plain
|
|
48
|
+
* {@link TargetGroup}[] leaves every existing `.groups` consumer
|
|
49
|
+
* (path/harnessNames/scope) unchanged; the state rides alongside, keyed by the
|
|
50
|
+
* group's `path`.
|
|
51
|
+
*
|
|
52
|
+
* `stateByPath` is what the WRITE side needs — a file is written unless every
|
|
53
|
+
* key in it is already current — and it is the aggregate of `stateByWrite`
|
|
54
|
+
* (see {@link aggregateMcpStates}), so the two can never disagree.
|
|
55
|
+
* `stateByWrite` is the finer grain a per-HARNESS report needs: two harnesses
|
|
56
|
+
* sharing one file own two different keys in it, and the file's aggregate is
|
|
57
|
+
* not either harness's standing.
|
|
49
58
|
*/
|
|
50
59
|
export interface McpDetection {
|
|
51
60
|
readonly groups: readonly TargetGroup[];
|
|
52
61
|
readonly stateByPath: ReadonlyMap<string, McpTargetState>;
|
|
62
|
+
/** Each write's own state, keyed by {@link mcpWriteKey} — see the docblock. */
|
|
63
|
+
readonly stateByWrite: ReadonlyMap<string, McpTargetState>;
|
|
53
64
|
readonly cwd: string;
|
|
54
65
|
readonly platform: PlatformEnv;
|
|
55
66
|
readonly writeMcpConfigTargets: WriteMcpConfigTargets;
|
|
@@ -74,49 +85,54 @@ const MCP_SERVE_ARGV = ["mcp", "serve"] as const;
|
|
|
74
85
|
/**
|
|
75
86
|
* The pragma MCP server entry we register — the SINGLE source of truth both the
|
|
76
87
|
* classifier (does the existing entry match this?) and the writer (`composeMcp`)
|
|
77
|
-
* consume, so "already
|
|
88
|
+
* consume, so "already registered" means byte-for-byte what a write would emit.
|
|
78
89
|
*
|
|
79
|
-
* The entry is
|
|
90
|
+
* The entry is SCOPE-shaped: a project-scope entry records the project root as
|
|
80
91
|
* `cwd` (that binding is the point of a per-repo registration), while a
|
|
81
|
-
* global-
|
|
92
|
+
* global-scope entry OMITS `cwd` entirely — a per-user server must not be
|
|
82
93
|
* pinned to whatever directory `setup mcp --global` happened to run from
|
|
83
94
|
* (observed: a global registration from `~/Downloads` permanently served
|
|
84
95
|
* `~/Downloads`, and re-running from repo B "repaired" it to repo B,
|
|
85
|
-
* ping-ponging the machine
|
|
96
|
+
* ping-ponging the machine scope between projects). The per-harness
|
|
86
97
|
* serializers already omit an undefined `cwd`, and the matcher treats an
|
|
87
98
|
* omitted controlled field as must-be-absent, so a stale global entry still
|
|
88
99
|
* carrying a `cwd` classifies as `drifted` and converges on the next write.
|
|
89
100
|
*
|
|
90
|
-
* @param cwd - The project root (recorded only on project-
|
|
91
|
-
* @param
|
|
101
|
+
* @param cwd - The project root (recorded only on project-scope entries).
|
|
102
|
+
* @param scope - The config scope the entry is written to.
|
|
92
103
|
* @returns The pragma {@link McpServerConfig}.
|
|
93
104
|
*/
|
|
94
|
-
export function pragmaMcpEntry(cwd: string,
|
|
95
|
-
return
|
|
105
|
+
export function pragmaMcpEntry(cwd: string, scope: Scope): McpServerConfig {
|
|
106
|
+
return scope === "project"
|
|
96
107
|
? { command: MCP_SERVER_NAME, args: [...MCP_SERVE_ARGV], cwd }
|
|
97
108
|
: { command: MCP_SERVER_NAME, args: [...MCP_SERVE_ARGV] };
|
|
98
109
|
}
|
|
99
110
|
|
|
100
111
|
/**
|
|
101
|
-
* Classify
|
|
102
|
-
*
|
|
103
|
-
* already
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
* per-harness serializer would emit for the group's
|
|
112
|
+
* Classify ONE write against its on-disk config: `absent` when the file carries
|
|
113
|
+
* no pragma entry under this write's `mcpKey`, `registered` when the entry
|
|
114
|
+
* already matches what this write would emit, `drifted` when one is there and
|
|
115
|
+
* differs. Reads the file for real via `readMcpConfigFrom` (which reads only
|
|
116
|
+
* this write's key), and matches the raw entry against what THAT write's
|
|
117
|
+
* per-harness serializer would emit for the group's SCOPE (`mcpEntryMatches`
|
|
107
118
|
* compares the serializer's controlled fields — so a global entry still
|
|
108
119
|
* pinning a `cwd` reads as drifted — and ignores extra keys a harness or user
|
|
109
120
|
* added, so we never churn a file we did not author).
|
|
110
121
|
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
122
|
+
* The write, not the file, is the unit that can be classified honestly: a
|
|
123
|
+
* shared `.vscode/mcp.json` holds VS Code's `servers` and Cline's `mcpServers`
|
|
124
|
+
* as two independent entries, and "the file" has no single state when one is
|
|
125
|
+
* current and the other is missing.
|
|
126
|
+
*
|
|
127
|
+
* @param write - The resolved config target to inspect.
|
|
128
|
+
* @param want - The canonical pragma entry for the group's scope.
|
|
113
129
|
* @param harnessesApi - The dynamically imported harnesses module.
|
|
114
130
|
* @param runTask - The node Task interpreter.
|
|
115
|
-
* @returns
|
|
116
|
-
* @note Impure — reads
|
|
131
|
+
* @returns This write's {@link McpTargetState}.
|
|
132
|
+
* @note Impure — reads the write's config file.
|
|
117
133
|
*/
|
|
118
|
-
async function
|
|
119
|
-
|
|
134
|
+
async function classifyWrite(
|
|
135
|
+
write: ConfigTarget,
|
|
120
136
|
want: McpServerConfig,
|
|
121
137
|
harnessesApi: Pick<
|
|
122
138
|
typeof import("@canonical/harnesses"),
|
|
@@ -124,37 +140,60 @@ async function classifyGroup(
|
|
|
124
140
|
>,
|
|
125
141
|
runTask: typeof import("@canonical/task/node").runTask,
|
|
126
142
|
): Promise<McpTargetState> {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
present += 1;
|
|
134
|
-
if (harnessesApi.mcpEntryMatches(existing, want, write.serializeEntry)) {
|
|
135
|
-
matching += 1;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
if (present === 0) return "absent";
|
|
139
|
-
if (matching === group.writes.length) return "configured";
|
|
140
|
-
return "drifted";
|
|
143
|
+
const servers = await runTask(harnessesApi.readMcpConfigFrom(write));
|
|
144
|
+
const existing = servers[MCP_SERVER_NAME];
|
|
145
|
+
if (existing === undefined) return "absent";
|
|
146
|
+
return harnessesApi.mcpEntryMatches(existing, want, write.serializeEntry)
|
|
147
|
+
? "registered"
|
|
148
|
+
: "drifted";
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The state of a whole FILE, aggregated from its writes: `absent` when no write
|
|
153
|
+
* carries the entry, `registered` when every write already carries a matching
|
|
154
|
+
* one, `drifted` otherwise. This is the rule the write side needs — a file is
|
|
155
|
+
* rewritten unless it is `registered` everywhere — and it is deliberately the
|
|
156
|
+
* only place the per-write states are collapsed, so nothing else re-derives it.
|
|
157
|
+
*
|
|
158
|
+
* @param states - Each write's own state, in the group's write order.
|
|
159
|
+
* @returns The group's {@link McpTargetState}.
|
|
160
|
+
*/
|
|
161
|
+
const aggregateMcpStates = (
|
|
162
|
+
states: readonly McpTargetState[],
|
|
163
|
+
): McpTargetState => {
|
|
164
|
+
if (states.every((state) => state === "absent")) return "absent";
|
|
165
|
+
if (states.every((state) => state === "registered")) return "registered";
|
|
166
|
+
return "drifted";
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The key one write's state is recorded under: the file it lands in AND the
|
|
171
|
+
* `mcpKey` it owns there. `(path, mcpKey)` is exactly the write-dedup key
|
|
172
|
+
* `groupConfigTargets` builds its `writes` from, so the map has one entry per
|
|
173
|
+
* write and no two harnesses sharing a file can collide. The separator is NUL
|
|
174
|
+
* because a config path may legally contain everything else.
|
|
175
|
+
*
|
|
176
|
+
* @param write - The resolved config target.
|
|
177
|
+
* @returns Its lookup key in {@link McpDetection.stateByWrite}.
|
|
178
|
+
*/
|
|
179
|
+
const mcpWriteKey = (write: ConfigTarget): string =>
|
|
180
|
+
`${write.path}\u0000${write.mcpKey}`;
|
|
181
|
+
|
|
143
182
|
/**
|
|
144
183
|
* Detect the AI harnesses present in the project (real reads, up front),
|
|
145
184
|
* resolve+dedup their config targets for the chosen scope, AND read each
|
|
146
|
-
* group's existing config to classify it `absent`/`
|
|
147
|
-
* the wizard can show prior state and default-deselect already-`
|
|
185
|
+
* group's existing config to classify it `absent`/`registered`/`drifted` — so
|
|
186
|
+
* the wizard can show prior state and default-deselect already-`registered`
|
|
148
187
|
* files.
|
|
149
188
|
*
|
|
150
189
|
* @param rt - The per-invocation runtime.
|
|
151
|
-
* @param
|
|
152
|
-
* @returns The
|
|
190
|
+
* @param scope - The scope being planned (global or project).
|
|
191
|
+
* @returns The scope's target groups, their prior states, + the config writer.
|
|
153
192
|
* @note Impure — reads the filesystem via `detectHarnesses` + `readMcpConfigFrom`.
|
|
154
193
|
*/
|
|
155
194
|
export async function detectMcp(
|
|
156
195
|
rt: PragmaRuntime,
|
|
157
|
-
|
|
196
|
+
scope: Scope,
|
|
158
197
|
): Promise<McpDetection> {
|
|
159
198
|
const cwd = rt.cwd;
|
|
160
199
|
const [
|
|
@@ -174,29 +213,45 @@ export async function detectMcp(
|
|
|
174
213
|
]);
|
|
175
214
|
const platform = readPlatformEnv();
|
|
176
215
|
const detected = await runTask(detectHarnesses(cwd, platform));
|
|
177
|
-
const groups = groupTargetsForScope(detected, cwd,
|
|
216
|
+
const groups = groupTargetsForScope(detected, cwd, scope, platform);
|
|
178
217
|
|
|
179
218
|
// Read each group's existing config (for real, up front) so the recap/preview
|
|
180
219
|
// and the default selection reflect true prior state — the same discipline
|
|
181
220
|
// `detectSkills` uses for its per-link create/skip/replace decision. The
|
|
182
|
-
// wanted entry is
|
|
183
|
-
// per group, not once.
|
|
221
|
+
// wanted entry is SCOPE-shaped (a global entry omits `cwd`), so it is built
|
|
222
|
+
// per group, not once. Each WRITE is classified and kept; the file's state is
|
|
223
|
+
// their aggregate, so the coarse view is derived from the fine one rather
|
|
224
|
+
// than measured separately.
|
|
184
225
|
const stateByPath = new Map<string, McpTargetState>();
|
|
226
|
+
const stateByWrite = new Map<string, McpTargetState>();
|
|
185
227
|
await Promise.all(
|
|
186
228
|
groups.map(async (group) => {
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
229
|
+
const want = pragmaMcpEntry(cwd, group.scope);
|
|
230
|
+
const classified = await Promise.all(
|
|
231
|
+
group.writes.map(async (write) => ({
|
|
232
|
+
write,
|
|
233
|
+
state: await classifyWrite(
|
|
234
|
+
write,
|
|
235
|
+
want,
|
|
236
|
+
{ readMcpConfigFrom, mcpEntryMatches },
|
|
237
|
+
runTask,
|
|
238
|
+
),
|
|
239
|
+
})),
|
|
240
|
+
);
|
|
241
|
+
for (const { write, state } of classified) {
|
|
242
|
+
stateByWrite.set(mcpWriteKey(write), state);
|
|
243
|
+
}
|
|
244
|
+
stateByPath.set(
|
|
245
|
+
group.path,
|
|
246
|
+
aggregateMcpStates(classified.map((c) => c.state)),
|
|
192
247
|
);
|
|
193
|
-
stateByPath.set(group.path, state);
|
|
194
248
|
}),
|
|
195
249
|
);
|
|
196
250
|
|
|
197
251
|
return {
|
|
198
252
|
groups,
|
|
199
253
|
stateByPath,
|
|
254
|
+
stateByWrite,
|
|
200
255
|
cwd,
|
|
201
256
|
platform,
|
|
202
257
|
writeMcpConfigTargets,
|
|
@@ -212,6 +267,22 @@ export function mcpGroupState(d: McpDetection, path: string): McpTargetState {
|
|
|
212
267
|
return d.stateByPath.get(path) ?? "absent";
|
|
213
268
|
}
|
|
214
269
|
|
|
270
|
+
/**
|
|
271
|
+
* The prior state of ONE write — the state of the single harness key it owns,
|
|
272
|
+
* not of the file it shares. Defaults to `absent` for an unknown write, the
|
|
273
|
+
* same way {@link mcpGroupState} does for an unknown path.
|
|
274
|
+
*
|
|
275
|
+
* @param d - The detection gathered up front.
|
|
276
|
+
* @param write - One of a group's {@link ConfigTarget} writes.
|
|
277
|
+
* @returns That write's {@link McpTargetState}.
|
|
278
|
+
*/
|
|
279
|
+
export function mcpWriteState(
|
|
280
|
+
d: McpDetection,
|
|
281
|
+
write: ConfigTarget,
|
|
282
|
+
): McpTargetState {
|
|
283
|
+
return d.stateByWrite.get(mcpWriteKey(write)) ?? "absent";
|
|
284
|
+
}
|
|
285
|
+
|
|
215
286
|
/** The target groups the user selected (by path), or all when none recorded. */
|
|
216
287
|
export function selectedGroups(
|
|
217
288
|
d: McpDetection,
|
|
@@ -222,7 +293,7 @@ export function selectedGroups(
|
|
|
222
293
|
|
|
223
294
|
/**
|
|
224
295
|
* Compose the per-target config writes for the SELECTED groups (pure —
|
|
225
|
-
* re-runnable; builds ABSOLUTE config paths itself). An already-`
|
|
296
|
+
* re-runnable; builds ABSOLUTE config paths itself). An already-`registered`
|
|
226
297
|
* group composes NOTHING: the entry on disk is byte-for-byte what a write would
|
|
227
298
|
* emit, so re-writing it would only move an mtime. Removal no longer depends on
|
|
228
299
|
* the forward plan having written something ({@link composeMcpRemoval}), which
|
|
@@ -241,9 +312,9 @@ export function composeMcp(
|
|
|
241
312
|
// Re-runnable combinators (NOT a single-use `gen`): `execute` interprets the
|
|
242
313
|
// task twice (preview + perform). One combined write per file keeps a shared
|
|
243
314
|
// file (VS Code + Cline) a single read-modify-write — dry-run safe. The
|
|
244
|
-
// written entry is
|
|
315
|
+
// written entry is SCOPE-shaped per group (a global entry omits `cwd`).
|
|
245
316
|
const pending = groups.filter(
|
|
246
|
-
(group) => mcpGroupState(d, group.path) !== "
|
|
317
|
+
(group) => mcpGroupState(d, group.path) !== "registered",
|
|
247
318
|
);
|
|
248
319
|
return sequence_(
|
|
249
320
|
pending.map((group) =>
|
|
@@ -257,8 +328,8 @@ export function composeMcp(
|
|
|
257
328
|
}
|
|
258
329
|
|
|
259
330
|
/**
|
|
260
|
-
* The groups this
|
|
261
|
-
* pragma entry as present (`
|
|
331
|
+
* The groups this scope OWNS right now — every file where detection classifies a
|
|
332
|
+
* pragma entry as present (`registered` or `drifted`). Removal acts on exactly
|
|
262
333
|
* this set, so a file that never carried the entry is never rewritten and a
|
|
263
334
|
* foreign server in a file that does is never touched.
|
|
264
335
|
*/
|