@canonical/pragma-cli 0.35.0 → 0.37.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 +24 -20
- package/dist/pragma.conf.js +266 -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/capabilities/hints.js +1 -1
- package/dist/src/capabilities/capabilities/hints.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/create.verb.js +2 -1
- package/dist/src/capabilities/create/create.verb.js.map +1 -1
- package/dist/src/capabilities/create/createSurface.generated.js +23 -1
- package/dist/src/capabilities/create/createSurface.generated.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/checkNodeVersion.js +11 -6
- package/dist/src/capabilities/doctor/checks/checkNodeVersion.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/isSupportedNodeVersion.js +38 -0
- package/dist/src/capabilities/doctor/checks/isSupportedNodeVersion.js.map +1 -0
- 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/setupCompletions.js +6 -2
- package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupConfig.js +4 -2
- package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupGenerator.js +156 -58
- package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupLsp.js +136 -38
- package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupMcp.js +101 -52
- package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -1
- package/dist/src/capabilities/setup/operations/setupSkills.js +329 -49
- 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 +165 -63
- package/dist/src/capabilities/setup/setup.verb.js.map +1 -1
- package/dist/src/capabilities/setup/targets.js +122 -51
- package/dist/src/capabilities/setup/targets.js.map +1 -1
- package/dist/src/capabilities/shared/index.js +7 -6
- 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 +13 -2
- 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 +310 -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 +46 -24
- 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 +42 -9
- 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/runtime/graphpack/embedded/pack.generated.js +2 -3
- package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -1
- package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +1 -1
- package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -1
- package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js +4 -0
- package/dist/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/embedded.js +13 -6
- package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -1
- package/dist/src/kernel/runtime/graphpack/read.js +14 -9
- package/dist/src/kernel/runtime/graphpack/read.js.map +1 -1
- package/dist/src/kernel/runtime/loadSession.js +3 -1
- package/dist/src/kernel/runtime/loadSession.js.map +1 -1
- package/dist/src/kernel/runtime/probe.js +1 -1
- package/dist/src/kernel/runtime/probe.js.map +1 -1
- package/dist/src/kernel/runtime/refs/parseRef.js +17 -1
- package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -1
- package/dist/src/kernel/runtime/refs/resolve.js +9 -3
- package/dist/src/kernel/runtime/refs/resolve.js.map +1 -1
- package/dist/src/kernel/spec/emitReference.js +1 -1
- package/dist/src/kernel/spec/emitReference.js.map +1 -1
- package/package.json +24 -20
- package/pragma.conf.ts +272 -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/capabilities/hints.ts +1 -1
- package/src/capabilities/colophon/colophon.render.ts +1 -2
- package/src/capabilities/colophon/colophon.test.ts +2 -2
- package/src/capabilities/config/collectConfigShow.test.ts +1 -0
- package/src/capabilities/create/byteEquality.test.ts +28 -1
- package/src/capabilities/create/create.test.ts +1 -0
- package/src/capabilities/create/create.verb.ts +2 -1
- package/src/capabilities/create/createGrammar.test.ts +5 -2
- package/src/capabilities/create/createSurface.generated.ts +23 -1
- package/src/capabilities/create/crossCli.subprocess.test.ts +10 -3
- package/src/capabilities/create/mount.ts +3 -3
- package/src/capabilities/distribution.parity.test.ts +4 -0
- package/src/capabilities/doctor/checks/checkNodeVersion.test.ts +49 -0
- package/src/capabilities/doctor/checks/checkNodeVersion.ts +11 -7
- 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/isSupportedNodeVersion.test.ts +111 -0
- package/src/capabilities/doctor/checks/isSupportedNodeVersion.ts +34 -0
- package/src/capabilities/doctor/checks/mcpCommand.test.ts +7 -1
- 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/lazy.test.ts +26 -2
- package/src/capabilities/listLookup.shipped.exec.test.ts +340 -0
- package/src/capabilities/prompt/prompt.render.ts +2 -2
- package/src/capabilities/renderVocabularySync.test.ts +82 -0
- package/src/capabilities/resources/resources.test.ts +38 -2
- 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/setupCompletions.ts +9 -2
- package/src/capabilities/setup/operations/setupConfig.ts +7 -2
- package/src/capabilities/setup/operations/setupGenerator.ts +227 -84
- package/src/capabilities/setup/operations/setupLsp.ts +203 -38
- package/src/capabilities/setup/operations/setupMcp.ts +139 -62
- package/src/capabilities/setup/operations/setupSkills.ts +399 -60
- 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 +1126 -50
- package/src/capabilities/setup/setup.verb.ts +174 -68
- package/src/capabilities/setup/targets.ts +138 -60
- package/src/capabilities/setup/types.ts +10 -18
- package/src/capabilities/shared/index.ts +18 -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 +14 -6
- 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 +14 -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 +19 -3
- package/src/kernel/config/readConfig.test.ts +1 -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 +327 -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 +48 -25
- 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 +44 -8
- package/src/kernel/project/mcp/registerVerb.ts +20 -1
- package/src/kernel/render/prefixes.test.ts +2 -0
- 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/runtime/graphpack/embedded/pack.generated.ts +2 -3
- package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
- package/src/kernel/runtime/graphpack/embedded/pack.manifest.generated.ts +3 -0
- package/src/kernel/runtime/graphpack/embedded.ts +14 -6
- package/src/kernel/runtime/graphpack/graphpack.test.ts +31 -5
- package/src/kernel/runtime/graphpack/read.ts +14 -12
- package/src/kernel/runtime/loadSession.ts +3 -1
- package/src/kernel/runtime/probe.ts +1 -1
- package/src/kernel/runtime/refs/parseRef.test.ts +49 -1
- package/src/kernel/runtime/refs/parseRef.ts +25 -1
- package/src/kernel/runtime/refs/resolve.ts +9 -3
- package/src/kernel/runtime/types.ts +17 -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/src/testing/helpers/runCli.test.ts +44 -0
- package/src/testing/helpers/runCli.ts +27 -3
- 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
|
@@ -1,28 +1,62 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `setup skills` — symlink discovered skills into harness skill directories.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* SCOPE-MATCHED: the scope picks the source root AND the link destination, and
|
|
5
5
|
* the two always match.
|
|
6
6
|
*
|
|
7
7
|
* - global (the default): INSTALLED skills (`$XDG_DATA_HOME/<bin>/skills`, where
|
|
8
|
-
* `sources update` puts package skills)
|
|
9
|
-
*
|
|
8
|
+
* `sources update` puts package skills) AND the BUNDLED snapshot this package
|
|
9
|
+
* ships (`bundled-skills/`, last in precedence) link into user-level harness
|
|
10
|
+
* skill directories — the cross-client `~/.agents/skills`, plus the user-level
|
|
10
11
|
* directory of each detected harness whose user-level location is verified.
|
|
12
|
+
* The global scope is therefore a root SET, and every rule below that used to
|
|
13
|
+
* read one `sourceRoot` ranges over it: what a skill resolves to (first-seen
|
|
14
|
+
* wins, so an installed skill beats the shipped copy), what this command
|
|
15
|
+
* OWNS, and what its stale sweep is entitled to touch.
|
|
11
16
|
* - project (`--local`): PROJECT skills (`<cwd>/.<bin>/skills`) link into the
|
|
12
17
|
* project's harness directories — the checked-in, team-shared arrangement.
|
|
13
18
|
*
|
|
14
|
-
* Cross-
|
|
19
|
+
* Cross-scope linking is what this replaces: linking installed skills into a
|
|
15
20
|
* repository's directories leaks machine state into a checkout, and linking a
|
|
16
21
|
* repository's skills into the home directory applies one project's skills
|
|
17
22
|
* machine-wide. Neither is ever what the user asked for.
|
|
18
23
|
*
|
|
24
|
+
* A BUNDLED SKILL IS LINKED, NOT COPIED, and that is a decision about who owns
|
|
25
|
+
* the target directory. Every other link this command writes points into a
|
|
26
|
+
* directory pragma controls (`$XDG_DATA_HOME/<bin>/skills`, or the user's own
|
|
27
|
+
* repository); a bundled one points into the package directory, which the
|
|
28
|
+
* PACKAGE MANAGER owns and may move or delete without telling us — a pnpm or
|
|
29
|
+
* npx layout puts the version in the path, so an upgrade leaves the link
|
|
30
|
+
* dangling. Copying would avoid that and cost more than it saves: `linkState`
|
|
31
|
+
* classifies a real directory as `other`, and this module treats `other` as
|
|
32
|
+
* hand-placed and never touches it. So a copied skill could never be updated,
|
|
33
|
+
* never be retired, and never be removed by `--undo` — pragma would be writing
|
|
34
|
+
* permanent, silently stale directories into `~/.claude/skills`.
|
|
35
|
+
*
|
|
36
|
+
* A dangling link, by contrast, the reconcile ALREADY repairs, and this was
|
|
37
|
+
* checked rather than assumed: `detectSkills` classifies a link that does not
|
|
38
|
+
* resolve to the current skill as `replaced` (delete + relink) whatever it
|
|
39
|
+
* points at, so the next `setup skills` — or the layer-2 pass `sources update`
|
|
40
|
+
* runs — moves it onto the new package directory. The one thing that had to
|
|
41
|
+
* change is ownership: `withinRoot` against the installed root alone read a
|
|
42
|
+
* bundled-pointing link as somebody else's and skipped it, so the sweep and the
|
|
43
|
+
* relink both range over the root SET now ({@link withinAnyRoot}). The residual
|
|
44
|
+
* window is between an upgrade and the next reconcile, which is the same window
|
|
45
|
+
* a `sources update` link into a cleared ref cache already has.
|
|
46
|
+
*
|
|
19
47
|
* Split into `detectSkills` (discovery, harness detection, and the per-link
|
|
20
48
|
* create/skip/replace DECISION, all against the real filesystem up front, so
|
|
21
49
|
* the plan and the recap reflect true state) and pure compose bodies performing
|
|
22
50
|
* only the symlink/delete effects the dry-run interpreter mocks.
|
|
23
51
|
*/
|
|
24
52
|
|
|
25
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
existsSync,
|
|
55
|
+
lstatSync,
|
|
56
|
+
readdirSync,
|
|
57
|
+
readlinkSync,
|
|
58
|
+
statSync,
|
|
59
|
+
} from "node:fs";
|
|
26
60
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
27
61
|
import {
|
|
28
62
|
deleteFile,
|
|
@@ -31,8 +65,9 @@ import {
|
|
|
31
65
|
symlink,
|
|
32
66
|
type Task,
|
|
33
67
|
} from "@canonical/task";
|
|
68
|
+
import { BIN_NAME } from "../../../constants.js";
|
|
34
69
|
import type { PragmaRuntime } from "../../../kernel/runtime/index.js";
|
|
35
|
-
import type {
|
|
70
|
+
import type { Scope } from "../types.js";
|
|
36
71
|
|
|
37
72
|
/** Cross-client skill directory, shared across all harnesses. */
|
|
38
73
|
const CROSS_CLIENT_DIR = ".agents/skills";
|
|
@@ -40,7 +75,7 @@ const CROSS_CLIENT_DIR = ".agents/skills";
|
|
|
40
75
|
/**
|
|
41
76
|
* The harnesses whose USER-LEVEL skills directory is verified against the
|
|
42
77
|
* harness's own documentation, and therefore safe to link into on the global
|
|
43
|
-
*
|
|
78
|
+
* scope. A harness absent from this set simply contributes no global link
|
|
44
79
|
* directory: an invented `~/.<something>/skills` would be a directory nothing
|
|
45
80
|
* reads, and creating it would be litter dressed up as configuration.
|
|
46
81
|
*
|
|
@@ -60,7 +95,7 @@ export interface SymlinkAction {
|
|
|
60
95
|
readonly action: "created" | "skipped" | "replaced";
|
|
61
96
|
readonly harnessName: string;
|
|
62
97
|
/**
|
|
63
|
-
* Whether this link path currently holds a symlink INTO the
|
|
98
|
+
* Whether this link path currently holds a symlink INTO the scope's skill
|
|
64
99
|
* root — the ownership test removal turns on. A link pointing anywhere else
|
|
65
100
|
* is the user's and is never this command's to delete, and a real (non-link)
|
|
66
101
|
* entry is never touched at all.
|
|
@@ -78,19 +113,45 @@ export interface SymlinkAction {
|
|
|
78
113
|
}
|
|
79
114
|
|
|
80
115
|
/**
|
|
81
|
-
* The detected skill-linking plan for ONE
|
|
116
|
+
* The detected skill-linking plan for ONE scope: the source root, the target
|
|
82
117
|
* directories, the decided per-link actions, and the counts the plan row
|
|
83
|
-
* reports. `available` is false when the
|
|
118
|
+
* reports. `available` is false when the scope's root holds no skills.
|
|
84
119
|
*/
|
|
85
120
|
export interface SkillsDetection {
|
|
86
|
-
readonly
|
|
87
|
-
/**
|
|
121
|
+
readonly scope: Scope;
|
|
122
|
+
/**
|
|
123
|
+
* The scope's source roots, in PRECEDENCE order — the directories skills are
|
|
124
|
+
* discovered from, and the set every ownership test ranges over. One entry
|
|
125
|
+
* for the project scope; installed-then-bundled for the global one.
|
|
126
|
+
*/
|
|
127
|
+
readonly sourceRoots: readonly string[];
|
|
128
|
+
/**
|
|
129
|
+
* The scope's PRIMARY source root — `sourceRoots[0]`, and the one the skip
|
|
130
|
+
* reason and the doctor row name. It is the root a user can DO something
|
|
131
|
+
* about: the project directory they fill by hand, or the installed root
|
|
132
|
+
* `sources update` writes. Naming the bundled snapshot instead would point
|
|
133
|
+
* them at a directory inside their node_modules that they must never edit.
|
|
134
|
+
*/
|
|
88
135
|
readonly sourceRoot: string;
|
|
136
|
+
/**
|
|
137
|
+
* Whether ANY of {@link sourceRoots} exists on disk — deliberately NOT the
|
|
138
|
+
* same question as {@link available}, which asks only whether they hold a
|
|
139
|
+
* skill.
|
|
140
|
+
*
|
|
141
|
+
* The two were one flag, and conflating them is a wipe hazard. A run where
|
|
142
|
+
* `XDG_DATA_HOME` points somewhere else, or where `sources update` has never
|
|
143
|
+
* run, discovers zero skills — and a reconcile keyed on that alone would read
|
|
144
|
+
* every link in `~/.claude/skills` as garbage and delete it. An ABSENT root
|
|
145
|
+
* means this command has NO OPINION about the scope and touches nothing. Only
|
|
146
|
+
* an EXISTING root that no longer holds a given skill is evidence the skill
|
|
147
|
+
* was retired, which is the one thing the sweep is entitled to act on.
|
|
148
|
+
*/
|
|
149
|
+
readonly rootExists: boolean;
|
|
89
150
|
readonly available: boolean;
|
|
90
151
|
readonly targets: readonly { readonly dir: string; readonly name: string }[];
|
|
91
152
|
readonly actions: readonly SymlinkAction[];
|
|
92
153
|
/**
|
|
93
|
-
* Links this
|
|
154
|
+
* Links this scope OWNS that no CURRENT skill accounts for: the source root
|
|
94
155
|
* emptied, or one skill removed upstream. They are not part of the forward
|
|
95
156
|
* plan — there is nothing left to link them to — but they are exactly what
|
|
96
157
|
* removal exists to clear, and deriving removal from the per-skill actions
|
|
@@ -98,7 +159,6 @@ export interface SkillsDetection {
|
|
|
98
159
|
*/
|
|
99
160
|
readonly orphans: readonly SymlinkAction[];
|
|
100
161
|
readonly skillCount: number;
|
|
101
|
-
readonly harnessCount: number;
|
|
102
162
|
readonly warnings: readonly string[];
|
|
103
163
|
}
|
|
104
164
|
|
|
@@ -109,7 +169,7 @@ type LinkState =
|
|
|
109
169
|
| { readonly kind: "other" };
|
|
110
170
|
|
|
111
171
|
/**
|
|
112
|
-
* Whether a link's destination lives INSIDE the
|
|
172
|
+
* Whether a link's destination lives INSIDE the scope's skill root — the
|
|
113
173
|
* ownership test, and the one that decides what `--undo` deletes.
|
|
114
174
|
*
|
|
115
175
|
* A raw `readlink` target may be relative, so it is resolved against the link's
|
|
@@ -121,13 +181,13 @@ type LinkState =
|
|
|
121
181
|
* The root itself is not "inside" it: a link pointing AT the root is not one of
|
|
122
182
|
* the per-skill links this command writes.
|
|
123
183
|
*
|
|
124
|
-
* @param root - The
|
|
184
|
+
* @param root - The scope's skill source root, absolute.
|
|
125
185
|
* @param linkPath - The absolute path of the link being classified.
|
|
126
186
|
* @param rawTarget - The link's destination exactly as `readlink` reported it.
|
|
127
187
|
* @returns Whether the destination is a path under the root.
|
|
128
188
|
* @note Pure — it resolves and compares strings, and reads nothing.
|
|
129
189
|
*/
|
|
130
|
-
function withinRoot(
|
|
190
|
+
export function withinRoot(
|
|
131
191
|
root: string,
|
|
132
192
|
linkPath: string,
|
|
133
193
|
rawTarget: string,
|
|
@@ -142,6 +202,73 @@ function withinRoot(
|
|
|
142
202
|
);
|
|
143
203
|
}
|
|
144
204
|
|
|
205
|
+
/**
|
|
206
|
+
* Whether a link's destination lives inside ANY of the scope's skill roots.
|
|
207
|
+
*
|
|
208
|
+
* The scope ownership test, once the global scope stopped being a single
|
|
209
|
+
* directory. Every caller that used to ask {@link withinRoot} about one path
|
|
210
|
+
* asks this about the set, so a link into the bundled snapshot is as much this
|
|
211
|
+
* command's to maintain as one into the installed root — and a link into
|
|
212
|
+
* neither is still nobody's business but the user's.
|
|
213
|
+
*
|
|
214
|
+
* @param roots - The scope's skill source roots, absolute.
|
|
215
|
+
* @param linkPath - The absolute path of the link being classified.
|
|
216
|
+
* @param rawTarget - The link's destination exactly as `readlink` reported it.
|
|
217
|
+
* @returns Whether the destination is a path under one of the roots.
|
|
218
|
+
* @note Pure — {@link withinRoot} reads nothing, and neither does this.
|
|
219
|
+
*/
|
|
220
|
+
export function withinAnyRoot(
|
|
221
|
+
roots: readonly string[],
|
|
222
|
+
linkPath: string,
|
|
223
|
+
rawTarget: string,
|
|
224
|
+
): boolean {
|
|
225
|
+
return roots.some((root) => withinRoot(root, linkPath, rawTarget));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The directory a release's bundled skills live in, inside the package.
|
|
230
|
+
*
|
|
231
|
+
* Named here because it is the ONE shape that identifies a link as pragma's
|
|
232
|
+
* without asking where the package currently sits.
|
|
233
|
+
*/
|
|
234
|
+
const BUNDLED_DIR_SEGMENT = "bundled-skills";
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Whether this link is pragma's to rewrite or remove.
|
|
238
|
+
*
|
|
239
|
+
* Residence in a current root is not sufficient, in EITHER direction.
|
|
240
|
+
*
|
|
241
|
+
* Too narrow: after an upgrade under a version-stamped layout (pnpm, npx,
|
|
242
|
+
* volta) a link points into `…/pragma-cli@0.34.0/bundled-skills/<name>`, a
|
|
243
|
+
* directory that no longer exists and is inside no current root. Ownership by
|
|
244
|
+
* residence alone reads pragma's own link as somebody else's, so the sweep
|
|
245
|
+
* declines it and the per-skill pass cannot reach it once the skill is gone —
|
|
246
|
+
* the dangling link then survives every reconcile, invisible to setup and to
|
|
247
|
+
* doctor. The path SHAPE still says whose it is.
|
|
248
|
+
*
|
|
249
|
+
* Too wide: the bundled root is present on every install now, so a folder name
|
|
250
|
+
* that collides with a bundled skill is reachable with no user action at all.
|
|
251
|
+
* A user's own `~/.claude/skills/design-auditor -> ~/work/design-auditor`
|
|
252
|
+
* must not become this command's to delete just because a bundled skill shares
|
|
253
|
+
* its name. That is the same rule a hand-placed real directory already gets.
|
|
254
|
+
*
|
|
255
|
+
* @param roots - The scope's source roots.
|
|
256
|
+
* @param linkPath - The link's own path, absolute.
|
|
257
|
+
* @param rawTarget - Its unresolved `readlink` value.
|
|
258
|
+
* @returns Whether pragma created this link.
|
|
259
|
+
* @note Pure — resolves strings, reads nothing.
|
|
260
|
+
*/
|
|
261
|
+
function ownsLink(
|
|
262
|
+
roots: readonly string[],
|
|
263
|
+
linkPath: string,
|
|
264
|
+
rawTarget: string,
|
|
265
|
+
): boolean {
|
|
266
|
+
if (withinAnyRoot(roots, linkPath, rawTarget)) return true;
|
|
267
|
+
return resolve(dirname(linkPath), rawTarget)
|
|
268
|
+
.split(sep)
|
|
269
|
+
.includes(BUNDLED_DIR_SEGMENT);
|
|
270
|
+
}
|
|
271
|
+
|
|
145
272
|
/**
|
|
146
273
|
* Classify a link path WITHOUT following it: absent, a symlink (with its raw
|
|
147
274
|
* target, resolving or not), or a real (non-symlink) entry. `lstat`-based, the
|
|
@@ -168,29 +295,29 @@ function linkState(linkPath: string): LinkState {
|
|
|
168
295
|
}
|
|
169
296
|
|
|
170
297
|
/**
|
|
171
|
-
* The link directories for a
|
|
298
|
+
* The link directories for a scope. Project: every detected harness's
|
|
172
299
|
* project-rooted skills directory plus the cross-client one, exactly as
|
|
173
300
|
* before. Global: the same construction rooted at the user's home directory,
|
|
174
301
|
* but only for the harnesses in {@link VERIFIED_GLOBAL_SKILL_HARNESSES} —
|
|
175
302
|
* plus the cross-client directory, which every client reads by convention.
|
|
176
303
|
*
|
|
177
304
|
* @param detected - The detected harnesses.
|
|
178
|
-
* @param
|
|
179
|
-
* @param root - The
|
|
305
|
+
* @param scope - The scope being set up.
|
|
306
|
+
* @param root - The scope's link root (`cwd` for project, home for global).
|
|
180
307
|
* @returns The deduplicated target directories, first-seen wins.
|
|
181
308
|
*/
|
|
182
309
|
function linkTargets(
|
|
183
310
|
detected: readonly {
|
|
184
311
|
harness: { id: string; name: string; skillsPath: (root: string) => string };
|
|
185
312
|
}[],
|
|
186
|
-
|
|
313
|
+
scope: Scope,
|
|
187
314
|
root: string,
|
|
188
315
|
): { dir: string; name: string }[] {
|
|
189
316
|
const seen = new Set<string>();
|
|
190
317
|
const targets: { dir: string; name: string }[] = [];
|
|
191
318
|
for (const d of detected) {
|
|
192
319
|
if (
|
|
193
|
-
|
|
320
|
+
scope === "global" &&
|
|
194
321
|
!VERIFIED_GLOBAL_SKILL_HARNESSES.has(d.harness.id)
|
|
195
322
|
) {
|
|
196
323
|
continue;
|
|
@@ -206,6 +333,61 @@ function linkTargets(
|
|
|
206
333
|
return targets;
|
|
207
334
|
}
|
|
208
335
|
|
|
336
|
+
/**
|
|
337
|
+
* The GLOBAL scope's link directories that ALREADY EXIST — the converge-only set
|
|
338
|
+
* `sources update` is allowed to refresh.
|
|
339
|
+
*
|
|
340
|
+
* The filter is the whole policy, and it is why this is a separate export
|
|
341
|
+
* rather than `linkTargets` made public. `composeSkills` mkdirs its targets and
|
|
342
|
+
* `linkTargets` unconditionally appends `~/.agents/skills`, so an update that
|
|
343
|
+
* reused them would CREATE harness directories on a machine that never asked
|
|
344
|
+
* for skill linking — and `sources update` is `mcp: { expose: true }` with
|
|
345
|
+
* `mutates: true`, which would mean an agent, over MCP, after a network fetch,
|
|
346
|
+
* conjuring `~/.claude/skills` into existence. Refresh what is there; never
|
|
347
|
+
* bring a directory into being.
|
|
348
|
+
*
|
|
349
|
+
* The scope is not widened by this: `VERIFIED_GLOBAL_SKILL_HARNESSES` and the
|
|
350
|
+
* scope covenant are exactly the ones `setup skills --global` already honours,
|
|
351
|
+
* because this is the same `linkTargets` call it makes.
|
|
352
|
+
*
|
|
353
|
+
* @param cwd - The invocation's working directory (harness detection reads it).
|
|
354
|
+
* @returns The existing global link directories.
|
|
355
|
+
* @note Impure — detects harnesses and stats each candidate directory.
|
|
356
|
+
*/
|
|
357
|
+
export async function existingGlobalSkillDirs(
|
|
358
|
+
cwd: string,
|
|
359
|
+
): Promise<readonly { dir: string; name: string }[]> {
|
|
360
|
+
const [{ detectHarnesses, readPlatformEnv, userHome }, { runTask }] =
|
|
361
|
+
await Promise.all([
|
|
362
|
+
import("@canonical/harnesses"),
|
|
363
|
+
import("@canonical/task/node"),
|
|
364
|
+
]);
|
|
365
|
+
const detected = await runTask(detectHarnesses(cwd));
|
|
366
|
+
const home = userHome(readPlatformEnv());
|
|
367
|
+
return linkTargets(detected, "global", home).filter((t) =>
|
|
368
|
+
rootIsPresent(t.dir),
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Whether the scope's source root exists as a directory.
|
|
374
|
+
*
|
|
375
|
+
* `stat`, not `lstat`: a root the user symlinked into place is still a root.
|
|
376
|
+
* Every failure — absent, unreadable, not a directory — answers false, which is
|
|
377
|
+
* the fail-closed direction: the sweep then declines to act at all.
|
|
378
|
+
*
|
|
379
|
+
* @param root - The scope's skill source root, absolute.
|
|
380
|
+
* @returns Whether it is a directory this run can have an opinion about.
|
|
381
|
+
* @note Impure — stats the real filesystem.
|
|
382
|
+
*/
|
|
383
|
+
function rootIsPresent(root: string): boolean {
|
|
384
|
+
try {
|
|
385
|
+
return statSync(root).isDirectory();
|
|
386
|
+
} catch {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
209
391
|
/**
|
|
210
392
|
* Every owned link sitting in the target directories that the per-skill pass
|
|
211
393
|
* did not already visit.
|
|
@@ -217,17 +399,35 @@ function linkTargets(
|
|
|
217
399
|
* containment test the forward pass applies, so a user's own link is no more
|
|
218
400
|
* removable here than it is there.
|
|
219
401
|
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
402
|
+
* SAFETY GATE: nothing is orphaned when the source root does not EXIST. An
|
|
403
|
+
* absent root is "no opinion", never "everything here is stale" — see
|
|
404
|
+
* {@link SkillsDetection.rootExists}. The containment test below is a pure
|
|
405
|
+
* string test that reads nothing, so it still answers correctly once the link's
|
|
406
|
+
* target is gone; the root's own existence is the separate question of whether
|
|
407
|
+
* this command is entitled to answer at all.
|
|
408
|
+
*
|
|
409
|
+
* THE GATE IS PER ROOT, not per scope. A scope with two roots must not let one
|
|
410
|
+
* of them vouch for the other: if the installed root is deleted (or
|
|
411
|
+
* `XDG_DATA_HOME` points elsewhere for one invocation) while the bundled
|
|
412
|
+
* snapshot — which ships inside the package and so is essentially always there
|
|
413
|
+
* — still exists, a scope-wide "some root exists" gate would hand the sweep every
|
|
414
|
+
* link into the vanished root and call it stale. So `existingRoots` is passed
|
|
415
|
+
* already filtered, and a link is orphanable only when the root it points into
|
|
416
|
+
* is one of them.
|
|
417
|
+
*
|
|
418
|
+
* @param targets - The scope's link directories.
|
|
419
|
+
* @param existingRoots - The scope's source roots that EXIST (empty ⇒ no
|
|
420
|
+
* orphans at all).
|
|
222
421
|
* @param covered - Link paths the per-skill pass already decided.
|
|
223
422
|
* @returns The owned links no current skill accounts for.
|
|
224
423
|
* @note Impure — reads each target directory and lstats its entries.
|
|
225
424
|
*/
|
|
226
425
|
function orphanedLinks(
|
|
227
426
|
targets: readonly { dir: string; name: string }[],
|
|
228
|
-
|
|
427
|
+
existingRoots: readonly string[],
|
|
229
428
|
covered: ReadonlySet<string>,
|
|
230
429
|
): SymlinkAction[] {
|
|
430
|
+
if (existingRoots.length === 0) return [];
|
|
231
431
|
const orphans: SymlinkAction[] = [];
|
|
232
432
|
for (const { dir, name } of targets) {
|
|
233
433
|
let entries: string[];
|
|
@@ -241,7 +441,11 @@ function orphanedLinks(
|
|
|
241
441
|
if (covered.has(linkPath)) continue;
|
|
242
442
|
const state = linkState(linkPath);
|
|
243
443
|
if (state.kind !== "symlink") continue;
|
|
244
|
-
|
|
444
|
+
// `ownsLink`, not `withinAnyRoot`: a link into a REMOVED version-stamped
|
|
445
|
+
// package directory is inside no current root, and once its skill is gone
|
|
446
|
+
// from the release no per-skill action covers the path either. Residence
|
|
447
|
+
// alone would leave it dangling forever.
|
|
448
|
+
if (!ownsLink(existingRoots, linkPath, state.target)) continue;
|
|
245
449
|
orphans.push({
|
|
246
450
|
skillName: entry,
|
|
247
451
|
target: resolve(dirname(linkPath), state.target),
|
|
@@ -257,25 +461,25 @@ function orphanedLinks(
|
|
|
257
461
|
}
|
|
258
462
|
|
|
259
463
|
/**
|
|
260
|
-
* Discover the
|
|
464
|
+
* Discover the scope's skills, detect harnesses, and decide each link action
|
|
261
465
|
* (real filesystem, up front).
|
|
262
466
|
*
|
|
263
|
-
* Target discovery runs even when the
|
|
467
|
+
* Target discovery runs even when the scope holds NO skills: the directories are
|
|
264
468
|
* where this command's own links live, and a removal has to be able to find
|
|
265
469
|
* them after the source root has been emptied.
|
|
266
470
|
*
|
|
267
471
|
* @param rt - The per-invocation runtime.
|
|
268
|
-
* @param
|
|
269
|
-
* @returns The
|
|
472
|
+
* @param scope - Which scope to plan: global (installed skills) or project.
|
|
473
|
+
* @returns The scope's link plan.
|
|
270
474
|
* @note Impure — reads the filesystem (skills + harnesses + existing links).
|
|
271
475
|
*/
|
|
272
476
|
export async function detectSkills(
|
|
273
477
|
rt: PragmaRuntime,
|
|
274
|
-
|
|
478
|
+
scope: Scope,
|
|
275
479
|
): Promise<SkillsDetection> {
|
|
276
480
|
const cwd = rt.cwd;
|
|
277
481
|
const [
|
|
278
|
-
{ discoverSkillsFrom,
|
|
482
|
+
{ discoverSkillsFrom, globalSkillRoots, projectSkillsDir },
|
|
279
483
|
{ detectHarnesses, readPlatformEnv, userHome },
|
|
280
484
|
{ runTask },
|
|
281
485
|
] = await Promise.all([
|
|
@@ -284,12 +488,20 @@ export async function detectSkills(
|
|
|
284
488
|
import("@canonical/task/node"),
|
|
285
489
|
]);
|
|
286
490
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
491
|
+
// The scope picks the root SET; `globalSkillRoots` owns the global scope's
|
|
492
|
+
// order (installed, then bundled) so this module and `sources update` cannot
|
|
493
|
+
// drift on which directories the scope is.
|
|
494
|
+
const sourceRoots =
|
|
495
|
+
scope === "project" ? [projectSkillsDir(cwd)] : globalSkillRoots();
|
|
496
|
+
const sourceRoot = sourceRoots[0] as string;
|
|
497
|
+
const linkRoot = scope === "project" ? cwd : userHome(readPlatformEnv());
|
|
498
|
+
// First-seen wins, so an installed skill shadows the bundled copy of the same
|
|
499
|
+
// name: someone who ran `sources update` links the CURRENT skill.
|
|
500
|
+
const skills = discoverSkillsFrom(sourceRoots);
|
|
501
|
+
const existingRoots = sourceRoots.filter(rootIsPresent);
|
|
502
|
+
const rootExists = existingRoots.length > 0;
|
|
291
503
|
const detected = await runTask(detectHarnesses(cwd));
|
|
292
|
-
const targets = linkTargets(detected,
|
|
504
|
+
const targets = linkTargets(detected, scope, linkRoot);
|
|
293
505
|
|
|
294
506
|
// Decide each action against REAL fs (so the preview is accurate). Dangling
|
|
295
507
|
// or wrong-target symlinks are `replaced` (delete + relink); a real
|
|
@@ -300,12 +512,40 @@ export async function detectSkills(
|
|
|
300
512
|
for (const skill of skills) {
|
|
301
513
|
const linkPath = resolve(dir, skill.folderName);
|
|
302
514
|
const state = linkState(linkPath);
|
|
515
|
+
// A link is already correct when it RESOLVES to the skill, not when its
|
|
516
|
+
// raw `readlink` string equals an absolute path. Comparing the raw target
|
|
517
|
+
// classified every RELATIVE link as `replaced`, so a functionally correct
|
|
518
|
+
// link was torn down and rebuilt on every run and doctor reported it as
|
|
519
|
+
// `N links point elsewhere`.
|
|
520
|
+
const resolvesToSkill =
|
|
521
|
+
state.kind === "symlink" &&
|
|
522
|
+
resolve(dirname(linkPath), state.target) === skill.sourcePath;
|
|
523
|
+
const owned =
|
|
524
|
+
state.kind === "symlink" &&
|
|
525
|
+
ownsLink(sourceRoots, linkPath, state.target);
|
|
526
|
+
// A link is FOREIGN when it resolves to something real that pragma does
|
|
527
|
+
// not own — a user's own `~/.claude/skills/design-auditor ->
|
|
528
|
+
// ~/work/design-auditor`. That is `skipped`, the same answer a
|
|
529
|
+
// hand-placed real directory gets: `composeSkills` composes every
|
|
530
|
+
// non-skipped row, so calling it `replaced` would delete the link. The
|
|
531
|
+
// bundled root is on every install now, so a name collision with a
|
|
532
|
+
// shipped skill is reachable with no user action at all.
|
|
533
|
+
//
|
|
534
|
+
// A DANGLING link is replaced regardless of where it pointed. Nothing
|
|
535
|
+
// depends on a broken link, so deleting it destroys nothing — while
|
|
536
|
+
// leaving it blocks the skill forever and, for pragma's own link into a
|
|
537
|
+
// cleared cache or a replaced package directory, is the repair. An
|
|
538
|
+
// unresolvable target cannot vouch for its owner either way.
|
|
539
|
+
const foreign =
|
|
540
|
+
state.kind === "symlink" &&
|
|
541
|
+
!owned &&
|
|
542
|
+
existsSync(resolve(dirname(linkPath), state.target));
|
|
303
543
|
const action: SymlinkAction["action"] =
|
|
304
544
|
state.kind === "absent"
|
|
305
545
|
? "created"
|
|
306
|
-
:
|
|
546
|
+
: resolvesToSkill
|
|
307
547
|
? "skipped"
|
|
308
|
-
: state.kind === "symlink"
|
|
548
|
+
: state.kind === "symlink" && !foreign
|
|
309
549
|
? "replaced"
|
|
310
550
|
: "skipped";
|
|
311
551
|
actions.push({
|
|
@@ -315,9 +555,7 @@ export async function detectSkills(
|
|
|
315
555
|
action,
|
|
316
556
|
harnessName: name,
|
|
317
557
|
blocked: state.kind === "other",
|
|
318
|
-
owned
|
|
319
|
-
state.kind === "symlink" &&
|
|
320
|
-
withinRoot(sourceRoot, linkPath, state.target),
|
|
558
|
+
owned,
|
|
321
559
|
});
|
|
322
560
|
}
|
|
323
561
|
}
|
|
@@ -329,22 +567,49 @@ export async function detectSkills(
|
|
|
329
567
|
);
|
|
330
568
|
|
|
331
569
|
return {
|
|
332
|
-
|
|
570
|
+
scope,
|
|
571
|
+
sourceRoots,
|
|
333
572
|
sourceRoot,
|
|
573
|
+
rootExists,
|
|
334
574
|
available: skills.length > 0,
|
|
335
575
|
targets,
|
|
336
576
|
actions,
|
|
577
|
+
// Ownership above spans EVERY root (a pure string test that reads nothing,
|
|
578
|
+
// so it still answers after a target is gone); the sweep below spans only
|
|
579
|
+
// the roots that EXIST. Those are two different questions and the split is
|
|
580
|
+
// deliberate — see {@link orphanedLinks}.
|
|
337
581
|
orphans: orphanedLinks(
|
|
338
582
|
targets,
|
|
339
|
-
|
|
583
|
+
existingRoots,
|
|
340
584
|
new Set(actions.map((a) => a.linkPath)),
|
|
341
585
|
),
|
|
342
586
|
skillCount: skills.length,
|
|
343
|
-
harnessCount: targets.length,
|
|
344
587
|
warnings,
|
|
345
588
|
};
|
|
346
589
|
}
|
|
347
590
|
|
|
591
|
+
/**
|
|
592
|
+
* The links a FORWARD run must retire: the ones this scope owns that no current
|
|
593
|
+
* skill accounts for.
|
|
594
|
+
*
|
|
595
|
+
* The forward pass used to read `d.actions` alone, and `d.actions` enumerates
|
|
596
|
+
* CURRENT skills — so once a skill was dropped upstream, the link it left behind
|
|
597
|
+
* was invisible to it. `d.orphans` was computed and then read only by `--undo`,
|
|
598
|
+
* which removes EVERY owned link rather than just the stale ones, so there was
|
|
599
|
+
* no command that reconciled. Forward is where a reconcile belongs: the plan row
|
|
600
|
+
* says so, and re-running the installer is what a user does to converge.
|
|
601
|
+
*
|
|
602
|
+
* Gated on {@link SkillsDetection.rootExists}: the sweep declines entirely when
|
|
603
|
+
* the source root is absent. `d.orphans` is already empty in that case; the
|
|
604
|
+
* guard is restated here so the safety rule is visible at the site that deletes.
|
|
605
|
+
*
|
|
606
|
+
* @param d - The detection gathered up front.
|
|
607
|
+
* @returns The owned links no current skill accounts for.
|
|
608
|
+
*/
|
|
609
|
+
export const staleSkillLinks = (
|
|
610
|
+
d: SkillsDetection,
|
|
611
|
+
): readonly SymlinkAction[] => (d.rootExists ? d.orphans : []);
|
|
612
|
+
|
|
348
613
|
/**
|
|
349
614
|
* Compose the (re)link effects from a detection (builds ABSOLUTE link paths
|
|
350
615
|
* itself).
|
|
@@ -352,15 +617,23 @@ export async function detectSkills(
|
|
|
352
617
|
* Built from re-runnable combinators (NOT a single-use `gen`) because `execute`
|
|
353
618
|
* interprets the task twice (preview + perform). `created` links carry an undo;
|
|
354
619
|
* a `replaced` link is delete-then-relink (idempotent); `skipped` is a no-op —
|
|
355
|
-
* and a detection where every action is `skipped`
|
|
356
|
-
* converged re-run performs zero filesystem mutations.
|
|
620
|
+
* and a detection where every action is `skipped` and nothing is stale composes
|
|
621
|
+
* NOTHING, so a converged re-run performs zero filesystem mutations.
|
|
622
|
+
*
|
|
623
|
+
* A forward run is a RECONCILE, not an append: it also deletes the links this
|
|
624
|
+
* scope owns that no current skill accounts for (see {@link staleSkillLinks}), each
|
|
625
|
+
* carrying its own re-creation as `undo`.
|
|
357
626
|
*
|
|
358
627
|
* @param d - The detection gathered up front.
|
|
359
|
-
* @returns A Task that mkdirs each needed target
|
|
628
|
+
* @returns A Task that mkdirs each needed target, creates/replaces links, and
|
|
629
|
+
* removes the stale ones.
|
|
360
630
|
*/
|
|
361
631
|
export function composeSkills(d: SkillsDetection): Task<void> {
|
|
362
|
-
const pending = d.
|
|
363
|
-
|
|
632
|
+
const pending = d.available
|
|
633
|
+
? d.actions.filter((a) => a.action !== "skipped")
|
|
634
|
+
: [];
|
|
635
|
+
const stale = staleSkillLinks(d);
|
|
636
|
+
if (pending.length === 0 && stale.length === 0) return sequence_([]);
|
|
364
637
|
const dirs = new Set(pending.map((a) => dirname(a.linkPath)));
|
|
365
638
|
const tasks: Task<unknown>[] = [...dirs]
|
|
366
639
|
.sort()
|
|
@@ -377,12 +650,26 @@ export function composeSkills(d: SkillsDetection): Task<void> {
|
|
|
377
650
|
);
|
|
378
651
|
}
|
|
379
652
|
}
|
|
653
|
+
// The undo clears the path before re-linking: the undo interpreter MOCKS the
|
|
654
|
+
// forward effects when collecting, so the delete above may never have run and
|
|
655
|
+
// `fs.symlink` refuses an occupied path. Same shape `sources update`'s own
|
|
656
|
+
// prune undo uses, for the same reason.
|
|
657
|
+
for (const a of stale) {
|
|
658
|
+
tasks.push(
|
|
659
|
+
deleteFile(a.linkPath, {
|
|
660
|
+
undo: sequence_([
|
|
661
|
+
deleteFile(a.linkPath),
|
|
662
|
+
symlink(a.target, a.linkPath),
|
|
663
|
+
]),
|
|
664
|
+
}),
|
|
665
|
+
);
|
|
666
|
+
}
|
|
380
667
|
return sequence_(tasks);
|
|
381
668
|
}
|
|
382
669
|
|
|
383
670
|
/**
|
|
384
|
-
* The links this
|
|
385
|
-
*
|
|
671
|
+
* The links this scope OWNS right now — every existing symlink pointing into the
|
|
672
|
+
* scope's skill root, including dangling ones, ones already correct, and the
|
|
386
673
|
* ones no current skill accounts for. This is what removal acts on, and it is
|
|
387
674
|
* deliberately NOT the forward plan: undoing a freshly composed forward plan on
|
|
388
675
|
* an already-linked tree reversed the mkdirs and removed nothing, while
|
|
@@ -394,17 +681,64 @@ export function composeSkills(d: SkillsDetection): Task<void> {
|
|
|
394
681
|
* for one condition is how a user ends up unsure whether they are looking at one
|
|
395
682
|
* problem or two.
|
|
396
683
|
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
684
|
+
* ABSENT AND EMPTY ARE NOT THE SAME SKIP. `available` is false for both — the
|
|
685
|
+
* root holds no skill either way — but the parenthetical names a filesystem
|
|
686
|
+
* fact, and telling a user their directory "is absent" while they are looking
|
|
687
|
+
* at it is a wrong diagnosis, not a terse one. {@link
|
|
688
|
+
* SkillsDetection.rootExists} is the flag that separates the two states
|
|
689
|
+
* everywhere else in this module, so it separates them here too, and both
|
|
690
|
+
* callers pass it rather than each re-deciding the wording.
|
|
691
|
+
*
|
|
692
|
+
* The GLOBAL sentence names where skills come from, because "no skills
|
|
693
|
+
* installed" on its own is a dead end: a reader has no way to know that skills
|
|
694
|
+
* arrive inside the configured packs rather than inside this CLI. The scope is
|
|
695
|
+
* named as `local project` — the phrase the `--local` flag that selects it
|
|
696
|
+
* points at — never by a type-layer word.
|
|
697
|
+
*
|
|
698
|
+
* @param sourceRoot - The scope's skill source root, already root-relative.
|
|
699
|
+
* @param scope - The scope being reported.
|
|
700
|
+
* @param rootExists - Whether that root exists ({@link
|
|
701
|
+
* SkillsDetection.rootExists}) — an existing root that holds no skill reports
|
|
702
|
+
* as empty, never as absent.
|
|
399
703
|
* @returns The reason line.
|
|
400
704
|
*/
|
|
401
705
|
export const skillsSkipReason = (
|
|
402
706
|
sourceRoot: string,
|
|
403
|
-
|
|
707
|
+
scope: Scope,
|
|
708
|
+
rootExists: boolean,
|
|
404
709
|
): string =>
|
|
405
|
-
|
|
406
|
-
?
|
|
407
|
-
|
|
710
|
+
scope === "project"
|
|
711
|
+
? rootExists
|
|
712
|
+
? `nothing to link — this project holds no skills (${sourceRoot} is empty)`
|
|
713
|
+
: `nothing to link — this project holds no skills (${sourceRoot} does not exist)`
|
|
714
|
+
: `nothing to link — no skills installed yet; they arrive with the packs \`${BIN_NAME} sources update\` builds`;
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* The remedy beneath that skip — {@link skillsSkipReason}'s twin, and authored
|
|
718
|
+
* here for the same reason: the setup row and the doctor row must not word the
|
|
719
|
+
* same finding differently.
|
|
720
|
+
*
|
|
721
|
+
* A skip with no remedy is a dead end. `setup` printed "no skills installed",
|
|
722
|
+
* exited 0 claiming success, and gave the user no next step — while the
|
|
723
|
+
* completions and lsp skips beside it each named an action. The scope decides
|
|
724
|
+
* which action that is, because the scope decides where the skills come from:
|
|
725
|
+
* the global root is filled ONLY by `sources update` (skills ship in the
|
|
726
|
+
* configured packages, not in this CLI), and the local project's is a directory
|
|
727
|
+
* the user fills by hand.
|
|
728
|
+
*
|
|
729
|
+
* It is the IMPERATIVE half of the pair. {@link skillsSkipReason} states the
|
|
730
|
+
* finding and where skills come from; this line is the command to type, so a
|
|
731
|
+
* surface that renders both reads as a fact followed by a next step rather than
|
|
732
|
+
* as the same sentence twice.
|
|
733
|
+
*
|
|
734
|
+
* @param sourceRoot - The scope's skill source root, already root-relative.
|
|
735
|
+
* @param scope - The scope being reported.
|
|
736
|
+
* @returns The remedy line — an action that works on THIS machine now.
|
|
737
|
+
*/
|
|
738
|
+
export const skillsSkipRemedy = (sourceRoot: string, scope: Scope): string =>
|
|
739
|
+
scope === "project"
|
|
740
|
+
? `add a skill at ${sourceRoot}/<name>/SKILL.md, then run this again`
|
|
741
|
+
: `run \`${BIN_NAME} sources update\`, then run this again`;
|
|
408
742
|
|
|
409
743
|
export const ownedSkillLinks = (
|
|
410
744
|
d: SkillsDetection,
|
|
@@ -420,14 +754,19 @@ export const ownedSkillLinks = (
|
|
|
420
754
|
* this command owns, never from what a fresh forward plan would create.
|
|
421
755
|
*
|
|
422
756
|
* @param d - The detection gathered up front.
|
|
757
|
+
* @param undoKey - Correlation key stamped on the reversal(s), echoed on
|
|
758
|
+
* their undo outcomes so the caller can read results back per row.
|
|
423
759
|
* @returns A Task whose undo removes every owned link.
|
|
424
760
|
*/
|
|
425
|
-
export function composeSkillsRemoval(
|
|
761
|
+
export function composeSkillsRemoval(
|
|
762
|
+
d: SkillsDetection,
|
|
763
|
+
undoKey?: string,
|
|
764
|
+
): Task<void> {
|
|
426
765
|
const owned = ownedSkillLinks(d);
|
|
427
766
|
if (owned.length === 0) return sequence_([]);
|
|
428
767
|
return sequence_(
|
|
429
768
|
owned.map((a) =>
|
|
430
|
-
symlink(a.target, a.linkPath, { undo: deleteFile(a.linkPath) }),
|
|
769
|
+
symlink(a.target, a.linkPath, { undo: deleteFile(a.linkPath), undoKey }),
|
|
431
770
|
),
|
|
432
771
|
);
|
|
433
772
|
}
|