@canonical/pragma-cli 0.34.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 +153 -30
- 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/.build-ok +0 -0
- package/dist/package.json +84 -0
- package/dist/pragma.conf.js +1016 -0
- package/dist/pragma.conf.js.map +1 -0
- package/dist/src/bin.js +356 -0
- package/dist/src/bin.js.map +1 -0
- package/dist/src/capabilities/capabilities/capabilities.render.js +70 -0
- package/dist/src/capabilities/capabilities/capabilities.render.js.map +1 -0
- package/dist/src/capabilities/capabilities/capabilities.verb.js +59 -0
- package/dist/src/capabilities/capabilities/capabilities.verb.js.map +1 -0
- package/dist/src/capabilities/capabilities/catalog.js +130 -0
- package/dist/src/capabilities/capabilities/catalog.js.map +1 -0
- package/dist/src/capabilities/capabilities/hints.js +195 -0
- package/dist/src/capabilities/capabilities/hints.js.map +1 -0
- package/dist/src/capabilities/capabilities/index.js +11 -0
- package/dist/src/capabilities/capabilities/index.js.map +1 -0
- package/dist/src/capabilities/capabilities/types.js +11 -0
- package/dist/src/capabilities/capabilities/types.js.map +1 -0
- package/dist/src/capabilities/colophon/collectColophon.js +83 -0
- package/dist/src/capabilities/colophon/collectColophon.js.map +1 -0
- package/dist/src/capabilities/colophon/colophon.render.js +59 -0
- package/dist/src/capabilities/colophon/colophon.render.js.map +1 -0
- package/dist/src/capabilities/colophon/colophon.verb.js +43 -0
- package/dist/src/capabilities/colophon/colophon.verb.js.map +1 -0
- package/dist/src/capabilities/colophon/index.js +5 -0
- package/dist/src/capabilities/colophon/index.js.map +1 -0
- package/dist/src/capabilities/colophon/markdownTerminal.js +90 -0
- package/dist/src/capabilities/colophon/markdownTerminal.js.map +1 -0
- package/dist/src/capabilities/colophon/types.js +10 -0
- package/dist/src/capabilities/colophon/types.js.map +1 -0
- package/dist/src/capabilities/config/collectConfigShow.js +59 -0
- package/dist/src/capabilities/config/collectConfigShow.js.map +1 -0
- package/dist/src/capabilities/config/field.render.js +22 -0
- package/dist/src/capabilities/config/field.render.js.map +1 -0
- package/dist/src/capabilities/config/fields.js +46 -0
- package/dist/src/capabilities/config/fields.js.map +1 -0
- package/dist/src/capabilities/config/get.verb.js +61 -0
- package/dist/src/capabilities/config/get.verb.js.map +1 -0
- package/dist/src/capabilities/config/index.js +5 -0
- package/dist/src/capabilities/config/index.js.map +1 -0
- package/dist/src/capabilities/config/runField.js +59 -0
- package/dist/src/capabilities/config/runField.js.map +1 -0
- package/dist/src/capabilities/config/runGet.js +28 -0
- package/dist/src/capabilities/config/runGet.js.map +1 -0
- package/dist/src/capabilities/config/runSet.js +53 -0
- package/dist/src/capabilities/config/runSet.js.map +1 -0
- package/dist/src/capabilities/config/set.verb.js +66 -0
- package/dist/src/capabilities/config/set.verb.js.map +1 -0
- package/dist/src/capabilities/config/show.render.js +84 -0
- package/dist/src/capabilities/config/show.render.js.map +1 -0
- package/dist/src/capabilities/config/show.verb.js +44 -0
- package/dist/src/capabilities/config/show.verb.js.map +1 -0
- package/dist/src/capabilities/config/types.js +5 -0
- package/dist/src/capabilities/config/types.js.map +1 -0
- package/dist/src/capabilities/config/unset.verb.js +49 -0
- package/dist/src/capabilities/config/unset.verb.js.map +1 -0
- package/dist/src/capabilities/create/cliProjection.js +195 -0
- package/dist/src/capabilities/create/cliProjection.js.map +1 -0
- package/dist/src/capabilities/create/constants.js +47 -0
- package/dist/src/capabilities/create/constants.js.map +1 -0
- package/dist/src/capabilities/create/create.render.js +100 -0
- package/dist/src/capabilities/create/create.render.js.map +1 -0
- package/dist/src/capabilities/create/create.verb.js +438 -0
- package/dist/src/capabilities/create/create.verb.js.map +1 -0
- package/dist/src/capabilities/create/createHelp.js +47 -0
- package/dist/src/capabilities/create/createHelp.js.map +1 -0
- package/dist/src/capabilities/create/createSurface.generated.js +394 -0
- package/dist/src/capabilities/create/createSurface.generated.js.map +1 -0
- package/dist/src/capabilities/create/generatorToVerbSpec.js +106 -0
- package/dist/src/capabilities/create/generatorToVerbSpec.js.map +1 -0
- package/dist/src/capabilities/create/index.js +18 -0
- package/dist/src/capabilities/create/index.js.map +1 -0
- package/dist/src/capabilities/create/messages.js +16 -0
- package/dist/src/capabilities/create/messages.js.map +1 -0
- package/dist/src/capabilities/create/mount.js +294 -0
- package/dist/src/capabilities/create/mount.js.map +1 -0
- package/dist/src/capabilities/create/pathJail.js +65 -0
- package/dist/src/capabilities/create/pathJail.js.map +1 -0
- package/dist/src/capabilities/create/pickGenerator.js +53 -0
- package/dist/src/capabilities/create/pickGenerator.js.map +1 -0
- package/dist/src/capabilities/create/types.js +2 -0
- package/dist/src/capabilities/create/types.js.map +1 -0
- package/dist/src/capabilities/distribution.js +40 -0
- package/dist/src/capabilities/distribution.js.map +1 -0
- package/dist/src/capabilities/doctor/checks/checkKeStore.js +40 -0
- package/dist/src/capabilities/doctor/checks/checkKeStore.js.map +1 -0
- package/dist/src/capabilities/doctor/checks/checkNodeVersion.js +21 -0
- package/dist/src/capabilities/doctor/checks/checkNodeVersion.js.map +1 -0
- package/dist/src/capabilities/doctor/checks/checkPackageRefs.js +148 -0
- package/dist/src/capabilities/doctor/checks/checkPackageRefs.js.map +1 -0
- package/dist/src/capabilities/doctor/checks/checkPragmaVersion.js +19 -0
- package/dist/src/capabilities/doctor/checks/checkPragmaVersion.js.map +1 -0
- package/dist/src/capabilities/doctor/checks/checkShellCompletions.js +199 -0
- package/dist/src/capabilities/doctor/checks/checkShellCompletions.js.map +1 -0
- 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 +27 -0
- package/dist/src/capabilities/doctor/checks/index.js.map +1 -0
- package/dist/src/capabilities/doctor/checks/mcpCommand.js +57 -0
- package/dist/src/capabilities/doctor/checks/mcpCommand.js.map +1 -0
- package/dist/src/capabilities/doctor/checks/targetHealth.js +349 -0
- package/dist/src/capabilities/doctor/checks/targetHealth.js.map +1 -0
- package/dist/src/capabilities/doctor/doctor.render.js +164 -0
- package/dist/src/capabilities/doctor/doctor.render.js.map +1 -0
- package/dist/src/capabilities/doctor/doctor.verb.js +49 -0
- package/dist/src/capabilities/doctor/doctor.verb.js.map +1 -0
- package/dist/src/capabilities/doctor/index.js +5 -0
- package/dist/src/capabilities/doctor/index.js.map +1 -0
- package/dist/src/capabilities/doctor/runChecks.js +86 -0
- package/dist/src/capabilities/doctor/runChecks.js.map +1 -0
- package/dist/src/capabilities/doctor/types.js +5 -0
- package/dist/src/capabilities/doctor/types.js.map +1 -0
- package/dist/src/capabilities/graph/index.js +19 -0
- package/dist/src/capabilities/graph/index.js.map +1 -0
- package/dist/src/capabilities/graph/inspect.render.js +76 -0
- package/dist/src/capabilities/graph/inspect.render.js.map +1 -0
- package/dist/src/capabilities/graph/inspect.verb.js +53 -0
- package/dist/src/capabilities/graph/inspect.verb.js.map +1 -0
- package/dist/src/capabilities/graph/query.render.js +73 -0
- package/dist/src/capabilities/graph/query.render.js.map +1 -0
- package/dist/src/capabilities/graph/query.verb.js +49 -0
- package/dist/src/capabilities/graph/query.verb.js.map +1 -0
- package/dist/src/capabilities/graph/runQuery.js +41 -0
- package/dist/src/capabilities/graph/runQuery.js.map +1 -0
- package/dist/src/capabilities/index.js +56 -0
- package/dist/src/capabilities/index.js.map +1 -0
- package/dist/src/capabilities/info/collectInfo.js +72 -0
- package/dist/src/capabilities/info/collectInfo.js.map +1 -0
- package/dist/src/capabilities/info/index.js +5 -0
- package/dist/src/capabilities/info/index.js.map +1 -0
- package/dist/src/capabilities/info/info.render.js +57 -0
- package/dist/src/capabilities/info/info.render.js.map +1 -0
- package/dist/src/capabilities/info/info.verb.js +42 -0
- package/dist/src/capabilities/info/info.verb.js.map +1 -0
- package/dist/src/capabilities/info/types.js +5 -0
- package/dist/src/capabilities/info/types.js.map +1 -0
- package/dist/src/capabilities/meta/complete.verb.js +43 -0
- package/dist/src/capabilities/meta/complete.verb.js.map +1 -0
- package/dist/src/capabilities/meta/index.js +13 -0
- package/dist/src/capabilities/meta/index.js.map +1 -0
- package/dist/src/capabilities/meta/mcp.verb.js +34 -0
- package/dist/src/capabilities/meta/mcp.verb.js.map +1 -0
- package/dist/src/capabilities/ontology/index.js +21 -0
- package/dist/src/capabilities/ontology/index.js.map +1 -0
- package/dist/src/capabilities/ontology/queries.js +123 -0
- package/dist/src/capabilities/ontology/queries.js.map +1 -0
- package/dist/src/capabilities/ontology/render.js +66 -0
- package/dist/src/capabilities/ontology/render.js.map +1 -0
- package/dist/src/capabilities/ontology/verbs.js +181 -0
- package/dist/src/capabilities/ontology/verbs.js.map +1 -0
- package/dist/src/capabilities/prompt/index.js +16 -0
- package/dist/src/capabilities/prompt/index.js.map +1 -0
- package/dist/src/capabilities/prompt/prompt.render.js +78 -0
- package/dist/src/capabilities/prompt/prompt.render.js.map +1 -0
- package/dist/src/capabilities/prompt/types.js +10 -0
- package/dist/src/capabilities/prompt/types.js.map +1 -0
- package/dist/src/capabilities/prompt/verbs.js +93 -0
- package/dist/src/capabilities/prompt/verbs.js.map +1 -0
- package/dist/src/capabilities/resources/index.js +10 -0
- package/dist/src/capabilities/resources/index.js.map +1 -0
- package/dist/src/capabilities/resources/provider.js +423 -0
- package/dist/src/capabilities/resources/provider.js.map +1 -0
- package/dist/src/capabilities/setup/buildPlan.js +172 -0
- package/dist/src/capabilities/setup/buildPlan.js.map +1 -0
- package/dist/src/capabilities/setup/index.js +5 -0
- package/dist/src/capabilities/setup/index.js.map +1 -0
- package/dist/src/capabilities/setup/mode.js +53 -0
- package/dist/src/capabilities/setup/mode.js.map +1 -0
- package/dist/src/capabilities/setup/operations/index.js +39 -0
- package/dist/src/capabilities/setup/operations/index.js.map +1 -0
- package/dist/src/capabilities/setup/operations/setupCompletions.js +160 -0
- package/dist/src/capabilities/setup/operations/setupCompletions.js.map +1 -0
- package/dist/src/capabilities/setup/operations/setupConfig.js +101 -0
- package/dist/src/capabilities/setup/operations/setupConfig.js.map +1 -0
- package/dist/src/capabilities/setup/operations/setupGenerator.js +390 -0
- package/dist/src/capabilities/setup/operations/setupGenerator.js.map +1 -0
- package/dist/src/capabilities/setup/operations/setupLsp.js +329 -0
- package/dist/src/capabilities/setup/operations/setupLsp.js.map +1 -0
- package/dist/src/capabilities/setup/operations/setupMcp.js +253 -0
- package/dist/src/capabilities/setup/operations/setupMcp.js.map +1 -0
- package/dist/src/capabilities/setup/operations/setupSkills.js +594 -0
- package/dist/src/capabilities/setup/operations/setupSkills.js.map +1 -0
- package/dist/src/capabilities/setup/plan.js +128 -0
- package/dist/src/capabilities/setup/plan.js.map +1 -0
- package/dist/src/capabilities/setup/plan.render.js +271 -0
- package/dist/src/capabilities/setup/plan.render.js.map +1 -0
- package/dist/src/capabilities/setup/setup.render.js +54 -0
- package/dist/src/capabilities/setup/setup.render.js.map +1 -0
- package/dist/src/capabilities/setup/setup.verb.js +387 -0
- package/dist/src/capabilities/setup/setup.verb.js.map +1 -0
- package/dist/src/capabilities/setup/shell.js +196 -0
- package/dist/src/capabilities/setup/shell.js.map +1 -0
- package/dist/src/capabilities/setup/targets.js +328 -0
- package/dist/src/capabilities/setup/targets.js.map +1 -0
- package/dist/src/capabilities/setup/types.js +5 -0
- package/dist/src/capabilities/setup/types.js.map +1 -0
- package/dist/src/capabilities/shared/assertExecOk.js +137 -0
- package/dist/src/capabilities/shared/assertExecOk.js.map +1 -0
- package/dist/src/capabilities/shared/index.js +27 -0
- package/dist/src/capabilities/shared/index.js.map +1 -0
- package/dist/src/capabilities/shared/packageManager.js +349 -0
- package/dist/src/capabilities/shared/packageManager.js.map +1 -0
- package/dist/src/capabilities/shared/registry.js +55 -0
- package/dist/src/capabilities/shared/registry.js.map +1 -0
- package/dist/src/capabilities/skill/discover.js +281 -0
- package/dist/src/capabilities/skill/discover.js.map +1 -0
- package/dist/src/capabilities/skill/index.js +10 -0
- package/dist/src/capabilities/skill/index.js.map +1 -0
- package/dist/src/capabilities/skill/render.js +49 -0
- package/dist/src/capabilities/skill/render.js.map +1 -0
- package/dist/src/capabilities/skill/verbs.js +83 -0
- package/dist/src/capabilities/skill/verbs.js.map +1 -0
- package/dist/src/capabilities/sources/collectStatus.js +103 -0
- package/dist/src/capabilities/sources/collectStatus.js.map +1 -0
- package/dist/src/capabilities/sources/index.js +13 -0
- package/dist/src/capabilities/sources/index.js.map +1 -0
- package/dist/src/capabilities/sources/installSkills.js +309 -0
- package/dist/src/capabilities/sources/installSkills.js.map +1 -0
- package/dist/src/capabilities/sources/runUpdate.js +460 -0
- package/dist/src/capabilities/sources/runUpdate.js.map +1 -0
- package/dist/src/capabilities/sources/status.render.js +64 -0
- package/dist/src/capabilities/sources/status.render.js.map +1 -0
- package/dist/src/capabilities/sources/status.verb.js +40 -0
- package/dist/src/capabilities/sources/status.verb.js.map +1 -0
- package/dist/src/capabilities/sources/types.js +6 -0
- package/dist/src/capabilities/sources/types.js.map +1 -0
- package/dist/src/capabilities/sources/update.render.js +34 -0
- package/dist/src/capabilities/sources/update.render.js.map +1 -0
- package/dist/src/capabilities/sources/update.verb.js +55 -0
- package/dist/src/capabilities/sources/update.verb.js.map +1 -0
- package/dist/src/capabilities/upgrade/index.js +5 -0
- package/dist/src/capabilities/upgrade/index.js.map +1 -0
- package/dist/src/capabilities/upgrade/runUpgrade.js +135 -0
- package/dist/src/capabilities/upgrade/runUpgrade.js.map +1 -0
- package/dist/src/capabilities/upgrade/types.js +5 -0
- package/dist/src/capabilities/upgrade/types.js.map +1 -0
- package/dist/src/capabilities/upgrade/upgrade.render.js +52 -0
- package/dist/src/capabilities/upgrade/upgrade.render.js.map +1 -0
- package/dist/src/capabilities/upgrade/upgrade.verb.js +52 -0
- package/dist/src/capabilities/upgrade/upgrade.verb.js.map +1 -0
- package/dist/src/capabilities/version/index.js +10 -0
- package/dist/src/capabilities/version/index.js.map +1 -0
- package/dist/src/capabilities/version/version.verb.js +36 -0
- package/dist/src/capabilities/version/version.verb.js.map +1 -0
- package/dist/src/constants.js +87 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/index.js +12 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/kernel/completion/complete.js +66 -0
- package/dist/src/kernel/completion/complete.js.map +1 -0
- package/dist/src/kernel/completion/emitScripts.js +92 -0
- package/dist/src/kernel/completion/emitScripts.js.map +1 -0
- package/dist/src/kernel/completion/entitySource.js +285 -0
- package/dist/src/kernel/completion/entitySource.js.map +1 -0
- package/dist/src/kernel/completion/index.js +14 -0
- package/dist/src/kernel/completion/index.js.map +1 -0
- package/dist/src/kernel/completion/model.js +301 -0
- package/dist/src/kernel/completion/model.js.map +1 -0
- package/dist/src/kernel/completion/parse.js +253 -0
- package/dist/src/kernel/completion/parse.js.map +1 -0
- package/dist/src/kernel/completion/rank.js +101 -0
- package/dist/src/kernel/completion/rank.js.map +1 -0
- package/dist/src/kernel/completion/resolve.js +100 -0
- package/dist/src/kernel/completion/resolve.js.map +1 -0
- package/dist/src/kernel/completion/templates/bash.js +209 -0
- package/dist/src/kernel/completion/templates/bash.js.map +1 -0
- package/dist/src/kernel/completion/templates/fish.js +214 -0
- package/dist/src/kernel/completion/templates/fish.js.map +1 -0
- package/dist/src/kernel/completion/templates/index.js +23 -0
- package/dist/src/kernel/completion/templates/index.js.map +1 -0
- package/dist/src/kernel/completion/templates/shared.js +98 -0
- package/dist/src/kernel/completion/templates/shared.js.map +1 -0
- package/dist/src/kernel/completion/templates/zsh.js +201 -0
- package/dist/src/kernel/completion/templates/zsh.js.map +1 -0
- package/dist/src/kernel/completion/types.js +13 -0
- package/dist/src/kernel/completion/types.js.map +1 -0
- package/dist/src/kernel/config/defaults.js +17 -0
- package/dist/src/kernel/config/defaults.js.map +1 -0
- package/dist/src/kernel/config/evaluateProjectConfig.js +126 -0
- package/dist/src/kernel/config/evaluateProjectConfig.js.map +1 -0
- package/dist/src/kernel/config/findProjectConfig.js +43 -0
- package/dist/src/kernel/config/findProjectConfig.js.map +1 -0
- package/dist/src/kernel/config/firstRun.js +34 -0
- package/dist/src/kernel/config/firstRun.js.map +1 -0
- package/dist/src/kernel/config/globalConfig.js +79 -0
- package/dist/src/kernel/config/globalConfig.js.map +1 -0
- package/dist/src/kernel/config/index.js +16 -0
- package/dist/src/kernel/config/index.js.map +1 -0
- package/dist/src/kernel/config/paths.js +35 -0
- package/dist/src/kernel/config/paths.js.map +1 -0
- package/dist/src/kernel/config/readConfig.js +78 -0
- package/dist/src/kernel/config/readConfig.js.map +1 -0
- package/dist/src/kernel/config/schema.js +130 -0
- package/dist/src/kernel/config/schema.js.map +1 -0
- package/dist/src/kernel/config/types.js +19 -0
- package/dist/src/kernel/config/types.js.map +1 -0
- package/dist/src/kernel/config/writeConfigField.js +59 -0
- package/dist/src/kernel/config/writeConfigField.js.map +1 -0
- package/dist/src/kernel/copy.js +43 -0
- package/dist/src/kernel/copy.js.map +1 -0
- package/dist/src/kernel/error/PragmaError.js +149 -0
- package/dist/src/kernel/error/PragmaError.js.map +1 -0
- package/dist/src/kernel/error/constants.js +40 -0
- package/dist/src/kernel/error/constants.js.map +1 -0
- package/dist/src/kernel/error/fromTaskError.js +96 -0
- package/dist/src/kernel/error/fromTaskError.js.map +1 -0
- package/dist/src/kernel/error/index.js +13 -0
- package/dist/src/kernel/error/index.js.map +1 -0
- package/dist/src/kernel/error/recovery.js +52 -0
- package/dist/src/kernel/error/recovery.js.map +1 -0
- package/dist/src/kernel/error/renderError.js +115 -0
- package/dist/src/kernel/error/renderError.js.map +1 -0
- package/dist/src/kernel/error/serialize.js +25 -0
- package/dist/src/kernel/error/serialize.js.map +1 -0
- package/dist/src/kernel/error/types.js +9 -0
- package/dist/src/kernel/error/types.js.map +1 -0
- package/dist/src/kernel/index.js +34 -0
- package/dist/src/kernel/index.js.map +1 -0
- package/dist/src/kernel/interactivity.js +68 -0
- package/dist/src/kernel/interactivity.js.map +1 -0
- package/dist/src/kernel/packs/collect.js +187 -0
- package/dist/src/kernel/packs/collect.js.map +1 -0
- package/dist/src/kernel/packs/compile.js +304 -0
- package/dist/src/kernel/packs/compile.js.map +1 -0
- package/dist/src/kernel/packs/disclosure.js +76 -0
- package/dist/src/kernel/packs/disclosure.js.map +1 -0
- package/dist/src/kernel/packs/glob.js +38 -0
- package/dist/src/kernel/packs/glob.js.map +1 -0
- package/dist/src/kernel/packs/graphql/buildLookupDocument.js +258 -0
- package/dist/src/kernel/packs/graphql/buildLookupDocument.js.map +1 -0
- package/dist/src/kernel/packs/graphql/fetchGraphqlLookup.js +157 -0
- package/dist/src/kernel/packs/graphql/fetchGraphqlLookup.js.map +1 -0
- package/dist/src/kernel/packs/graphql/index.js +20 -0
- package/dist/src/kernel/packs/graphql/index.js.map +1 -0
- package/dist/src/kernel/packs/graphql/nameMap.js +63 -0
- package/dist/src/kernel/packs/graphql/nameMap.js.map +1 -0
- package/dist/src/kernel/packs/index.js +34 -0
- package/dist/src/kernel/packs/index.js.map +1 -0
- package/dist/src/kernel/packs/iri.js +110 -0
- package/dist/src/kernel/packs/iri.js.map +1 -0
- package/dist/src/kernel/packs/renderPack.js +129 -0
- package/dist/src/kernel/packs/renderPack.js.map +1 -0
- package/dist/src/kernel/packs/resolveEntity.js +274 -0
- package/dist/src/kernel/packs/resolveEntity.js.map +1 -0
- package/dist/src/kernel/packs/runBodies.js +140 -0
- package/dist/src/kernel/packs/runBodies.js.map +1 -0
- package/dist/src/kernel/packs/sample.js +56 -0
- package/dist/src/kernel/packs/sample.js.map +1 -0
- package/dist/src/kernel/packs/schema.js +421 -0
- package/dist/src/kernel/packs/schema.js.map +1 -0
- package/dist/src/kernel/packs/sparql/applyFilters.js +167 -0
- package/dist/src/kernel/packs/sparql/applyFilters.js.map +1 -0
- package/dist/src/kernel/packs/sparql/applySearch.js +25 -0
- package/dist/src/kernel/packs/sparql/applySearch.js.map +1 -0
- package/dist/src/kernel/packs/sparql/buildLookupQuery.js +480 -0
- package/dist/src/kernel/packs/sparql/buildLookupQuery.js.map +1 -0
- package/dist/src/kernel/packs/sparql/escape.js +21 -0
- package/dist/src/kernel/packs/sparql/escape.js.map +1 -0
- package/dist/src/kernel/packs/sparql/index.js +31 -0
- package/dist/src/kernel/packs/sparql/index.js.map +1 -0
- package/dist/src/kernel/packs/sparql/runSelect.js +101 -0
- package/dist/src/kernel/packs/sparql/runSelect.js.map +1 -0
- package/dist/src/kernel/packs/types.js +30 -0
- package/dist/src/kernel/packs/types.js.map +1 -0
- package/dist/src/kernel/packs/uniqueness.js +30 -0
- package/dist/src/kernel/packs/uniqueness.js.map +1 -0
- package/dist/src/kernel/project/cli/buildProgram.js +263 -0
- package/dist/src/kernel/project/cli/buildProgram.js.map +1 -0
- package/dist/src/kernel/project/cli/constants.js +27 -0
- package/dist/src/kernel/project/cli/constants.js.map +1 -0
- package/dist/src/kernel/project/cli/dispatch.js +420 -0
- package/dist/src/kernel/project/cli/dispatch.js.map +1 -0
- package/dist/src/kernel/project/cli/exitCodes.js +66 -0
- package/dist/src/kernel/project/cli/exitCodes.js.map +1 -0
- package/dist/src/kernel/project/cli/globalFlags.js +219 -0
- package/dist/src/kernel/project/cli/globalFlags.js.map +1 -0
- package/dist/src/kernel/project/cli/helpFormat.js +58 -0
- package/dist/src/kernel/project/cli/helpFormat.js.map +1 -0
- package/dist/src/kernel/project/cli/index.js +14 -0
- package/dist/src/kernel/project/cli/index.js.map +1 -0
- package/dist/src/kernel/project/cli/rootHelp.js +227 -0
- package/dist/src/kernel/project/cli/rootHelp.js.map +1 -0
- package/dist/src/kernel/project/cli/suggest.js +75 -0
- package/dist/src/kernel/project/cli/suggest.js.map +1 -0
- package/dist/src/kernel/project/cli/suggestNames.js +96 -0
- package/dist/src/kernel/project/cli/suggestNames.js.map +1 -0
- package/dist/src/kernel/project/cli/verbHelp.js +131 -0
- package/dist/src/kernel/project/cli/verbHelp.js.map +1 -0
- package/dist/src/kernel/project/mcp/buildServer.js +67 -0
- package/dist/src/kernel/project/mcp/buildServer.js.map +1 -0
- package/dist/src/kernel/project/mcp/envelope.js +37 -0
- package/dist/src/kernel/project/mcp/envelope.js.map +1 -0
- package/dist/src/kernel/project/mcp/index.js +8 -0
- package/dist/src/kernel/project/mcp/index.js.map +1 -0
- package/dist/src/kernel/project/mcp/instructions.js +44 -0
- package/dist/src/kernel/project/mcp/instructions.js.map +1 -0
- package/dist/src/kernel/project/mcp/mcpError.js +42 -0
- package/dist/src/kernel/project/mcp/mcpError.js.map +1 -0
- package/dist/src/kernel/project/mcp/prompts/index.js +36 -0
- package/dist/src/kernel/project/mcp/prompts/index.js.map +1 -0
- package/dist/src/kernel/project/mcp/prompts/provider.js +98 -0
- package/dist/src/kernel/project/mcp/prompts/provider.js.map +1 -0
- package/dist/src/kernel/project/mcp/prompts/source.js +154 -0
- package/dist/src/kernel/project/mcp/prompts/source.js.map +1 -0
- package/dist/src/kernel/project/mcp/registerVerb.js +315 -0
- package/dist/src/kernel/project/mcp/registerVerb.js.map +1 -0
- package/dist/src/kernel/project/mcp/serve.js +35 -0
- package/dist/src/kernel/project/mcp/serve.js.map +1 -0
- package/dist/src/kernel/render/compactUri.js +24 -0
- package/dist/src/kernel/render/compactUri.js.map +1 -0
- package/dist/src/kernel/render/contracts.js +10 -0
- package/dist/src/kernel/render/contracts.js.map +1 -0
- package/dist/src/kernel/render/disclosure.js +34 -0
- package/dist/src/kernel/render/disclosure.js.map +1 -0
- package/dist/src/kernel/render/envelope.js +30 -0
- package/dist/src/kernel/render/envelope.js.map +1 -0
- package/dist/src/kernel/render/formatters.js +27 -0
- package/dist/src/kernel/render/formatters.js.map +1 -0
- package/dist/src/kernel/render/index.js +15 -0
- package/dist/src/kernel/render/index.js.map +1 -0
- package/dist/src/kernel/render/prefixes.js +46 -0
- package/dist/src/kernel/render/prefixes.js.map +1 -0
- package/dist/src/kernel/render/renderers.js +468 -0
- package/dist/src/kernel/render/renderers.js.map +1 -0
- package/dist/src/kernel/render/style.js +105 -0
- package/dist/src/kernel/render/style.js.map +1 -0
- package/dist/src/kernel/render/turtle.js +194 -0
- package/dist/src/kernel/render/turtle.js.map +1 -0
- package/dist/src/kernel/render/vocabulary.js +174 -0
- package/dist/src/kernel/render/vocabulary.js.map +1 -0
- package/dist/src/kernel/render/writeStdout.js +43 -0
- package/dist/src/kernel/render/writeStdout.js.map +1 -0
- package/dist/src/kernel/runtime/boot.js +49 -0
- package/dist/src/kernel/runtime/boot.js.map +1 -0
- package/dist/src/kernel/runtime/facade.js +39 -0
- package/dist/src/kernel/runtime/facade.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/build.js +122 -0
- package/dist/src/kernel/runtime/graphpack/build.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/buildIndex.js +303 -0
- package/dist/src/kernel/runtime/graphpack/buildIndex.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js +6 -0
- package/dist/src/kernel/runtime/graphpack/embedded/pack.generated.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js +4 -0
- package/dist/src/kernel/runtime/graphpack/embedded/pack.index.generated.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/embedded/pack.stories.generated.js +4 -0
- package/dist/src/kernel/runtime/graphpack/embedded/pack.stories.generated.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/embedded.js +82 -0
- package/dist/src/kernel/runtime/graphpack/embedded.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/hash.js +38 -0
- package/dist/src/kernel/runtime/graphpack/hash.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/index.js +11 -0
- package/dist/src/kernel/runtime/graphpack/index.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/manifest.js +145 -0
- package/dist/src/kernel/runtime/graphpack/manifest.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/read.js +92 -0
- package/dist/src/kernel/runtime/graphpack/read.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/schemas.js +70 -0
- package/dist/src/kernel/runtime/graphpack/schemas.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/stories.js +68 -0
- package/dist/src/kernel/runtime/graphpack/stories.js.map +1 -0
- package/dist/src/kernel/runtime/graphpack/types.js +55 -0
- package/dist/src/kernel/runtime/graphpack/types.js.map +1 -0
- package/dist/src/kernel/runtime/index.js +8 -0
- package/dist/src/kernel/runtime/index.js.map +1 -0
- package/dist/src/kernel/runtime/loadSession.js +34 -0
- package/dist/src/kernel/runtime/loadSession.js.map +1 -0
- package/dist/src/kernel/runtime/paths.js +78 -0
- package/dist/src/kernel/runtime/paths.js.map +1 -0
- package/dist/src/kernel/runtime/probe.js +37 -0
- package/dist/src/kernel/runtime/probe.js.map +1 -0
- package/dist/src/kernel/runtime/readEntity.js +363 -0
- package/dist/src/kernel/runtime/readEntity.js.map +1 -0
- package/dist/src/kernel/runtime/refs/gitOps.js +65 -0
- package/dist/src/kernel/runtime/refs/gitOps.js.map +1 -0
- package/dist/src/kernel/runtime/refs/index.js +29 -0
- package/dist/src/kernel/runtime/refs/index.js.map +1 -0
- package/dist/src/kernel/runtime/refs/parseRef.js +70 -0
- package/dist/src/kernel/runtime/refs/parseRef.js.map +1 -0
- package/dist/src/kernel/runtime/refs/resolve.js +332 -0
- package/dist/src/kernel/runtime/refs/resolve.js.map +1 -0
- package/dist/src/kernel/runtime/resolveSources.js +62 -0
- package/dist/src/kernel/runtime/resolveSources.js.map +1 -0
- package/dist/src/kernel/runtime/store.js +75 -0
- package/dist/src/kernel/runtime/store.js.map +1 -0
- package/dist/src/kernel/runtime/storeReadiness.js +51 -0
- package/dist/src/kernel/runtime/storeReadiness.js.map +1 -0
- package/dist/src/kernel/runtime/types.js +11 -0
- package/dist/src/kernel/runtime/types.js.map +1 -0
- package/dist/src/kernel/spec/asVerb.js +21 -0
- package/dist/src/kernel/spec/asVerb.js.map +1 -0
- package/dist/src/kernel/spec/emitReference.js +578 -0
- package/dist/src/kernel/spec/emitReference.js.map +1 -0
- package/dist/src/kernel/spec/emitSurface.js +202 -0
- package/dist/src/kernel/spec/emitSurface.js.map +1 -0
- package/dist/src/kernel/spec/index.js +11 -0
- package/dist/src/kernel/spec/index.js.map +1 -0
- package/dist/src/kernel/spec/surfaceConformance.js +103 -0
- package/dist/src/kernel/spec/surfaceConformance.js.map +1 -0
- package/dist/src/kernel/spec/types.js +14 -0
- package/dist/src/kernel/spec/types.js.map +1 -0
- package/dist/src/kernel/spec/validate.js +110 -0
- package/dist/src/kernel/spec/validate.js.map +1 -0
- package/dist/src/kernel/vocabulary.js +97 -0
- package/dist/src/kernel/vocabulary.js.map +1 -0
- package/dist/src/types.js +11 -0
- package/dist/src/types.js.map +1 -0
- package/package.json +22 -23
- package/pragma.conf.ts +486 -90
- package/src/bin.ts +274 -82
- package/src/capabilities/block.parity.test.ts +29 -12
- package/src/capabilities/block.shipped.exec.test.ts +282 -0
- package/src/capabilities/block.tierRank.exec.test.ts +300 -0
- package/src/capabilities/capabilities/capabilities.render.ts +1 -1
- package/src/capabilities/capabilities/capabilities.verb.ts +1 -1
- package/src/capabilities/capabilities/catalog.ts +3 -3
- package/src/capabilities/capabilities/hints.ts +32 -7
- package/src/capabilities/capabilities/index.ts +1 -1
- package/src/capabilities/colophon/collectColophon.ts +35 -24
- package/src/capabilities/colophon/colophon.render.ts +24 -1
- package/src/capabilities/colophon/colophon.test.ts +42 -21
- package/src/capabilities/colophon/colophon.verb.ts +4 -4
- package/src/capabilities/config/collectConfigShow.test.ts +1 -0
- package/src/capabilities/config/collectConfigShow.ts +3 -3
- package/src/capabilities/config/field.render.ts +4 -5
- package/src/capabilities/config/fields.ts +17 -10
- package/src/capabilities/config/get.verb.ts +70 -0
- package/src/capabilities/config/getUnset.test.ts +264 -0
- package/src/capabilities/config/index.ts +5 -1
- package/src/capabilities/config/runField.ts +40 -22
- package/src/capabilities/config/runGet.ts +34 -0
- package/src/capabilities/config/runSet.ts +35 -9
- package/src/capabilities/config/set.test.ts +48 -14
- package/src/capabilities/config/set.verb.ts +8 -7
- package/src/capabilities/config/show.render.ts +1 -1
- package/src/capabilities/config/show.verb.ts +9 -6
- package/src/capabilities/config/types.ts +18 -4
- package/src/capabilities/config/unset.verb.ts +58 -0
- package/src/capabilities/create/byteEquality.subprocess.test.ts +2 -3
- package/src/capabilities/create/byteEquality.test.ts +25 -11
- package/src/capabilities/create/cliProjection.ts +227 -0
- package/src/capabilities/create/constants.ts +36 -61
- package/src/capabilities/create/create.mcp.test.ts +89 -5
- package/src/capabilities/create/create.render.ts +71 -7
- package/src/capabilities/create/create.test.ts +433 -219
- package/src/capabilities/create/create.verb.ts +297 -328
- package/src/capabilities/create/createGrammar.test.ts +865 -0
- package/src/capabilities/create/createHelp.ts +71 -0
- package/src/capabilities/create/createSurface.generated.ts +400 -0
- package/src/capabilities/create/crossCli.subprocess.test.ts +745 -0
- package/src/capabilities/create/cwdJail.test.ts +24 -5
- package/src/capabilities/create/generatorToVerbSpec.test.ts +186 -0
- package/src/capabilities/create/generatorToVerbSpec.ts +13 -11
- package/src/capabilities/create/index.ts +7 -4
- package/src/capabilities/create/messages.ts +18 -0
- package/src/capabilities/create/mount.ts +391 -0
- package/src/capabilities/create/pathJail.test.ts +90 -0
- package/src/capabilities/create/pathJail.ts +8 -5
- package/src/capabilities/create/pickGenerator.ts +23 -35
- package/src/capabilities/create/shippedCreate.subprocess.test.ts +164 -0
- package/src/capabilities/create/types.ts +20 -0
- package/src/capabilities/distribution.parity.test.ts +4 -0
- package/src/capabilities/distribution.test.ts +3 -1
- package/src/capabilities/distribution.ts +10 -15
- package/src/capabilities/doctor/checks/checkKeStore.ts +7 -5
- package/src/capabilities/doctor/checks/checkPackageRefs.ts +75 -8
- package/src/capabilities/doctor/checks/checkPragmaVersion.ts +2 -2
- package/src/capabilities/doctor/checks/checkShellCompletions.test.ts +76 -29
- package/src/capabilities/doctor/checks/checkShellCompletions.ts +63 -17
- 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 +27 -0
- package/src/capabilities/doctor/checks/mcpCommand.test.ts +100 -0
- package/src/capabilities/doctor/checks/mcpCommand.ts +69 -0
- package/src/capabilities/doctor/checks/targetHealth.ts +444 -0
- package/src/capabilities/doctor/doctor.render.test.ts +125 -38
- package/src/capabilities/doctor/doctor.render.ts +74 -74
- package/src/capabilities/doctor/doctor.test.ts +338 -37
- package/src/capabilities/doctor/doctor.verb.ts +8 -3
- package/src/capabilities/doctor/runChecks.ts +65 -63
- package/src/capabilities/doctor/types.ts +22 -15
- package/src/capabilities/emptyRecovery.test.ts +21 -8
- package/src/capabilities/graph/examples.exec.test.ts +107 -0
- package/src/capabilities/graph/index.ts +1 -1
- package/src/capabilities/graph/inspect.render.ts +70 -22
- package/src/capabilities/graph/inspect.test.ts +260 -5
- package/src/capabilities/graph/inspect.verb.ts +18 -6
- package/src/capabilities/graph/query.render.ts +15 -3
- package/src/capabilities/graph/query.verb.ts +3 -3
- package/src/capabilities/graph/runQuery.ts +2 -3
- package/src/capabilities/index.ts +3 -1
- package/src/capabilities/info/collectInfo.ts +13 -5
- package/src/capabilities/info/info.render.ts +7 -3
- package/src/capabilities/info/info.test.ts +37 -1
- package/src/capabilities/info/info.verb.ts +1 -1
- package/src/capabilities/info/types.ts +10 -2
- package/src/capabilities/lazy.test.ts +32 -0
- package/src/capabilities/meta/complete.verb.ts +1 -1
- package/src/capabilities/meta/index.ts +1 -1
- package/src/capabilities/meta/mcp.verb.ts +10 -8
- package/src/capabilities/ontology/index.ts +15 -11
- package/src/capabilities/ontology/ontology.test.ts +6 -55
- package/src/capabilities/ontology/queries.ts +2 -2
- package/src/capabilities/ontology/render.ts +3 -4
- package/src/capabilities/ontology/verbs.ts +27 -68
- package/src/capabilities/prompt/index.ts +2 -2
- package/src/capabilities/prompt/prompt.render.ts +6 -2
- package/src/capabilities/prompt/types.ts +1 -1
- package/src/capabilities/prompt/verbs.ts +4 -5
- package/src/capabilities/reference.test.ts +63 -0
- package/src/capabilities/renderVocabularySync.test.ts +82 -0
- package/src/capabilities/resources/provider.ts +342 -85
- package/src/capabilities/resources/resources.test.ts +355 -59
- package/src/capabilities/scopeTypeSync.test.ts +29 -15
- package/src/capabilities/setup/buildPlan.ts +240 -0
- package/src/capabilities/setup/index.ts +7 -7
- package/src/capabilities/setup/mode.ts +74 -0
- package/src/capabilities/setup/operations/index.ts +79 -0
- package/src/capabilities/setup/operations/setupCompletions.ts +97 -24
- package/src/capabilities/setup/operations/setupConfig.ts +124 -0
- package/src/capabilities/setup/operations/setupGenerator.ts +478 -334
- package/src/capabilities/setup/operations/setupLsp.ts +420 -67
- package/src/capabilities/setup/operations/setupMcp.ts +238 -127
- package/src/capabilities/setup/operations/setupSkills.ts +667 -105
- package/src/capabilities/setup/plan.render.ts +336 -0
- package/src/capabilities/setup/plan.ts +241 -0
- package/src/capabilities/setup/setup.mcp.correctness.test.ts +432 -0
- package/src/capabilities/setup/setup.render.test.ts +297 -75
- package/src/capabilities/setup/setup.render.ts +45 -77
- package/src/capabilities/setup/setup.test.ts +1819 -225
- package/src/capabilities/setup/setup.verb.ts +288 -78
- package/src/capabilities/setup/shell.test.ts +86 -0
- package/src/capabilities/setup/shell.ts +137 -11
- package/src/capabilities/setup/targets.ts +435 -0
- package/src/capabilities/setup/types.ts +23 -70
- package/src/capabilities/shared/assertExecOk.ts +68 -10
- package/src/capabilities/shared/index.ts +43 -0
- package/src/capabilities/shared/packageManager.test.ts +470 -0
- package/src/capabilities/shared/packageManager.ts +425 -34
- package/src/capabilities/skill/bundledSkills.test.ts +347 -0
- package/src/capabilities/skill/discover.ts +131 -10
- package/src/capabilities/skill/index.ts +1 -1
- package/src/capabilities/skill/render.ts +10 -9
- package/src/capabilities/skill/skill.test.ts +22 -7
- package/src/capabilities/skill/verbs.ts +3 -4
- package/src/capabilities/sources/collectStatus.ts +7 -2
- package/src/capabilities/sources/index.ts +1 -1
- package/src/capabilities/sources/installSkills.ts +265 -10
- package/src/capabilities/sources/runUpdate.ts +131 -23
- package/src/capabilities/sources/sources.test.ts +382 -2
- package/src/capabilities/sources/status.render.ts +1 -1
- package/src/capabilities/sources/status.verb.ts +1 -1
- package/src/capabilities/sources/update.render.ts +1 -1
- package/src/capabilities/sources/update.verb.ts +4 -4
- package/src/capabilities/surface.test.ts +136 -19
- package/src/capabilities/upgrade/runUpgrade.ts +53 -14
- package/src/capabilities/upgrade/types.ts +10 -2
- package/src/capabilities/upgrade/upgrade.render.ts +13 -7
- package/src/capabilities/upgrade/upgrade.test.ts +75 -3
- package/src/capabilities/upgrade/upgrade.verb.ts +1 -1
- package/src/capabilities/version/index.ts +12 -0
- package/src/capabilities/version/version.test.ts +66 -0
- package/src/capabilities/version/version.verb.ts +39 -0
- package/src/constants.ts +36 -1
- package/src/identity.test.ts +57 -29
- package/src/kernel/completion/__snapshots__/emitScripts.test.ts.snap +15 -13
- package/src/kernel/completion/complete.test.ts +12 -2
- package/src/kernel/completion/complete.ts +1 -1
- package/src/kernel/completion/emitScripts.ts +1 -1
- package/src/kernel/completion/entitySource.test.ts +16 -3
- package/src/kernel/completion/entitySource.ts +21 -8
- package/src/kernel/completion/model.test.ts +164 -12
- package/src/kernel/completion/model.ts +94 -14
- package/src/kernel/completion/parse.test.ts +4 -7
- package/src/kernel/completion/parse.ts +15 -0
- package/src/kernel/completion/resolve.test.ts +0 -1
- package/src/kernel/completion/safety.test.ts +50 -35
- package/src/kernel/completion/shellDrive.test.ts +28 -9
- package/src/kernel/completion/templates/bash.ts +7 -2
- package/src/kernel/completion/templates/index.ts +23 -0
- package/src/kernel/completion/types.ts +7 -0
- package/src/kernel/config/defaults.test.ts +27 -3
- package/src/kernel/config/defaults.ts +4 -3
- package/src/kernel/config/evaluateProjectConfig.ts +31 -8
- package/src/kernel/config/firstRun.test.ts +44 -38
- package/src/kernel/config/firstRun.ts +24 -88
- package/src/kernel/config/index.ts +1 -1
- package/src/kernel/config/readConfig.test.ts +6 -3
- package/src/kernel/config/readConfig.ts +1 -1
- package/src/kernel/config/schema.ts +3 -1
- package/src/kernel/config/types.ts +3 -1
- package/src/kernel/copy.test.ts +34 -4
- package/src/kernel/copy.ts +54 -0
- package/src/kernel/error/PragmaError.test.ts +5 -2
- package/src/kernel/error/PragmaError.ts +4 -2
- package/src/kernel/error/constants.ts +7 -5
- package/src/kernel/error/fromTaskError.ts +28 -6
- package/src/kernel/error/recovery.ts +14 -0
- package/src/kernel/error/renderError.ts +42 -1
- package/src/kernel/error/types.ts +21 -1
- package/src/kernel/index.ts +35 -0
- package/src/kernel/interactivity.test.ts +76 -0
- package/src/kernel/interactivity.ts +69 -0
- package/src/kernel/packs/collect.ts +12 -16
- package/src/kernel/packs/compile.test.ts +222 -1
- package/src/kernel/packs/compile.ts +93 -3
- package/src/kernel/packs/disclosure.ts +2 -2
- package/src/kernel/packs/graphql/buildLookupDocument.ts +1 -1
- package/src/kernel/packs/graphql/fetchGraphqlLookup.ts +3 -4
- package/src/kernel/packs/graphql/index.ts +20 -0
- package/src/kernel/packs/index.ts +40 -0
- package/src/kernel/packs/iri.test.ts +73 -0
- package/src/kernel/packs/iri.ts +66 -14
- package/src/kernel/packs/renderPack.ts +25 -6
- package/src/kernel/packs/resolveEntity.test.ts +274 -0
- package/src/kernel/packs/resolveEntity.ts +200 -42
- package/src/kernel/packs/runBodies.ts +86 -22
- package/src/kernel/packs/sample.ts +1 -1
- package/src/kernel/packs/schema.ts +78 -5
- package/src/kernel/packs/sparql/applyFilters.ts +136 -16
- package/src/kernel/packs/sparql/buildLookupQuery.test.ts +290 -0
- package/src/kernel/packs/sparql/buildLookupQuery.ts +367 -17
- package/src/kernel/packs/sparql/index.ts +37 -0
- package/src/kernel/packs/sparql/runSelect.test.ts +1 -1
- package/src/kernel/packs/sparql/runSelect.ts +3 -4
- package/src/kernel/packs/toolDescription.test.ts +77 -4
- package/src/kernel/packs/types.ts +136 -0
- package/src/kernel/packs/uniqueness.ts +1 -1
- package/src/kernel/project/cli/buildProgram.test.ts +218 -12
- package/src/kernel/project/cli/buildProgram.ts +84 -18
- package/src/kernel/project/cli/constants.ts +34 -0
- package/src/kernel/project/cli/dispatch.test.ts +160 -2
- package/src/kernel/project/cli/dispatch.ts +226 -45
- package/src/kernel/project/cli/exitCodes.ts +1 -1
- package/src/kernel/project/cli/globalFlags.test.ts +117 -1
- package/src/kernel/project/cli/globalFlags.ts +134 -31
- package/src/kernel/project/cli/helpFormat.ts +14 -2
- package/src/kernel/project/cli/rootHelp.ts +74 -12
- package/src/kernel/project/cli/suggest.test.ts +33 -1
- package/src/kernel/project/cli/suggest.ts +25 -1
- package/src/kernel/project/cli/verbHelp.test.ts +30 -0
- package/src/kernel/project/cli/verbHelp.ts +23 -7
- package/src/kernel/project/mcp/buildServer.ts +7 -4
- package/src/kernel/project/mcp/envelope.ts +2 -2
- package/src/kernel/project/mcp/instructions.ts +2 -2
- package/src/kernel/project/mcp/mcpError.ts +1 -1
- package/src/kernel/project/mcp/parity.test.ts +5 -2
- package/src/kernel/project/mcp/prompts/index.ts +37 -0
- package/src/kernel/project/mcp/prompts/source.ts +2 -2
- package/src/kernel/project/mcp/registerVerb.ts +55 -10
- package/src/kernel/project/mcp/serve.ts +2 -2
- package/src/kernel/render/__snapshots__/renderers.test.ts.snap +7 -4
- package/src/kernel/render/contracts.ts +21 -0
- package/src/kernel/render/envelope.ts +2 -3
- package/src/kernel/render/formatters.ts +1 -1
- package/src/kernel/render/renderers.test.ts +243 -6
- package/src/kernel/render/renderers.ts +222 -20
- package/src/kernel/render/style.test.ts +75 -24
- package/src/kernel/render/style.ts +59 -6
- package/src/kernel/render/turtle.ts +222 -0
- package/src/kernel/render/vocabulary.ts +276 -0
- package/src/kernel/runtime/graphpack/buildIndex.ts +1 -1
- package/src/kernel/runtime/graphpack/embedded/pack.generated.ts +3 -3
- package/src/kernel/runtime/graphpack/embedded/pack.index.generated.ts +1 -1
- package/src/kernel/runtime/graphpack/embedded.ts +3 -3
- package/src/kernel/runtime/graphpack/manifest.ts +1 -1
- package/src/kernel/runtime/graphpack/read.ts +1 -2
- package/src/kernel/runtime/loadSession.ts +1 -1
- package/src/kernel/runtime/probe.ts +6 -6
- package/src/kernel/runtime/readEntity.ts +472 -28
- package/src/kernel/runtime/refs/index.ts +39 -0
- package/src/kernel/runtime/refs/parseRef.ts +2 -2
- package/src/kernel/runtime/refs/resolve.ts +4 -4
- package/src/kernel/runtime/storeReadiness.ts +1 -2
- package/src/kernel/runtime/types.ts +15 -1
- package/src/kernel/runtime/wasmEmbed.test.ts +37 -39
- package/src/kernel/spec/emitReference.ts +105 -30
- package/src/kernel/spec/emitSurface.ts +48 -6
- package/src/kernel/spec/surfaceConformance.test.ts +4 -2
- package/src/kernel/spec/types.ts +277 -4
- package/src/testing/PARITY_GAPS.ts +9 -3
- package/src/testing/behavioral/docExamples.test.ts +16 -0
- package/src/testing/behavioral/errorMatrix.mcp.test.ts +112 -9
- package/src/testing/behavioral/firstRun.e2e.test.ts +24 -24
- package/src/testing/behavioral/journeys.cli.test.ts +1 -1
- package/src/testing/behavioral/journeys.defaultPack.test.ts +7 -7
- package/src/testing/behavioral/journeys.firstInstall.test.ts +7 -2
- package/src/testing/behavioral/journeys.implementation.test.ts +206 -0
- package/src/testing/behavioral/journeys.interactiveTty.e2e.test.ts +2 -2
- package/src/testing/behavioral/journeys.packageStories.test.ts +10 -7
- package/src/testing/behavioral/mcpServe.e2e.test.ts +57 -2
- package/src/testing/behavioral/mcpSurface.test.ts +1 -1
- package/src/testing/behavioral/rootCli.e2e.test.ts +26 -2
- package/src/testing/eval/__snapshots__/eval.test.ts.snap +26 -1
- package/src/testing/eval/cases/effectful.ts +3 -2
- package/src/testing/eval/cases/readNouns.ts +245 -9
- package/src/testing/fixtures/blockGraph.ts +104 -10
- package/src/testing/fixtures/graph/canonical.ts +182 -13
- package/src/testing/fixtures/graph/defaultPack.ts +1 -1
- package/src/testing/helpers/blankCanonicalRanges.ts +36 -0
- package/src/testing/helpers/projectMcp.ts +32 -6
- package/src/testing/helpers/runCli.ts +35 -18
- package/src/testing/perf/budgets.test.ts +20 -9
- package/src/testing/perf/budgets.ts +58 -9
- package/src/testing/perf/codegen.test.ts +126 -0
- package/src/testing/perf/globalSetup.test.ts +286 -0
- package/src/testing/perf/globalSetup.ts +505 -23
- package/src/testing/perf/measure.test.ts +38 -0
- package/src/testing/perf/measure.ts +23 -4
- package/src/testing/setupXdgIsolation.ts +81 -21
- package/src/testing/tempRoot.globalSetup.ts +122 -0
- package/dist/pragma +0 -0
- package/src/capabilities/create/compiledCreate.subprocess.test.ts +0 -189
- package/src/capabilities/create/templates.embedded.generated.ts +0 -28
- package/src/capabilities/doctor/checks/checkConfigFile.ts +0 -42
- package/src/capabilities/doctor/checks/checkMcpCommands.ts +0 -114
- package/src/capabilities/doctor/checks/checkMcpConfigured.ts +0 -68
- package/src/capabilities/doctor/checks/checkSkillsSymlinked.ts +0 -61
- package/src/capabilities/doctor/checks/deriveBand.test.ts +0 -42
- package/src/capabilities/doctor/checks/deriveBand.ts +0 -28
- package/src/capabilities/shared/bands.ts +0 -16
- package/src/kernel/config/__snapshots__/firstRun.test.ts.snap +0 -11
|
@@ -15,18 +15,23 @@
|
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
existsSync,
|
|
18
|
+
lstatSync,
|
|
18
19
|
mkdirSync,
|
|
19
20
|
mkdtempSync,
|
|
20
21
|
readFileSync,
|
|
22
|
+
readlinkSync,
|
|
21
23
|
rmSync,
|
|
24
|
+
statSync,
|
|
25
|
+
symlinkSync,
|
|
22
26
|
writeFileSync,
|
|
23
27
|
} from "node:fs";
|
|
24
28
|
import { tmpdir } from "node:os";
|
|
25
|
-
import { join } from "node:path";
|
|
29
|
+
import { join, relative, sep } from "node:path";
|
|
26
30
|
import { execute } from "@canonical/summon-core";
|
|
27
|
-
import { dryRun, type Effect, type Task } from "@canonical/task";
|
|
28
|
-
import { runTask } from "@canonical/task/node";
|
|
29
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
31
|
+
import { collectUndos, dryRun, type Effect, type Task } from "@canonical/task";
|
|
32
|
+
import { runTask, runUndo } from "@canonical/task/node";
|
|
33
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
34
|
+
import { BIN_NAME } from "../../constants.js";
|
|
30
35
|
import { emitScripts } from "../../kernel/completion/emitScripts.js";
|
|
31
36
|
import { asPragmaError } from "../../kernel/error/fromTaskError.js";
|
|
32
37
|
import { executeVerb } from "../../kernel/project/cli/dispatch.js";
|
|
@@ -36,10 +41,19 @@ import type { VerbSpec } from "../../kernel/spec/types.js";
|
|
|
36
41
|
import { projectCli } from "../../testing/helpers/projectCli.js";
|
|
37
42
|
import { projectMcp } from "../../testing/helpers/projectMcp.js";
|
|
38
43
|
import { capabilities } from "../index.js";
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
44
|
+
import { detectCompletions } from "./operations/setupCompletions.js";
|
|
45
|
+
import { buildSetupRun, type RowEvent } from "./operations/setupGenerator.js";
|
|
46
|
+
import {
|
|
47
|
+
composeSkills,
|
|
48
|
+
composeSkillsRemoval,
|
|
49
|
+
detectSkills,
|
|
50
|
+
ownedSkillLinks,
|
|
51
|
+
skillsSkipReason,
|
|
52
|
+
skillsSkipRemedy,
|
|
53
|
+
} from "./operations/setupSkills.js";
|
|
54
|
+
import { DRY_RUN_HINT, PREVIEW_HINT } from "./setup.render.js";
|
|
41
55
|
import { setupModule } from "./setup.verb.js";
|
|
42
|
-
import { completionScriptPath } from "./shell.js";
|
|
56
|
+
import { completionScriptPath, detectShell, type ShellId } from "./shell.js";
|
|
43
57
|
|
|
44
58
|
const FLAGS: GlobalFlags = {
|
|
45
59
|
llm: false,
|
|
@@ -63,85 +77,72 @@ const tmp = (prefix: string): string => {
|
|
|
63
77
|
return dir;
|
|
64
78
|
};
|
|
65
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The shell this test process is actually running in.
|
|
82
|
+
*
|
|
83
|
+
* Detection reads the PROCESS TREE now, not `$SHELL` — `$SHELL` is the login
|
|
84
|
+
* shell and forcing it proved nothing about the shell in use. So the
|
|
85
|
+
* completions cases assert against whatever shell really started the run, and
|
|
86
|
+
* stand down where that cannot be established (a CI runner with no shell
|
|
87
|
+
* ancestor), rather than asserting a shell nobody is in.
|
|
88
|
+
*/
|
|
89
|
+
const DETECTED = detectShell();
|
|
90
|
+
const SHELL = DETECTED.kind === "detected" ? DETECTED.shell : null;
|
|
91
|
+
const withShell = it.skipIf(SHELL === null);
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A PATH directory carrying a `pragma` stub. The completions target refuses to
|
|
95
|
+
* install a script whose `pragma __complete` cannot be found — an inert script
|
|
96
|
+
* is worse than none — so every PATH these tests build has to satisfy that.
|
|
97
|
+
*/
|
|
98
|
+
const stubPath = (): string => {
|
|
99
|
+
const dir = tmp("pragma-setup-path-");
|
|
100
|
+
writeFileSync(join(dir, "pragma"), "");
|
|
101
|
+
return dir;
|
|
102
|
+
};
|
|
103
|
+
|
|
66
104
|
let prevHome: string | undefined;
|
|
67
|
-
let
|
|
105
|
+
let prevPath: string | undefined;
|
|
68
106
|
beforeEach(() => {
|
|
69
107
|
prevHome = process.env.HOME;
|
|
70
|
-
|
|
108
|
+
prevPath = process.env.PATH;
|
|
71
109
|
process.env.HOME = tmp("pragma-setup-home-");
|
|
72
|
-
process.env.
|
|
110
|
+
process.env.PATH = stubPath();
|
|
73
111
|
});
|
|
74
112
|
afterEach(() => {
|
|
75
113
|
process.env.HOME = prevHome;
|
|
76
|
-
process.env.
|
|
114
|
+
process.env.PATH = prevPath;
|
|
77
115
|
for (const dir of roots) rmSync(dir, { recursive: true, force: true });
|
|
78
116
|
roots.length = 0;
|
|
79
117
|
});
|
|
80
118
|
|
|
81
119
|
describe("setup completions", () => {
|
|
82
|
-
it("
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
);
|
|
90
|
-
|
|
91
|
-
expect(existsSync(path)).toBe(true);
|
|
92
|
-
expect(readFileSync(path, "utf-8")).toBe(emitScripts(capabilities).zsh);
|
|
93
|
-
});
|
|
120
|
+
it("sees a Windows `.cmd` shim, so the row does not skip on Windows", async () => {
|
|
121
|
+
// npm installs this CLI as `pragma.cmd` on Windows and never as a bare
|
|
122
|
+
// name, so joining the name onto each PATH directory found nothing: the
|
|
123
|
+
// row skipped with "not on PATH" on a machine where the command runs.
|
|
124
|
+
// Spelled to match a default `PATHEXT` entry exactly: this test runs on a
|
|
125
|
+
// case-sensitive filesystem, while Windows matches `pragma.cmd` against
|
|
126
|
+
// `.CMD` case-insensitively.
|
|
127
|
+
const dir = tmp("pragma-setup-winpath-");
|
|
128
|
+
writeFileSync(join(dir, `${BIN_NAME}.CMD`), "");
|
|
94
129
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
105
|
-
const path = completionScriptPath("zsh");
|
|
106
|
-
const outcome = await executeVerb(
|
|
107
|
-
completionsVerb,
|
|
108
|
-
{},
|
|
109
|
-
YES,
|
|
110
|
-
bootRuntime(FLAGS, cwd),
|
|
111
|
-
);
|
|
112
|
-
expect(outcome.exitCode).toBe(0);
|
|
113
|
-
const written = readFileSync(path, "utf-8");
|
|
114
|
-
// The installed script is EXACTLY the config-baked emit (minChars 5, the
|
|
115
|
-
// `block` family scrubbed) — the config was threaded end to end.
|
|
116
|
-
expect(written).toBe(
|
|
117
|
-
emitScripts(capabilities, {
|
|
118
|
-
minChars: 5,
|
|
119
|
-
disabledFamilies: ["block"],
|
|
120
|
-
}).zsh,
|
|
121
|
-
);
|
|
122
|
-
// Both knobs actually moved the output: it differs from the default emit
|
|
123
|
-
// (minChars), and from the minChars-only emit (the family opt-out).
|
|
124
|
-
expect(written).not.toBe(emitScripts(capabilities).zsh);
|
|
125
|
-
expect(written).not.toBe(emitScripts(capabilities, { minChars: 5 }).zsh);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
it("--dry-run previews the write against the detected shell, writing nothing", async () => {
|
|
129
|
-
const path = completionScriptPath("zsh");
|
|
130
|
-
const outcome = await executeVerb(
|
|
131
|
-
completionsVerb,
|
|
132
|
-
{},
|
|
133
|
-
DRY,
|
|
134
|
-
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
130
|
+
const detected = await detectCompletions(
|
|
131
|
+
dir,
|
|
132
|
+
{ kind: "unknown" },
|
|
133
|
+
{
|
|
134
|
+
platform: "win32",
|
|
135
|
+
env: { PATH: dir },
|
|
136
|
+
home: dir,
|
|
137
|
+
isWsl: false,
|
|
138
|
+
},
|
|
135
139
|
);
|
|
136
|
-
|
|
137
|
-
expect(
|
|
138
|
-
// The confirm gate / answer prompts are never part of a plan.
|
|
139
|
-
expect(outcome.stdout).not.toContain("Prompt");
|
|
140
|
-
expect(existsSync(path)).toBe(false);
|
|
140
|
+
|
|
141
|
+
expect(detected.binOnPath).toBe(true);
|
|
141
142
|
});
|
|
142
143
|
|
|
143
|
-
|
|
144
|
-
|
|
144
|
+
withShell("writes exactly emitScripts(capabilities)[shell]", async () => {
|
|
145
|
+
const path = completionScriptPath(SHELL as ShellId);
|
|
145
146
|
const outcome = await executeVerb(
|
|
146
147
|
completionsVerb,
|
|
147
148
|
{},
|
|
@@ -149,11 +150,94 @@ describe("setup completions", () => {
|
|
|
149
150
|
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
150
151
|
);
|
|
151
152
|
expect(outcome.exitCode).toBe(0);
|
|
152
|
-
expect(existsSync(
|
|
153
|
+
expect(existsSync(path)).toBe(true);
|
|
154
|
+
expect(readFileSync(path, "utf-8")).toBe(
|
|
155
|
+
emitScripts(capabilities)[SHELL as ShellId],
|
|
156
|
+
);
|
|
153
157
|
});
|
|
154
158
|
|
|
155
|
-
|
|
156
|
-
|
|
159
|
+
withShell(
|
|
160
|
+
"bakes the completion config (minChars + per-family opt-out) into the emitted script",
|
|
161
|
+
async () => {
|
|
162
|
+
// Proves the reconciled `detectCompletions(cwd)` reads `completion` config
|
|
163
|
+
// and threads {minChars, disabledFamilies} into `emitScripts` — a silent
|
|
164
|
+
// regression if the fold had dropped autocomplete's config-threading for
|
|
165
|
+
// setup's argument-free detect.
|
|
166
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
167
|
+
writeFileSync(
|
|
168
|
+
join(cwd, "pragma.config.ts"),
|
|
169
|
+
"export default { completion: { minChars: 5, families: { block: false } } };\n",
|
|
170
|
+
);
|
|
171
|
+
const path = completionScriptPath(SHELL as ShellId);
|
|
172
|
+
const outcome = await executeVerb(
|
|
173
|
+
completionsVerb,
|
|
174
|
+
{},
|
|
175
|
+
YES,
|
|
176
|
+
bootRuntime(FLAGS, cwd),
|
|
177
|
+
);
|
|
178
|
+
expect(outcome.exitCode).toBe(0);
|
|
179
|
+
const written = readFileSync(path, "utf-8");
|
|
180
|
+
// The installed script is EXACTLY the config-baked emit (minChars 5, the
|
|
181
|
+
// `block` family scrubbed) — the config was threaded end to end.
|
|
182
|
+
expect(written).toBe(
|
|
183
|
+
emitScripts(capabilities, {
|
|
184
|
+
minChars: 5,
|
|
185
|
+
disabledFamilies: ["block"],
|
|
186
|
+
})[SHELL as ShellId],
|
|
187
|
+
);
|
|
188
|
+
// Both knobs actually moved the output: it differs from the default emit
|
|
189
|
+
// (minChars), and from the minChars-only emit (the family opt-out).
|
|
190
|
+
expect(written).not.toBe(emitScripts(capabilities)[SHELL as ShellId]);
|
|
191
|
+
expect(written).not.toBe(
|
|
192
|
+
emitScripts(capabilities, { minChars: 5 })[SHELL as ShellId],
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
withShell(
|
|
198
|
+
"--dry-run previews the write against the detected shell, writing nothing",
|
|
199
|
+
async () => {
|
|
200
|
+
const path = completionScriptPath(SHELL as ShellId);
|
|
201
|
+
const outcome = await executeVerb(
|
|
202
|
+
completionsVerb,
|
|
203
|
+
{},
|
|
204
|
+
DRY,
|
|
205
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
206
|
+
);
|
|
207
|
+
// The dry-run renders the PLAN through the verb's formatPlan seam, not the
|
|
208
|
+
// kernel's raw effect dump: one row, the shell it detected, and the script
|
|
209
|
+
// path rendered against the header's roots instead of repeated absolutely.
|
|
210
|
+
expect(outcome.stdout).toContain("Setup plan — global");
|
|
211
|
+
expect(outcome.stdout).toMatch(
|
|
212
|
+
new RegExp(`completions\\s+install\\s+${SHELL} →`),
|
|
213
|
+
);
|
|
214
|
+
// The confirm gate / answer prompts are never part of a plan.
|
|
215
|
+
expect(outcome.stdout).not.toContain("Prompt");
|
|
216
|
+
expect(existsSync(path)).toBe(false);
|
|
217
|
+
},
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
withShell(
|
|
221
|
+
"refuses to install a script whose `pragma` the shell cannot find",
|
|
222
|
+
async () => {
|
|
223
|
+
// The script delegates every name context to `pragma __complete`. With
|
|
224
|
+
// the binary unreachable the file installs cleanly, doctor goes green,
|
|
225
|
+
// and TAB does nothing — so this is a named skip, not a silent write.
|
|
226
|
+
process.env.PATH = tmp("pragma-setup-nobin-"); // no `pragma` in it
|
|
227
|
+
const outcome = await executeVerb(
|
|
228
|
+
completionsVerb,
|
|
229
|
+
{},
|
|
230
|
+
YES,
|
|
231
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
232
|
+
);
|
|
233
|
+
expect(outcome.exitCode).toBe(0);
|
|
234
|
+
expect(outcome.stdout).toContain("cannot find on PATH");
|
|
235
|
+
expect(existsSync(completionScriptPath(SHELL as ShellId))).toBe(false);
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
withShell("--undo reverses the write", async () => {
|
|
240
|
+
const path = completionScriptPath(SHELL as ShellId);
|
|
157
241
|
const cwd = tmp("pragma-setup-proj-");
|
|
158
242
|
await executeVerb(completionsVerb, {}, YES, bootRuntime(FLAGS, cwd));
|
|
159
243
|
expect(existsSync(path)).toBe(true);
|
|
@@ -168,9 +252,11 @@ describe("setup mcp — recap gate", () => {
|
|
|
168
252
|
mkdirSync(join(cwd, ".cursor"), { recursive: true }); // makes Cursor detected
|
|
169
253
|
const configPath = join(cwd, ".cursor", "mcp.json");
|
|
170
254
|
|
|
255
|
+
// `--local`: the project scope is opt-in now, so a bare `setup mcp` would
|
|
256
|
+
// write the HOME config and leave this repository untouched.
|
|
171
257
|
const outcome = await executeVerb(
|
|
172
258
|
verbOf("mcp"),
|
|
173
|
-
{},
|
|
259
|
+
{ local: true },
|
|
174
260
|
YES,
|
|
175
261
|
bootRuntime(FLAGS, cwd),
|
|
176
262
|
);
|
|
@@ -185,7 +271,7 @@ describe("setup mcp — recap gate", () => {
|
|
|
185
271
|
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
186
272
|
const configPath = join(cwd, ".cursor", "mcp.json");
|
|
187
273
|
|
|
188
|
-
const { generator } = await
|
|
274
|
+
const { generator } = await buildSetupRun(
|
|
189
275
|
bootRuntime(FLAGS, cwd),
|
|
190
276
|
"mcp",
|
|
191
277
|
"both",
|
|
@@ -207,7 +293,7 @@ describe("setup mcp — scope & dedup", () => {
|
|
|
207
293
|
let prevPath: string | undefined;
|
|
208
294
|
beforeEach(() => {
|
|
209
295
|
prevPath = process.env.PATH;
|
|
210
|
-
process.env.PATH =
|
|
296
|
+
process.env.PATH = stubPath();
|
|
211
297
|
});
|
|
212
298
|
afterEach(() => {
|
|
213
299
|
process.env.PATH = prevPath;
|
|
@@ -230,7 +316,7 @@ describe("setup mcp — scope & dedup", () => {
|
|
|
230
316
|
writeFileSync(join(clineExt, "package.json"), "{}");
|
|
231
317
|
const outcome = await executeVerb(
|
|
232
318
|
verbOf("mcp"),
|
|
233
|
-
{},
|
|
319
|
+
{ local: true },
|
|
234
320
|
YES,
|
|
235
321
|
bootRuntime(FLAGS, cwd),
|
|
236
322
|
);
|
|
@@ -317,43 +403,498 @@ describe("setup mcp — scope & dedup", () => {
|
|
|
317
403
|
).toBe(true);
|
|
318
404
|
expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(false);
|
|
319
405
|
});
|
|
406
|
+
|
|
407
|
+
it("a GLOBAL entry omits cwd — registrations from two directories are byte-identical", async () => {
|
|
408
|
+
// A per-user server must not be pinned to whatever directory `setup mcp
|
|
409
|
+
// --global` happened to run from (a registration made from ~/Downloads
|
|
410
|
+
// used to serve ~/Downloads forever, and re-running from repo B flipped
|
|
411
|
+
// the machine scope to repo B).
|
|
412
|
+
const home = process.env.HOME ?? "";
|
|
413
|
+
const configPath = join(home, ".codeium", "windsurf", "mcp_config.json");
|
|
414
|
+
const register = async (cwd: string): Promise<string> => {
|
|
415
|
+
mkdirSync(join(cwd, ".windsurf"), { recursive: true });
|
|
416
|
+
await executeVerb(
|
|
417
|
+
verbOf("mcp"),
|
|
418
|
+
{ global: true },
|
|
419
|
+
YES,
|
|
420
|
+
bootRuntime(FLAGS, cwd),
|
|
421
|
+
);
|
|
422
|
+
return readFileSync(configPath, "utf-8");
|
|
423
|
+
};
|
|
424
|
+
const fromA = await register(tmp("pragma-setup-projA-"));
|
|
425
|
+
const fromB = await register(tmp("pragma-setup-projB-"));
|
|
426
|
+
expect(fromA).toBe(fromB);
|
|
427
|
+
const entry = JSON.parse(fromA).mcpServers?.pragma;
|
|
428
|
+
expect(entry).toEqual({ command: "pragma", args: ["mcp", "serve"] });
|
|
429
|
+
expect(entry).not.toHaveProperty("cwd");
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it("a stale cwd-pinned GLOBAL entry converges: drifted once, configured after", async () => {
|
|
433
|
+
const home = process.env.HOME ?? "";
|
|
434
|
+
const configPath = join(home, ".codeium", "windsurf", "mcp_config.json");
|
|
435
|
+
mkdirSync(join(home, ".codeium", "windsurf"), { recursive: true });
|
|
436
|
+
writeFileSync(
|
|
437
|
+
configPath,
|
|
438
|
+
JSON.stringify({
|
|
439
|
+
mcpServers: {
|
|
440
|
+
pragma: {
|
|
441
|
+
command: "pragma",
|
|
442
|
+
args: ["mcp"],
|
|
443
|
+
cwd: "/home/u/Downloads",
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
}),
|
|
447
|
+
);
|
|
448
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
449
|
+
mkdirSync(join(cwd, ".windsurf"), { recursive: true });
|
|
450
|
+
const rt = bootRuntime(FLAGS, cwd);
|
|
451
|
+
const { detectMcp, mcpGroupState } = await import(
|
|
452
|
+
"./operations/setupMcp.js"
|
|
453
|
+
);
|
|
454
|
+
// The pinned entry reads as drift (the omitted cwd is a controlled field).
|
|
455
|
+
const before = await detectMcp(rt, "global");
|
|
456
|
+
expect(mcpGroupState(before, configPath)).toBe("drifted");
|
|
457
|
+
// One write converges it...
|
|
458
|
+
await executeVerb(
|
|
459
|
+
verbOf("mcp"),
|
|
460
|
+
{ global: true },
|
|
461
|
+
YES,
|
|
462
|
+
bootRuntime(FLAGS, cwd),
|
|
463
|
+
);
|
|
464
|
+
expect(
|
|
465
|
+
JSON.parse(readFileSync(configPath, "utf-8")).mcpServers.pragma,
|
|
466
|
+
).toEqual({ command: "pragma", args: ["mcp", "serve"] });
|
|
467
|
+
// ...and it stays `registered` afterwards (no churn on every run).
|
|
468
|
+
const after = await detectMcp(bootRuntime(FLAGS, cwd), "global");
|
|
469
|
+
expect(mcpGroupState(after, configPath)).toBe("registered");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("a PROJECT entry still records the project root as cwd", async () => {
|
|
473
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
474
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
475
|
+
await executeVerb(
|
|
476
|
+
verbOf("mcp"),
|
|
477
|
+
{ local: true },
|
|
478
|
+
YES,
|
|
479
|
+
bootRuntime(FLAGS, cwd),
|
|
480
|
+
);
|
|
481
|
+
const entry = JSON.parse(
|
|
482
|
+
readFileSync(join(cwd, ".cursor", "mcp.json"), "utf-8"),
|
|
483
|
+
).mcpServers?.pragma;
|
|
484
|
+
expect(entry.cwd).toBe(cwd);
|
|
485
|
+
});
|
|
320
486
|
});
|
|
321
487
|
|
|
322
|
-
describe("setup mcp —
|
|
323
|
-
it("
|
|
488
|
+
describe("setup mcp — the per-file question, asked directly", () => {
|
|
489
|
+
it("asks the per-file multiselect with NO `customize` meta-question in front", async () => {
|
|
490
|
+
// `answers.customize` was read in exactly one place in the repo — this
|
|
491
|
+
// prompt's `when` — and by nothing else: not `generate`, not `applied`, not
|
|
492
|
+
// any plan, recap, renderer or JSON projection. Answering no just meant the
|
|
493
|
+
// child prompt went unasked, its key was absent, and the compose bodies
|
|
494
|
+
// read that as "all" — so everything detected was configured anyway.
|
|
324
495
|
const cwd = tmp("pragma-setup-proj-");
|
|
325
|
-
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
326
|
-
|
|
496
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
497
|
+
mkdirSync(join(cwd, ".gemini"), { recursive: true }); // 2 children ⇒ narrowable
|
|
498
|
+
const { generator } = await buildSetupRun(
|
|
327
499
|
bootRuntime(FLAGS, cwd),
|
|
328
500
|
"mcp",
|
|
329
|
-
"
|
|
501
|
+
"project",
|
|
330
502
|
);
|
|
331
|
-
|
|
503
|
+
expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
|
|
332
504
|
const targets = generator.prompts.find((p) => p.name === "mcpTargets");
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
505
|
+
expect(targets).toBeDefined();
|
|
506
|
+
// It is asked whenever its row is in the run — the row's own selection is
|
|
507
|
+
// the only gate, and the choices carry the detected state per file.
|
|
508
|
+
expect(targets?.when?.({})).toBe(true);
|
|
509
|
+
expect(
|
|
510
|
+
(targets?.choices as { label: string }[]).every((c) =>
|
|
511
|
+
/— (add|update|unchanged)$/.test(c.label),
|
|
512
|
+
),
|
|
513
|
+
).toBe(true);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("recaps the files the run KEPT, not the ones it offered", async () => {
|
|
517
|
+
// The note counted every child of the plan row, so narrowing two MCP files
|
|
518
|
+
// down to one still recapped `2 added`: the plan and the result described
|
|
519
|
+
// different work.
|
|
520
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
521
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
522
|
+
mkdirSync(join(cwd, ".gemini"), { recursive: true });
|
|
523
|
+
const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
|
|
524
|
+
const row = run.plan.rows.find((r) => r.target === "mcp");
|
|
525
|
+
expect(row?.children?.length).toBe(2);
|
|
526
|
+
const kept = row?.children?.[0]?.key as string;
|
|
527
|
+
|
|
528
|
+
const applied = run.applied({
|
|
529
|
+
targets: ["project:mcp"],
|
|
530
|
+
mcpTargets: [kept],
|
|
531
|
+
});
|
|
532
|
+
expect(applied.rows.find((r) => r.target === "mcp")?.outcome?.note).toBe(
|
|
533
|
+
"1 added",
|
|
534
|
+
);
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
describe("setup — live row events (the wizard's step feed)", () => {
|
|
539
|
+
it("brackets each row with start/done, in the recap's own sentence", async () => {
|
|
540
|
+
// The wizard's live progress renders the PLAN's rows, not the effect
|
|
541
|
+
// transcript — one `start` as a row's body is entered, one `done` carrying
|
|
542
|
+
// the same `target detail — note` sentence the recap will print, so the
|
|
543
|
+
// watcher and the recap reader see one dialect.
|
|
544
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
545
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
546
|
+
const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
|
|
547
|
+
const events: RowEvent[] = [];
|
|
548
|
+
run.setRowListener((event) => events.push(event));
|
|
549
|
+
await runTask(run.generator.generate({}), { onLog: () => {} });
|
|
550
|
+
expect(events.map((event) => event.status)).toEqual(["start", "done"]);
|
|
551
|
+
expect(events[0]?.key).toBe("project:mcp");
|
|
552
|
+
// The start label is the row without an outcome; the done label appends
|
|
553
|
+
// the SAME note `applied()` puts on the recap row.
|
|
554
|
+
const applied = run.applied({});
|
|
555
|
+
const note = applied.rows.find((r) => r.target === "mcp")?.outcome?.note;
|
|
556
|
+
expect(note).toBe("1 added");
|
|
557
|
+
expect(events[1]?.label).toBe(`${events[0]?.label} — ${note}`);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it("no listener registered costs nothing — the run is unchanged", async () => {
|
|
561
|
+
// `--yes`, `--dry-run` and MCP never register one; the bracket emits into
|
|
562
|
+
// the void and the composed effects are identical.
|
|
563
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
564
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
565
|
+
const run = await buildSetupRun(bootRuntime(FLAGS, cwd), "mcp", "project");
|
|
566
|
+
await runTask(run.generator.generate({}), { onLog: () => {} });
|
|
567
|
+
expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(true);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it("a failing row reports `failed` with its cause, and its siblings still bracket", async () => {
|
|
571
|
+
// Same arrangement as the S1-1 isolation test: an editor on PATH with no
|
|
572
|
+
// `bun`, so the LSP row's compose fails. The event stream must name the
|
|
573
|
+
// failure — the wizard paints that row ✗ live — while the run continues.
|
|
574
|
+
const prevPath = process.env.PATH;
|
|
575
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
576
|
+
process.env.XDG_DATA_HOME = tmp("pragma-rowevents-data-");
|
|
577
|
+
const dir = stubPath();
|
|
578
|
+
writeFileSync(join(dir, "code"), "");
|
|
579
|
+
process.env.PATH = dir;
|
|
580
|
+
try {
|
|
581
|
+
const run = await buildSetupRun(
|
|
582
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
583
|
+
"lsp",
|
|
584
|
+
"global",
|
|
585
|
+
);
|
|
586
|
+
const events: RowEvent[] = [];
|
|
587
|
+
run.setRowListener((event) => events.push(event));
|
|
588
|
+
await runTask(run.generator.generate({}), { onLog: () => {} });
|
|
589
|
+
expect(events.map((event) => event.status)).toEqual(["start", "failed"]);
|
|
590
|
+
expect(events[1]?.label).toContain("bun");
|
|
591
|
+
} finally {
|
|
592
|
+
process.env.PATH = prevPath;
|
|
593
|
+
if (prevData === undefined) delete process.env.XDG_DATA_HOME;
|
|
594
|
+
else process.env.XDG_DATA_HOME = prevData;
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
describe("setup lsp — per-editor multiselect (child rows)", () => {
|
|
600
|
+
let prevPath: string | undefined;
|
|
601
|
+
let stubDir = "";
|
|
602
|
+
beforeEach(() => {
|
|
603
|
+
prevPath = process.env.PATH;
|
|
604
|
+
stubDir = stubPath();
|
|
605
|
+
// Two VS Code forks on PATH, neither carrying the extension.
|
|
606
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
607
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
608
|
+
process.env.PATH = stubDir;
|
|
609
|
+
});
|
|
610
|
+
afterEach(() => {
|
|
611
|
+
process.env.PATH = prevPath;
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
it("offers one child per detected editor, like the mcp row offers files", async () => {
|
|
615
|
+
const { plan } = await buildSetupRun(
|
|
616
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
617
|
+
"lsp",
|
|
618
|
+
"global",
|
|
619
|
+
);
|
|
620
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
621
|
+
expect(row?.action).toBe("install");
|
|
622
|
+
expect(row?.children?.map((c) => c.key)).toEqual(["code", "codium"]);
|
|
623
|
+
expect(row?.children?.every((c) => c.action === "add")).toBe(true);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("marks an editor that already has the extension unchanged", async () => {
|
|
627
|
+
mkdirSync(
|
|
628
|
+
join(
|
|
629
|
+
process.env.HOME ?? "",
|
|
630
|
+
".vscode",
|
|
631
|
+
"extensions",
|
|
632
|
+
"canonical.terrazzo-lsp-extension-1.2.3",
|
|
633
|
+
),
|
|
634
|
+
{ recursive: true },
|
|
635
|
+
);
|
|
636
|
+
const { plan } = await buildSetupRun(
|
|
637
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
638
|
+
"lsp",
|
|
639
|
+
"global",
|
|
640
|
+
);
|
|
641
|
+
const children = plan.rows.find((r) => r.target === "lsp")?.children;
|
|
642
|
+
expect(children?.find((c) => c.key === "code")?.action).toBe("unchanged");
|
|
643
|
+
expect(children?.find((c) => c.key === "codium")?.action).toBe("add");
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it("asks the per-editor multiselect directly, with both forks pre-selected", async () => {
|
|
647
|
+
const { generator } = await buildSetupRun(
|
|
648
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
649
|
+
"lsp",
|
|
650
|
+
"global",
|
|
651
|
+
);
|
|
652
|
+
expect(generator.prompts.map((p) => p.name)).not.toContain("customize");
|
|
653
|
+
const editors = generator.prompts.find((p) => p.name === "lspEditors");
|
|
654
|
+
// Both pending editors are pre-selected, so pressing enter installs into
|
|
655
|
+
// every fork — the same default the meta-question used to stand for.
|
|
656
|
+
expect(editors?.default).toEqual(["code", "codium"]);
|
|
657
|
+
expect(editors?.when?.({})).toBe(true);
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("sideloads only into the editors the user kept", async () => {
|
|
661
|
+
const { detectLsp, composeLsp } = await import("./operations/setupLsp.js");
|
|
662
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
663
|
+
const { effects } = dryRun(composeLsp(detected, ["codium"]));
|
|
664
|
+
const execs = effects.filter((e) => e._tag === "Exec") as (Effect & {
|
|
665
|
+
_tag: "Exec";
|
|
666
|
+
command: string;
|
|
667
|
+
})[];
|
|
668
|
+
// The fetch still runs once; the sideload runs for `codium` alone.
|
|
669
|
+
expect(execs.map((e) => e.command)).toEqual(["bun", "codium"]);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("composes nothing at all when every editor is deselected", async () => {
|
|
673
|
+
// Not even the package fetch: there is nothing to install it into.
|
|
674
|
+
const { detectLsp, composeLsp } = await import("./operations/setupLsp.js");
|
|
675
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
676
|
+
const { effects } = dryRun(composeLsp(detected, []));
|
|
677
|
+
expect(effects.some((e) => e._tag === "Exec")).toBe(false);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("the removal is `none` when no editor carries the extension", async () => {
|
|
681
|
+
// Both forks are on PATH, neither has a copy — there is nothing this
|
|
682
|
+
// command owns, so a removal has honestly nothing to do.
|
|
683
|
+
const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
|
|
684
|
+
"./operations/setupLsp.js"
|
|
685
|
+
);
|
|
686
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
687
|
+
expect(ownedLspEditors(detected)).toEqual([]);
|
|
688
|
+
expect(collectUndos(composeLspRemoval(detected))).toEqual([]);
|
|
689
|
+
|
|
690
|
+
const { plan } = await buildSetupRun(
|
|
691
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
692
|
+
"lsp",
|
|
693
|
+
"global",
|
|
694
|
+
true,
|
|
695
|
+
);
|
|
696
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
697
|
+
expect(row?.action).toBe("none");
|
|
698
|
+
expect(row?.detail).toBe("no editor carries the extension");
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
describe("setup lsp — the removal contract the other four rows implement", () => {
|
|
703
|
+
let prevPath: string | undefined;
|
|
704
|
+
let stubDir = "";
|
|
705
|
+
beforeEach(() => {
|
|
706
|
+
prevPath = process.env.PATH;
|
|
707
|
+
stubDir = stubPath();
|
|
708
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
709
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
710
|
+
process.env.PATH = stubDir;
|
|
711
|
+
});
|
|
712
|
+
afterEach(() => {
|
|
713
|
+
process.env.PATH = prevPath;
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
/** Seed a versioned extension copy in an editor's extensions dir. */
|
|
717
|
+
const seedExtension = (dir: string, version: string): void => {
|
|
718
|
+
mkdirSync(
|
|
719
|
+
join(
|
|
720
|
+
process.env.HOME ?? "",
|
|
721
|
+
dir,
|
|
722
|
+
"extensions",
|
|
723
|
+
`canonical.terrazzo-lsp-extension-${version}`,
|
|
724
|
+
),
|
|
725
|
+
{ recursive: true },
|
|
726
|
+
);
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
it("carries one `--uninstall-extension` exec per OWNED editor as its undo", async () => {
|
|
730
|
+
// The row hardcoded a skip behind a docblock claiming "an `exec` carries no
|
|
731
|
+
// reversal" — factually wrong: `Exec` has an `undo` slot, and the sibling
|
|
732
|
+
// `composeMcpRemoval` already uses this exact carrier shape.
|
|
733
|
+
seedExtension(".vscode", "1.2.3");
|
|
734
|
+
const { detectLsp, composeLspRemoval } = await import(
|
|
735
|
+
"./operations/setupLsp.js"
|
|
736
|
+
);
|
|
737
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
738
|
+
|
|
739
|
+
// The FORWARD side must be walkable with every effect mocked: it is a
|
|
740
|
+
// `mkdir` of the editor's own extensions dir, which reads nothing.
|
|
741
|
+
const forward = dryRun(composeLspRemoval(detected));
|
|
742
|
+
expect(forward.effects.map((e) => e._tag)).toEqual(["MakeDir"]);
|
|
743
|
+
expect(forward.effects.some((e) => e._tag === "Exec")).toBe(false);
|
|
744
|
+
|
|
745
|
+
const undos = collectUndos(composeLspRemoval(detected));
|
|
746
|
+
expect(undos).toHaveLength(1);
|
|
747
|
+
const execs = undos
|
|
748
|
+
.flatMap((u) => dryRun(u).effects)
|
|
749
|
+
.filter((e) => e._tag === "Exec") as (Effect & {
|
|
750
|
+
_tag: "Exec";
|
|
751
|
+
command: string;
|
|
752
|
+
args: string[];
|
|
753
|
+
})[];
|
|
754
|
+
expect(execs.map((e) => e.command)).toEqual(["code"]);
|
|
755
|
+
expect(execs[0].args).toEqual([
|
|
756
|
+
"--uninstall-extension",
|
|
757
|
+
"canonical.terrazzo-lsp-extension",
|
|
758
|
+
]);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("REGRESSION: a pre-0.8.3 copy still plans `install` forward but `remove` back", async () => {
|
|
762
|
+
// The conflation that would have caused a silent wrong-skip.
|
|
763
|
+
// `extensionInstalled` was version-gated, so an editor carrying a dead
|
|
764
|
+
// pre-0.8.3 copy — one this command itself installed — reported
|
|
765
|
+
// `installed: false`, and a removal keyed on it would have walked past the
|
|
766
|
+
// dead extension while reporting success.
|
|
767
|
+
seedExtension(".vscode", "0.8.1");
|
|
768
|
+
const { detectLsp, composeLspRemoval, ownedLspEditors } = await import(
|
|
769
|
+
"./operations/setupLsp.js"
|
|
770
|
+
);
|
|
771
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
772
|
+
const code = detected.editors.find((e) => e.editor.cli === "code");
|
|
773
|
+
// Present, but not current — the two facts stay separate.
|
|
774
|
+
expect(code?.present).toBe(true);
|
|
775
|
+
expect(code?.installed).toBe(false);
|
|
776
|
+
|
|
777
|
+
// Forward is UNCHANGED by this fix: the dead copy is still "not installed".
|
|
778
|
+
const forward = await buildSetupRun(
|
|
779
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
780
|
+
"lsp",
|
|
781
|
+
"global",
|
|
782
|
+
);
|
|
783
|
+
expect(forward.plan.rows.find((r) => r.target === "lsp")?.action).toBe(
|
|
784
|
+
"install",
|
|
785
|
+
);
|
|
786
|
+
|
|
787
|
+
// Removal keys on ownership, so the dead copy IS removed.
|
|
788
|
+
expect(ownedLspEditors(detected).map((e) => e.editor.cli)).toEqual([
|
|
789
|
+
"code",
|
|
790
|
+
]);
|
|
791
|
+
const { plan } = await buildSetupRun(
|
|
792
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
793
|
+
"lsp",
|
|
794
|
+
"global",
|
|
795
|
+
true,
|
|
796
|
+
);
|
|
797
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
798
|
+
expect(row?.action).toBe("remove");
|
|
799
|
+
expect(row?.children?.map((c) => c.key)).toEqual(["code"]);
|
|
800
|
+
expect(
|
|
801
|
+
collectUndos(composeLspRemoval(detected))
|
|
802
|
+
.flatMap((u) => dryRun(u).effects)
|
|
803
|
+
.filter((e) => e._tag === "Exec"),
|
|
804
|
+
).toHaveLength(1);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
it("the remedy names an OWNING editor, not the first one on PATH", async () => {
|
|
808
|
+
// With Cursor (or here: `code`) installed and the extension only in
|
|
809
|
+
// VSCodium, the printed remedy named the wrong editor — a command that
|
|
810
|
+
// would report success having uninstalled nothing.
|
|
811
|
+
seedExtension(".vscode-oss", "1.2.3"); // codium's extensions dir
|
|
812
|
+
const { detectLsp, lspUninstallRemedy } = await import(
|
|
813
|
+
"./operations/setupLsp.js"
|
|
814
|
+
);
|
|
815
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
816
|
+
expect(detected.editors[0]?.editor.cli).toBe("code"); // first on PATH
|
|
817
|
+
expect(lspUninstallRemedy(detected)).toBe(
|
|
818
|
+
"codium --uninstall-extension canonical.terrazzo-lsp-extension",
|
|
819
|
+
);
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("still SKIPS with the named reason when no editor CLI is on PATH", async () => {
|
|
823
|
+
process.env.PATH = stubPath(); // no editor CLI at all
|
|
824
|
+
const { plan } = await buildSetupRun(
|
|
825
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
826
|
+
"lsp",
|
|
827
|
+
"global",
|
|
828
|
+
true,
|
|
829
|
+
);
|
|
830
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
831
|
+
expect(row?.action).toBe("skip");
|
|
832
|
+
expect(row?.reason).toContain("no VS Code-family editor CLI on PATH");
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("end to end: `setup lsp --undo` reports `Undid 1 step(s)` against a STUB cli", async () => {
|
|
836
|
+
// The reported symptom was `Undid 0 step(s).` at exit 0. The stub is a
|
|
837
|
+
// marker-writing script — never a real editor: an undo test must not be
|
|
838
|
+
// able to mutate the machine running it.
|
|
839
|
+
const marker = join(tmp("pragma-lsp-undo-"), "uninstalled");
|
|
840
|
+
const dir = stubPath();
|
|
841
|
+
writeFileSync(
|
|
842
|
+
join(dir, "code"),
|
|
843
|
+
`#!/bin/sh\nprintf '%s\\n' "$@" > ${marker}\n`,
|
|
844
|
+
{ mode: 0o755 },
|
|
845
|
+
);
|
|
846
|
+
process.env.PATH = dir;
|
|
847
|
+
seedExtension(".vscode", "1.2.3");
|
|
848
|
+
|
|
849
|
+
const outcome = await executeVerb(
|
|
850
|
+
verbOf("lsp"),
|
|
851
|
+
{},
|
|
852
|
+
UNDO,
|
|
853
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
854
|
+
);
|
|
855
|
+
expect(outcome.exitCode).toBe(0);
|
|
856
|
+
expect(outcome.stdout).toBe("Undid 1 step(s).\n");
|
|
857
|
+
expect(readFileSync(marker, "utf-8")).toBe(
|
|
858
|
+
"--uninstall-extension\ncanonical.terrazzo-lsp-extension\n",
|
|
859
|
+
);
|
|
860
|
+
// NON-GOAL guard: the staging dir is a cache, not this removal's business.
|
|
861
|
+
// Nothing here deletes it, and nothing here touches the VSIX.
|
|
862
|
+
const { detectLsp, composeLspRemoval } = await import(
|
|
863
|
+
"./operations/setupLsp.js"
|
|
864
|
+
);
|
|
865
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
866
|
+
const all = [
|
|
867
|
+
...dryRun(composeLspRemoval(detected)).effects,
|
|
868
|
+
...collectUndos(composeLspRemoval(detected)).flatMap(
|
|
869
|
+
(u) => dryRun(u).effects,
|
|
870
|
+
),
|
|
871
|
+
];
|
|
872
|
+
expect(all.some((e) => e._tag === "DeleteFile")).toBe(false);
|
|
873
|
+
expect(all.some((e) => e._tag === "DeleteDirectory")).toBe(false);
|
|
341
874
|
});
|
|
342
875
|
});
|
|
343
876
|
|
|
344
877
|
describe("setup (run-all wizard) — scope threading", () => {
|
|
345
878
|
// Isolate PATH so an ambient `claude`/`codex` can't inject a harness via a
|
|
346
879
|
// `process` signal — detection is driven only by the dirs each test makes.
|
|
880
|
+
// A stubbed `code` CLI makes the LSP step's editor detection deterministic.
|
|
347
881
|
let prevPath: string | undefined;
|
|
882
|
+
let editorPath = "";
|
|
348
883
|
beforeEach(() => {
|
|
349
884
|
prevPath = process.env.PATH;
|
|
350
|
-
|
|
885
|
+
editorPath = stubPath();
|
|
886
|
+
process.env.PATH = editorPath;
|
|
351
887
|
});
|
|
352
888
|
afterEach(() => {
|
|
353
889
|
process.env.PATH = prevPath;
|
|
354
890
|
});
|
|
355
891
|
|
|
356
|
-
/**
|
|
892
|
+
/** Put a stub `code` on the isolated PATH so an LSP install is composable. */
|
|
893
|
+
const seedEditorCli = (): void => {
|
|
894
|
+
writeFileSync(join(editorPath, "code"), "");
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
/** Seed a discoverable skill so the project-scope skills step is offerable. */
|
|
357
898
|
const seedSkill = (cwd: string): void => {
|
|
358
899
|
const dir = join(cwd, ".pragma", "skills", "s");
|
|
359
900
|
mkdirSync(dir, { recursive: true });
|
|
@@ -363,10 +904,10 @@ describe("setup (run-all wizard) — scope threading", () => {
|
|
|
363
904
|
);
|
|
364
905
|
};
|
|
365
906
|
|
|
366
|
-
it("--local omits the global-
|
|
907
|
+
it("--local omits the global-scope completions + lsp steps, keeping project steps", async () => {
|
|
367
908
|
const cwd = tmp("pragma-setup-proj-");
|
|
368
|
-
mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-
|
|
369
|
-
seedSkill(cwd); // project-
|
|
909
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true }); // project-scope MCP target
|
|
910
|
+
seedSkill(cwd); // project-scope skills step
|
|
370
911
|
const outcome = await executeVerb(
|
|
371
912
|
setupSelfVerb,
|
|
372
913
|
{ local: true },
|
|
@@ -375,39 +916,159 @@ describe("setup (run-all wizard) — scope threading", () => {
|
|
|
375
916
|
);
|
|
376
917
|
expect(outcome.exitCode).toBe(0);
|
|
377
918
|
const plan = outcome.stdout ?? "";
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
//
|
|
919
|
+
// A scope FILTERS the run-all: the out-of-scope targets are still named, as
|
|
920
|
+
// rows saying why they are not in this run, so nothing is silently absent —
|
|
921
|
+
// and the reason names the flag that WOULD reach them, so the row is not a
|
|
922
|
+
// dead end either.
|
|
923
|
+
expect(plan).toContain("completions nothing to do");
|
|
924
|
+
expect(plan).toContain("this one is global only — run it with `--global`");
|
|
925
|
+
// The per-project steps remain: MCP into .cursor, skills into .agents/skills.
|
|
382
926
|
expect(plan).toContain("mcp.json");
|
|
383
927
|
expect(plan).toContain(".agents/skills");
|
|
384
928
|
});
|
|
385
929
|
|
|
386
|
-
|
|
930
|
+
withShell(
|
|
931
|
+
"--global omits the project-scope skills step, keeping global steps",
|
|
932
|
+
async () => {
|
|
933
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
934
|
+
seedSkill(cwd); // WOULD be offered under the default `both`
|
|
935
|
+
seedEditorCli(); // vscode detected ⇒ the LSP install is in the plan
|
|
936
|
+
const outcome = await executeVerb(
|
|
937
|
+
setupSelfVerb,
|
|
938
|
+
{ global: true },
|
|
939
|
+
DRY,
|
|
940
|
+
bootRuntime(FLAGS, cwd),
|
|
941
|
+
);
|
|
942
|
+
expect(outcome.exitCode).toBe(0);
|
|
943
|
+
const plan = outcome.stdout ?? "";
|
|
944
|
+
// The global-scope steps are present under --global. Paths render against
|
|
945
|
+
// the header's roots, so the row shows `~/.zfunc/_pragma`, not an absolute
|
|
946
|
+
// prefix repeated on every line.
|
|
947
|
+
expect(plan).toMatch(new RegExp(`completions\\s+install\\s+${SHELL} →`));
|
|
948
|
+
expect(plan).toContain("lsp");
|
|
949
|
+
// The project-scope skills step is gone (the bug: it used to run under
|
|
950
|
+
// --global). `shortenPath` renders a project path as `./…` and a global
|
|
951
|
+
// one as `~/…`, and the two scopes' cross-client directories differ only
|
|
952
|
+
// by that marker — so the covenant is asserted on the PROJECT spelling.
|
|
953
|
+
// A bare "not .agents/skills" no longer separates them: the global scope
|
|
954
|
+
// legitimately links its own `~/.agents/skills` now that the bundled
|
|
955
|
+
// snapshot gives it skills on a machine that has run nothing.
|
|
956
|
+
expect(plan).not.toContain(`.${sep}.agents${sep}skills`);
|
|
957
|
+
expect(plan).toContain(`~${sep}.agents${sep}skills`);
|
|
958
|
+
},
|
|
959
|
+
);
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
describe("setup skills", () => {
|
|
963
|
+
it("empty skills are a named skip at exit 0, not a failure", async () => {
|
|
964
|
+
// A skip is "nothing to do here, honestly named" — the same semantics
|
|
965
|
+
// doctor gives its skip glyph. Failing the direct sub-verb made a dotfiles
|
|
966
|
+
// script fail on a machine that simply has no skills installed yet, which
|
|
967
|
+
// teaches people to ignore the exit code.
|
|
387
968
|
const cwd = tmp("pragma-setup-proj-");
|
|
388
|
-
seedSkill(cwd); // WOULD be offered under the default `both`
|
|
389
969
|
const outcome = await executeVerb(
|
|
390
|
-
|
|
391
|
-
{
|
|
392
|
-
|
|
970
|
+
verbOf("skills"),
|
|
971
|
+
{ local: true },
|
|
972
|
+
YES,
|
|
393
973
|
bootRuntime(FLAGS, cwd),
|
|
394
974
|
);
|
|
395
975
|
expect(outcome.exitCode).toBe(0);
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
expect(plan).not.toContain(".agents/skills");
|
|
976
|
+
expect(outcome.stdout).toContain("skills");
|
|
977
|
+
expect(outcome.stdout).toContain("this project holds no skills");
|
|
978
|
+
// The skip is no longer a dead end: it names the action that settles it on
|
|
979
|
+
// THIS machine. The reason does NOT invert — the row still skips.
|
|
980
|
+
expect(outcome.stdout).toContain("SKILL.md, then run this again");
|
|
402
981
|
});
|
|
403
|
-
});
|
|
404
982
|
|
|
405
|
-
|
|
406
|
-
|
|
983
|
+
it("an EXISTING but empty project skills root is not reported as absent", async () => {
|
|
984
|
+
// `available` is false for an absent root and for an empty one alike, so
|
|
985
|
+
// the shared reason line said "is absent" about a directory the user is
|
|
986
|
+
// looking at. `rootExists` is the flag that already separates those two
|
|
987
|
+
// states everywhere else in the module; the wording now honours it.
|
|
407
988
|
const cwd = tmp("pragma-setup-proj-");
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
989
|
+
mkdirSync(join(cwd, ".pragma", "skills"), { recursive: true });
|
|
990
|
+
|
|
991
|
+
const { plan } = await buildSetupRun(
|
|
992
|
+
bootRuntime(FLAGS, cwd),
|
|
993
|
+
"skills",
|
|
994
|
+
"project",
|
|
995
|
+
);
|
|
996
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
997
|
+
expect(row?.action).toBe("skip"); // still nothing to do...
|
|
998
|
+
expect(row?.reason).toContain("is empty"); // ...for the honest reason
|
|
999
|
+
expect(row?.reason).not.toContain("does not exist");
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("a genuinely MISSING project skills root still reports absent", async () => {
|
|
1003
|
+
// The other half of the distinction: the empty-root wording must not have
|
|
1004
|
+
// been bought by making every skip vague.
|
|
1005
|
+
const { plan } = await buildSetupRun(
|
|
1006
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1007
|
+
"skills",
|
|
1008
|
+
"project",
|
|
1009
|
+
);
|
|
1010
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
1011
|
+
expect(row?.action).toBe("skip");
|
|
1012
|
+
expect(row?.reason).toContain("does not exist");
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it("the GLOBAL scope OFFERS skills on a machine that has run nothing", async () => {
|
|
1016
|
+
// THE BEHAVIOUR CHANGE, asserted at the scope that used to be empty. The
|
|
1017
|
+
// global scope's only source was the installed root, so an empty
|
|
1018
|
+
// `XDG_DATA_HOME` — a fresh install — planned `skip` and told the user to
|
|
1019
|
+
// go and run `sources update` first. The bundled snapshot is the scope's
|
|
1020
|
+
// second root, so the row is actionable with no network and no prior
|
|
1021
|
+
// command. The empty-root WORDING this replaces is still asserted, on the
|
|
1022
|
+
// pure reason/remedy pair, in the cell below.
|
|
1023
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
1024
|
+
process.env.XDG_DATA_HOME = tmp("pragma-skills-data-");
|
|
1025
|
+
try {
|
|
1026
|
+
const { plan } = await buildSetupRun(
|
|
1027
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1028
|
+
"skills",
|
|
1029
|
+
"global",
|
|
1030
|
+
);
|
|
1031
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
1032
|
+
expect(row?.action).toBe("link");
|
|
1033
|
+
expect(row?.detail).toMatch(/\d+ skills? →/);
|
|
1034
|
+
|
|
1035
|
+
// End to end, inside the same jail (an ambient data root that happened to
|
|
1036
|
+
// hold a skill would stop this exercising the fresh-machine condition).
|
|
1037
|
+
const outcome = await executeVerb(
|
|
1038
|
+
verbOf("skills"),
|
|
1039
|
+
{},
|
|
1040
|
+
YES,
|
|
1041
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1042
|
+
);
|
|
1043
|
+
expect(outcome.exitCode).toBe(0);
|
|
1044
|
+
expect(outcome.stdout).not.toContain("no skills installed");
|
|
1045
|
+
} finally {
|
|
1046
|
+
// `process.env.X = undefined` STORES THE STRING "undefined"; it does not
|
|
1047
|
+
// unset. The variable is always set here (`setupXdgIsolation.ts` gives
|
|
1048
|
+
// every test file its own XDG root), so this restores rather than
|
|
1049
|
+
// deletes in practice — but a test must not leave a poisoned path behind
|
|
1050
|
+
// for the files that run after it if that ever stops being true.
|
|
1051
|
+
if (prevData === undefined) delete process.env.XDG_DATA_HOME;
|
|
1052
|
+
else process.env.XDG_DATA_HOME = prevData;
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it("the GLOBAL skip still names `sources update` when it does happen", async () => {
|
|
1057
|
+
// The remedy the cell above used to assert end-to-end. That state is no
|
|
1058
|
+
// longer reachable with a snapshot in the package — it takes a build whose
|
|
1059
|
+
// `bundled-skills/` is missing AND an empty installed root — so it is
|
|
1060
|
+
// asserted where it actually lives: on the pure pair that both the setup row
|
|
1061
|
+
// and the doctor row read, which is what stops the two surfaces wording one
|
|
1062
|
+
// finding differently. A skip with no next step is the defect.
|
|
1063
|
+
expect(
|
|
1064
|
+
skillsSkipReason("~/.local/share/pragma/skills", "global", true),
|
|
1065
|
+
).toBe(
|
|
1066
|
+
"nothing to link — no skills installed yet; they arrive with the packs " +
|
|
1067
|
+
`\`${BIN_NAME} sources update\` builds`,
|
|
1068
|
+
);
|
|
1069
|
+
expect(
|
|
1070
|
+
skillsSkipRemedy("~/.local/share/pragma/skills", "global"),
|
|
1071
|
+
).toContain(`${BIN_NAME} sources update`);
|
|
411
1072
|
});
|
|
412
1073
|
|
|
413
1074
|
it("detects a created action and composes a symlink carrying an undo", async () => {
|
|
@@ -419,7 +1080,7 @@ describe("setup skills", () => {
|
|
|
419
1080
|
"---\nname: my-skill\ndescription: A test skill.\n---\n",
|
|
420
1081
|
);
|
|
421
1082
|
|
|
422
|
-
const detected = await detectSkills(bootRuntime(FLAGS, cwd));
|
|
1083
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
423
1084
|
expect(detected.available).toBe(true);
|
|
424
1085
|
expect(detected.skillCount).toBe(1);
|
|
425
1086
|
expect(detected.actions.some((a) => a.action === "created")).toBe(true);
|
|
@@ -432,16 +1093,431 @@ describe("setup skills", () => {
|
|
|
432
1093
|
expect(symlinkEffect?.undo).toBeDefined();
|
|
433
1094
|
expect(existsSync(join(cwd, ".agents", "skills", "my-skill"))).toBe(false);
|
|
434
1095
|
});
|
|
435
|
-
});
|
|
436
1096
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
1097
|
+
it("NEVER deletes a user's own symlink that collides with a shipped skill (REGRESSION)", async () => {
|
|
1098
|
+
// Bundled skills ship on every install, so a folder-name collision with a
|
|
1099
|
+
// user's own link is reachable with NO user action — where previously an
|
|
1100
|
+
// empty skill set meant no action was ever composed for that path.
|
|
1101
|
+
// `composeSkills` composes every non-skipped row, so classifying a
|
|
1102
|
+
// resolving foreign link as `replaced` deletes the user's link.
|
|
1103
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1104
|
+
const skillDir = join(cwd, ".pragma", "skills", "my-skill");
|
|
1105
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1106
|
+
writeFileSync(
|
|
1107
|
+
join(skillDir, "SKILL.md"),
|
|
1108
|
+
"---\nname: my-skill\ndescription: A test skill.\n---\n",
|
|
1109
|
+
);
|
|
1110
|
+
|
|
1111
|
+
// Somewhere entirely outside every pragma root, and it RESOLVES.
|
|
1112
|
+
const mine = join(cwd, "my-own-work", "my-skill");
|
|
1113
|
+
mkdirSync(mine, { recursive: true });
|
|
1114
|
+
writeFileSync(join(mine, "SKILL.md"), "---\nname: mine\n---\n");
|
|
1115
|
+
|
|
1116
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1117
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1118
|
+
const linkPath = join(linkDir, "my-skill");
|
|
1119
|
+
symlinkSync(mine, linkPath);
|
|
1120
|
+
|
|
1121
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1122
|
+
const action = detected.actions.find((a) => a.linkPath === linkPath);
|
|
1123
|
+
expect(action?.action).toBe("skipped");
|
|
1124
|
+
expect(action?.owned).toBe(false);
|
|
1125
|
+
|
|
1126
|
+
// Neither the preview nor the real run may touch it.
|
|
1127
|
+
const { effects } = dryRun(composeSkills(detected));
|
|
1128
|
+
expect(
|
|
1129
|
+
effects.filter(
|
|
1130
|
+
(e) =>
|
|
1131
|
+
e._tag === "DeleteFile" && (e as { path?: string }).path === linkPath,
|
|
1132
|
+
),
|
|
1133
|
+
).toHaveLength(0);
|
|
1134
|
+
|
|
1135
|
+
await runTask(composeSkills(detected));
|
|
1136
|
+
expect(readlinkSync(linkPath)).toBe(mine);
|
|
1137
|
+
expect(existsSync(join(linkPath, "SKILL.md"))).toBe(true);
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
it("sweeps a link into a REMOVED package directory once its skill is gone (REGRESSION)", async () => {
|
|
1141
|
+
// The upgrade shape under a version-stamped layout (pnpm, npx, volta): the
|
|
1142
|
+
// link points into `…@0.34.0/bundled-skills/<name>`, a directory the
|
|
1143
|
+
// upgrade replaced. Ownership by residence in a CURRENT root cannot see it,
|
|
1144
|
+
// and once the release drops that skill no per-skill action covers the path
|
|
1145
|
+
// either — so the dangling link would survive every reconcile, invisible to
|
|
1146
|
+
// setup and to doctor alike.
|
|
1147
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1148
|
+
const skillDir = join(cwd, ".pragma", "skills", "still-here");
|
|
1149
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1150
|
+
writeFileSync(
|
|
1151
|
+
join(skillDir, "SKILL.md"),
|
|
1152
|
+
"---\nname: still-here\ndescription: A test skill.\n---\n",
|
|
1153
|
+
);
|
|
1154
|
+
|
|
1155
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1156
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1157
|
+
// Never created: this is the point — the old package directory is gone.
|
|
1158
|
+
const retired = join(cwd, "pragma-cli@0.34.0", "bundled-skills", "retired");
|
|
1159
|
+
const linkPath = join(linkDir, "retired");
|
|
1160
|
+
symlinkSync(retired, linkPath);
|
|
1161
|
+
|
|
1162
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1163
|
+
const orphan = detected.orphans.find((o) => o.linkPath === linkPath);
|
|
1164
|
+
expect(orphan).toBeDefined();
|
|
1165
|
+
expect(orphan?.owned).toBe(true);
|
|
1166
|
+
});
|
|
1167
|
+
|
|
1168
|
+
it("classifies a DANGLING symlink as replaced and repairs it (S1-2)", async () => {
|
|
1169
|
+
// `existsSync` FOLLOWS a symlink, so a dangling link used to read as
|
|
1170
|
+
// "absent" → plan `created` → the real symlink() crashed EEXIST labeled
|
|
1171
|
+
// INTERNAL_ERROR ("please report this issue"), leaving the broken link in
|
|
1172
|
+
// place — and the dry-run previewed a clean create the run then belied.
|
|
1173
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1174
|
+
const skillDir = join(cwd, ".pragma", "skills", "my-skill");
|
|
1175
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1176
|
+
writeFileSync(
|
|
1177
|
+
join(skillDir, "SKILL.md"),
|
|
1178
|
+
"---\nname: my-skill\ndescription: A test skill.\n---\n",
|
|
1179
|
+
);
|
|
1180
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1181
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1182
|
+
const linkPath = join(linkDir, "my-skill");
|
|
1183
|
+
symlinkSync(join(cwd, "nonexistent-target"), linkPath);
|
|
1184
|
+
|
|
1185
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1186
|
+
const action = detected.actions.find((a) => a.linkPath === linkPath);
|
|
1187
|
+
expect(action?.action).toBe("replaced");
|
|
1188
|
+
|
|
1189
|
+
// The dry-run preview now PREDICTS the repair: a delete before the relink.
|
|
1190
|
+
const { effects } = dryRun(composeSkills(detected));
|
|
1191
|
+
const deletes = effects.filter(
|
|
1192
|
+
(e) =>
|
|
1193
|
+
e._tag === "DeleteFile" && (e as { path?: string }).path === linkPath,
|
|
1194
|
+
);
|
|
1195
|
+
expect(deletes.length).toBe(1);
|
|
1196
|
+
|
|
1197
|
+
// The real run repairs the link instead of crashing EEXIST.
|
|
1198
|
+
await runTask(composeSkills(detected));
|
|
1199
|
+
expect(readlinkSync(linkPath)).toBe(skillDir);
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
it("skips a real (non-symlink) directory at the link path, never deleting it", async () => {
|
|
1203
|
+
// A hand-placed skill directory is not this command's to delete — the
|
|
1204
|
+
// sibling planner (sources/installSkills.ts) already refuses to clobber
|
|
1205
|
+
// it, and the two planners must agree on classification.
|
|
1206
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1207
|
+
const skillDir = join(cwd, ".pragma", "skills", "my-skill");
|
|
1208
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1209
|
+
writeFileSync(
|
|
1210
|
+
join(skillDir, "SKILL.md"),
|
|
1211
|
+
"---\nname: my-skill\ndescription: A test skill.\n---\n",
|
|
1212
|
+
);
|
|
1213
|
+
const realDir = join(cwd, ".agents", "skills", "my-skill");
|
|
1214
|
+
mkdirSync(realDir, { recursive: true });
|
|
1215
|
+
writeFileSync(join(realDir, "SKILL.md"), "hand-placed\n");
|
|
1216
|
+
|
|
1217
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1218
|
+
const action = detected.actions.find((a) => a.linkPath === realDir);
|
|
1219
|
+
expect(action?.action).toBe("skipped");
|
|
1220
|
+
|
|
1221
|
+
const { effects } = dryRun(composeSkills(detected));
|
|
1222
|
+
expect(
|
|
1223
|
+
effects.some(
|
|
1224
|
+
(e) =>
|
|
1225
|
+
e._tag === "DeleteFile" && (e as { path?: string }).path === realDir,
|
|
1226
|
+
),
|
|
1227
|
+
).toBe(false);
|
|
1228
|
+
expect(readFileSync(join(realDir, "SKILL.md"), "utf-8")).toBe(
|
|
1229
|
+
"hand-placed\n",
|
|
1230
|
+
);
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
it("never owns — and never removes — a link into a SIBLING of the skill root", async () => {
|
|
1234
|
+
// Ownership was a string prefix, and `<sourceRoot>-backup/my-skill` starts
|
|
1235
|
+
// with `<sourceRoot>`. So a link the USER made into their own backup
|
|
1236
|
+
// directory was classified as ours, and `--undo` deleted it: a file this
|
|
1237
|
+
// command never created and had no business touching.
|
|
1238
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1239
|
+
const skillDir = join(cwd, ".pragma", "skills", "my-skill");
|
|
1240
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1241
|
+
writeFileSync(
|
|
1242
|
+
join(skillDir, "SKILL.md"),
|
|
1243
|
+
"---\nname: my-skill\ndescription: A test skill.\n---\n",
|
|
1244
|
+
);
|
|
1245
|
+
const backup = join(cwd, ".pragma", "skills-backup", "my-skill");
|
|
1246
|
+
mkdirSync(backup, { recursive: true });
|
|
1247
|
+
writeFileSync(join(backup, "SKILL.md"), "the user's own copy\n");
|
|
1248
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1249
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1250
|
+
const linkPath = join(linkDir, "my-skill");
|
|
1251
|
+
symlinkSync(backup, linkPath);
|
|
1252
|
+
|
|
1253
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1254
|
+
const action = detected.actions.find((a) => a.linkPath === linkPath);
|
|
1255
|
+
expect(action?.owned).toBe(false);
|
|
1256
|
+
expect(ownedSkillLinks(detected).map((a) => a.linkPath)).not.toContain(
|
|
1257
|
+
linkPath,
|
|
1258
|
+
);
|
|
1259
|
+
|
|
1260
|
+
// The removal is what `--undo` runs: it must leave both the link and the
|
|
1261
|
+
// directory it points at exactly where they were.
|
|
1262
|
+
await runUndo(composeSkillsRemoval(detected));
|
|
1263
|
+
expect(readlinkSync(linkPath)).toBe(backup);
|
|
1264
|
+
expect(readFileSync(join(backup, "SKILL.md"), "utf-8")).toBe(
|
|
1265
|
+
"the user's own copy\n",
|
|
1266
|
+
);
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
it("removes an owned link whose skill is gone from an emptied source root", async () => {
|
|
1270
|
+
// Detection returned before target discovery whenever the source root held
|
|
1271
|
+
// no skills, so `--undo` looked at an empty action list, reported zero work
|
|
1272
|
+
// and left this command's own links on disk forever.
|
|
1273
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1274
|
+
const skillsRoot = join(cwd, ".pragma", "skills");
|
|
1275
|
+
const skillDir = join(skillsRoot, "my-skill");
|
|
1276
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1277
|
+
writeFileSync(
|
|
1278
|
+
join(skillDir, "SKILL.md"),
|
|
1279
|
+
"---\nname: my-skill\ndescription: A test skill.\n---\n",
|
|
1280
|
+
);
|
|
1281
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1282
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1283
|
+
const linkPath = join(linkDir, "my-skill");
|
|
1284
|
+
symlinkSync(skillDir, linkPath);
|
|
1285
|
+
// The skill is removed upstream: the link is now ours, owned, and dangling.
|
|
1286
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
1287
|
+
|
|
1288
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1289
|
+
expect(detected.available).toBe(false);
|
|
1290
|
+
expect(ownedSkillLinks(detected).map((a) => a.linkPath)).toEqual([
|
|
1291
|
+
linkPath,
|
|
1292
|
+
]);
|
|
1293
|
+
|
|
1294
|
+
await runUndo(composeSkillsRemoval(detected));
|
|
1295
|
+
// `existsSync` follows the link, so the dangling one is read through lstat.
|
|
1296
|
+
expect(() => lstatSync(linkPath)).toThrow();
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it("owns a RELATIVE link into the skill root, resolved against the link dir", async () => {
|
|
1300
|
+
// `readlink` reports the raw target, which may be relative — resolving it
|
|
1301
|
+
// against the link's own directory is what makes the containment test
|
|
1302
|
+
// answer about a real path rather than a string.
|
|
1303
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1304
|
+
const skillDir = join(cwd, ".pragma", "skills", "my-skill");
|
|
1305
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1306
|
+
writeFileSync(
|
|
1307
|
+
join(skillDir, "SKILL.md"),
|
|
1308
|
+
"---\nname: my-skill\ndescription: A test skill.\n---\n",
|
|
1309
|
+
);
|
|
1310
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1311
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1312
|
+
const linkPath = join(linkDir, "my-skill");
|
|
1313
|
+
symlinkSync(relative(linkDir, skillDir), linkPath);
|
|
1314
|
+
|
|
1315
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1316
|
+
const action = detected.actions.find((a) => a.linkPath === linkPath);
|
|
1317
|
+
expect(action?.owned).toBe(true);
|
|
1318
|
+
// A relative link that RESOLVES to the skill is already correct. Comparing
|
|
1319
|
+
// the raw `readlink` string to an absolute path classified it `replaced`,
|
|
1320
|
+
// so it churned on every run and doctor called it "points elsewhere".
|
|
1321
|
+
expect(action?.action).toBe("skipped");
|
|
1322
|
+
expect(dryRun(composeSkills(detected)).effects).toEqual([]);
|
|
1323
|
+
|
|
1324
|
+
await runUndo(composeSkillsRemoval(detected));
|
|
1325
|
+
expect(existsSync(linkPath)).toBe(false);
|
|
1326
|
+
});
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
describe("setup skills — the forward pass is a RECONCILE", () => {
|
|
1330
|
+
/** Seed one project skill and return its source dir. */
|
|
1331
|
+
const seed = (cwd: string, name: string): string => {
|
|
1332
|
+
const dir = join(cwd, ".pragma", "skills", name);
|
|
1333
|
+
mkdirSync(dir, { recursive: true });
|
|
1334
|
+
writeFileSync(
|
|
1335
|
+
join(dir, "SKILL.md"),
|
|
1336
|
+
`---\nname: ${name}\ndescription: A skill.\n---\n`,
|
|
1337
|
+
);
|
|
1338
|
+
return dir;
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
it("removes the link a dropped skill left behind, without --undo", async () => {
|
|
1342
|
+
// The reported bug. `d.orphans` was computed and read ONLY by `--undo`,
|
|
1343
|
+
// which removes every owned link rather than the stale ones — so no command
|
|
1344
|
+
// reconciled, and a skill dropped upstream kept its link forever.
|
|
1345
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1346
|
+
const kept = seed(cwd, "kept");
|
|
1347
|
+
const dropped = seed(cwd, "dropped");
|
|
1348
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1349
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1350
|
+
symlinkSync(kept, join(linkDir, "kept"));
|
|
1351
|
+
symlinkSync(dropped, join(linkDir, "dropped"));
|
|
1352
|
+
rmSync(dropped, { recursive: true, force: true }); // retired upstream
|
|
1353
|
+
|
|
1354
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1355
|
+
expect(detected.orphans.map((o) => o.skillName)).toEqual(["dropped"]);
|
|
1356
|
+
|
|
1357
|
+
await runTask(composeSkills(detected));
|
|
1358
|
+
// `existsSync` FOLLOWS a link, so a dangling one false-negatives — lstat.
|
|
1359
|
+
expect(() => lstatSync(join(linkDir, "dropped"))).toThrow();
|
|
1360
|
+
expect(readlinkSync(join(linkDir, "kept"))).toBe(kept);
|
|
1361
|
+
});
|
|
1362
|
+
|
|
1363
|
+
it("plans the sweep as an ACTIONABLE row, not a deselected `none`", async () => {
|
|
1364
|
+
// An orphan-only tree planned `none` → `defaultSelected(none)` is false →
|
|
1365
|
+
// the row was never composed, so even the fixed reconcile would not run.
|
|
1366
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1367
|
+
const dropped = seed(cwd, "dropped");
|
|
1368
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1369
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1370
|
+
symlinkSync(dropped, join(linkDir, "dropped"));
|
|
1371
|
+
rmSync(dropped, { recursive: true, force: true });
|
|
1372
|
+
|
|
1373
|
+
const { plan } = await buildSetupRun(
|
|
1374
|
+
bootRuntime(FLAGS, cwd),
|
|
1375
|
+
"skills",
|
|
1376
|
+
"project",
|
|
1377
|
+
);
|
|
1378
|
+
const row = plan.rows.find((r) => r.target === "skills");
|
|
1379
|
+
expect(row?.action).toBe("update");
|
|
1380
|
+
expect(row?.selected).toBe(true);
|
|
1381
|
+
expect(row?.detail).toContain("1 stale link to remove");
|
|
1382
|
+
});
|
|
1383
|
+
|
|
1384
|
+
it("the sweep is reversible — undo puts the stale link back", async () => {
|
|
1385
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1386
|
+
const dropped = seed(cwd, "dropped");
|
|
1387
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1388
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1389
|
+
const linkPath = join(linkDir, "dropped");
|
|
1390
|
+
symlinkSync(dropped, linkPath);
|
|
1391
|
+
rmSync(dropped, { recursive: true, force: true });
|
|
1392
|
+
|
|
1393
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1394
|
+
const { undoCount } = await runUndo(composeSkills(detected));
|
|
1395
|
+
expect(undoCount).toBeGreaterThan(0);
|
|
1396
|
+
expect(readlinkSync(linkPath)).toBe(dropped);
|
|
1397
|
+
});
|
|
1398
|
+
|
|
1399
|
+
it("is idempotent — a second pass composes zero effects", async () => {
|
|
1400
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1401
|
+
const dropped = seed(cwd, "dropped");
|
|
1402
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1403
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1404
|
+
symlinkSync(dropped, join(linkDir, "dropped"));
|
|
1405
|
+
rmSync(dropped, { recursive: true, force: true });
|
|
1406
|
+
|
|
1407
|
+
await runTask(
|
|
1408
|
+
composeSkills(await detectSkills(bootRuntime(FLAGS, cwd), "project")),
|
|
1409
|
+
);
|
|
1410
|
+
const second = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1411
|
+
expect(dryRun(composeSkills(second)).effects).toEqual([]);
|
|
1412
|
+
});
|
|
1413
|
+
|
|
1414
|
+
it("SAFETY: an ABSENT source root means no opinion — nothing is swept", async () => {
|
|
1415
|
+
// `available` conflated "the root is empty" with "the root does not exist".
|
|
1416
|
+
// Sweeping on that alone would wipe every pragma link out of the harness
|
|
1417
|
+
// dirs on any machine where XDG_DATA_HOME moved or sources update never ran.
|
|
1418
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1419
|
+
const skillDir = seed(cwd, "gone");
|
|
1420
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1421
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1422
|
+
const linkPath = join(linkDir, "gone");
|
|
1423
|
+
symlinkSync(skillDir, linkPath);
|
|
1424
|
+
// The whole source ROOT disappears, not just one skill.
|
|
1425
|
+
rmSync(join(cwd, ".pragma", "skills"), { recursive: true, force: true });
|
|
1426
|
+
|
|
1427
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1428
|
+
expect(detected.rootExists).toBe(false);
|
|
1429
|
+
expect(detected.available).toBe(false);
|
|
1430
|
+
expect(detected.orphans).toEqual([]);
|
|
1431
|
+
expect(dryRun(composeSkills(detected)).effects).toEqual([]);
|
|
1432
|
+
|
|
1433
|
+
await runTask(composeSkills(detected));
|
|
1434
|
+
expect(readlinkSync(linkPath)).toBe(skillDir);
|
|
1435
|
+
});
|
|
1436
|
+
|
|
1437
|
+
it("SAFETY: a real dir, a resolving user link and a `-backup` sibling all survive", async () => {
|
|
1438
|
+
// The three shapes the sweep must never touch, in one tree: a hand-placed
|
|
1439
|
+
// directory, a link into someone's own checkout, and a link into a SIBLING
|
|
1440
|
+
// whose name merely EXTENDS the root's (the old string-prefix bug).
|
|
1441
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1442
|
+
const root = join(cwd, ".pragma", "skills");
|
|
1443
|
+
mkdirSync(root, { recursive: true }); // exists, but holds no skill
|
|
1444
|
+
const backup = join(cwd, ".pragma", "skills-backup", "sibling");
|
|
1445
|
+
mkdirSync(backup, { recursive: true });
|
|
1446
|
+
writeFileSync(join(backup, "SKILL.md"), "the user's own copy\n");
|
|
1447
|
+
const elsewhere = join(cwd, "elsewhere", "mine");
|
|
1448
|
+
mkdirSync(elsewhere, { recursive: true });
|
|
1449
|
+
|
|
1450
|
+
const linkDir = join(cwd, ".agents", "skills");
|
|
1451
|
+
mkdirSync(linkDir, { recursive: true });
|
|
1452
|
+
const realDir = join(linkDir, "hand-placed");
|
|
1453
|
+
mkdirSync(realDir, { recursive: true });
|
|
1454
|
+
writeFileSync(join(realDir, "SKILL.md"), "hand-placed\n");
|
|
1455
|
+
symlinkSync(elsewhere, join(linkDir, "mine"));
|
|
1456
|
+
symlinkSync(backup, join(linkDir, "sibling"));
|
|
1457
|
+
|
|
1458
|
+
const detected = await detectSkills(bootRuntime(FLAGS, cwd), "project");
|
|
1459
|
+
expect(detected.rootExists).toBe(true); // the sweep IS entitled to act
|
|
1460
|
+
expect(detected.orphans).toEqual([]); // ...and finds nothing of its own
|
|
1461
|
+
expect(dryRun(composeSkills(detected)).effects).toEqual([]);
|
|
1462
|
+
|
|
1463
|
+
await runTask(composeSkills(detected));
|
|
1464
|
+
expect(readFileSync(join(realDir, "SKILL.md"), "utf-8")).toBe(
|
|
1465
|
+
"hand-placed\n",
|
|
1466
|
+
);
|
|
1467
|
+
expect(readlinkSync(join(linkDir, "mine"))).toBe(elsewhere);
|
|
1468
|
+
expect(readlinkSync(join(linkDir, "sibling"))).toBe(backup);
|
|
1469
|
+
expect(readFileSync(join(backup, "SKILL.md"), "utf-8")).toBe(
|
|
1470
|
+
"the user's own copy\n",
|
|
1471
|
+
);
|
|
1472
|
+
});
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
describe("setup — detections settle independently", () => {
|
|
1476
|
+
it("gives a rejecting detection its own failed row, leaving the rest planned", async () => {
|
|
1477
|
+
// The targets are independent, and one `Promise.all` made them share a
|
|
1478
|
+
// fate: a single rejecting detection prevented a run being built at all,
|
|
1479
|
+
// so NONE of the other targets ran and doctor rendered no scoped rows.
|
|
1480
|
+
const { TARGETS } = await import("./targets.js");
|
|
1481
|
+
const lsp = TARGETS.find((t) => t.id === "lsp") as (typeof TARGETS)[number];
|
|
1482
|
+
const spy = vi
|
|
1483
|
+
.spyOn(lsp, "detect")
|
|
1484
|
+
.mockRejectedValue(new Error("EACCES: permission denied, open '/x'"));
|
|
1485
|
+
try {
|
|
1486
|
+
const run = await buildSetupRun(
|
|
1487
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1488
|
+
"all",
|
|
1489
|
+
"global",
|
|
1490
|
+
);
|
|
1491
|
+
const row = run.plan.rows.find((r) => r.target === "lsp");
|
|
1492
|
+
expect(row?.reason).toContain("EACCES");
|
|
1493
|
+
// The point of the fix: the siblings are still planned.
|
|
1494
|
+
expect(
|
|
1495
|
+
run.plan.rows.filter((r) => r.target !== "lsp").length,
|
|
1496
|
+
).toBeGreaterThan(0);
|
|
1497
|
+
// And the row is attributable, not a quiet skip: it exits non-zero.
|
|
1498
|
+
const applied = run.applied({});
|
|
1499
|
+
expect(
|
|
1500
|
+
applied.rows.find((r) => r.target === "lsp")?.outcome?.status,
|
|
1501
|
+
).toBe("failed");
|
|
1502
|
+
} finally {
|
|
1503
|
+
spy.mockRestore();
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
});
|
|
1507
|
+
|
|
1508
|
+
describe("setup lsp — prerequisites (bun absent / no editor CLI)", () => {
|
|
1509
|
+
it("surfaces a NAMED UNSUPPORTED (not INTERNAL_ERROR) when bun is off PATH", async () => {
|
|
1510
|
+
// Drives the REAL composeLsp fetch (YES — not a dry-run mock) with a
|
|
1511
|
+
// stubbed `code` CLI on PATH (so the sideload is attempted) but no `bun`,
|
|
1512
|
+
// so the fetch spawn REJECTS with ENOENT. The guard names it; without the
|
|
1513
|
+
// guard the raw reject collapses to INTERNAL_ERROR ("please report this
|
|
1514
|
+
// issue") at the boundary.
|
|
443
1515
|
const prevPath = process.env.PATH;
|
|
444
|
-
process.env.
|
|
1516
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
1517
|
+
const stubDir = stubPath();
|
|
1518
|
+
writeFileSync(join(stubDir, "code"), ""); // present ⇒ vscode is a target
|
|
1519
|
+
process.env.PATH = stubDir;
|
|
1520
|
+
process.env.XDG_DATA_HOME = tmp("pragma-lsp-data-"); // jail the staging dir
|
|
445
1521
|
let thrown: unknown;
|
|
446
1522
|
try {
|
|
447
1523
|
await executeVerb(
|
|
@@ -454,16 +1530,207 @@ describe("setup lsp — missing-binary guard (bunx absent)", () => {
|
|
|
454
1530
|
thrown = error;
|
|
455
1531
|
} finally {
|
|
456
1532
|
process.env.PATH = prevPath;
|
|
1533
|
+
process.env.XDG_DATA_HOME = prevData;
|
|
457
1534
|
}
|
|
458
1535
|
expect(thrown).toBeDefined();
|
|
459
1536
|
const err = asPragmaError(thrown);
|
|
460
1537
|
expect(err.code).toBe("UNSUPPORTED");
|
|
461
1538
|
expect(err.code).not.toBe("INTERNAL_ERROR");
|
|
462
|
-
expect(err.message).toContain("
|
|
1539
|
+
expect(err.message).toContain("bun");
|
|
463
1540
|
expect(err.message).toMatch(/not found on your PATH/i);
|
|
464
1541
|
// The recovery is the actionable install hint, not "report this issue".
|
|
465
1542
|
expect(err.recovery?.message ?? "").not.toMatch(/report this issue/i);
|
|
466
1543
|
});
|
|
1544
|
+
|
|
1545
|
+
it("sideloads the bundled VSIX into EACH detected editor missing it (per-editor CLI, not a hardcoded `code`)", async () => {
|
|
1546
|
+
// Two editor CLIs on PATH; VS Code already has the extension (its
|
|
1547
|
+
// extensions dir carries a versioned copy), VSCodium does not — so the
|
|
1548
|
+
// composed plan fetches the package once (bun, into a durable staging dir)
|
|
1549
|
+
// and sideloads ONLY into `codium`. This is the VSCodium fix: the editor
|
|
1550
|
+
// CLI is a registry lookup, no longer the hardcoded `code`.
|
|
1551
|
+
const prevPath = process.env.PATH;
|
|
1552
|
+
const stubDir = tmp("pragma-forks-path-");
|
|
1553
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
1554
|
+
writeFileSync(join(stubDir, "codium"), "");
|
|
1555
|
+
process.env.PATH = stubDir;
|
|
1556
|
+
try {
|
|
1557
|
+
mkdirSync(
|
|
1558
|
+
join(
|
|
1559
|
+
process.env.HOME ?? "",
|
|
1560
|
+
".vscode",
|
|
1561
|
+
"extensions",
|
|
1562
|
+
"canonical.terrazzo-lsp-extension-1.2.3",
|
|
1563
|
+
),
|
|
1564
|
+
{ recursive: true },
|
|
1565
|
+
);
|
|
1566
|
+
const { detectLsp, composeLsp } = await import(
|
|
1567
|
+
"./operations/setupLsp.js"
|
|
1568
|
+
);
|
|
1569
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
1570
|
+
expect(detected.state).toBe("absent");
|
|
1571
|
+
expect(detected.editors.map((e) => [e.editor.cli, e.installed])).toEqual([
|
|
1572
|
+
["code", true],
|
|
1573
|
+
["codium", false],
|
|
1574
|
+
]);
|
|
1575
|
+
|
|
1576
|
+
const { effects } = dryRun(composeLsp(detected));
|
|
1577
|
+
const execs = effects.filter((e) => e._tag === "Exec") as (Effect & {
|
|
1578
|
+
_tag: "Exec";
|
|
1579
|
+
command: string;
|
|
1580
|
+
args: string[];
|
|
1581
|
+
})[];
|
|
1582
|
+
// One fetch (bun add, durable staging) + one sideload per PENDING editor.
|
|
1583
|
+
expect(execs.map((e) => e.command)).toEqual(["bun", "codium"]);
|
|
1584
|
+
expect(execs[0].args).toEqual([
|
|
1585
|
+
"add",
|
|
1586
|
+
"@canonical/terrazzo-lsp-extension@latest",
|
|
1587
|
+
]);
|
|
1588
|
+
expect(execs[1].args[0]).toBe("--install-extension");
|
|
1589
|
+
// The VSIX path is the staging dir's — durable, not a bunx /tmp cache.
|
|
1590
|
+
expect(execs[1].args[1]).toContain(detected.stagingDir);
|
|
1591
|
+
expect(execs[1].args[1]).toContain("terrazzo-lsp.vsix");
|
|
1592
|
+
} finally {
|
|
1593
|
+
process.env.PATH = prevPath;
|
|
1594
|
+
}
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
it("treats an extension too old to WORK as not installed", async () => {
|
|
1598
|
+
// Every VSIX before 0.8.3 ships a language server that cannot resolve its
|
|
1599
|
+
// own dependencies — it only appeared to run because the extension prefers
|
|
1600
|
+
// Bun and Bun fetches missing packages off the network at spawn time.
|
|
1601
|
+
// Matching any version meant such a machine reported `installed`, so
|
|
1602
|
+
// `setup lsp` skipped it and `doctor` called it healthy: a wrong skip that
|
|
1603
|
+
// reads as correct, which is worse than a wrong failure. Everyone who ran
|
|
1604
|
+
// setup before that release would have kept a dead server forever.
|
|
1605
|
+
const prevPath = process.env.PATH;
|
|
1606
|
+
const stubDir = tmp("pragma-stale-ext-path-");
|
|
1607
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
1608
|
+
process.env.PATH = stubDir;
|
|
1609
|
+
try {
|
|
1610
|
+
mkdirSync(
|
|
1611
|
+
join(
|
|
1612
|
+
process.env.HOME ?? "",
|
|
1613
|
+
".vscode",
|
|
1614
|
+
"extensions",
|
|
1615
|
+
"canonical.terrazzo-lsp-extension-0.8.1",
|
|
1616
|
+
),
|
|
1617
|
+
{ recursive: true },
|
|
1618
|
+
);
|
|
1619
|
+
const { detectLsp } = await import("./operations/setupLsp.js");
|
|
1620
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
1621
|
+
expect(detected.state).not.toBe("installed");
|
|
1622
|
+
} finally {
|
|
1623
|
+
process.env.PATH = prevPath;
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
|
|
1627
|
+
it("treats a version it cannot READ as too old, not as newer", async () => {
|
|
1628
|
+
// The fail-closed claim has to survive the parser. `Number.parseInt`
|
|
1629
|
+
// accepts numeric prefixes and a `|| 0` fallback turns NaN into zero, so
|
|
1630
|
+
// `1.invalid.0` and `1.0.0junk` both read as [1,0,0] — comparing NEWER
|
|
1631
|
+
// than the minimum and reporting a broken extension as installed, which
|
|
1632
|
+
// is the wrong skip this check exists to prevent.
|
|
1633
|
+
const prevPath = process.env.PATH;
|
|
1634
|
+
const stubDir = tmp("pragma-badver-path-");
|
|
1635
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
1636
|
+
process.env.PATH = stubDir;
|
|
1637
|
+
try {
|
|
1638
|
+
for (const suffix of ["1.invalid.0", "1.0.0junk", "garbage", ""]) {
|
|
1639
|
+
mkdirSync(
|
|
1640
|
+
join(
|
|
1641
|
+
process.env.HOME ?? "",
|
|
1642
|
+
".vscode",
|
|
1643
|
+
"extensions",
|
|
1644
|
+
`canonical.terrazzo-lsp-extension-${suffix}`,
|
|
1645
|
+
),
|
|
1646
|
+
{ recursive: true },
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
const { detectLsp } = await import("./operations/setupLsp.js");
|
|
1650
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
1651
|
+
expect(detected.state).not.toBe("installed");
|
|
1652
|
+
} finally {
|
|
1653
|
+
process.env.PATH = prevPath;
|
|
1654
|
+
}
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
it("reports already-installed (a true no-op) when every detected editor has the extension", async () => {
|
|
1658
|
+
const prevPath = process.env.PATH;
|
|
1659
|
+
const stubDir = tmp("pragma-installed-path-");
|
|
1660
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
1661
|
+
process.env.PATH = stubDir;
|
|
1662
|
+
try {
|
|
1663
|
+
mkdirSync(
|
|
1664
|
+
join(
|
|
1665
|
+
process.env.HOME ?? "",
|
|
1666
|
+
".vscode",
|
|
1667
|
+
"extensions",
|
|
1668
|
+
"canonical.terrazzo-lsp-extension-1.2.3",
|
|
1669
|
+
),
|
|
1670
|
+
{ recursive: true },
|
|
1671
|
+
);
|
|
1672
|
+
const { detectLsp, composeLsp } = await import(
|
|
1673
|
+
"./operations/setupLsp.js"
|
|
1674
|
+
);
|
|
1675
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
1676
|
+
expect(detected.state).toBe("installed");
|
|
1677
|
+
const { effects } = dryRun(composeLsp(detected));
|
|
1678
|
+
expect(effects.some((e) => e._tag === "Exec")).toBe(false);
|
|
1679
|
+
} finally {
|
|
1680
|
+
process.env.PATH = prevPath;
|
|
1681
|
+
}
|
|
1682
|
+
});
|
|
1683
|
+
|
|
1684
|
+
it("matches the FULL extension id, not any terrazzo-named directory", async () => {
|
|
1685
|
+
// The old probe matched the substring "terrazzo", which any other
|
|
1686
|
+
// terrazzo-named extension would false-positive.
|
|
1687
|
+
const prevPath = process.env.PATH;
|
|
1688
|
+
const stubDir = tmp("pragma-substr-path-");
|
|
1689
|
+
writeFileSync(join(stubDir, "code"), "");
|
|
1690
|
+
process.env.PATH = stubDir;
|
|
1691
|
+
try {
|
|
1692
|
+
mkdirSync(
|
|
1693
|
+
join(
|
|
1694
|
+
process.env.HOME ?? "",
|
|
1695
|
+
".vscode",
|
|
1696
|
+
"extensions",
|
|
1697
|
+
"someoneelse.terrazzo-tools-9.9.9",
|
|
1698
|
+
),
|
|
1699
|
+
{ recursive: true },
|
|
1700
|
+
);
|
|
1701
|
+
const { detectLsp } = await import("./operations/setupLsp.js");
|
|
1702
|
+
const detected = await detectLsp(tmp("pragma-setup-proj-"));
|
|
1703
|
+
expect(detected.state).toBe("absent");
|
|
1704
|
+
} finally {
|
|
1705
|
+
process.env.PATH = prevPath;
|
|
1706
|
+
}
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
it("skips with a NAMED message (exit 0) when no editor CLI exists at all", async () => {
|
|
1710
|
+
// A machine with no VS Code-family CLI cannot be installed into — the
|
|
1711
|
+
// honest outcome is a named skip, not the old UNSUPPORTED with a
|
|
1712
|
+
// "permissions or network" guess and a dead /tmp path (§3, S1-1's trigger).
|
|
1713
|
+
const prevPath = process.env.PATH;
|
|
1714
|
+
process.env.PATH = stubPath(); // empty ⇒ no editor CLI
|
|
1715
|
+
try {
|
|
1716
|
+
const outcome = await executeVerb(
|
|
1717
|
+
verbOf("lsp"),
|
|
1718
|
+
{},
|
|
1719
|
+
YES,
|
|
1720
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1721
|
+
);
|
|
1722
|
+
expect(outcome.exitCode).toBe(0);
|
|
1723
|
+
expect(outcome.stdout).toContain("skipped");
|
|
1724
|
+
expect(outcome.stdout).toContain("no VS Code-family editor CLI on PATH");
|
|
1725
|
+
// The remedy names what would count, and states plainly that nothing is
|
|
1726
|
+
// possible here yet rather than offering a command this machine lacks.
|
|
1727
|
+
expect(outcome.stdout).toContain(
|
|
1728
|
+
"no action is possible on this machine yet",
|
|
1729
|
+
);
|
|
1730
|
+
} finally {
|
|
1731
|
+
process.env.PATH = prevPath;
|
|
1732
|
+
}
|
|
1733
|
+
});
|
|
467
1734
|
});
|
|
468
1735
|
|
|
469
1736
|
describe("setup — idempotent detection of already-present config", () => {
|
|
@@ -471,7 +1738,7 @@ describe("setup — idempotent detection of already-present config", () => {
|
|
|
471
1738
|
let prevPath: string | undefined;
|
|
472
1739
|
beforeEach(() => {
|
|
473
1740
|
prevPath = process.env.PATH;
|
|
474
|
-
process.env.PATH =
|
|
1741
|
+
process.env.PATH = stubPath();
|
|
475
1742
|
});
|
|
476
1743
|
afterEach(() => {
|
|
477
1744
|
process.env.PATH = prevPath;
|
|
@@ -483,42 +1750,41 @@ describe("setup — idempotent detection of already-present config", () => {
|
|
|
483
1750
|
const configPath = join(cwd, ".cursor", "mcp.json");
|
|
484
1751
|
|
|
485
1752
|
// First run writes the pragma entry.
|
|
486
|
-
await executeVerb(
|
|
1753
|
+
await executeVerb(
|
|
1754
|
+
verbOf("mcp"),
|
|
1755
|
+
{ local: true },
|
|
1756
|
+
YES,
|
|
1757
|
+
bootRuntime(FLAGS, cwd),
|
|
1758
|
+
);
|
|
487
1759
|
const firstBody = readFileSync(configPath, "utf-8");
|
|
488
1760
|
|
|
489
|
-
//
|
|
490
|
-
|
|
1761
|
+
// The plan's mcp row now reads `none` — nothing to do — and its child for
|
|
1762
|
+
// this file reads `unchanged`.
|
|
1763
|
+
const { plan } = await buildSetupRun(
|
|
491
1764
|
bootRuntime(FLAGS, cwd),
|
|
492
1765
|
"mcp",
|
|
493
|
-
"
|
|
1766
|
+
"project",
|
|
494
1767
|
);
|
|
495
|
-
const
|
|
496
|
-
expect(
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
1768
|
+
const row = plan.rows.find((r) => r.target === "mcp");
|
|
1769
|
+
expect(row?.action).toBe("none");
|
|
1770
|
+
expect(row?.children?.find((c) => c.key === configPath)?.action).toBe(
|
|
1771
|
+
"unchanged",
|
|
1772
|
+
);
|
|
1773
|
+
// An already-current row is offered DE-selected, so a re-run never proposes
|
|
1774
|
+
// to rewrite what is already correct.
|
|
1775
|
+
expect(row?.selected).toBe(false);
|
|
500
1776
|
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
const
|
|
1777
|
+
// A real second run is idempotent: the file stays byte-identical AND the
|
|
1778
|
+
// converged row composes no effect at all, so the mtime is untouched.
|
|
1779
|
+
const before = statSync(configPath).mtimeMs;
|
|
1780
|
+
await executeVerb(
|
|
1781
|
+
verbOf("mcp"),
|
|
1782
|
+
{ local: true },
|
|
1783
|
+
YES,
|
|
504
1784
|
bootRuntime(FLAGS, cwd),
|
|
505
|
-
"mcp",
|
|
506
|
-
"both",
|
|
507
1785
|
);
|
|
508
|
-
const multiselect = generator.prompts.find((p) => p.name === "mcpTargets");
|
|
509
|
-
expect(multiselect?.default).toEqual([]); // the only file is configured
|
|
510
|
-
expect(
|
|
511
|
-
(multiselect?.choices ?? []).some((c) =>
|
|
512
|
-
typeof c === "object" && "label" in c
|
|
513
|
-
? c.label.includes("already configured")
|
|
514
|
-
: false,
|
|
515
|
-
),
|
|
516
|
-
).toBe(true);
|
|
517
|
-
|
|
518
|
-
// A real second run is idempotent: the file stays byte-identical (the
|
|
519
|
-
// read-modify-write re-merges the SAME pragma entry).
|
|
520
|
-
await executeVerb(verbOf("mcp"), {}, YES, bootRuntime(FLAGS, cwd));
|
|
521
1786
|
expect(readFileSync(configPath, "utf-8")).toBe(firstBody);
|
|
1787
|
+
expect(statSync(configPath).mtimeMs).toBe(before);
|
|
522
1788
|
});
|
|
523
1789
|
|
|
524
1790
|
it("mcp: a drifted pragma entry (wrong cwd) reads as `drifted` and is updated", async () => {
|
|
@@ -539,111 +1805,438 @@ describe("setup — idempotent detection of already-present config", () => {
|
|
|
539
1805
|
)}\n`,
|
|
540
1806
|
);
|
|
541
1807
|
|
|
542
|
-
const {
|
|
1808
|
+
const { plan } = await buildSetupRun(
|
|
543
1809
|
bootRuntime(FLAGS, cwd),
|
|
544
1810
|
"mcp",
|
|
545
|
-
"
|
|
1811
|
+
"project",
|
|
546
1812
|
);
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
expect(
|
|
1813
|
+
const row = plan.rows.find((r) => r.target === "mcp");
|
|
1814
|
+
// A drifted file is an `update` child, and the row stays SELECTED by
|
|
1815
|
+
// default because it needs the write.
|
|
1816
|
+
expect(row?.children?.find((c) => c.key === configPath)?.action).toBe(
|
|
1817
|
+
"update",
|
|
1818
|
+
);
|
|
1819
|
+
expect(row?.action).toBe("update");
|
|
1820
|
+
expect(row?.selected).toBe(true);
|
|
554
1821
|
|
|
555
|
-
await executeVerb(
|
|
1822
|
+
await executeVerb(
|
|
1823
|
+
verbOf("mcp"),
|
|
1824
|
+
{ local: true },
|
|
1825
|
+
YES,
|
|
1826
|
+
bootRuntime(FLAGS, cwd),
|
|
1827
|
+
);
|
|
556
1828
|
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
557
1829
|
expect(config.mcpServers.pragma.cwd).toBe(cwd); // updated to the real cwd
|
|
558
1830
|
});
|
|
559
1831
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
1832
|
+
withShell(
|
|
1833
|
+
"completions: a second run detects the up-to-date script (state=installed)",
|
|
1834
|
+
async () => {
|
|
1835
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1836
|
+
const path = completionScriptPath(SHELL as ShellId);
|
|
1837
|
+
// First install.
|
|
1838
|
+
await executeVerb(completionsVerb, {}, YES, bootRuntime(FLAGS, cwd));
|
|
1839
|
+
const firstBody = readFileSync(path, "utf-8");
|
|
566
1840
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
1841
|
+
// The plan's completions row now reads `none` — the installed bytes are
|
|
1842
|
+
// current, so a re-run composes nothing.
|
|
1843
|
+
const { plan } = await buildSetupRun(
|
|
1844
|
+
bootRuntime(FLAGS, cwd),
|
|
1845
|
+
"completions",
|
|
1846
|
+
"global",
|
|
1847
|
+
);
|
|
1848
|
+
expect(plan.rows.find((r) => r.target === "completions")?.action).toBe(
|
|
1849
|
+
"none",
|
|
1850
|
+
);
|
|
577
1851
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
1852
|
+
// A real second run is idempotent — the byte-identical script survives.
|
|
1853
|
+
await executeVerb(completionsVerb, {}, YES, bootRuntime(FLAGS, cwd));
|
|
1854
|
+
expect(readFileSync(path, "utf-8")).toBe(firstBody);
|
|
1855
|
+
},
|
|
1856
|
+
);
|
|
582
1857
|
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
1858
|
+
withShell(
|
|
1859
|
+
"completions: a stale script (different body) reads as `stale`",
|
|
1860
|
+
async () => {
|
|
1861
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1862
|
+
const path = completionScriptPath(SHELL as ShellId);
|
|
1863
|
+
mkdirSync(join(path, ".."), { recursive: true });
|
|
1864
|
+
writeFileSync(path, "# stale hand-edited completion\n");
|
|
1865
|
+
const { plan } = await buildSetupRun(
|
|
1866
|
+
bootRuntime(FLAGS, cwd),
|
|
1867
|
+
"completions",
|
|
1868
|
+
"global",
|
|
1869
|
+
);
|
|
1870
|
+
// A hand-edited script is `update`, not `install`: the row says which of
|
|
1871
|
+
// the two it is, because overwriting someone's edit is worth naming.
|
|
1872
|
+
expect(plan.rows.find((r) => r.target === "completions")?.action).toBe(
|
|
1873
|
+
"update",
|
|
1874
|
+
);
|
|
1875
|
+
},
|
|
1876
|
+
);
|
|
598
1877
|
|
|
599
1878
|
it("lsp: reports `unknown` when the `code` CLI is absent from PATH", async () => {
|
|
600
1879
|
// PATH is the isolated empty dir (beforeEach), so `code` is unresolvable:
|
|
601
1880
|
// detection cannot enumerate and reports `unknown` (installer still runs).
|
|
602
|
-
const {
|
|
1881
|
+
const { plan } = await buildSetupRun(
|
|
603
1882
|
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
604
1883
|
"lsp",
|
|
605
|
-
"
|
|
1884
|
+
"global",
|
|
606
1885
|
);
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
1886
|
+
// No editor CLI resolves, so the row is a SKIP carrying its reason —
|
|
1887
|
+
// never a silent omission, and never a failure.
|
|
1888
|
+
const row = plan.rows.find((r) => r.target === "lsp");
|
|
1889
|
+
expect(row?.action).toBe("skip");
|
|
1890
|
+
expect(row?.reason).toContain("no VS Code-family editor CLI on PATH");
|
|
612
1891
|
});
|
|
613
1892
|
});
|
|
614
1893
|
|
|
615
1894
|
describe("setup (run-all wizard)", () => {
|
|
616
|
-
|
|
1895
|
+
// Pin PATH to a stub dir carrying only a `code` CLI: the LSP step's editor
|
|
1896
|
+
// detection (and the ambient-harness process signals) must not depend on
|
|
1897
|
+
// what happens to be installed on the host running the tests.
|
|
1898
|
+
let prevPath: string | undefined;
|
|
1899
|
+
beforeEach(() => {
|
|
1900
|
+
prevPath = process.env.PATH;
|
|
1901
|
+
const dir = stubPath();
|
|
1902
|
+
writeFileSync(join(dir, "code"), "");
|
|
1903
|
+
process.env.PATH = dir;
|
|
1904
|
+
});
|
|
1905
|
+
afterEach(() => {
|
|
1906
|
+
process.env.PATH = prevPath;
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
withShell(
|
|
1910
|
+
"--dry-run previews every DETECTED step (completions + lsp + mcp), writing nothing",
|
|
1911
|
+
async () => {
|
|
1912
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
1913
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true }); // harness detected
|
|
1914
|
+
const outcome = await executeVerb(
|
|
1915
|
+
setupSelfVerb,
|
|
1916
|
+
{},
|
|
1917
|
+
DRY,
|
|
1918
|
+
bootRuntime(FLAGS, cwd),
|
|
1919
|
+
);
|
|
1920
|
+
expect(outcome.exitCode).toBe(0);
|
|
1921
|
+
const plan = outcome.stdout ?? "";
|
|
1922
|
+
// EVERY target is a visible row — including the ones that will skip. The
|
|
1923
|
+
// run-all used to build its choices only from detectable steps, so a
|
|
1924
|
+
// target it could not offer vanished from the plan and from the recap.
|
|
1925
|
+
for (const id of ["config", "completions", "lsp", "mcp", "skills"]) {
|
|
1926
|
+
expect(plan).toContain(id);
|
|
1927
|
+
}
|
|
1928
|
+
expect(plan).toContain(`${SHELL} →`); // completions row
|
|
1929
|
+
expect(plan).not.toContain("Prompt"); // recap gate / multiselects filtered
|
|
1930
|
+
// Nothing is written by a preview.
|
|
1931
|
+
expect(existsSync(completionScriptPath(SHELL as ShellId))).toBe(false);
|
|
1932
|
+
expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(false);
|
|
1933
|
+
},
|
|
1934
|
+
);
|
|
1935
|
+
|
|
1936
|
+
it("a failing LSP step no longer aborts the run — MCP and skills still apply, exit reflects the failure (S1-1)", async () => {
|
|
1937
|
+
// The audit's headline break: with an unsatisfiable LSP prerequisite the
|
|
1938
|
+
// single advertised onboarding command configured NOTHING (no MCP write,
|
|
1939
|
+
// no skills link) and exited 1. Steps are independent — one failure must
|
|
1940
|
+
// report, let the rest proceed, and only then fail the run. Here the LSP
|
|
1941
|
+
// step has an editor (`code` stub on PATH) but no `bun`, so its fetch
|
|
1942
|
+
// fails; MCP (.cursor) and skills (a seeded skill) must still apply.
|
|
1943
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
1944
|
+
process.env.XDG_DATA_HOME = tmp("pragma-s11-data-"); // jail the staging dir
|
|
617
1945
|
const cwd = tmp("pragma-setup-proj-");
|
|
618
|
-
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
1946
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
1947
|
+
const skillDir = join(cwd, ".pragma", "skills", "s");
|
|
1948
|
+
mkdirSync(skillDir, { recursive: true });
|
|
1949
|
+
writeFileSync(
|
|
1950
|
+
join(skillDir, "SKILL.md"),
|
|
1951
|
+
"---\nname: s\ndescription: A skill.\n---\n",
|
|
1952
|
+
);
|
|
1953
|
+
let thrown: unknown;
|
|
1954
|
+
try {
|
|
1955
|
+
// `--scope both`: the failing target (lsp) is global-only, and the two
|
|
1956
|
+
// that must survive it write the project scope, so the run has to cover
|
|
1957
|
+
// both. A bare run-all is the GLOBAL scope now and would not touch this
|
|
1958
|
+
// repository at all.
|
|
1959
|
+
await executeVerb(
|
|
1960
|
+
setupSelfVerb,
|
|
1961
|
+
{ scope: "both" },
|
|
1962
|
+
YES,
|
|
1963
|
+
bootRuntime(FLAGS, cwd),
|
|
1964
|
+
);
|
|
1965
|
+
} catch (error) {
|
|
1966
|
+
thrown = error;
|
|
1967
|
+
} finally {
|
|
1968
|
+
process.env.XDG_DATA_HOME = prevData;
|
|
1969
|
+
}
|
|
1970
|
+
// The satisfiable steps ran to completion...
|
|
1971
|
+
expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(true);
|
|
1972
|
+
expect(existsSync(join(cwd, ".agents", "skills", "s"))).toBe(true);
|
|
1973
|
+
// ...and the run still failed, with the ORIGINAL step error surfaced.
|
|
1974
|
+
expect(thrown).toBeDefined();
|
|
1975
|
+
const err = asPragmaError(thrown);
|
|
1976
|
+
expect(err.code).toBe("UNSUPPORTED");
|
|
1977
|
+
// One failed row reports ITS OWN cause — a count would throw away the only
|
|
1978
|
+
// sentence that says what is wrong.
|
|
1979
|
+
expect(err.message).toContain("lsp");
|
|
1980
|
+
expect(err.message).toContain("bun");
|
|
1981
|
+
});
|
|
1982
|
+
|
|
1983
|
+
it("omits skills gracefully when none are discovered (no mid-wizard EMPTY_RESULTS)", async () => {
|
|
1984
|
+
// A run-all in a scope with no skills must NOT throw — it just doesn't offer
|
|
1985
|
+
// the skills step. Reaching a clean plan proves the graceful degrade.
|
|
1986
|
+
//
|
|
1987
|
+
// POINTED AT THE PROJECT BAND, which is where an empty skills root is still
|
|
1988
|
+
// an ordinary condition: the global scope now always has the bundled
|
|
1989
|
+
// snapshot behind it, so a run-all there can no longer produce the empty
|
|
1990
|
+
// row this cell exists to survive. The condition is the same one; only the
|
|
1991
|
+
// scope that can still reach it has changed.
|
|
619
1992
|
const outcome = await executeVerb(
|
|
620
1993
|
setupSelfVerb,
|
|
1994
|
+
{ local: true },
|
|
1995
|
+
DRY,
|
|
1996
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
1997
|
+
);
|
|
1998
|
+
expect(outcome.exitCode).toBe(0);
|
|
1999
|
+
// The skills row is PRESENT and says why it cannot act, rather than being
|
|
2000
|
+
// dropped from a plan that then claims completeness.
|
|
2001
|
+
expect(outcome.stdout).toContain("skills");
|
|
2002
|
+
expect(outcome.stdout).toContain("this project holds no skills");
|
|
2003
|
+
expect(outcome.stdout).toContain("lsp");
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
it("the GLOBAL run-all offers the skills row on a fresh machine", async () => {
|
|
2007
|
+
// The other side of the same graceful-degrade question, and the one a fresh
|
|
2008
|
+
// install actually meets: a bare run-all is the global scope, whose skills
|
|
2009
|
+
// row is now offerable from the bundled snapshot rather than skipped with a
|
|
2010
|
+
// remedy the user has to go away and run.
|
|
2011
|
+
const prevData = process.env.XDG_DATA_HOME;
|
|
2012
|
+
process.env.XDG_DATA_HOME = tmp("pragma-runall-data-");
|
|
2013
|
+
try {
|
|
2014
|
+
const outcome = await executeVerb(
|
|
2015
|
+
setupSelfVerb,
|
|
2016
|
+
{},
|
|
2017
|
+
DRY,
|
|
2018
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2019
|
+
);
|
|
2020
|
+
expect(outcome.exitCode).toBe(0);
|
|
2021
|
+
expect(outcome.stdout).toContain("skills");
|
|
2022
|
+
expect(outcome.stdout).not.toContain("no skills installed");
|
|
2023
|
+
} finally {
|
|
2024
|
+
if (prevData === undefined) delete process.env.XDG_DATA_HOME;
|
|
2025
|
+
else process.env.XDG_DATA_HOME = prevData;
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
});
|
|
2029
|
+
|
|
2030
|
+
describe("setup — a converged machine is reported, never asked", () => {
|
|
2031
|
+
// A converged run composes ZERO effects, and the confirm gate then mounted
|
|
2032
|
+
// Ink to render summon-core's "No operations planned." above "Proceed?".
|
|
2033
|
+
// On a sub-verb, whose prompt list is empty, that contentless gate was the
|
|
2034
|
+
// first and only thing the user ever saw.
|
|
2035
|
+
let prevPath: string | undefined;
|
|
2036
|
+
beforeEach(() => {
|
|
2037
|
+
prevPath = process.env.PATH;
|
|
2038
|
+
process.env.PATH = stubPath(); // no editor CLI ⇒ lsp skips
|
|
2039
|
+
});
|
|
2040
|
+
afterEach(() => {
|
|
2041
|
+
process.env.PATH = prevPath;
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
const NO = { dryRun: false, undo: false, yes: false };
|
|
2045
|
+
|
|
2046
|
+
it("non-interactive + no --yes: the recap, NOT the `run again with --yes` hint", async () => {
|
|
2047
|
+
// The compounding defect: a converged plan printed "Nothing was applied.
|
|
2048
|
+
// Run again with --yes to apply." over a machine where there was nothing
|
|
2049
|
+
// to apply in the first place.
|
|
2050
|
+
const outcome = await executeVerb(
|
|
2051
|
+
verbOf("lsp"),
|
|
2052
|
+
{},
|
|
2053
|
+
NO,
|
|
2054
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2055
|
+
);
|
|
2056
|
+
expect(outcome.exitCode).toBe(0);
|
|
2057
|
+
expect(outcome.stdout).not.toContain("Run again with --yes");
|
|
2058
|
+
expect(outcome.stdout).not.toContain("Setup plan");
|
|
2059
|
+
// It says what was detected and what happened to it — doctor's house shape.
|
|
2060
|
+
expect(outcome.stdout).toContain("skipped");
|
|
2061
|
+
expect(outcome.stdout).toContain("no VS Code-family editor CLI on PATH");
|
|
2062
|
+
});
|
|
2063
|
+
|
|
2064
|
+
it("a converged TTY run without --yes mounts NO Ink and prints the recap", async () => {
|
|
2065
|
+
// The acceptance case. Faking a terminal on both streams is what makes
|
|
2066
|
+
// `cliIsTTY()` true, which is the ONLY gate between this run and the wizard.
|
|
2067
|
+
const prevIn = process.stdin.isTTY;
|
|
2068
|
+
const prevErr = process.stderr.isTTY;
|
|
2069
|
+
(process.stdin as { isTTY?: boolean }).isTTY = true;
|
|
2070
|
+
(process.stderr as { isTTY?: boolean }).isTTY = true;
|
|
2071
|
+
let outcome: Awaited<ReturnType<typeof executeVerb>>;
|
|
2072
|
+
try {
|
|
2073
|
+
outcome = await executeVerb(
|
|
2074
|
+
verbOf("lsp"),
|
|
2075
|
+
{},
|
|
2076
|
+
NO,
|
|
2077
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2078
|
+
);
|
|
2079
|
+
} finally {
|
|
2080
|
+
(process.stdin as { isTTY?: boolean }).isTTY = prevIn;
|
|
2081
|
+
(process.stderr as { isTTY?: boolean }).isTTY = prevErr;
|
|
2082
|
+
}
|
|
2083
|
+
expect(outcome.exitCode).toBe(0);
|
|
2084
|
+
expect(outcome.stdout).not.toContain("Proceed");
|
|
2085
|
+
expect(outcome.stdout).toContain("skipped");
|
|
2086
|
+
// The same lazy-React probe the PROTECTED guard below uses: had the
|
|
2087
|
+
// contentless gate mounted, Ink would be in the module cache.
|
|
2088
|
+
const isReactPkg = (k: string) =>
|
|
2089
|
+
/[\\/](react|react-dom|ink)@\d/.test(k) ||
|
|
2090
|
+
/[\\/]node_modules[\\/](react|react-dom|ink)[\\/]/.test(k);
|
|
2091
|
+
expect(Object.keys(require.cache ?? {}).filter(isReactPkg)).toEqual([]);
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
it("--dry-run is untouched — it still ends with the dry-run hint", async () => {
|
|
2095
|
+
// The guard sits AFTER the `previewing` computation precisely so a preview
|
|
2096
|
+
// keeps saying it was a preview.
|
|
2097
|
+
const outcome = await executeVerb(
|
|
2098
|
+
verbOf("lsp"),
|
|
621
2099
|
{},
|
|
622
2100
|
DRY,
|
|
2101
|
+
bootRuntime(FLAGS, tmp("pragma-setup-proj-")),
|
|
2102
|
+
);
|
|
2103
|
+
expect(outcome.exitCode).toBe(0);
|
|
2104
|
+
expect(outcome.stdout).toContain("Setup plan");
|
|
2105
|
+
expect(outcome.stdout).toContain(DRY_RUN_HINT);
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
it("a PARTIALLY converged plan is NOT short-circuited", async () => {
|
|
2109
|
+
// One actionable row is enough: the run goes through `execute` as before,
|
|
2110
|
+
// so the wizard is still reachable on a machine with something to do.
|
|
2111
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2112
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2113
|
+
const outcome = await executeVerb(
|
|
2114
|
+
verbOf("mcp"),
|
|
2115
|
+
{ local: true },
|
|
2116
|
+
NO,
|
|
623
2117
|
bootRuntime(FLAGS, cwd),
|
|
624
2118
|
);
|
|
625
2119
|
expect(outcome.exitCode).toBe(0);
|
|
626
|
-
|
|
627
|
-
expect(
|
|
628
|
-
expect(
|
|
629
|
-
expect(plan).toContain("mcp.json"); // mcp step
|
|
630
|
-
expect(plan).not.toContain("Prompt"); // recap gate / multiselects filtered
|
|
631
|
-
// Nothing is written by a preview.
|
|
632
|
-
expect(existsSync(completionScriptPath("zsh"))).toBe(false);
|
|
633
|
-
expect(existsSync(join(cwd, ".cursor", "mcp.json"))).toBe(false);
|
|
2120
|
+
// Non-interactive without --yes ⇒ still the preview, hint and all.
|
|
2121
|
+
expect(outcome.stdout).toContain("Setup plan");
|
|
2122
|
+
expect(outcome.stdout).toContain(PREVIEW_HINT);
|
|
634
2123
|
});
|
|
635
2124
|
|
|
636
|
-
it("
|
|
637
|
-
|
|
638
|
-
//
|
|
2125
|
+
it("the run-all on a fully converged machine recaps every row at exit 0", async () => {
|
|
2126
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2127
|
+
// First pass configures whatever this machine can hold...
|
|
2128
|
+
await executeVerb(setupSelfVerb, {}, YES, bootRuntime(FLAGS, cwd));
|
|
2129
|
+
// ...the second has nothing left to do.
|
|
639
2130
|
const outcome = await executeVerb(
|
|
640
2131
|
setupSelfVerb,
|
|
641
2132
|
{},
|
|
642
|
-
|
|
643
|
-
bootRuntime(FLAGS,
|
|
2133
|
+
NO,
|
|
2134
|
+
bootRuntime(FLAGS, cwd),
|
|
644
2135
|
);
|
|
645
2136
|
expect(outcome.exitCode).toBe(0);
|
|
646
|
-
expect(outcome.stdout).toContain("
|
|
2137
|
+
expect(outcome.stdout).not.toContain("Run again with --yes");
|
|
2138
|
+
expect(outcome.stdout).toContain("targets configured");
|
|
2139
|
+
});
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
describe("setup — the detection summary", () => {
|
|
2143
|
+
let prevPath: string | undefined;
|
|
2144
|
+
beforeEach(() => {
|
|
2145
|
+
prevPath = process.env.PATH;
|
|
2146
|
+
process.env.PATH = stubPath();
|
|
2147
|
+
});
|
|
2148
|
+
afterEach(() => {
|
|
2149
|
+
process.env.PATH = prevPath;
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* Drive a run, capturing STDERR — which is where the report seam writes, and
|
|
2154
|
+
* the same stream the Ink frame would use. The dispatcher builds its own
|
|
2155
|
+
* runtime for a mutation, so an `rt.report` set on the boot runtime is
|
|
2156
|
+
* replaced; capturing the stream is what actually pins the behaviour.
|
|
2157
|
+
*/
|
|
2158
|
+
const onStderr = async (
|
|
2159
|
+
verb: VerbSpec,
|
|
2160
|
+
params: Record<string, unknown>,
|
|
2161
|
+
flags: GlobalFlags,
|
|
2162
|
+
cwd: string,
|
|
2163
|
+
mutation = YES,
|
|
2164
|
+
): Promise<string[]> => {
|
|
2165
|
+
const chunks: string[] = [];
|
|
2166
|
+
const spy = vi
|
|
2167
|
+
.spyOn(process.stderr, "write")
|
|
2168
|
+
.mockImplementation((chunk: unknown): boolean => {
|
|
2169
|
+
chunks.push(String(chunk));
|
|
2170
|
+
return true;
|
|
2171
|
+
});
|
|
2172
|
+
try {
|
|
2173
|
+
await executeVerb(verb, params, mutation, bootRuntime(flags, cwd));
|
|
2174
|
+
} finally {
|
|
2175
|
+
spy.mockRestore();
|
|
2176
|
+
}
|
|
2177
|
+
return chunks;
|
|
2178
|
+
};
|
|
2179
|
+
|
|
2180
|
+
/** The one write the report seam made for the summary block, if any. */
|
|
2181
|
+
const summaryOf = (chunks: readonly string[]): string =>
|
|
2182
|
+
chunks.find((c) => c.startsWith("Detected —")) ?? "";
|
|
2183
|
+
|
|
2184
|
+
it("names what was detected BEFORE anything is asked, detected rows only", async () => {
|
|
2185
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2186
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2187
|
+
const chunks = await onStderr(setupSelfVerb, { local: true }, FLAGS, cwd);
|
|
2188
|
+
// The report seam writes the block as ONE write, before the generator is
|
|
2189
|
+
// driven — so it is also the first thing on the stream.
|
|
2190
|
+
expect(chunks[0]?.startsWith("Detected —")).toBe(true);
|
|
2191
|
+
const summary = summaryOf(chunks);
|
|
2192
|
+
// FIRST — the Ink frame and this line both go to stderr, so only a line
|
|
2193
|
+
// written before `execute` lands above the frame in scrollback.
|
|
2194
|
+
expect(summary).toContain("Detected —");
|
|
2195
|
+
expect(summary).toContain("mcp");
|
|
2196
|
+
// A skip is "nothing detected here", so it is out of the default view...
|
|
2197
|
+
expect(summary).not.toContain("user-level only");
|
|
2198
|
+
expect(summary).toContain("--verbose lists them");
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
it("--verbose widens it to every row, with no `not detected` footer", async () => {
|
|
2202
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2203
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2204
|
+
const summary = summaryOf(
|
|
2205
|
+
await onStderr(
|
|
2206
|
+
setupSelfVerb,
|
|
2207
|
+
{ local: true },
|
|
2208
|
+
{ ...FLAGS, verbose: true },
|
|
2209
|
+
cwd,
|
|
2210
|
+
),
|
|
2211
|
+
);
|
|
2212
|
+
// ...and back in under `--verbose`, which consumes the EXISTING global flag
|
|
2213
|
+
// rather than adding a signal.
|
|
2214
|
+
expect(summary).toContain("this one is global only");
|
|
2215
|
+
expect(summary).not.toContain("--verbose lists them");
|
|
2216
|
+
});
|
|
2217
|
+
|
|
2218
|
+
it("says nothing on a --dry-run — the plan table IS that run's output", async () => {
|
|
2219
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2220
|
+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
|
|
2221
|
+
const chunks = await onStderr(
|
|
2222
|
+
setupSelfVerb,
|
|
2223
|
+
{ local: true },
|
|
2224
|
+
FLAGS,
|
|
2225
|
+
cwd,
|
|
2226
|
+
DRY,
|
|
2227
|
+
);
|
|
2228
|
+
expect(summaryOf(chunks)).toBe("");
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
it("is silent over MCP — `rt.report` is a no-op there, so no Ink and no noise", async () => {
|
|
2232
|
+
// The seam is the point: a plain-string report, not an Ink mount, is what
|
|
2233
|
+
// keeps this off the React graph AND out of the MCP data stream.
|
|
2234
|
+
const cwd = tmp("pragma-setup-proj-");
|
|
2235
|
+
const mcp = await projectMcp([setupModule], cwd);
|
|
2236
|
+
const result = await mcp.callTool("setup", { confirm: true });
|
|
2237
|
+
await mcp.cleanup();
|
|
2238
|
+
expect(result.ok).toBe(true);
|
|
2239
|
+
expect(JSON.stringify(result.data)).not.toContain("Detected —");
|
|
647
2240
|
});
|
|
648
2241
|
});
|
|
649
2242
|
|
|
@@ -669,7 +2262,7 @@ describe("lazy-React discipline (PROTECTED)", () => {
|
|
|
669
2262
|
});
|
|
670
2263
|
|
|
671
2264
|
describe("setup — mixed-noun wiring & MCP surface", () => {
|
|
672
|
-
it("registers a single `setup` command with an action and
|
|
2265
|
+
it("registers a single `setup` command with an action and five sub-verbs", () => {
|
|
673
2266
|
const program = projectCli([setupModule]);
|
|
674
2267
|
const setups = program.commands.filter((c) => c.name() === "setup");
|
|
675
2268
|
expect(setups).toHaveLength(1);
|
|
@@ -678,9 +2271,10 @@ describe("setup — mixed-noun wiring & MCP surface", () => {
|
|
|
678
2271
|
expect(setup?.options.map((o) => o.long)).toEqual(
|
|
679
2272
|
expect.arrayContaining(["--dry-run", "--undo", "--yes"]),
|
|
680
2273
|
);
|
|
681
|
-
// The
|
|
2274
|
+
// The five CLI-only sub-verbs hang under it — one per target-table row.
|
|
682
2275
|
expect(setup?.commands.map((c) => c.name()).sort()).toEqual([
|
|
683
2276
|
"completions",
|
|
2277
|
+
"config",
|
|
684
2278
|
"lsp",
|
|
685
2279
|
"mcp",
|
|
686
2280
|
"skills",
|