@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,20 @@
|
|
|
26
26
|
* accurate; the composed Task performs only the symlink/delete effects.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
existsSync,
|
|
31
|
+
lstatSync,
|
|
32
|
+
readdirSync,
|
|
33
|
+
readlinkSync,
|
|
34
|
+
statSync,
|
|
35
|
+
} from "node:fs";
|
|
30
36
|
import { basename, join, resolve } from "node:path";
|
|
31
37
|
import type { ResolvedPackage } from "../../kernel/runtime/refs/index.js";
|
|
38
|
+
// The ownership test, imported rather than reimplemented: `setup skills` and
|
|
39
|
+
// this module must not be able to disagree about which links belong to pragma.
|
|
40
|
+
// `setupSkills.ts` reaches `@canonical/harnesses` only through a DYNAMIC
|
|
41
|
+
// import, so this edge adds no static one (`capabilities/lazy.test.ts`).
|
|
42
|
+
import { withinAnyRoot } from "../setup/operations/setupSkills.js";
|
|
32
43
|
import { installedSkillsDir } from "../skill/discover.js";
|
|
33
44
|
|
|
34
45
|
/** One planned skill symlink into the installed-skills root. */
|
|
@@ -94,7 +105,11 @@ function packageSkillDirs(root: string): string[] {
|
|
|
94
105
|
for (const name of names) {
|
|
95
106
|
const dir = join(skillsDir, name);
|
|
96
107
|
try {
|
|
97
|
-
|
|
108
|
+
// `stat`, not `lstat`: a package that ships `skills/<name>` as a SYMLINK
|
|
109
|
+
// — the ordinary pnpm / monorepo / `file:` layout — contributed nothing
|
|
110
|
+
// at all, silently, because `lstat` reports the link and not the
|
|
111
|
+
// directory behind it. Discovery uses `stat`; so does this now.
|
|
112
|
+
if (statSync(dir).isDirectory() && existsSync(join(dir, "SKILL.md"))) {
|
|
98
113
|
out.push(dir);
|
|
99
114
|
}
|
|
100
115
|
} catch {
|
|
@@ -176,10 +191,17 @@ export function planSkillInstall(
|
|
|
176
191
|
seen.add(folderName);
|
|
177
192
|
const linkPath = resolve(dest, folderName);
|
|
178
193
|
const state = linkState(linkPath);
|
|
194
|
+
// Already correct means the link RESOLVES to the skill dir, not that its
|
|
195
|
+
// raw `readlink` string equals an absolute path. A relative link was
|
|
196
|
+
// therefore never "already correct": it was torn down and rebuilt on
|
|
197
|
+
// every single update, and the sibling planner reported it as drift.
|
|
198
|
+
const resolvesToSkill =
|
|
199
|
+
state.kind === "symlink" &&
|
|
200
|
+
resolve(dest, state.target) === resolve(skillDir);
|
|
179
201
|
const action: SkillLinkAction["action"] =
|
|
180
202
|
state.kind === "absent"
|
|
181
203
|
? "created"
|
|
182
|
-
:
|
|
204
|
+
: resolvesToSkill
|
|
183
205
|
? "skipped"
|
|
184
206
|
: state.kind === "symlink"
|
|
185
207
|
? "replaced"
|
|
@@ -199,3 +221,159 @@ export function planSkillInstall(
|
|
|
199
221
|
actions.push(...planStaleLinkPrunes(dest, seen));
|
|
200
222
|
return actions;
|
|
201
223
|
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* One planned link action in a HARNESS skills directory (layer 2).
|
|
227
|
+
*
|
|
228
|
+
* Layer 1 (`<installedSkillsDir>/<name>` → the package's folder) is what
|
|
229
|
+
* {@link planSkillInstall} owns. This is the layer above it: the links inside
|
|
230
|
+
* `~/.claude/skills` and `~/.agents/skills` that point AT layer 1 — a link to a
|
|
231
|
+
* link — and that `setup skills` owns.
|
|
232
|
+
*/
|
|
233
|
+
export interface HarnessLinkAction {
|
|
234
|
+
readonly target: string;
|
|
235
|
+
readonly linkPath: string;
|
|
236
|
+
readonly action: "created" | "replaced" | "pruned";
|
|
237
|
+
readonly folderName: string;
|
|
238
|
+
/** The harness directory's display name, for the progress line. */
|
|
239
|
+
readonly dirName: string;
|
|
240
|
+
/**
|
|
241
|
+
* The target the link ALREADY holds, recorded only for a `replaced` action so
|
|
242
|
+
* its undo can put that link back. Without it the undo could only delete what
|
|
243
|
+
* the forward run created, which restores an ABSENT path — a state that never
|
|
244
|
+
* existed — instead of the link the run overwrote.
|
|
245
|
+
*
|
|
246
|
+
* Stored as `readlink` returned it, not resolved: a relative link is relative
|
|
247
|
+
* to its own directory, which for a candidate of `dir` is `dir` itself, so
|
|
248
|
+
* re-linking the raw value reproduces the link exactly as found.
|
|
249
|
+
*/
|
|
250
|
+
readonly previousTarget?: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Plan the CONVERGE-ONLY refresh of the global scope's harness skill links.
|
|
255
|
+
*
|
|
256
|
+
* The reported bug lives here. `sources update` installed and pruned layer 1
|
|
257
|
+
* and stopped, so a new pack skill reached no harness directory until the user
|
|
258
|
+
* separately ran `setup skills`, and a dropped one left its layer-2 link
|
|
259
|
+
* dangling with nothing that would ever clear it. This closes the loop, under
|
|
260
|
+
* two rules that are the entire safety argument:
|
|
261
|
+
*
|
|
262
|
+
* 1. CONVERGE-ONLY. `dirs` are directories that already exist — this function
|
|
263
|
+
* never creates one, and the caller composes no `mkdir`. A `sources update`
|
|
264
|
+
* that materialised `~/.claude/skills` would be litter, and would silently
|
|
265
|
+
* opt the user into linking they never asked for.
|
|
266
|
+
* 2. OWNERSHIP, by the SAME test `setup skills` uses. A path is this command's
|
|
267
|
+
* to touch only when it holds a symlink resolving INSIDE one of the global
|
|
268
|
+
* scope's roots (`withinAnyRoot`, a pure path-SEGMENT test that reads nothing
|
|
269
|
+
* — so it still answers correctly after the layer-1 target is gone, which is
|
|
270
|
+
* exactly the stale case). A real directory is a hand-installed skill; a
|
|
271
|
+
* symlink pointing anywhere else is the user's own; a `<root>-backup/foo`
|
|
272
|
+
* sibling is not "inside" despite sharing a string prefix. None is removed.
|
|
273
|
+
*
|
|
274
|
+
* THE SET, NOT JUST THE INSTALLED ROOT, and that is what keeps precedence
|
|
275
|
+
* true one layer up. A harness link left pointing at the BUNDLED snapshot
|
|
276
|
+
* for a skill this update has just installed for real is owned, so it is
|
|
277
|
+
* `replaced` onto the installed copy. Tested against the installed root
|
|
278
|
+
* alone it read as "someone else's link" and was skipped — and the user who
|
|
279
|
+
* deliberately ran `sources update` would have kept running the shipped copy.
|
|
280
|
+
*
|
|
281
|
+
* The plan is derived from the layer-1 plan rather than from a filesystem
|
|
282
|
+
* re-read, because at planning time the layer-1 writes have not happened yet:
|
|
283
|
+
* `surviving` is the post-update truth, and reading the disk would see the
|
|
284
|
+
* pre-update state. That is also why a surviving name always targets
|
|
285
|
+
* `roots[0]`: layer 1 is about to hold it, whether or not it does now.
|
|
286
|
+
*
|
|
287
|
+
* A RETIRED NAME IS NOT AUTOMATICALLY DEBRIS any more. The fallback roots
|
|
288
|
+
* (today: the bundled snapshot) are NOT written by this update, so their
|
|
289
|
+
* contents ARE readable at plan time — and a skill the packages dropped that
|
|
290
|
+
* the shipped snapshot still carries is still discoverable, still listed by
|
|
291
|
+
* `skill list`, and so still belongs in the harness directory. Such a link is
|
|
292
|
+
* RETARGETED onto the surviving root rather than pruned; only a name no root
|
|
293
|
+
* provides at all is deleted.
|
|
294
|
+
*
|
|
295
|
+
* @param dirs - The EXISTING global link directories.
|
|
296
|
+
* @param roots - The global scope's source roots in PRECEDENCE order.
|
|
297
|
+
* `roots[0]` is the installed root (layer 1) every surviving name targets;
|
|
298
|
+
* the rest are fallbacks a retired name may still resolve in. The whole set
|
|
299
|
+
* is the ownership set.
|
|
300
|
+
* @param surviving - Folder names layer 1 will hold after this update.
|
|
301
|
+
* @param retired - Folder names layer 1 is pruning in this update.
|
|
302
|
+
* @returns The layer-2 actions, for the plan and its effects.
|
|
303
|
+
* @note Impure — lstats each candidate link path, and stats the fallback roots
|
|
304
|
+
* for each retired name.
|
|
305
|
+
*/
|
|
306
|
+
export function planHarnessSkillLinks(
|
|
307
|
+
dirs: readonly { dir: string; name: string }[],
|
|
308
|
+
roots: readonly string[],
|
|
309
|
+
surviving: readonly string[],
|
|
310
|
+
retired: readonly string[],
|
|
311
|
+
): HarnessLinkAction[] {
|
|
312
|
+
const dest = resolve(roots[0] as string);
|
|
313
|
+
/** The first FALLBACK root that still holds `folderName`, if any. */
|
|
314
|
+
const fallback = (folderName: string): string | undefined =>
|
|
315
|
+
roots
|
|
316
|
+
.slice(1)
|
|
317
|
+
.map((root) => resolve(root, folderName))
|
|
318
|
+
.find((candidate) => existsSync(candidate));
|
|
319
|
+
const out: HarnessLinkAction[] = [];
|
|
320
|
+
for (const { dir, name } of dirs) {
|
|
321
|
+
for (const folderName of surviving) {
|
|
322
|
+
const linkPath = resolve(dir, folderName);
|
|
323
|
+
const target = resolve(dest, folderName);
|
|
324
|
+
const state = linkState(linkPath);
|
|
325
|
+
if (state.kind === "other") continue; // A hand-placed entry — never ours.
|
|
326
|
+
if (state.kind === "absent") {
|
|
327
|
+
out.push({
|
|
328
|
+
target,
|
|
329
|
+
linkPath,
|
|
330
|
+
action: "created",
|
|
331
|
+
folderName,
|
|
332
|
+
dirName: name,
|
|
333
|
+
});
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (resolve(dir, state.target) === target) continue; // Already correct.
|
|
337
|
+
// A symlink pointing outside every scope root is the user's own link into
|
|
338
|
+
// their own checkout. Unplanned does not make it ours to replace.
|
|
339
|
+
if (!withinAnyRoot(roots, linkPath, state.target)) continue;
|
|
340
|
+
out.push({
|
|
341
|
+
target,
|
|
342
|
+
linkPath,
|
|
343
|
+
action: "replaced",
|
|
344
|
+
folderName,
|
|
345
|
+
dirName: name,
|
|
346
|
+
previousTarget: state.target,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
for (const folderName of retired) {
|
|
350
|
+
const linkPath = resolve(dir, folderName);
|
|
351
|
+
const state = linkState(linkPath);
|
|
352
|
+
if (state.kind !== "symlink") continue;
|
|
353
|
+
if (!withinAnyRoot(roots, linkPath, state.target)) continue;
|
|
354
|
+
const surviving = fallback(folderName);
|
|
355
|
+
if (surviving !== undefined) {
|
|
356
|
+
// Dropped upstream, still shipped: retarget rather than delete. Already
|
|
357
|
+
// correct is nothing to do.
|
|
358
|
+
if (resolve(dir, state.target) === surviving) continue;
|
|
359
|
+
out.push({
|
|
360
|
+
target: surviving,
|
|
361
|
+
linkPath,
|
|
362
|
+
action: "replaced",
|
|
363
|
+
folderName,
|
|
364
|
+
dirName: name,
|
|
365
|
+
previousTarget: state.target,
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
out.push({
|
|
370
|
+
target: resolve(dir, state.target),
|
|
371
|
+
linkPath,
|
|
372
|
+
action: "pruned",
|
|
373
|
+
folderName,
|
|
374
|
+
dirName: name,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
@@ -49,8 +49,12 @@ import {
|
|
|
49
49
|
redactUrl,
|
|
50
50
|
resolvePackage,
|
|
51
51
|
} from "../../kernel/runtime/refs/index.js";
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// The global scope's EXISTING link directories, from the module that owns the
|
|
53
|
+
// scope covenant — so `sources update` cannot drift away from what
|
|
54
|
+
// `setup skills --global` links, or widen the scope on its own.
|
|
55
|
+
import { existingGlobalSkillDirs } from "../setup/operations/setupSkills.js";
|
|
56
|
+
import { globalSkillRoots, installedSkillsDir } from "../skill/discover.js";
|
|
57
|
+
import { planHarnessSkillLinks, planSkillInstall } from "./installSkills.js";
|
|
54
58
|
import type { SourcesUpdateData } from "./types.js";
|
|
55
59
|
|
|
56
60
|
/** Generic-core prefixes; config `prefixes` merge over them (config wins). */
|
|
@@ -343,6 +347,45 @@ export async function buildUpdateTask(
|
|
|
343
347
|
`Removing ${staleLinks.length} skill link(s) no longer provided by any package`,
|
|
344
348
|
);
|
|
345
349
|
|
|
350
|
+
// Converge the GLOBAL scope (layer 2). Layer 1 above is the installed root;
|
|
351
|
+
// the harness directories link AT it, and until now nothing here touched
|
|
352
|
+
// them — a new pack skill reached no harness directory until the user
|
|
353
|
+
// separately ran `setup skills`, and a dropped one left a dangling link that
|
|
354
|
+
// nothing would ever clear. That two-step was the reported bug.
|
|
355
|
+
//
|
|
356
|
+
// CONVERGE-ONLY, and this is a policy line, not an optimisation:
|
|
357
|
+
// `existingGlobalSkillDirs` returns only directories that ALREADY EXIST, and
|
|
358
|
+
// nothing below composes a `mkdir`. `sources update` is `mcp: {expose:true}`
|
|
359
|
+
// with `mutates: true` and `needsNetwork: true`, so creating directories here
|
|
360
|
+
// would mean an agent, over MCP, after a network fetch, conjuring
|
|
361
|
+
// `~/.claude/skills` into being and opting the user into linking they never
|
|
362
|
+
// asked for. Refresh what is there; never bring a directory into existence.
|
|
363
|
+
//
|
|
364
|
+
// Ownership is the SAME `withinAnyRoot` test `setup skills` applies, over the
|
|
365
|
+
// SAME root set (`globalSkillRoots`), so a real directory, a still-resolving
|
|
366
|
+
// link into the user's own checkout, and a `<root>-backup/foo` sibling are all
|
|
367
|
+
// left exactly as found — while a link still pointing at the BUNDLED snapshot
|
|
368
|
+
// for a skill this update just installed is ours, and is moved onto it.
|
|
369
|
+
const harnessPlan = planHarnessSkillLinks(
|
|
370
|
+
await existingGlobalSkillDirs(runtime.cwd),
|
|
371
|
+
globalSkillRoots(),
|
|
372
|
+
skillPlan
|
|
373
|
+
.filter((link) => link.action !== "pruned")
|
|
374
|
+
.map((link) => link.folderName),
|
|
375
|
+
staleLinks.map((link) => link.folderName),
|
|
376
|
+
);
|
|
377
|
+
const harnessLinks = harnessPlan.filter((link) => link.action !== "pruned");
|
|
378
|
+
const harnessStale = harnessPlan.filter((link) => link.action === "pruned");
|
|
379
|
+
if (harnessLinks.length > 0)
|
|
380
|
+
report?.(
|
|
381
|
+
`Linking ${harnessLinks.length} skill(s) into your harness folders`,
|
|
382
|
+
);
|
|
383
|
+
if (harnessStale.length > 0)
|
|
384
|
+
report?.(`Removing ${harnessStale.length} stale harness skill link(s)`);
|
|
385
|
+
if (verbose)
|
|
386
|
+
for (const link of harnessPlan)
|
|
387
|
+
report?.(` ${link.action} ${link.folderName} in ${link.dirName}`);
|
|
388
|
+
|
|
346
389
|
report?.(`Pointing ${runtime.cwd} at pack ${built.contentHash.slice(0, 12)}`);
|
|
347
390
|
return gen(function* () {
|
|
348
391
|
yield* $(writeFile(path, built.contentHash, { undo }));
|
|
@@ -376,6 +419,39 @@ export async function buildUpdateTask(
|
|
|
376
419
|
}),
|
|
377
420
|
);
|
|
378
421
|
}
|
|
422
|
+
// Layer 2, in the same Task and so under the same `--undo`. Still no
|
|
423
|
+
// `mkdir`: every directory here was proven to exist at plan time, and
|
|
424
|
+
// creating one is the thing converge-only exists to forbid.
|
|
425
|
+
for (const link of harnessLinks) {
|
|
426
|
+
// A `created` link undoes by deletion — absent IS the state it replaced.
|
|
427
|
+
// A `replaced` one does not: the path held a link, and the delete above
|
|
428
|
+
// carries no undo of its own, so deleting what the symlink created would
|
|
429
|
+
// leave the path ABSENT rather than pointing where it pointed before.
|
|
430
|
+
// Its undo is therefore the same delete-then-relink shape the prunes
|
|
431
|
+
// below use, against the target detection recorded — and it clears the
|
|
432
|
+
// path first because undo collection MOCKS forward effects, so the link
|
|
433
|
+
// may still be there and `fs.symlink` refuses an existing path.
|
|
434
|
+
const previous = link.previousTarget;
|
|
435
|
+
const undoLink =
|
|
436
|
+
link.action === "replaced" && previous !== undefined
|
|
437
|
+
? gen(function* () {
|
|
438
|
+
yield* $(deleteFile(link.linkPath));
|
|
439
|
+
yield* $(symlink(previous, link.linkPath));
|
|
440
|
+
})
|
|
441
|
+
: deleteFile(link.linkPath);
|
|
442
|
+
if (link.action === "replaced") yield* $(deleteFile(link.linkPath));
|
|
443
|
+
yield* $(symlink(link.target, link.linkPath, { undo: undoLink }));
|
|
444
|
+
}
|
|
445
|
+
for (const stale of harnessStale) {
|
|
446
|
+
yield* $(
|
|
447
|
+
deleteFile(stale.linkPath, {
|
|
448
|
+
undo: gen(function* () {
|
|
449
|
+
yield* $(deleteFile(stale.linkPath));
|
|
450
|
+
yield* $(symlink(stale.target, stale.linkPath));
|
|
451
|
+
}),
|
|
452
|
+
}),
|
|
453
|
+
);
|
|
454
|
+
}
|
|
379
455
|
return data;
|
|
380
456
|
});
|
|
381
457
|
}
|
|
@@ -714,14 +714,25 @@ describe("sources update — installs package skills (U10)", () => {
|
|
|
714
714
|
return pkg;
|
|
715
715
|
}
|
|
716
716
|
|
|
717
|
+
let savedHome: string | undefined;
|
|
718
|
+
let home: string;
|
|
719
|
+
|
|
717
720
|
beforeEach(() => {
|
|
718
721
|
savedDataHome = process.env.XDG_DATA_HOME;
|
|
719
722
|
dataHome = tmp("pragma-datahome-");
|
|
720
723
|
process.env.XDG_DATA_HOME = dataHome;
|
|
724
|
+
// HOME is isolated because an update now converges the GLOBAL scope, and
|
|
725
|
+
// the global scope's link directories live under it. A test that read the
|
|
726
|
+
// real home would be writing into the developer's own harness folders.
|
|
727
|
+
savedHome = process.env.HOME;
|
|
728
|
+
home = tmp("pragma-home-");
|
|
729
|
+
process.env.HOME = home;
|
|
721
730
|
});
|
|
722
731
|
afterEach(() => {
|
|
723
732
|
if (savedDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
724
733
|
else process.env.XDG_DATA_HOME = savedDataHome;
|
|
734
|
+
if (savedHome === undefined) delete process.env.HOME;
|
|
735
|
+
else process.env.HOME = savedHome;
|
|
725
736
|
});
|
|
726
737
|
|
|
727
738
|
it("symlinks a package skill into the installed root so `skill list` finds it", async () => {
|
|
@@ -920,6 +931,223 @@ describe("sources update — installs package skills (U10)", () => {
|
|
|
920
931
|
|
|
921
932
|
expect(readdirSync(join(dataHome, "pragma", "skills"))).toEqual(["foo"]);
|
|
922
933
|
});
|
|
934
|
+
|
|
935
|
+
it("sees a package that ships `skills/<name>` as a SYMLINK", async () => {
|
|
936
|
+
// `lstat().isDirectory()` reports the LINK, not the directory behind it, so
|
|
937
|
+
// a pnpm / monorepo / `file:` layout contributed nothing at all, silently.
|
|
938
|
+
const real = tmp("pragma-realskill-");
|
|
939
|
+
mkdirSync(join(real, "linked-skill"), { recursive: true });
|
|
940
|
+
writeFileSync(
|
|
941
|
+
join(real, "linked-skill", "SKILL.md"),
|
|
942
|
+
"---\nname: linked-skill\ndescription: Behind a link.\n---\n",
|
|
943
|
+
);
|
|
944
|
+
const pkg = filePackage();
|
|
945
|
+
mkdirSync(join(pkg, "skills"), { recursive: true });
|
|
946
|
+
symlinkSync(
|
|
947
|
+
join(real, "linked-skill"),
|
|
948
|
+
join(pkg, "skills", "linked-skill"),
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
const cwd = tmp("pragma-proj-");
|
|
952
|
+
await runTask(
|
|
953
|
+
await buildUpdateTask(
|
|
954
|
+
runtimeFor(cwd, [{ name: "pkg-a", source: `file://${pkg}` }]),
|
|
955
|
+
),
|
|
956
|
+
);
|
|
957
|
+
expect(discoverSkills(cwd).map((s) => s.name)).toContain("linked-skill");
|
|
958
|
+
});
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
describe("sources update — converges the global scope (converge-only)", () => {
|
|
962
|
+
let savedDataHome: string | undefined;
|
|
963
|
+
let savedHome: string | undefined;
|
|
964
|
+
let dataHome: string;
|
|
965
|
+
let home: string;
|
|
966
|
+
|
|
967
|
+
/** A local package that also ships `skills/<name>/SKILL.md`. */
|
|
968
|
+
function skillPackage(...skillNames: string[]): string {
|
|
969
|
+
const pkg = filePackage();
|
|
970
|
+
for (const skillName of skillNames) {
|
|
971
|
+
const skillDir = join(pkg, "skills", skillName);
|
|
972
|
+
mkdirSync(skillDir, { recursive: true });
|
|
973
|
+
writeFileSync(
|
|
974
|
+
join(skillDir, "SKILL.md"),
|
|
975
|
+
`---\nname: ${skillName}\ndescription: From a package.\n---\nBody.`,
|
|
976
|
+
);
|
|
977
|
+
}
|
|
978
|
+
return pkg;
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
beforeEach(() => {
|
|
982
|
+
savedDataHome = process.env.XDG_DATA_HOME;
|
|
983
|
+
savedHome = process.env.HOME;
|
|
984
|
+
dataHome = tmp("pragma-datahome-");
|
|
985
|
+
home = tmp("pragma-home-");
|
|
986
|
+
process.env.XDG_DATA_HOME = dataHome;
|
|
987
|
+
process.env.HOME = home;
|
|
988
|
+
});
|
|
989
|
+
afterEach(() => {
|
|
990
|
+
if (savedDataHome === undefined) delete process.env.XDG_DATA_HOME;
|
|
991
|
+
else process.env.XDG_DATA_HOME = savedDataHome;
|
|
992
|
+
if (savedHome === undefined) delete process.env.HOME;
|
|
993
|
+
else process.env.HOME = savedHome;
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
/** The installed root (layer 1). */
|
|
997
|
+
const installedRoot = (): string => join(dataHome, "pragma", "skills");
|
|
998
|
+
/** The cross-client harness dir (layer 2), created so it is convergeable. */
|
|
999
|
+
const harnessDir = (): string => {
|
|
1000
|
+
const dir = join(home, ".agents", "skills");
|
|
1001
|
+
mkdirSync(dir, { recursive: true });
|
|
1002
|
+
return dir;
|
|
1003
|
+
};
|
|
1004
|
+
/** Retire a skill from the package IN PLACE — how a pack upgrade drops one. */
|
|
1005
|
+
const dropSkill = (pkg: string, name: string): void => {
|
|
1006
|
+
rmSync(join(pkg, "skills", name), { recursive: true, force: true });
|
|
1007
|
+
};
|
|
1008
|
+
const update = async (pkg: string, cwd: string): Promise<void> => {
|
|
1009
|
+
await runTask(
|
|
1010
|
+
await buildUpdateTask(
|
|
1011
|
+
runtimeFor(cwd, [{ name: "design-system", source: `file://${pkg}` }]),
|
|
1012
|
+
),
|
|
1013
|
+
);
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
it("a NEW pack skill reaches the harness folder in one step", async () => {
|
|
1017
|
+
// The two-step: `sources update` wrote layer 1 and stopped, so a new pack
|
|
1018
|
+
// skill appeared in no harness directory until the user separately ran
|
|
1019
|
+
// `setup skills`.
|
|
1020
|
+
const dir = harnessDir();
|
|
1021
|
+
await update(skillPackage("added"), tmp("pragma-proj-"));
|
|
1022
|
+
expect(readlinkSync(join(dir, "added"))).toBe(
|
|
1023
|
+
join(installedRoot(), "added"),
|
|
1024
|
+
);
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it("a DROPPED pack skill has its harness link removed (the reported case)", async () => {
|
|
1028
|
+
const dir = harnessDir();
|
|
1029
|
+
const cwd = tmp("pragma-proj-");
|
|
1030
|
+
const pkg = skillPackage("kept", "dropped");
|
|
1031
|
+
// 0.1.2 shipped both...
|
|
1032
|
+
await update(pkg, cwd);
|
|
1033
|
+
expect(existsSync(join(dir, "dropped"))).toBe(true);
|
|
1034
|
+
// ...0.2.0 drops one, in place, so the layer-1 link now dangles and its
|
|
1035
|
+
// prune fires. Layer 2 must follow it.
|
|
1036
|
+
dropSkill(pkg, "dropped");
|
|
1037
|
+
await update(pkg, cwd);
|
|
1038
|
+
// `existsSync` FOLLOWS a link, so a dangling one false-negatives — lstat.
|
|
1039
|
+
expect(() => lstatSync(join(dir, "dropped"))).toThrow();
|
|
1040
|
+
expect(readlinkSync(join(dir, "kept"))).toBe(join(installedRoot(), "kept"));
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
it("CONVERGE-ONLY: a machine that never ran `setup skills` gets no new dirs", async () => {
|
|
1044
|
+
// The operator ruling, and the reason this is safe to expose over MCP:
|
|
1045
|
+
// `sources update` is `mutates: true` AND `expose: true`, so an agent must
|
|
1046
|
+
// not be able to make it conjure `~/.claude/skills` into existence.
|
|
1047
|
+
await update(skillPackage("added"), tmp("pragma-proj-"));
|
|
1048
|
+
expect(existsSync(join(home, ".agents"))).toBe(false);
|
|
1049
|
+
expect(existsSync(join(home, ".claude"))).toBe(false);
|
|
1050
|
+
// Layer 1 is still installed — only the scope convergence stood down.
|
|
1051
|
+
expect(existsSync(join(installedRoot(), "added"))).toBe(true);
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
it("SAFETY: a real dir, a resolving user link and a `-backup` sibling all survive", async () => {
|
|
1055
|
+
const dir = harnessDir();
|
|
1056
|
+
const cwd = tmp("pragma-proj-");
|
|
1057
|
+
const pkg = skillPackage("kept", "dropped");
|
|
1058
|
+
await update(pkg, cwd);
|
|
1059
|
+
|
|
1060
|
+
// A hand-placed skill directory, at a name the pack also provides.
|
|
1061
|
+
const real = join(dir, "hand-placed");
|
|
1062
|
+
mkdirSync(real, { recursive: true });
|
|
1063
|
+
writeFileSync(join(real, "SKILL.md"), "hand-placed\n");
|
|
1064
|
+
// Someone's own link into their own checkout.
|
|
1065
|
+
const mine = join(tmp("pragma-mine-"), "mine");
|
|
1066
|
+
mkdirSync(mine, { recursive: true });
|
|
1067
|
+
symlinkSync(mine, join(dir, "mine"));
|
|
1068
|
+
// A link into a SIBLING whose name merely EXTENDS the root's — the old
|
|
1069
|
+
// string-prefix bug classified this as ours and deleted it.
|
|
1070
|
+
const backup = join(`${installedRoot()}-backup`, "dropped");
|
|
1071
|
+
mkdirSync(backup, { recursive: true });
|
|
1072
|
+
rmSync(join(dir, "dropped"));
|
|
1073
|
+
symlinkSync(backup, join(dir, "dropped"));
|
|
1074
|
+
|
|
1075
|
+
dropSkill(pkg, "dropped"); // retired upstream
|
|
1076
|
+
await update(pkg, cwd);
|
|
1077
|
+
|
|
1078
|
+
expect(readFileSync(join(real, "SKILL.md"), "utf-8")).toBe("hand-placed\n");
|
|
1079
|
+
expect(readlinkSync(join(dir, "mine"))).toBe(mine);
|
|
1080
|
+
// Not ours despite sharing a string prefix with the installed root.
|
|
1081
|
+
expect(readlinkSync(join(dir, "dropped"))).toBe(backup);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it("is idempotent — a second update re-links nothing", async () => {
|
|
1085
|
+
const dir = harnessDir();
|
|
1086
|
+
const cwd = tmp("pragma-proj-");
|
|
1087
|
+
const pkg = skillPackage("stable");
|
|
1088
|
+
await update(pkg, cwd);
|
|
1089
|
+
const before = lstatSync(join(dir, "stable")).mtimeMs;
|
|
1090
|
+
await update(pkg, cwd);
|
|
1091
|
+
expect(lstatSync(join(dir, "stable")).mtimeMs).toBe(before);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it("is reversible — undo RESTORES a replaced harness link's old target", async () => {
|
|
1095
|
+
// The `created` case below undoes by deletion, because absent is the state
|
|
1096
|
+
// it replaced. A `replaced` link's is not: the path already held a link,
|
|
1097
|
+
// the forward delete carries no undo, and deleting what the symlink
|
|
1098
|
+
// created therefore left the path ABSENT — a state it was never in. So the
|
|
1099
|
+
// undo must delete-then-relink the target detection recorded.
|
|
1100
|
+
const dir = harnessDir();
|
|
1101
|
+
const cwd = tmp("pragma-proj-");
|
|
1102
|
+
const pkg = skillPackage("kept", "other");
|
|
1103
|
+
await update(pkg, cwd);
|
|
1104
|
+
|
|
1105
|
+
// Drift the harness link for `kept` onto a SIBLING inside the installed
|
|
1106
|
+
// root: still owned (so the next plan is entitled to fix it), wrong target
|
|
1107
|
+
// (so the plan calls it `replaced`).
|
|
1108
|
+
const link = join(dir, "kept");
|
|
1109
|
+
const previous = join(installedRoot(), "other");
|
|
1110
|
+
rmSync(link);
|
|
1111
|
+
symlinkSync(previous, link);
|
|
1112
|
+
|
|
1113
|
+
// Two tasks off the SAME pre-run state: `gen` drives one iterator, so a
|
|
1114
|
+
// task that has been run forward cannot also be walked for its undos, and
|
|
1115
|
+
// re-planning after the forward run would see a converged tree with
|
|
1116
|
+
// nothing left to reverse.
|
|
1117
|
+
const runtime = runtimeFor(cwd, [
|
|
1118
|
+
{ name: "design-system", source: `file://${pkg}` },
|
|
1119
|
+
]);
|
|
1120
|
+
const forward = await buildUpdateTask(runtime);
|
|
1121
|
+
const reverse = await buildUpdateTask(runtime);
|
|
1122
|
+
|
|
1123
|
+
await runTask(forward);
|
|
1124
|
+
expect(readlinkSync(link)).toBe(join(installedRoot(), "kept")); // repointed
|
|
1125
|
+
|
|
1126
|
+
const { runUndo } = await import("@canonical/task/node");
|
|
1127
|
+
await runUndo(reverse);
|
|
1128
|
+
|
|
1129
|
+
// `existsSync` FOLLOWS a link and would false-negative on a dangling one —
|
|
1130
|
+
// and, more to the point here, an existence check alone passes the buggy
|
|
1131
|
+
// shape too. The TARGET is the assertion.
|
|
1132
|
+
expect(lstatSync(link).isSymbolicLink()).toBe(true);
|
|
1133
|
+
expect(readlinkSync(link)).toBe(previous);
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
it("is reversible — undo removes the harness link it created", async () => {
|
|
1137
|
+
const dir = harnessDir();
|
|
1138
|
+
const { runUndo } = await import("@canonical/task/node");
|
|
1139
|
+
await runUndo(
|
|
1140
|
+
await buildUpdateTask(
|
|
1141
|
+
runtimeFor(tmp("pragma-proj-"), [
|
|
1142
|
+
{
|
|
1143
|
+
name: "design-system",
|
|
1144
|
+
source: `file://${skillPackage("added")}`,
|
|
1145
|
+
},
|
|
1146
|
+
]),
|
|
1147
|
+
),
|
|
1148
|
+
);
|
|
1149
|
+
expect(existsSync(join(dir, "added"))).toBe(false);
|
|
1150
|
+
});
|
|
923
1151
|
});
|
|
924
1152
|
|
|
925
1153
|
describe("sources status — entityCount from the manifest (A10)", () => {
|
|
@@ -25,12 +25,12 @@ export const updateVerb: VerbSpec<
|
|
|
25
25
|
> = {
|
|
26
26
|
path: ["sources", "update"],
|
|
27
27
|
summary: "Resolve configured packs and build the local store from them.",
|
|
28
|
-
doc: "Resolves each configured pack (git
|
|
28
|
+
doc: "Resolves each configured pack (git, file, or npm) and builds one local pack from them, which every later run reads without touching the network. Put a commit SHA in a pack source ref to pin it to that revision.",
|
|
29
29
|
params: [
|
|
30
30
|
{
|
|
31
31
|
kind: "boolean",
|
|
32
32
|
name: "skipInvalid",
|
|
33
|
-
doc: "
|
|
33
|
+
doc: "Build from the sources that parse, warning about each one that does not, instead of failing the whole update.",
|
|
34
34
|
},
|
|
35
35
|
],
|
|
36
36
|
output: { formatters: updateFormatters },
|
|
@@ -38,7 +38,7 @@ export const updateVerb: VerbSpec<
|
|
|
38
38
|
{ cmd: `${BIN_NAME} sources update`, note: "resolve and build" },
|
|
39
39
|
{
|
|
40
40
|
cmd: `${BIN_NAME} sources update --skip-invalid`,
|
|
41
|
-
note: "build from the
|
|
41
|
+
note: "build from the sources that parse, and name the ones dropped",
|
|
42
42
|
},
|
|
43
43
|
],
|
|
44
44
|
capability: {
|
|
@@ -257,8 +257,8 @@ describe("surface conformance — capabilities ⊆ covenant (PROTECTED)", () =>
|
|
|
257
257
|
it("emits setup as a mixed self+sub noun (self is a tool, sub-verbs are mcp:false)", () => {
|
|
258
258
|
// The one covenant noun both directly runnable AND with sub-verbs. The self
|
|
259
259
|
// verb is the `setup` tool; the four installers are CLI-only (mcp:false).
|
|
260
|
-
// The
|
|
261
|
-
// flags; completions/lsp are single-
|
|
260
|
+
// The scope-aware verbs (setup/mcp/skills) carry the --scope/--global/--local
|
|
261
|
+
// flags; completions/lsp are single-scope installers with no scope.
|
|
262
262
|
expect(emitted.nouns.setup?.verbs).toEqual([
|
|
263
263
|
{
|
|
264
264
|
v: "setup",
|