From c76e9a64d6ce6d7a9d7d3301233dddff68216bb1 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Thu, 1 Oct 2026 12:56:35 -0700 Subject: [PATCH 01/12] Use standalone public PR pipeline Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/pr.yml | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/eng/pipelines/pr.yml b/eng/pipelines/pr.yml index c7b9c96..fac0871 100644 --- a/eng/pipelines/pr.yml +++ b/eng/pipelines/pr.yml @@ -5,22 +5,15 @@ pr: include: - main -resources: - repositories: - - repository: 1esPipelines - type: git - name: 1ESPipelineTemplates/1ESPipelineTemplates - ref: refs/tags/release +pool: + name: NetCore-Public + demands: + - ImageOverride -equals windows.vs2026.amd64.open -extends: - template: v1/1ES.Unofficial.PipelineTemplate.yml@1esPipelines - parameters: - pool: - name: NetCore-Public - demands: ImageOverride -equals windows.vs2026.amd64.open - stages: - - stage: Build - jobs: - - job: HelloWorld - steps: - - script: echo Hello world! +stages: +- stage: Build + jobs: + - job: HelloWorld + steps: + - script: echo Hello world! + displayName: Run PR smoke check From b7d8cb8f26053cb24ed30df7a62bc0b22e749075 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Thu, 1 Oct 2026 16:12:36 -0700 Subject: [PATCH 02/12] Import dotnet-package-skills with isolated CI pipelines Import the pinned tool source without Python terminal tests. Add one removable Windows stage for .NET 8/10 validation, automatic package versions, isolated package checks, and fail-closed official ESRP signing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 4 + dotnet-package-skills/.gitignore | 7 + dotnet-package-skills/CONTRIBUTING.md | 348 +++ dotnet-package-skills/Directory.Build.props | 13 + .../DotnetPackageSkills.slnx | 11 + dotnet-package-skills/NuGet.config | 13 + dotnet-package-skills/README.md | 567 +++++ .../docs/dotnet-package-skills.md | 369 +++ dotnet-package-skills/docs/functional-spec.md | 473 ++++ dotnet-package-skills/docs/scenarios.md | 292 +++ dotnet-package-skills/global.json | 7 + .../Contoso.Widgets/Contoso.Widgets.csproj | 35 + .../samples/Contoso.Widgets/Widget.cs | 7 + .../contoso.widgets-widget-testing/SKILL.md | 8 + .../contoso.widgets-widget-usage/SKILL.md | 11 + .../references/batching.md | 3 + .../Cli/CommandLineDiagnostics.cs | 47 + .../Cli/ConsoleViewport.cs | 132 + .../src/DotnetPackageSkills/Cli/ITerminal.cs | 281 +++ .../Cli/InteractiveScreen.cs | 87 + .../Cli/InteractiveSkills.cs | 73 + .../DotnetPackageSkills/Cli/OutputWriter.cs | 203 ++ .../DotnetPackageSkills/Cli/PickerLayout.cs | 296 +++ .../DotnetPackageSkills/Cli/SkillPicker.cs | 379 +++ .../DotnetPackageSkills/Cli/TerminalText.cs | 256 ++ .../DotnetPackageSkills.csproj | 44 + .../Infrastructure/DotnetCli.cs | 20 + .../Infrastructure/ProcessRunner.cs | 95 + .../NuGet/GlobalPackagesLocator.cs | 99 + .../NuGet/PackageCoordinate.cs | 104 + .../NuGet/PackageLister.cs | 214 ++ .../NuGet/PackagePathResolver.cs | 101 + .../NuGet/TargetLocator.cs | 119 + .../PackageSkillsException.cs | 8 + .../src/DotnetPackageSkills/Program.cs | 394 +++ .../SkillInstallService.cs | 505 ++++ .../Skills/BundledSkill.cs | 25 + .../Skills/DestinationLock.cs | 193 ++ .../Skills/InstallManifest.cs | 348 +++ .../Skills/SkillDescriptionReader.cs | 241 ++ .../Skills/SkillDiscovery.cs | 75 + .../Skills/SkillInstaller.cs | 460 ++++ .../CommandLineDiagnosticsTests.cs | 179 ++ .../CommandLineTests.cs | 363 +++ .../DestinationLockTests.cs | 151 ++ .../DotnetPackageSkills.Tests.csproj | 28 + .../DotnetPackageSkills.Tests/FakeTerminal.cs | 426 ++++ .../GlobalPackagesLocatorTests.cs | 54 + .../InstallManifestTests.cs | 347 +++ .../InteractiveSkillsTests.cs | 186 ++ .../OutputLayoutTests.cs | 199 ++ .../OutputWriterTests.cs | 448 ++++ .../PackageCoordinateTests.cs | 70 + .../PackageListerTests.cs | 212 ++ .../PackagePathResolverTests.cs | 80 + .../PickerLayoutTests.cs | 259 ++ .../PipelineVersionTests.cs | 90 + .../SkillDescriptionReaderTests.cs | 439 ++++ .../SkillDiscoveryTests.cs | 122 + .../SkillInstallServiceTests.cs | 1082 ++++++++ .../SkillInstallerTests.cs | 943 +++++++ .../SkillPickerTests.cs | 2194 +++++++++++++++++ .../TargetLocatorTests.cs | 118 + .../TempDirectory.cs | 61 + .../TerminalTextTests.cs | 211 ++ .../Get-PackageVersion.ps1 | 50 + eng/pipelines/dotnet-package-skills/README.md | 145 ++ .../dotnet-package-skills/Verify-Package.ps1 | 186 ++ eng/pipelines/dotnet-package-skills/stage.yml | 215 ++ .../dotnet-package-skills/steps-sign.yml | 121 + eng/pipelines/official.yml | 15 +- eng/pipelines/pr.yml | 10 +- 72 files changed, 15960 insertions(+), 11 deletions(-) create mode 100644 dotnet-package-skills/.gitignore create mode 100644 dotnet-package-skills/CONTRIBUTING.md create mode 100644 dotnet-package-skills/Directory.Build.props create mode 100644 dotnet-package-skills/DotnetPackageSkills.slnx create mode 100644 dotnet-package-skills/NuGet.config create mode 100644 dotnet-package-skills/README.md create mode 100644 dotnet-package-skills/docs/dotnet-package-skills.md create mode 100644 dotnet-package-skills/docs/functional-spec.md create mode 100644 dotnet-package-skills/docs/scenarios.md create mode 100644 dotnet-package-skills/global.json create mode 100644 dotnet-package-skills/samples/Contoso.Widgets/Contoso.Widgets.csproj create mode 100644 dotnet-package-skills/samples/Contoso.Widgets/Widget.cs create mode 100644 dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-testing/SKILL.md create mode 100644 dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/SKILL.md create mode 100644 dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/references/batching.md create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Cli/CommandLineDiagnostics.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Cli/ConsoleViewport.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Cli/ITerminal.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveScreen.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveSkills.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Cli/OutputWriter.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Cli/PickerLayout.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Cli/SkillPicker.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Cli/TerminalText.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/DotnetPackageSkills.csproj create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/DotnetCli.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/ProcessRunner.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/NuGet/GlobalPackagesLocator.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageCoordinate.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageLister.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackagePathResolver.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/NuGet/TargetLocator.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/PackageSkillsException.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Program.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/SkillInstallService.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Skills/BundledSkill.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Skills/DestinationLock.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Skills/InstallManifest.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDescriptionReader.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDiscovery.cs create mode 100644 dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillInstaller.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineDiagnosticsTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/DestinationLockTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/DotnetPackageSkills.Tests.csproj create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/FakeTerminal.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/GlobalPackagesLocatorTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/InstallManifestTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/InteractiveSkillsTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputLayoutTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputWriterTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageCoordinateTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageListerTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackagePathResolverTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/PickerLayoutTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/PipelineVersionTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDescriptionReaderTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDiscoveryTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallServiceTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallerTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillPickerTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/TargetLocatorTests.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/TempDirectory.cs create mode 100644 dotnet-package-skills/tests/DotnetPackageSkills.Tests/TerminalTextTests.cs create mode 100644 eng/pipelines/dotnet-package-skills/Get-PackageVersion.ps1 create mode 100644 eng/pipelines/dotnet-package-skills/README.md create mode 100644 eng/pipelines/dotnet-package-skills/Verify-Package.ps1 create mode 100644 eng/pipelines/dotnet-package-skills/stage.yml create mode 100644 eng/pipelines/dotnet-package-skills/steps-sign.yml diff --git a/README.md b/README.md index 6c0b599..29fcdf2 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,7 @@ # Client.Tools This repository contains tools shipped by the NuGet Client team to help developers effectively use the latest NuGet features. + +| Tool | Description | +| --- | --- | +| [dotnet-package-skills](dotnet-package-skills/README.md) | Copies agent skills bundled in NuGet packages into a repository's skills directory. | diff --git a/dotnet-package-skills/.gitignore b/dotnet-package-skills/.gitignore new file mode 100644 index 0000000..2220015 --- /dev/null +++ b/dotnet-package-skills/.gitignore @@ -0,0 +1,7 @@ +bin/ +obj/ +artifacts/ +node_modules/ +*.user +.DS_Store +.agents/ diff --git a/dotnet-package-skills/CONTRIBUTING.md b/dotnet-package-skills/CONTRIBUTING.md new file mode 100644 index 0000000..e4ea93f --- /dev/null +++ b/dotnet-package-skills/CONTRIBUTING.md @@ -0,0 +1,348 @@ +# Contributing + +Thanks for helping out. This is a small, deliberately boring tool — the bar for changes is that +they keep it small and boring. + +## Getting set up + +Use the .NET 10 SDK selected by this folder's `global.json`, the .NET 8 runtime, and +PowerShell 7. The tool and its C# test suite target both net8.0 and net10.0; CI runs on Windows. + +```powershell +git clone https://github.com/NuGet/Client.Tools.git +Set-Location .\Client.Tools\dotnet-package-skills +dotnet restore .\DotnetPackageSkills.slnx --configfile .\NuGet.config +dotnet build .\DotnetPackageSkills.slnx -c Release --no-restore +dotnet test .\tests\DotnetPackageSkills.Tests\DotnetPackageSkills.Tests.csproj -c Release --no-build --no-restore +``` + +Try your build against a real repository without installing it: + +```bash +dotnet run --project src/DotnetPackageSkills -f net10.0 -- list --target /path/to/YourApp.sln +``` + +Pack and verify your build without replacing a globally installed tool: + +```powershell +dotnet pack .\src\DotnetPackageSkills\DotnetPackageSkills.csproj -c Release --no-build --no-restore -o .\artifacts\packages +pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 ` + -PackagePath .\artifacts\packages\dotnet-package-skills.0.1.0-dev.nupkg ` + -ExpectedVersion 0.1.0-dev ` + -BuildOutputPath .\src\DotnetPackageSkills\bin\Release +``` + +## Origin + +This folder was imported from +[`kartheekp-ms/dotnet-package-skills` at `59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3`](https://github.com/kartheekp-ms/dotnet-package-skills/tree/59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3) +as a source snapshot, without rewriting or transferring the original repository's history. +The source README and package metadata declare MIT; Client.Tools retains its root MIT license. +The source repository is unchanged. + +The Python/Node terminal regression harness was intentionally excluded. The C# picker tests +and interactive tool behavior remain. Build configuration and pipeline integration are scoped +to this tool so it can be retired independently when its functionality moves into the .NET SDK. + +## Layout + +``` +src/DotnetPackageSkills/ +├── Program.cs CLI surface: commands, options, exit codes +├── SkillInstallService.cs Orchestration — the only place the steps are sequenced +├── Cli/OutputWriter.cs Human-readable reports +├── Cli/SkillPicker.cs The --interactive picker, paged so one screen is one page +├── Cli/ITerminal.cs Console access behind an interface, so the picker can be tested +├── Cli/InteractiveSkills.cs Picker-only metadata and selection mapping +├── Infrastructure/ Process execution and the dotnet CLI wrapper +├── NuGet/ Target detection, package listing, cache path resolution +└── Skills/ Discovery, copying, version-change removal, the install manifest + +tests/DotnetPackageSkills.Tests/ xunit; application tests use in-process fakes +samples/Contoso.Widgets/ Example of a package that ships a skill +``` + +## Invariants + +These are the things worth being careful about. Each exists for a reason that is not obvious from +the code alone, so please don't quietly change them. + +**Copy from the global packages folder; never move.** It is NuGet's content-addressable cache, +validated during restore and shared by every project on the machine. Moving files out can make +restore treat a cached package as corrupt, and removes the skill from every other repository using +that package. + +**Removal is driven by the manifest, never by scanning the destination.** `.dotnet-package-skills.json` +records, for each package ID, the one installed version and the skill folder names it owns; +`install` (when a package changes version) and `uninstall` act only on those names. Users keep +their own hand-written skills in the same folder, and deleting one of those would be unforgivable. + +**An unreadable manifest stops every operation that needs ownership.** Never treat a malformed or +unreadable manifest as empty. Empty means the existing folders are user-owned; corrupt means their +ownership is unknown. `install` and `uninstall` must fail before changing anything and preserve the +file so the user can repair or restore it. `list` may still run because it does not read ownership +or write anything. +An existing manifest must have a whole-number format `version` that the tool supports and a +`packages` object. Package IDs must be valid by NuGet's own rule (`PackageCoordinate.IsValidId`, +which allows letters outside ASCII), every package needs a non-empty version, JSON properties must +be unique ignoring case, and case-insensitive destination claims must be unique. A newer format +version asks the user to update the tool. A pre-release manifest, which has an `installed` array, +is refused rather than converted. `SetSkills` refuses an ID that the reader would refuse, so the +tool never writes a manifest that locks the destination. +For v1, use ordinary manifest files and update them in place. The tool does not create symbolic +links, and linked or redirected manifests are outside the v1 safety guarantees. Do not replace an +existing manifest with a new inode merely to handle links: that can change Unix ownership or ACLs. + +**The manifest is a public contract.** Reports have no machine-readable form, so the manifest is +what scripts read, and teams commit it. Its shape follows `dotnet-tools.json`: a format `version` +and a `packages` object keyed by lowercase package ID. A change that an older tool would misread +needs a new format `version`, so older tools refuse the file instead of guessing. Write it the same +way on every platform: UTF-8 without a BOM, LF line endings with a final newline, packages and +skills in ordinal order, and values escaped identically on `net8.0` and `net10.0` +(`JsonWriterOptions.NewLine` doesn't exist on .NET 8, so the writer's CRLF is replaced after +serializing). A platform-dependent byte is churn in someone's pull request. + +**No tracked skills means no manifest and no folder.** When the last entry goes, `install` and +`uninstall` both delete `.dotnet-package-skills.json` and drop the destination folder if it is +empty, so a repository where nothing ships a skill never grows a stray `.agents/skills/`. The +folder only goes when it is genuinely empty — hand-written skills keep it alive. + +**Descriptions are read-only presentation metadata, not installation requirements.** Discovery +still identifies skills by folder structure. Only interactive pickers read the top-level YAML +`description` in `SKILL.md`, using a bounded frontmatter reader and an established YAML parser. +Never interpret the Markdown body, execute metadata, invent a description, or rewrite the file. +Missing metadata gets an explicit placeholder; unreadable or invalid metadata gets a visible +warning without hiding the skill. This intentionally replaces the former no-frontmatter-parsing +rule so users can make an informed selection. Reports and the manifest never include descriptions. + +**Skill names from packages are untrusted input.** They become path segments in the user's repo. +`SkillDiscovery.IsSafeSkillName` is the shared discovery/manifest gate; keep it strict. Reject names +ending in dots or spaces on every platform, since Windows can normalize them to another folder or +the destination itself. Before mutation, resolve all affected skill paths as direct children of the +destination; removing a skill must not walk up and delete its parents. + +**`install` removes a skill only when its package changes version.** A noninteractive install +offers the resolved packages it found in the cache. A tracked skill goes only if its package is +offered at a different normalized version, the new version doesn't ship it, and no ownership +conflict protects it. When a conflict does protect it, `install` stops instead: removing it would +hand its name to the other package, and relabeling it would record the old version's copy under +the new version, where no later run would remove it. A package that left the project is never a +reason to delete, because a reference can vanish for a moment: `install` reports those skills as +unreferenced, and `uninstall --stale` removes them when asked. A version missing from the cache +never causes a removal, and a target install stops before any writes, including previews, when a +resolved package is missing. An incomplete cache must never look like permission to delete skills. + +**`install -i` only adds.** The checklist lists only skills that would install cleanly and aren't +tracked, nothing starts checked, and the installer gets an empty offered-packages map, so nothing +is refreshed or removed. Every check that could stop the run happens before the checklist opens: +two versions of a package; with a target, a missing package or a stale skill; with `--package`, a +named package tracked at another version. Adding beside a stale skill would leave the manifest +disagreeing with the project, and adding beside another version would give a package two versions. + +**`uninstall --stale` reads references, not packages.** It needs a solution or project, and it +compares the manifest with the target's direct package references from `dotnet list package`. It +never asks where the NuGet cache is, so a missing or partial cache can't change what counts as +stale. A skill is stale when no referenced package has its ID and installed version, which also +keeps it working when a target resolves two versions. `--stale` can't be combined with `--package`, +and `uninstall` accepts `--target` only with `--stale`. + +**The picker pages, and that is the point.** A solution can reference many packages that ship +skills. `SkillPicker` renders a frame that fits the window and redraws it in place, so the list +can never scroll off the top unread — agreeing to skills you did not see is the failure mode worth +designing against. Page size follows rendered height, including descriptions and help, rather +than an item-count ceiling. The picker itself has no filesystem access: `InteractiveSkills` +supplies package descriptions for install and installed-file descriptions for uninstall. + +**The frame is measured from its contents and bounded by the window.** Names and descriptions +share a row with ` - ` immediately after the authored name, not a padded name column. Do not append +package/version metadata or strip the package prefix from the name. Each skill's wrapped +description continues at the skill-text edge and uses the remaining row width, not an indent +as wide as the name. Measure those lines and every +wrapped footer before assigning whole skill entries to pages. An oversized description must be +scrollable, never silently truncated. Reflow on resize while preserving focus and selections. +Page boundaries must not shift just because a checkbox or cursor changed. Partial last pages end +at their actual content rather than a run of blank rows. + +Two things follow from redrawing in place, and both are easy to break. Rows are padded to the +measured width, and rows below a shorter frame are blanked, because overwriting is the only way +to erase without ANSI. And the widest summary, with every row ticked, is measured rather than the +current one, so counts growing as you select never reflow the frame. Chrome that would do nothing +is dropped: no counter on a single page, no movement or select-all keys for a single skill. The +note under the title is optional chrome too: when the window can't fit it, the layout drops the +note rather than refusing to open. + +**Keyboard hints follow Aspire's checklist style.** The primary hint is +`(Press to select, to accept)`, below the list. Use the same angle-bracket key +notation for paging, select-all, clear-all, cancel, and description scrolling, beginning each +keyboard-help line with `Press`. Wrap help rather than clipping away the keys. Only advertise +paging and scrolling when they are useful. + +**Focus and checked state are the only cues.** The focused skill's text and all wrapped +description lines are blue. A checked item has a blue uppercase `X` in both pickers; names and +brackets do not change color because of selection. Neither picker marks what a tick does: each +does one thing, so the title and the summary say it, and the uninstall summary also says how many +will go. No-color terminals show `>` and `[X]` with no extra marker and no legend; respect +`NO_COLOR`. A dedicated status column would take space away from descriptions. + +**A tick means install in one picker and remove in the other.** Neither starts with anything +ticked, so pressing enter without touching anything changes nothing in both. `PickerMode` carries +the difference, which shows only in the summary; the caller supplies the title. The uninstall list +comes from the manifest, so a skill someone wrote by hand is never offered for deletion. + +**The picker owns the terminal, so it has to hand it back.** `Choose` hides the cursor and takes +Ctrl+C as input, and restores both in a `finally`. Ctrl+C is why: left to the runtime it ends the +process mid-frame, so the restore never runs and the user is left typing into a terminal with no +cursor. Taken as a key it cancels through the same path as `esc`. Note the modifier is tested +before the switch, because a bare `c` clears the selection. + +**An interactive choice applies only to the ownership it was made against.** Ownership snapshots +are rechecked before applying an interactive choice; concurrent ownership changes invalidate it +rather than changing which package's files are affected. +Hold the destination lock from ownership loading through the final manifest write. Both +installation and uninstallation participate, so another tool invocation cannot change ownership +between the check and mutation. Canonicalize destination aliases before choosing the lock. + +**Resizing invalidates an in-progress frame.** Read width and height together, restart a redraw +if its viewport changes, and clear cells in place rather than scrolling blank lines. Otherwise +old picker copies accumulate in terminal history and can wrap incorrectly when the host resizes. +Preserve prior scrollback, focus, and selections; never swallow unrelated rendering failures. +The picker owns an alternate screen for its entire lifetime. Clearing just the current viewport +cannot erase old rows that the host has already reflowed into normal history. Restore the original +screen and output mode on every managed exit, then write the final report on the normal screen. +Clear and home the alternate viewport before the first frame, just as after a resize. Entering the +alternate screen can preserve the shell cursor position; reserving space with blank lines then +leaves a gap above a compact checklist. Never clear the normal screen to fix that gap. + +Every render also parks the cursor directly below the last line it drew, rather than at the bottom +of the layout's maximum height. `SkillPickerTests` pins that placement for short pages; managed exits +restore the original shell cursor independently when they leave the alternate screen. + +**Picker chrome is ASCII; author text is not restricted to English.** Keep control hints and +markers ASCII so legacy console encodings do not lose them. Display Unicode descriptions without +splitting text elements, measuring terminal cells rather than UTF-16 code units. Strip unsafe +terminal control sequences from author-supplied text. All color goes through `ITerminal`, and the +picker uses BOM-less UTF-8 while prompting. Restore the original encoding and terminal styling +when it exits or fails, so ordinary command output retains its existing behavior. + +**Human-readable reports must not execute metadata as terminal commands.** Sanitize each untrusted +display field with `TerminalText.Sanitize`, including package/version metadata, paths, skipped +reasons, and operational errors. Framework parser diagnostics and suggestions use a separate output +path and must be sanitized too, including split writes. Keep multiline error guidance readable. +Never sanitize arguments before validation or persist sanitized display values; canonical +identities must remain intact. + +**Reports are for people; there is no JSON report.** The manifest is the machine-readable record, +and exit codes carry success or failure. Don't bring back a `--json` report without revisiting +that decision. Because `--package` accepts several values, the parser hands it any unknown option +that follows, such as a `--json` left in an old script; its validator reports a value starting +with `-` as an unrecognized argument rather than as a malformed package. + +**`--package` refuses floating versions and ranges.** Resolving one means choosing a version, and +the only correct answer comes from a project's restore. `PackageCoordinate.Parse` is the gate. + +**Only direct dependencies are scanned.** Applications code against their direct package +references, not implementation details brought in transitively. Do not add `--include-transitive` +or parse `transitivePackages` without revisiting that product decision. + +**The authored skill folder name is the destination folder name.** A package skill at +`skills/contoso.widgets-widget-usage/` lands at +`/contoso.widgets-widget-usage/`. Package and version remain manifest metadata; they +do not create destination path segments. A skill must be an immediate subdirectory containing +`SKILL.md`; a lone `skills/SKILL.md` is intentionally unsupported. + +**Collisions warn and skip; they never overwrite silently.** Destination names compare +case-insensitively. Package enumeration and skill discovery stay deterministic so the first match +wins reproducibly. An existing untracked destination folder is user-owned and untouchable. +Different packages cannot transfer an already-tracked destination between owners in any install +mode. A skipped conflicting path is protected from version-change removal as well as copying. +Same-package version refreshes remain allowed. A protected skill is never relabeled to a version +that doesn't ship it; that case stops the install, as described above. +Keep all discovery candidates internally until install-time ownership is known. Prefer the +current owner's candidate; `list` remains a destination-independent discovery report. +Package authors avoid collisions by prefixing skill folders with their lowercased package ID, but +the tool does not enforce that naming convention. +For v1, retain logical case-insensitive matching without reconciling distinct physical case variants. +Package authors should keep folder casing stable. Case-only renames and mixed-case physical entries +on case-sensitive filesystems are outside the v1 ownership guarantees; do not promise safe migration +or add special reconciliation logic without revisiting that scope. + +**Package filters must not broaden destructive operations.** An explicitly blank filter is an +error; only an absent `--package` means all packages. Interactive and noninteractive uninstall +use the same normalized version matcher. + +**One version per package, or no install.** The manifest records one version per package. When +the resolved packages, or the `--package` coordinates, include two normalized versions of one ID, +every install mode stops before any change and asks for the versions to be aligned; repositories +are expected to use NuGet Central Package Management. `PackageLister.Parse` keeps distinct +`(id, version)` pairs so the check can see them, and `list` still shows both. + +**Errors should read as guidance.** Throw `PackageSkillsException` with a message that tells the +user what to do next. `Program.cs` prints it without a stack trace. If a message would leave +someone stuck, it needs more words. A command in a message has to work when pasted as printed: +build it with `SkillInstallService.UninstallCommand`, which repeats the run's `--target` and +non-default `--destination`. + +## Tests + +Application unit tests run offline and never invoke `dotnet`. Anything that needs the CLI goes through +`IProcessRunner`, which `SkillInstallServiceTests` fakes — see `FakeDotnet` there for the pattern. +Use `TempDirectory` for anything touching the file system; it cleans up after itself. + +The interactive picker goes through `ITerminal`, which `FakeTerminal` drives from a scripted key +sequence and reads back as a screen buffer. It models a buffer rather than concatenating writes +because the picker redraws in place, so appending every write would show frames stacked on top of +each other instead of the one page a user sees. + +### Pipeline and package checks + +`PipelineVersionTests` invokes the tool's PowerShell version calculator to cover PR, preview, +manual, and stable-release versions, plus invalid identifiers and release requests. It needs +`pwsh` on PATH but no network or signing credentials. + +The package verifier described above separately installs the produced `.nupkg` for each target +framework and checks its version, payload, and install/list/uninstall behavior in temporary +directories. Official builds additionally require valid package and assembly signatures. + +Keep all tool-specific pipeline logic under `eng\pipelines\dotnet-package-skills` at the +repository root. See its [guide](../eng/pipelines/dotnet-package-skills/README.md) for versioning, +official signing setup, and the retirement checklist. + +### Naming unit tests + +Name tests as a sentence describing the behaviour, not the method under test: + +```csharp +[Fact] +public void Install_skips_a_later_skill_when_destination_names_collide() +``` + +New behaviour needs a test. Bug fixes need a test that fails without the fix — the `.slnx` +preference bug shipped with one, and that is why it stayed fixed. + +## Style + +`TreatWarningsAsErrors` is on; builds must be warning-clean. Beyond that, match the surrounding +code. Comments explain *why*, not what — if a comment restates the code, delete it. + +## Compatibility + +- The tool targets `net8.0` and `net10.0`. Don't drop `net8.0` without a discussion; it is the LTS + a lot of teams are still on. +- `dotnet list package --format json` requires SDK 7.0.200+. That is the floor for what the tool + can inspect, and the error message says so when it isn't met. +- **The tool never restores.** It runs `dotnet list package` as it is, without `--no-restore`, + and never runs `dotnet restore` itself. The .NET 10 SDK restores during the listing when it needs + to; earlier SDKs report that the target has to be restored first. A failed listing stops the + command with what the SDK reported, read from the JSON `problems` array when there is one, so the + customer can restore or fix the target and run the command again. `PackageListerTests` pins + both halves: the exact arguments, and that no restore is ever attempted. +- Output of `dotnet nuget locals` has changed shape across SDK versions. Parsing keys off the + `global-packages:` label rather than line position — keep it that way. + +## Pull requests + +- One change per PR. +- `dotnet build` and `dotnet test` pass. +- README updated if you changed the CLI surface. +- Say what you tested it against. "Ran `install` on a solution with 40 packages, two of which ship + skills" is worth more than a description of the diff. diff --git a/dotnet-package-skills/Directory.Build.props b/dotnet-package-skills/Directory.Build.props new file mode 100644 index 0000000..1f409fc --- /dev/null +++ b/dotnet-package-skills/Directory.Build.props @@ -0,0 +1,13 @@ + + + + latest + enable + enable + true + true + true + true + + + diff --git a/dotnet-package-skills/DotnetPackageSkills.slnx b/dotnet-package-skills/DotnetPackageSkills.slnx new file mode 100644 index 0000000..c4b0f84 --- /dev/null +++ b/dotnet-package-skills/DotnetPackageSkills.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/dotnet-package-skills/NuGet.config b/dotnet-package-skills/NuGet.config new file mode 100644 index 0000000..c94f073 --- /dev/null +++ b/dotnet-package-skills/NuGet.config @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md new file mode 100644 index 0000000..2646ea1 --- /dev/null +++ b/dotnet-package-skills/README.md @@ -0,0 +1,567 @@ +# dotnet-package-skills + +Copies agent skills bundled inside NuGet packages into a folder your coding agent actually reads. + +For a product-oriented command reference and sample outputs, see the +[functional specification](docs/functional-spec.md). For the expected behavior in each situation, +such as a package upgrade or a package leaving the project, see [scenarios](docs/scenarios.md). + +## The problem + +Package authors are the domain experts on their own libraries, and some of them now ship an +**agent skill** inside the package — instructions covering the conventions, gotchas, and correct +usage patterns for that library. Those files are packed at +`skills/-/SKILL.md`. + +Restore extracts the package into the **NuGet global packages folder** (`~/.nuget/packages` by +default), which lives outside your repository and is shared by every project on the machine. +Coding agents only scan a skills directory *inside* the working repo. So the skill is on disk, +correct, and invisible. + +This tool bridges that gap. + +``` +~/.nuget/packages/mockly/1.10.0/skills/mockly-usage/SKILL.md ← where restore puts it + ↓ +.agents/skills/mockly-usage/SKILL.md ← where your agent looks +``` + +## Install + +```bash +dotnet tool install --global dotnet-package-skills +``` + +## Use + +From your repository root: + +```bash +dotnet-package-skills install +``` + +That is the whole workflow. It finds your solution or project, lists its packages, locates each +direct dependency in the NuGet cache, and copies any bundled skills into `.agents/skills/`. + +Run it again after adding or upgrading packages. It refreshes the skills of the packages it finds, +and when a package moves to a new version, it removes the skills that version no longer ships. It +never removes skills because a package left the project. Instead, it lists them, and +`dotnet-package-skills uninstall --stale` removes them. + +### Commands + +| Command | What it does | +| --- | --- | +| `install` | Copy bundled skills into the destination. Add `--interactive` to choose which new skills to add. | +| `list` | Show which packages ship skills, without copying anything. | +| `uninstall` | Remove skills this tool copied in. Add `--stale` to remove only the skills the project no longer references, or `--interactive` to pick them. | + +### What to point it at + +Three ways to say which packages to take skills from: + +```bash +dotnet-package-skills install # auto-detect solution or project +dotnet-package-skills install --target src/MyApp.slnx # a specific solution or project +dotnet-package-skills install --package Mockly@1.10.0 # exact packages, no project needed +``` + +`--package` is repeatable and needs an **exact version** — `Mockly@1.*` and `Mockly@[1.0,2.0)` are +refused. Resolving a range means picking a version, and the only correct answer to "which version" +comes from a project's restore, which is what `--target` is for. Guessing would copy skills +describing a release you do not actually reference. + +`--target` and `--package` cannot be combined; both answer the same question. + +Naming packages explicitly touches only the packages you name and leaves every other installed +skill alone. A target describes the project's complete set of packages, so a target install can +also tell you which installed skills belong to packages the project no longer references. If a +package the target resolves is missing from the NuGet cache, `install` stops before changing +anything; restore first. + +The tool expects one version of each package, which is what +[Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) +gives a repository. When the target resolves two versions of the same package, or `--package` +names two, `install` stops without changing anything and names the versions to align. + +### Keeping skills in step with the project + +When a package moves to a new version, `install` copies the new version's skills over the old ones +and removes any skill the new version no longer ships. The manifest records one version per +package, so it always says which release the installed guidance describes. + +When a package leaves the project, `install` keeps its skills and lists them: + +``` +2 installed skills belong to a package that the target no longer references: + fabrikam.testing-fakes (fabrikam.testing 1.4.0) + fabrikam.testing-fixtures (fabrikam.testing 1.4.0) +Run 'dotnet-package-skills uninstall --stale' to remove them. +``` + +The suggested command repeats the `--target` and `--destination` you passed, so you can run it as +printed. The same goes for every command that the tool's errors suggest. + +A reference can disappear for a moment, for example halfway through a refactor, so removing +skills is always a command you run on purpose. `uninstall --stale` removes every **stale** skill: +one whose package the target no longer references, or references at a different version. Preview +it with `--dry-run`, or pick among the stale skills with `--interactive`: + +```bash +dotnet-package-skills uninstall --stale --dry-run +dotnet-package-skills uninstall --stale +``` + +`--stale` reads the project's package references, so it needs a solution or project: the one in +the current directory, or the one you pass with `--target`. Skills you added with +`install --package` for packages outside the project count as stale too. + +### Choosing which skills to install + +By default `install` copies everything it finds. Add `--interactive` to choose which new skills to +add: + +```bash +dotnet-package-skills install --interactive # everything the project references +dotnet-package-skills install --package Mockly@1.10.0 --interactive # just one package's skills +``` + +It composes with `--target` and `--package`, so you can narrow to a single package first and then +pick among the skills it ships — which is what you want when one package bundles a dozen of them. + +``` +Which skills should be installed? (MyApp.slnx) +Installed skills aren't listed. + +> [X] contoso.widgets-widget-usage - Correct usage patterns for the + Contoso.Widgets library, including lifetime rules and the batching API. + Use whenever code creates, configures, or disposes a Widget. + [ ] fabrikam.testing-fakes - Create fakes and verify their calls in unit + tests. + [ ] fabrikam.testing-fixtures - Share expensive setup across tests with + fixtures. + +1 of 3 selected +(Press to select, to accept) +(Press / to move, / for first/last) +(Press to select all, to clear all, // to cancel) +Blue X: selected +``` + +The checklist lists only skills that aren't installed, and nothing starts checked, so accepting +adds exactly the skills you ticked. An interactive install never refreshes or removes anything: +run `install` without `--interactive` to refresh, and `uninstall` to remove. When every skill the +packages ship is already installed, it prints `Nothing new to install.` and doesn't open the +checklist. Skills it can't add, such as a name already taken by another package or by a folder you +wrote yourself, aren't listed; the report names them under the skipped warning. + +Adding only makes sense when the installed skills match the packages, so before the checklist +opens, an interactive install stops without changing anything when: + +- the target resolves more than one version of a package, or `--package` names more than one; +- with a target, a package the target resolves is missing from the NuGet cache; +- with a target, an installed skill is stale. Run `dotnet-package-skills uninstall --stale` first; +- with `--package`, a named package is installed at another version. Run + `dotnet-package-skills uninstall --package ` first, or run `install --package @` + without `--interactive` to move it to the new version. + +Each description follows the authored skill name immediately after ` - `, without a padded +column or a package/version suffix. Package prefixes in authored names are kept, and continuation +lines flow beneath the skill text, using the available width rather than leaving a name-sized gap. +The focused skill's text, including wrapped description lines, is blue. Checked items have a blue +`X`; other skill names and descriptions use their normal color. The summary counts the checked +skills; there is no separate status column. With `NO_COLOR` set, or on a terminal without color +support, `>` marks the focused skill and `[X]` the checked ones, with nothing else beside them. + +The keyboard hints appear below the list, using Aspire's +`(Press to select, to accept)` style. Every keyboard-help line starts with `Press`, +including movement, paging, select-all, clear-all, cancel, and description scrolling. Controls +that do nothing are left out. These keys work: + +| Key | Does | +| --- | --- | +| `up` / `down` | Move, wrapping around at either end | +| `left` / `right`, `pgup` / `pgdn` | Previous / next page | +| `home` / `end` | Jump to the first / last skill | +| `space` | Toggle the highlighted skill | +| `a` / `c` | Select all / clear all, across every page | +| `ctrl+up` / `ctrl+down` | Scroll a description when one skill is taller than a page | +| `enter` | Confirm the selection | +| `esc` / `q` / `ctrl+c` | Cancel, changing nothing | + +Pages are measured in rendered lines, including wrapped descriptions and keyboard hints, rather +than a fixed number of skills. Each ordinary skill stays together on one page. A description too +long for a page can be scrolled without changing the selection. Resizing the terminal reflows the +page in place while preserving the highlighted skill and checked items, even during a redraw. +Old picker frames are not pushed into scrollback. When scrolling an oversized description, the +skill row stays visible while its continuation lines scroll. Short lists and partial final pages +do not leave a screenful of blank rows, and a single page has no page counter. The note under the +title gives way when the window is too small to fit it, so a small window keeps the checklist +rather than refusing to open. +The live picker uses a temporary terminal screen and starts at its top, regardless of the shell's +previous cursor position. Host-driven reflow cannot leave duplicate copies in normal scrollback. +Accepting, cancelling, or a handled failure restores the previous shell screen; the final report +is written there, not alongside an old checklist. + +Descriptions come from the top-level YAML `description` in each package's `SKILL.md`. Missing +descriptions say `No description provided.`; unreadable or malformed metadata shows an explicit +description warning without hiding the skill or preventing its selection. Only the interactive +checklists read this metadata; reports and the ownership manifest don't include descriptions. + +`--interactive` needs a terminal. Pair it with `--dry-run` to see what a selection would change +before committing to it. + +### Choosing what to remove + +`uninstall` takes `--interactive` too, and lists only what this tool installed — never a skill you +wrote yourself, because it reads the manifest rather than the folder: + +```bash +dotnet-package-skills uninstall --interactive +``` + +``` +Which skills should be uninstalled? + +> [X] contoso.widgets-widget-testing - Testing patterns for code that uses + Contoso.Widgets. Use when writing unit or integration tests involving + widgets. + [ ] contoso.widgets-widget-usage - Correct usage patterns for the + Contoso.Widgets library, including lifetime rules and the batching API. + Use whenever code creates, configures, or disposes a Widget. + +1 of 2 selected; 1 to remove +(Press to select, to accept) +(Press / to move, / for first/last) +(Press to select all, to clear all, // to cancel) +Blue X: selected +``` + +Nothing starts ticked, so a mistaken enter removes nothing. A ticked row looks the same as in the +install checklist: each checklist does only one thing, so the title and the summary say what a +tick does. Narrow the list first with `--package` if you only care about one package, or with +`--stale` to see only the skills that no longer match the project: + +``` +Which skills should be uninstalled? +Only skills that don't match the target are listed. + +> [X] fabrikam.testing-fakes - Create fakes and verify their calls in unit + tests. + [ ] fabrikam.testing-fixtures - Share expensive setup across tests with + fixtures. + +1 of 2 selected; 1 to remove +``` + +Add `--dry-run` to see the outcome without it happening. Descriptions are read from the installed +copies, not from the NuGet cache. A missing or damaged `SKILL.md` does not prevent removal of a +manifest-owned skill. Package matching ignores case, and version filters are normalized in both +modes (`1.10` matches `1.10.0`). Blank, missing, or repeated uninstall `--package` values are +errors, not an unfiltered uninstall. `--stale` and `--package` can't be combined. + +### Options + +| Option | Applies to | Description | +| --- | --- | --- | +| `-t, --target ` | install, list | Solution or project to inspect. Defaults to searching the current directory. | +| `-p, --package ` | install, list | Take skills from an exact package instead of a project. Repeatable. No floating versions. | +| `-d, --destination ` | install, list | Where skills are copied. Default `.agents/skills`. | +| `-d, --destination ` | uninstall | Where to remove them from. Must match the one you installed to. | +| `--global-packages ` | install, list | Override the NuGet global packages folder. | +| `-i, --interactive` | install | Choose which new skills to add, with descriptions and pagination. Lists only skills that aren't installed. Combines with `--target` or `--package`. | +| `-i, --interactive` | uninstall | Choose which installed skills to remove, with descriptions and pagination. Lists only what this tool installed. | +| `-p, --package ` | uninstall | Remove only this package's skills — whichever version is installed, or only if it's the version you name. | +| `--stale` | uninstall | Remove only stale skills: those whose package the target no longer references, or references at a different version. Needs a solution or project. Not with `--package`. | +| `-t, --target ` | uninstall | With `--stale`, the solution or project to compare against. Defaults to searching the current directory. | +| `--dry-run` | install, uninstall | Report what would change without writing anything. | + +### Targeting another agent's folder + +`.agents/skills` is the vendor-neutral default. Point `--destination` anywhere else: + +```bash +dotnet-package-skills install --destination .claude/skills +dotnet-package-skills install --destination .codex/skills +``` + +`uninstall` takes the same option, and needs it: it only looks where you point it, so removing +what you put in `.claude/skills` means saying so again. + +```bash +dotnet-package-skills uninstall --destination .claude/skills +``` + +### Scripts and CI + +Reports are written for people, and the manifest is the only machine-readable output. In a +script, rely on the exit code: `0` when the command succeeded, and `1` when it stopped, with the +reason on stderr. A command that stops changes nothing. Argument errors also print help on stdout. + +A job that keeps a committed skills folder in step with the project can run: + +```bash +dotnet-package-skills install +dotnet-package-skills uninstall --stale +``` + +To see what's installed, read `.agents/skills/.dotnet-package-skills.json`, described in +[What you get](#what-you-get). `--interactive` needs a terminal, so leave it out of scripts. + +Human-readable reports and diagnostics, including argument-validation errors and parser suggestions, +remove terminal escape sequences and unsafe control characters from metadata, paths, and diagnostic +text. This is display-only: arguments are validated as supplied, and stored identities are +unchanged. + +## What you get + +Each authored skill folder lands directly under the destination: + +``` +.agents/skills/ +├── .dotnet-package-skills.json # what this tool copied in; do not hand-edit +├── contoso.widgets-widget-usage/ +│ ├── SKILL.md +│ └── references/ +│ └── batching.md +└── contoso.widgets-widget-testing/ + └── SKILL.md +``` + +The tool preserves the skill folder name from the package. Package id and version remain in the +install manifest for attribution and uninstall filtering, but they are not added to the path. + +The manifest follows the shape of the .NET local tool manifest (`dotnet-tools.json`): a format +`version`, then one entry per package, keyed by its lowercase package ID, with the one version its +skills came from and the skill folders it owns: + +```json +{ + "version": 1, + "packages": { + "contoso.widgets": { + "version": "2.3.0", + "skills": [ + "contoso.widgets-widget-testing", + "contoso.widgets-widget-usage" + ] + } + } +} +``` + +The file is safe to commit. The tool writes it the same way on every platform: UTF-8 without a +byte order mark, LF line endings, and entries in a stable order, so a Windows checkout and a Linux +checkout produce the same bytes. It ignores properties it doesn't recognize, and it refuses a +manifest with a newer format `version` and asks you to update the tool. + +Package authors should prefix every folder with their lowercased package id, as shown above. This +keeps names globally unique when skills from many packages share one destination. The convention is +documented rather than enforced, so existing safe names still work. + +### Name collisions + +Destination names are compared case-insensitively. If two package skills choose the same name, the +first one in deterministic package order is copied and later collisions are skipped with a warning. +An existing destination folder not tracked by this tool is treated as user-owned and is also +skipped, never overwritten. +The same protection applies to a name already owned by a different package: every install mode +warns and preserves that owner rather than transferring it automatically. Explicitly uninstall +the old skill before installing its replacement. Upgrading the same package remains supported. +If both the owner and another package offer the same name, installation prefers the owner's +candidate so the conflict does not prevent a legitimate refresh. + +One combination stops `install` instead: the owner's package moves to a version that no longer +ships the skill, while another package ships a skill with that name. Removing the old copy would +hand the name over, and the manifest can't keep an older version's copy under the new version, so +`install` changes nothing and suggests `uninstall --package ` for the owner. After that, +`install` copies both packages' current skills. + +V1 does not reconcile distinct physical case variants on case-sensitive filesystems. Keep authored +skill-folder casing stable across versions and avoid folders such as `guide` and `GUIDE` in the +same destination. Case-only renames or collisions between those physical variants can leave +untracked old copies or overwrite a handwritten variant; those scenarios are outside v1 guarantees. + +Refreshing a tracked skill replaces its entire folder, including local edits and added files. +Keep hand-written guidance in separate, untracked skill folders. + +### Package versions + +The destination holds skills from one version of each package, and the manifest records that +version. Keep the projects in a repository on one version of each package, which is what +[NuGet Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) +does. When a target resolves more than one version of a package, `install` stops without changing +anything and names the versions to align; `--package` with two versions of one package stops the +same way. `list` still shows every version it finds. + +### Should I commit this folder? + +Either is defensible. Commit it so the whole team and CI get the skills without running anything, +or gitignore it and let each machine refresh it. Pick one and say so in your contributing guide. + +## For package authors: shipping a skill + +Put each skill under `skills/-/`, with its own `SKILL.md` and any supporting +files. Prefixing the folder with your lowercased package ID keeps your skills from colliding with +other packages on the consumer's machine. + +```xml + + + + +``` + +A complete working example is in [`samples/Contoso.Widgets`](samples/Contoso.Widgets). + +Every skill must have its own immediate subdirectory under `skills/`; a lone `skills/SKILL.md` is +not discovered. + +Give each skill a useful `description` in its YAML frontmatter so customers can decide whether +they need it: + +```yaml +--- +name: contoso.widgets-widget-usage +description: > + Correct usage patterns for Contoso.Widgets, including lifetime rules and batching. + Use when creating, configuring, or disposing a Widget. +--- +``` + +Plain, quoted, literal (`|`), and folded (`>`) descriptions are supported. The interactive picker +reads only bounded frontmatter, never interprets the Markdown instructions, and never rewrites +the file. Description metadata is informative, not an additional installation requirement. +Frontmatter is limited to 65,536 decoded characters and 32 collection levels. Explicit YAML tags, +anchors, and aliases are not supported by the description reader; they produce a visible metadata +warning rather than preventing installation. + +## How it works + +1. `dotnet list package --format json` — the resolved direct packages. The tool never + restores: the .NET 10 SDK restores during this step when it needs to, and earlier SDKs say the + target has to be restored first. If the step fails, the tool shows what it reported, so you can + restore or fix the target and run the tool again. +2. `dotnet nuget locals global-packages --list` — where restore extracted them. `NUGET_PACKAGES` + and `--global-packages` take precedence, in that order. +3. `install` stops without changing anything if a package resolves to more than one version, or + if a package the target resolves is missing from the cache. +4. For each package, look in `///skills/`. +5. Copy each `skills//` folder to `//`, skipping and warning on collisions. + For a package that moved to a new version, remove the skills the new version no longer ships. +6. Record what was copied in `/.dotnet-package-skills.json`. + +`uninstall --stale` needs only step 1: it compares the manifest with the target's package +references and never looks in the NuGet cache for skills. + +Nothing inside a skill is read or interpreted. The package author decides what a skill contains; +this tool only puts it where an agent will look. + +### It copies, it never moves + +The global packages folder is NuGet's content-addressable cache. It is validated during restore +and shared by every project on the machine, so moving files out of it can make restore treat the +cached package as corrupt — and would strip the skill from every other repository using that +package. + +### Removal is manifest-driven + +`.dotnet-package-skills.json` records what was copied in. `install` (when a package moves to a new +version) and `uninstall` remove only paths listed there, rather than scanning arbitrary folders. +Keep hand-written guidance in separate, untracked folders, subject to the v1 case-variant and +linked-manifest limitations described here. + +If that manifest exists but cannot be read, `install` and `uninstall` stop without changing +anything and preserve the file for repair. Resolve any merge conflict or restore it from source +control before retrying. If it cannot be recovered, move the whole destination folder aside before +installing again; the tool will not guess which existing folders it owns. +A manifest is also refused when it names a newer format version (update the tool), when it was +written by a pre-release build of this tool (move the skills folder aside and install again), or +when a package is missing its version, a package ID is invalid, or a skill is claimed twice. +Skill names must identify a single folder directly inside the destination. Names ending in a dot +or space, including `...`, are rejected because Windows can resolve them to another folder or the +destination itself. A manifest containing such a name blocks install and uninstall, including +interactive and dry-run modes, before any skill files or manifest bytes are changed. +The tool creates an ordinary manifest file by default and updates an existing manifest in place. +Symbolic-link or other redirected manifests are unsupported in v1. The tool does not create those +links or protect their targets: normal filesystem operations may follow a link, including one +already present in a checked-out repository. Use a regular manifest file in the skills destination; +customers who provide links are responsible for their effects. +Concurrent tool operations on the same destination are serialized, and an interactive choice +is rejected if ownership changed before it could be applied. + +## A note on trust + +A bundled skill is a set of instructions written by a third party that your agent will then +follow. That is a supply-chain surface. This tool only ever copies from packages your project +already depends on, and it prints every skill it copied so you can review them. Treat a new skill +the way you would treat any new dependency. + +## Troubleshooting + +**"No bundled skills found"** — the common and correct outcome; most packages do not ship skills. + +**"'dotnet list ... package' failed"** — the tool reads the target's packages with `dotnet list +package` and shows what it reported, such as a restore that failed or a target that earlier SDKs +say needs restoring. The tool never restores. Resolve what it reports, for example with +`dotnet restore`, and run the tool again. + +**"resolved packages are missing from"** the NuGet cache — run `dotnet restore` for the target and +try again. This also happens when packages come from a NuGet *fallback folder* (common in +containers and on hosted build agents); point `--global-packages` at that folder. + +**"resolve to more than one version"** — projects in the target reference different versions of a +package. Align them, for example with Central Package Management, and try again. + +**"installed skills don't match the target"** (from `install --interactive`) — some installed +skills are stale. Preview them with `dotnet-package-skills uninstall --stale --dry-run`, remove +them with `uninstall --stale`, and try again. + +**"is already installed, and an interactive install only adds skills"** — `install --interactive +--package` named a package that is installed at another version. Run `install --package` without +`--interactive` to move it to the new version, or `uninstall --package ` first. + +**"Could not read the install manifest"** — the manifest has a merge conflict or was edited into a +shape the tool can't trust. See [Removal is manifest-driven](#removal-is-manifest-driven). + +**"Unrecognized option '--format'"** — the SDK predates 7.0.200. Upgrade it. + +**Wrong global packages folder** — nuget.config discovery walks up from the current directory, so +run the tool from your repository root, or pass `--global-packages` explicitly. + +**Solution filters (`.slnf`)** are not accepted by `dotnet list package` on all SDKs. Pass the +underlying `.sln`, or run once per project with `--target`. + +## Building from source + +Run from the `dotnet-package-skills` folder in a Client.Tools checkout so `global.json` +selects the pinned .NET SDK. Install the .NET 8 runtime as well as .NET 10, and PowerShell 7 +for the C# pipeline-version tests. CI currently validates on Windows. + +```powershell +Set-Location .\dotnet-package-skills +dotnet restore .\DotnetPackageSkills.slnx --configfile .\NuGet.config +dotnet build .\DotnetPackageSkills.slnx -c Release --no-restore +dotnet test .\tests\DotnetPackageSkills.Tests\DotnetPackageSkills.Tests.csproj -c Release --no-build --no-restore +dotnet pack .\src\DotnetPackageSkills\DotnetPackageSkills.csproj -c Release --no-build --no-restore -o .\artifacts\packages +pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 ` + -PackagePath .\artifacts\packages\dotnet-package-skills.0.1.0-dev.nupkg ` + -ExpectedVersion 0.1.0-dev ` + -BuildOutputPath .\src\DotnetPackageSkills\bin\Release +``` + +The verification command installs the exact local package into temporary tool paths for +.NET 8 and .NET 10, exercises its non-interactive commands, and removes its temporary files. +It does not replace a globally installed tool or modify your installed skills. + +Local builds use `0.1.0-dev`. PR builds use `0.1.0-pr..`; ordinary official +builds use `0.1.0-preview.`. Only an explicit manual official release run produces +the stable base version. Build artifacts are not automatically published to a NuGet feed. +See the [pipeline and release guide](../eng/pipelines/dotnet-package-skills/README.md). + +## License + +MIT diff --git a/dotnet-package-skills/docs/dotnet-package-skills.md b/dotnet-package-skills/docs/dotnet-package-skills.md new file mode 100644 index 0000000..752e903 --- /dev/null +++ b/dotnet-package-skills/docs/dotnet-package-skills.md @@ -0,0 +1,369 @@ +--- +title: dotnet-package-skills command +description: The 'dotnet-package-skills' command copies agent skills bundled in NuGet packages into a repository, where coding agents can find them. +ms.date: 09/24/2026 +--- +# dotnet-package-skills + +**This article applies to:** ✔️ `dotnet-package-skills` version 0.1.0 + +## Name + +`dotnet-package-skills` - Discovers, installs, and removes agent skills bundled in NuGet packages. + +> [!NOTE] +> `dotnet-package-skills` is a .NET tool, not part of the .NET SDK. Install it with [`dotnet tool install`](https://learn.microsoft.com/dotnet/core/tools/dotnet-tool-install), for example `dotnet tool install --global dotnet-package-skills`. To install it from a local package feed, add `--add-source `. The tool requires the .NET 8 runtime or later, and commands that read a solution or project also require the .NET SDK. + +## Synopsis + +```dotnetcli +dotnet-package-skills install [-d|--destination ] [--dry-run] + [--global-packages ] [-i|--interactive] + [-p|--package ...] [-t|--target ] + +dotnet-package-skills list [-d|--destination ] [--global-packages ] + [-p|--package ...] [-t|--target ] + +dotnet-package-skills uninstall [-d|--destination ] [--dry-run] + [-i|--interactive] [-p|--package ] + +dotnet-package-skills uninstall --stale [-d|--destination ] [--dry-run] + [-i|--interactive] [-t|--target ] + +dotnet-package-skills [install|list|uninstall] -h|--help + +dotnet-package-skills --version +``` + +## Description + +Some NuGet packages include *agent skills*: instructions from the package author that teach coding agents how to use the package. Each skill is a folder that contains a `SKILL.md` file and any supporting files. Restore extracts these folders into the NuGet global packages folder, outside your repository, where agents don't look for them. The `dotnet-package-skills` command copies them into your repository. + +To install the skills that your packages ship, run these commands from the root of your repository: + +```dotnetcli +dotnet restore +dotnet-package-skills install +``` + +The skills are copied to `.agents/skills` under the current directory. If your agent reads skills from another folder, add `--destination`, for example `--destination .claude/skills`. If your solution or project isn't in the current directory, pass its path to both commands, for example `dotnet restore src/MyApp.slnx` and `dotnet-package-skills install --target src/MyApp.slnx`. To choose which skills to install, add `--interactive`. Run `install` again after you add or upgrade packages. + +> [!IMPORTANT] +> Skills are instructions that your coding agent follows. Review them before you rely on them. + +The command reads only the direct package references of your solution or project, not the packages that they depend on. It doesn't download packages or change your project files. + +This article uses these terms: + +- **Target**: the solution or project whose package references the command reads. Without `--target`, the command looks for one in the current directory, and then in its subdirectories. Reports show the target after `Target:`. +- **Skills folder**: the folder that skills are copied to, `.agents/skills` under the current directory unless you specify `--destination`. `--target` doesn't change it. Reports show it after `Destination:`. +- **Tracked skill**: a skill that the command installed, as recorded in the [manifest](#manifest-file) in the skills folder. The command updates and removes only tracked skills, never folders that you created. +- **Stale skill**: a tracked skill whose package the target no longer references at the version that the skill came from. + +### List available skills + +`dotnet-package-skills list` shows the skills that the target's packages ship, without copying anything. It runs [`dotnet list package`](https://learn.microsoft.com/dotnet/core/tools/dotnet-package-list) on the target to find its top-level (direct) package references, and then looks for skills in those packages in the NuGet global packages folder: + +```output +Target: C:\src\MyApp\MyApp.slnx +NuGet cache: C:\packages +Destination: C:\src\MyApp\.agents\skills +Scanned 2 packages (direct). + +Found 4 skills: + contoso.widgets-widget-testing (Contoso.Widgets 2.3.0) + contoso.widgets-widget-usage (Contoso.Widgets 2.3.0) + fabrikam.testing-fakes (Fabrikam.Testing 1.4.0) + fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) +``` + +`NuGet cache` is the NuGet global packages folder that skills are read from, and `Destination` is the skills folder that `install` would copy them to. `list` doesn't look in the skills folder, so it doesn't show which skills are installed; the [manifest](#manifest-file) records those. + +Restore the target before you run the command. The command doesn't run `dotnet restore` itself, although with the .NET 10 SDK, `dotnet list package` restores the target when it needs to, even during a dry run. If `dotnet list package` fails, the command shows what it reported and changes nothing; fix the problem, for example by restoring the target, and then run the command again. If a package that the target references isn't in the NuGet global packages folder, `list` skips the package and `install` stops. + +To read skills from specific packages instead of a target, specify `--package @`. The package must already be in the NuGet global packages folder, because the command doesn't download it. If the package isn't there, the command finds no skills in it and doesn't warn you. + +### Install and update skills + +`dotnet-package-skills install` copies the target's skills into the skills folder and records them in the manifest. Each skill keeps its folder name from the package. After the same first lines as the `list` report, the report lists the skills that were copied: + +```output +Copied 4 skills: + contoso.widgets-widget-testing (Contoso.Widgets 2.3.0) + contoso.widgets-widget-usage (Contoso.Widgets 2.3.0) + fabrikam.testing-fakes (Fabrikam.Testing 1.4.0) + fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) + +These skills are instructions written by the package authors, and your coding agent will follow them. Review them before relying on them. +``` + +Run `install` again whenever your packages change; there's no separate update command. Each run compares the tracked skills with the target's packages: + +| If you've... | `install`... | +| --- | --- | +| Added a package that ships skills | Copies its skills. | +| Upgraded or downgraded a package | Replaces its skills with those of the new version, and removes the ones that the new version no longer ships. | +| Removed a package | Keeps its skills, and lists them as in the following example. To remove them, see [Remove stale skills](#remove-stale-skills). | +| Changed nothing | Copies each package's skills again, replacing the installed copies. | + +```output +2 installed skills belong to a package that the target no longer references: + fabrikam.testing-fakes (fabrikam.testing 1.4.0) + fabrikam.testing-fixtures (fabrikam.testing 1.4.0) +Run 'dotnet-package-skills uninstall --stale' to remove them. +``` + +> [!WARNING] +> `install` replaces the whole folder of each skill that it copies, including your edits and any files that you added. Keep your own instructions in separate skill folders; the command never changes folders that it didn't install. + +With `--package`, `install` does the same for the packages that you name, and leaves all other skills alone. With `--interactive`, it only adds the skills that you choose; see [Choose skills interactively](#choose-skills-interactively). To preview an installation, add `--dry-run`. The report then lists the planned changes under `Would copy` and `Would remove`, and nothing changes. + +The skills folder holds the skills of only one version of each package. If the target references a package at more than one version, or `--package` names more than one, `install` stops and names the versions. [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) keeps all the projects in a repository on one version of each package. + +Skill names are compared without regard to case. If two packages ship a skill with the same name, only one of them is copied. `install` also never replaces a tracked skill with another package's skill, or overwrites a folder that it didn't install. It skips such skills, and lists them in the report under `Warning:`. To give a skill name to another package, first remove the tracked skill, for example with `uninstall --package `. + +### Remove skills + +`dotnet-package-skills uninstall` removes every tracked skill from the skills folder. It doesn't remove folders that you created or any NuGet packages, and it doesn't need a target. If you installed skills into another folder, specify the same `--destination`. + +```output +Destination: C:\src\MyApp\.agents\skills + +Removed 2 skills: + contoso.widgets-widget-testing (contoso.widgets 2.3.0) + contoso.widgets-widget-usage (contoso.widgets 2.3.0) +``` + +To remove only one package's skills, add `--package `, or add `--package @` to remove them only if that version is installed. Add `--dry-run` to preview the removal, or `--interactive` to choose the skills to remove. If no skills are tracked, the command says so and succeeds. + +### Remove stale skills + +`install` keeps the skills of packages that the target no longer references. To remove them, run: + +```dotnetcli +dotnet-package-skills uninstall --stale +``` + +This command removes every stale skill. Run `install` first, so that the skills of upgraded packages are updated instead of removed. The report looks like the `uninstall` report, with a `Target:` line first. Add `--dry-run` to preview the removal, or `--interactive` to choose among the stale skills. + +`--stale` needs a target, which the command finds the same way as for `install`, or which you specify with `--target`. + +Skills that you installed with `install --package` are stale unless the target references the same package version. For each skills folder, use either a target or `--package`, not both. + +### Choose skills interactively + +Add `--interactive` (`-i`) to choose skills in a paged checklist. `install -i` lists the skills that aren't installed yet, and `uninstall -i` lists the tracked skills, or with `--stale`, only the stale ones. Nothing starts checked. The following example shows an install checklist after one skill is checked: + +```output +Which skills should be installed? (MyApp.slnx) +Installed skills aren't listed. + +> [X] contoso.widgets-widget-usage - Correct usage patterns for the Contoso.Widgets library, including lifetime rules and the batching API. Use whenever code creates, configures, or disposes a Widget. + [ ] fabrikam.testing-fakes - Create fakes and verify their calls in unit tests. + [ ] fabrikam.testing-fixtures - Share expensive setup across tests with fixtures. + +1 of 3 selected +(Press to select, to accept) +(Press / to move, / for first/last) +(Press to select all, to clear all, // to cancel) +Blue X: selected +``` + +Each row shows a skill's name and the `description` from its `SKILL.md` file. `>` marks the focused row, and `X` marks a checked skill. When the terminal shows color, the focused row and each `X` are blue. + +| Key | Action | +| --- | --- | +| Up, Down | Move to the previous or next skill. | +| Left, Right, PageUp, PageDown | Move to the previous or next page. | +| Home, End | Go to the first or last skill. | +| Space | Check or uncheck the focused skill. | +| A, C | Check or clear all skills, on every page. | +| Ctrl+Up, Ctrl+Down | Scroll a description that's too long for the page. | +| Enter | Install or remove the checked skills. With `--dry-run`, only report what would change. | +| Esc, Q, Ctrl+C | Cancel without changing any skills. | + +`install -i` only adds the skills that you check; it never updates or removes skills. Because of that, it first checks that the tracked skills match the packages, and stops before the checklist opens if they don't: + +- With a target, it stops if any tracked skill is stale. Run `uninstall --stale`, and then try again. +- With `--package`, it stops if a named package is installed at another version. Run `uninstall --package ` first, or run `install --package` without `--interactive` to switch versions. + +Skills that `install` would skip because their name is in use aren't listed; the report after the checklist names them. If every skill is already installed, the checklist doesn't open, and the command reports `Nothing new to install.` + +### Manifest file + +The manifest, `.dotnet-package-skills.json` in the skills folder, records the skills that the command installed and the package version that each came from. Its shape follows the .NET local tool manifest, `dotnet-tools.json`: + +```json +{ + "version": 1, + "packages": { + "contoso.widgets": { + "version": "2.3.0", + "skills": [ + "contoso.widgets-widget-testing", + "contoso.widgets-widget-usage" + ] + } + } +} +``` + +| Property | Meaning | +| --- | --- | +| `version` | The manifest format version, `1`. | +| `packages` | One entry per package, keyed by the lowercase package ID. | +| `packages..version` | The package version that the skills were installed from. | +| `packages..skills` | The names of the skill folders installed from the package. | + +If you commit the skills folder to source control, commit the manifest with it. The command writes the file the same way on every platform, in UTF-8 with LF line endings and a stable order, so it doesn't cause line-ending churn. Don't edit the file by hand: the command relies on it to decide which folders it can replace or remove, and drops properties that it doesn't recognize when it rewrites the file. When the last tracked skill is removed, the command deletes the manifest, and the skills folder if it's empty. + +### Exit codes and troubleshooting + +The command returns `0` on success, including when there's nothing to do or you cancel a checklist, and `1` on failure. Errors are written to standard error. Skipped skills are reported as warnings and don't change the exit code, so check the report when it matters that every skill was installed. Reports are meant for people: scripts should rely on the exit code, and read the manifest to find the installed skills. + +Each of these errors stops the command before it changes any skills or the manifest: + +| Problem | What to do | +| --- | --- | +| `dotnet list package` fails, for example because the target isn't restored. | Fix what it reports, for example by running `dotnet restore`, and then run the command again. | +| `install` reports packages that are missing from the NuGet global packages folder. | Restore the target into that folder, and then run `install` again. If you use `--global-packages`, restore into the same folder, for example with `dotnet restore --packages `. | +| The target references a package at more than one version, or `--package` names more than one. | Align the versions, for example with [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management). With `--package`, name one version of each package. | +| `install --interactive` says that installed skills don't match the target, or that a named package is installed at another version. | Run the `uninstall` command that the error suggests, and then try again. To switch a package to another version instead, run `install` without `--interactive`. | +| The manifest can't be read. | Resolve any merge conflict in `.dotnet-package-skills.json`, or restore the file from source control. If the error says that the manifest uses a newer format version, update the tool. If it says that a pre-release version of the tool wrote the manifest, move the skills folder aside, and then run `install` again. | +| Another run of the command is using the skills folder. | The command waits up to 30 seconds for the other run to finish, and then stops. Run the command again after the other run finishes. | +| The terminal is too small for the checklist. | Enlarge the window, or run the command without `--interactive`. | + +For other errors, run the command that the error suggests. Suggested commands repeat the `--target` and `--destination` that you specified, so you can run them as printed. + +If a file system error interrupts a copy or removal, the command reports it but doesn't undo the changes that it already made, and copied folders might be missing from the manifest. Move the affected skill folders aside, and then run the command again. + +## Commands + +- **`install`** + + Copies the skills of the target's direct package references, or of the packages named with `--package`, into the skills folder, and updates the skills that it installed before. + +- **`list`** + + Lists the skills available from the target's direct package references, or from the packages named with `--package`, without copying anything. + +- **`uninstall`** + + Removes tracked skills from the skills folder. With `--stale`, removes only stale skills. + +## Options + +- **`-d|--destination `** + + For `install`, `list`, and `uninstall`, specifies the skills folder. Defaults to `.agents/skills`. A relative path is resolved from the current directory, even when `--target` points somewhere else. `list` only shows the folder in its report. To remove skills, specify the folder that they were installed to. + +- **`--dry-run`** + + For `install` and `uninstall`, reports the planned changes without copying or removing skills or writing the manifest. With the .NET 10 SDK, `dotnet list package` can still restore the target. + +- **`--global-packages `** + + For `install` and `list`, specifies the NuGet global packages folder to read packages from. The folder must exist. This option overrides the `NUGET_PACKAGES` environment variable and the folder configured for NuGet, but it doesn't change where restore extracts packages. Unlike `--destination`, a relative path is resolved from the target's directory, or from the current directory when you use `--package`. + +- **`-?|-h|--help`** + + Prints out a description of how to use the command. + +- **`-i|--interactive`** + + For `install` and `uninstall`, opens a checklist for choosing the skills to install or remove. `install` lists only skills that aren't installed, and only adds skills. Requires a terminal when there's something to choose. Can be combined with `--dry-run`, and with either `--package` or, for `uninstall`, `--stale`. For more information, see [Choose skills interactively](#choose-skills-interactively). + +- **`-p|--package `** + + For `install` and `list`, reads skills from the specified package instead of a target. Specify an exact version, such as `Contoso.Widgets@2.3.0`; floating versions and version ranges aren't accepted. Repeat the option to name several packages. Duplicates, such as `Mockly@1.10` and `Mockly@1.10.0`, count once. `install` accepts only one version of each package, while `list` shows every version that you name. The package must already be in the NuGet global packages folder; if it isn't, the command finds no skills in it and doesn't warn you. Can't be combined with `--target`. + +- **`-p|--package `** + + For `uninstall`, removes only the skills of the specified package. With a version, removes them only if that version is installed. Package IDs are compared without regard to case, and `1.10` matches `1.10.0`. Specify the option at most once; a blank value is an error. Can't be combined with `--stale`. + +- **`--stale`** + + For `uninstall`, removes only stale skills. Requires a target. Can be combined with `--target`, `--dry-run`, and `--interactive`, but not with `--package`. For more information, see [Remove stale skills](#remove-stale-skills). + +- **`-t|--target `** + + For `install`, `list`, and `uninstall --stale`, specifies the solution (`.slnx` or `.sln`), project (`.csproj`, `.fsproj`, or `.vbproj`), or directory to read package references from. For a directory, or when the option is omitted, the command looks for a solution or project in that directory, and then in its subdirectories. Doesn't change the skills folder. Can't be combined with `--package`. + +- **`--version`** + + Displays the tool version. Available without a command. + +## Examples + +- List the available skills: + + ```dotnetcli + dotnet-package-skills list + ``` + +- Install every available skill: + + ```dotnetcli + dotnet-package-skills install + ``` + +- Install the skills of a solution in a subfolder: + + ```dotnetcli + dotnet-package-skills install --target src/MyApp.slnx + ``` + +- Install skills into `.claude/skills` instead of `.agents/skills`: + + ```dotnetcli + dotnet-package-skills install --destination .claude/skills + ``` + +- Preview an installation without changing any skills: + + ```dotnetcli + dotnet-package-skills install --dry-run + ``` + +- Choose which skills to install: + + ```dotnetcli + dotnet-package-skills install --interactive + ``` + +- Install the skills of exact package versions, without a solution or project: + + ```dotnetcli + dotnet-package-skills install --package Contoso.Widgets@2.3.0 --package Mockly@1.10.0 + ``` + +- Remove every tracked skill from `.agents/skills`: + + ```dotnetcli + dotnet-package-skills uninstall + ``` + +- Remove the skills of one package: + + ```dotnetcli + dotnet-package-skills uninstall --package Contoso.Widgets + ``` + +- Choose which skills to remove: + + ```dotnetcli + dotnet-package-skills uninstall --interactive + ``` + +- Preview the removal of stale skills: + + ```dotnetcli + dotnet-package-skills uninstall --stale --dry-run + ``` + +- Keep a committed skills folder in step with the solution or project, for example in a CI job. `install` runs first, so that the skills of upgraded packages are updated instead of removed: + + ```dotnetcli + dotnet-package-skills install + dotnet-package-skills uninstall --stale + ``` diff --git a/dotnet-package-skills/docs/functional-spec.md b/dotnet-package-skills/docs/functional-spec.md new file mode 100644 index 0000000..13aa12c --- /dev/null +++ b/dotnet-package-skills/docs/functional-spec.md @@ -0,0 +1,473 @@ +# .NET Package Skills: Functional Specification + +## 1. Purpose and scope + +The tool makes agent skills shipped in NuGet packages available inside a developer's repository, where their coding agent can find them. It supports three jobs: discover available skills, install the skills the developer wants, and remove previously installed skills. + +It copies whole skill folders, including supporting documents, from the NuGet cache. It does not move or modify the source package, change project package references, install an agent, or configure an MCP server. In this version, only direct package dependencies are scanned. + +The default destination is `.agents\skills` under the directory where the command runs. Developers can choose another destination, such as `.claude\skills`. Agent support for a destination remains the agent's responsibility. + +Examples below use illustrative packages and paths. Interactive page sizes and line wrapping vary with the terminal dimensions; the examples are not fixed screen layouts. + +## 2. Command structure + +Invoke the tool by its command name: + +```powershell +dotnet-package-skills [options] +``` + +There are three subcommands. Interactive selection, previews, and stale cleanup are options, not additional subcommands. + +| Command | Customer intent | Effect on destination skills | +| --- | --- | --- | +| `list` | See which package-provided skills are available. | No changes. | +| `install` | Copy or refresh available skills. | Refreshes the skills it finds, and removes the skills a package's new version no longer ships. Never removes skills because a package left the project. Interactive runs only add. | +| `uninstall` | Remove skills previously installed by this tool. | Removes all matching tracked skills, only the stale ones with `--stale`, or an interactive selection. | +| `--help` | Learn the commands and options. | No changes. | +| `--version` | Identify the tool build. | No changes. | + +The tool requires a compatible .NET runtime; current builds target .NET 8 and .NET 10. Project discovery also uses the installed .NET SDK. + +Installing the .NET tool itself is separate from installing skills. For evaluation with a supplied local tool package: + +```powershell +dotnet tool install --global --add-source C:\tool-feed dotnet-package-skills --version 0.1.0 +``` + +### Help and version + +```powershell +dotnet-package-skills --help +dotnet-package-skills install --help +dotnet-package-skills list --help +dotnet-package-skills uninstall --help +dotnet-package-skills --version +``` + +Representative root help: + +```text +Usage: + dotnet-package-skills [command] [options] + +Options: + -?, -h, --help Show help and usage information + --version Show version information + +Commands: + install Copy skills bundled in NuGet packages into the repository. + list Show which packages ship skills, without copying anything. + uninstall Remove skills this tool previously copied in. +``` + +Version output starts with `0.1.0` and may include build metadata after `+`. + +## 3. Discover available skills: `list` + +```powershell +dotnet-package-skills list +``` + +The tool runs [`dotnet list package`](https://learn.microsoft.com/dotnet/core/tools/dotnet-package-list) on a solution or project to find its top-level package references, and then looks for immediate subfolders of each package's `skills` directory that contain `SKILL.md`. Without `--target`, it uses a solution or project from the current directory or one of its subdirectories; the chosen path appears in `Target:`. Name a target when that choice matters. + +Sample output: + +```text +Target: C:\src\MyApp\MyApp.slnx +NuGet cache: C:\packages +Destination: C:\src\MyApp\.agents\skills +Scanned 2 packages (direct). + +Found 4 skills: + contoso.widgets-widget-testing (Contoso.Widgets 2.3.0) + contoso.widgets-widget-usage (Contoso.Widgets 2.3.0) + fabrikam.testing-fakes (Fabrikam.Testing 1.4.0) + fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) +``` + +`list` shows what is available from packages, not an inventory of installed skills. It never copies skills or writes the ownership manifest. The tool never restores: the .NET 10 SDK restores the target during `dotnet list package` when it needs to, and earlier SDKs report that it has to be restored first. When `dotnet list package` fails, every command that reads packages stops without changes and shows what it reported, so the user can restore or fix the target and run the command again. + +Other ways to scope discovery: + +```powershell +dotnet-package-skills list --target C:\src\MyApp\MyApp.slnx +dotnet-package-skills list --target C:\src\MyApp\App.Web\App.Web.csproj +dotnet-package-skills list --package Contoso.Widgets@2.3.0 +``` + +An explicit package must already be extracted in the selected NuGet cache. Naming it does not download it or add it to a project. Such reports use `Target: (packages named on the command line)` and `Scanned N packages (named explicitly)`. + +The cache directory itself must already exist. If it is absent, restore the project first. `list` skips packages that aren't extracted in the selected cache, without reporting them. When projects resolve different versions of one package, `list` shows each version; `install` refuses to proceed until they're aligned (section 4). `list` does not read destination ownership, so its first discovery candidate can differ from the owner-preferred candidate used by `install`. + +## 4. Install or refresh skills: `install` + +```powershell +dotnet-package-skills install +``` + +Without `--interactive`, the tool attempts to install every discovered skill. It preserves the authored folder names and records ownership in `.dotnet-package-skills.json` inside the destination. Refreshing a tracked skill replaces its whole folder, including local edits or added files. Protection for hand-written skills applies to separate, untracked folders. + +The report uses the same context header as `list`. Its result section looks like: + +```text +Copied 4 skills: + contoso.widgets-widget-testing (Contoso.Widgets 2.3.0) + contoso.widgets-widget-usage (Contoso.Widgets 2.3.0) + fabrikam.testing-fakes (Fabrikam.Testing 1.4.0) + fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) + +These skills are instructions written by the package authors, and your coding agent will follow them. Review them before relying on them. +``` + +### Refresh and cleanup rules + +| Scope | Behavior | +| --- | --- | +| Any noninteractive run | Refresh the skills of the packages found in the cache. When a package's version differs from the version the manifest records, refresh its skills and remove its installed skills that the new version doesn't ship. A package at the same version never loses a skill. Paths protected by an ownership conflict are retained. | +| Target | Keep the installed skills of packages the target no longer references, and list them with a pointer to `uninstall --stale` (section 6). | +| `--package` | Touch only the named packages. Other installed skills are left in place without comment. | +| Interactive install | Only add skills that aren't installed (section 5). Never refresh or remove. | +| A different package claims an installed name | Preserve the existing owner and skip the conflicting copy with a warning in every mode. If the run also supplies the current owner's candidate, refresh that candidate rather than letting the conflict block it. Replacement requires an explicit uninstall first. | +| The owner's package moves to a version that no longer ships that name | Stop before any change, including with `--dry-run`. Removing the old copy would hand the name to the other package, and keeping it would record the old version's copy under the new version. The error suggests `uninstall --package ` for the owner. | + +A target report ends with the skills it kept: + +```text +2 installed skills belong to a package that the target no longer references: + fabrikam.testing-fakes (fabrikam.testing 1.4.0) + fabrikam.testing-fixtures (fabrikam.testing 1.4.0) +Run 'dotnet-package-skills uninstall --stale' to remove them. +``` + +Installed skills are named by the lowercase package ID the manifest records. A reference can disappear temporarily, for example during a refactor, so removal after a package leaves the project is always an explicit command. The suggested command, like every command that an error suggests, repeats the `--target` and a non-default `--destination` of the run, so it can be run as printed. + +If any resolved package is missing from a target's cache, `install` stops before any skill or manifest change, including with `-i` or `--dry-run`. Restore into the selected cache before retrying. With `--package`, a package missing from the cache contributes no skills. + +There is no separate update command: running `install` again refreshes the applicable skill copies. + +```powershell +dotnet-package-skills install --package Contoso.Widgets@2.3.0 --package Fabrikam.Testing@1.4.0 +dotnet-package-skills install --destination .claude\skills +``` + +### One version per package + +Repositories are expected to keep one version of each package, for example with [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management). The manifest records one version per package. When the target resolves more than one version of a package, or `--package` names more than one, every `install` mode stops before any change and names the versions: + +```text +error: Cannot install skills because these packages resolve to more than one version: Contoso.Widgets (2.2.0, 2.3.0). Skills can come from only one version of each package. Align the versions, for example with Central Package Management, and then try again. No skills were changed. +``` + +Equivalent versions, such as `1.10` and `1.10.0`, count as one. `list` still shows every version, and `uninstall --stale` still works. + +### Preview without installing + +```powershell +dotnet-package-skills install --dry-run +``` + +Result excerpt: + +```text +Would copy 4 skills: + contoso.widgets-widget-testing (Contoso.Widgets 2.3.0) + contoso.widgets-widget-usage (Contoso.Widgets 2.3.0) + fabrikam.testing-fakes (Fabrikam.Testing 1.4.0) + fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) +``` + +Any planned removals appear under `Would remove`. A dry run does not copy or delete skills or create/update the ownership manifest. The .NET SDK can still restore the target while `dotnet list package` runs. + +## 5. Choose skills interactively + +```powershell +dotnet-package-skills install --interactive +dotnet-package-skills install --package Contoso.Widgets@2.3.0 -i +dotnet-package-skills install -i --dry-run +``` + +Representative page after making a selection, with `contoso.widgets-widget-testing` already installed: + +```text +Which skills should be installed? (MyApp.slnx) +Installed skills aren't listed. + +> [X] contoso.widgets-widget-usage - Correct usage patterns for the + Contoso.Widgets library, including lifetime rules and the batching API. + Use whenever code creates, configures, or disposes a Widget. + [ ] fabrikam.testing-fakes - Create fakes and verify their calls in unit + tests. + [ ] fabrikam.testing-fixtures - Share expensive setup across tests with + fixtures. + +1 of 3 selected +(Press to select, to accept) +(Press / to move, / for first/last) +(Press to select all, to clear all, // to cancel) +Blue X: selected +``` + +### Selection rules + +- The checklist lists only skills that would install cleanly and aren't installed. Nothing starts checked. The line under the title says that installed skills aren't listed. +- Acceptance copies only the checked skills. An interactive install never refreshes or removes a skill; refreshing is what a noninteractive `install` does, and removal is what `uninstall` does. +- Candidates that would be skipped, such as a name owned by another package or used by an untracked folder, aren't listed. The final report names them under the skipped warning. +- When every discovered skill is already installed, the command prints the following and exits with code `0` without opening the checklist. When some candidates were skipped, only the first sentence is printed, followed by the skipped warning. + + ```text + Nothing new to install. Every skill that these packages ship is already installed. + ``` + +Because the checklist only adds, it needs the installed skills to agree with the packages. Before the checklist opens, an interactive install fails with exit code `1`, changing nothing, in these cases: + +| Case | Message excerpt | Resolution | +| --- | --- | --- | +| More than one version of a package, in any mode | `...resolve to more than one version...` or `--package names more than one version...` | Align the versions, or name one version per package. | +| Target: a resolved package is missing from the cache | `...resolved packages are missing from...` | Restore the target. | +| Target: an installed skill is stale | `Cannot choose skills interactively because 2 installed skills don't match the target...` | Run `dotnet-package-skills uninstall --stale`. | +| `--package`: a named package is installed at another version | `Contoso.Widgets 2.2.0 is already installed, and an interactive install only adds skills...` | Run `install --package` without `-i` to change the version, or `uninstall --package ` first. | + +A skill is **stale** when the target no longer references its package, or references a different version than the manifest records. Skills installed with `install --package` from packages outside the target count as stale for target commands, so a target `install -i` stops until they are removed. + +### Presentation + +- The format is always `skill-name - description`, without a package/version suffix or a separate description column. Authored package prefixes in skill names remain intact. Long display names may be clipped with `...`; their canonical identities are unchanged. +- Descriptions wrap beneath the skill text with a small list indent, using the available width. Page sizes reflect rendered lines, not a fixed number of skills. A list that fits needs no paging. +- Live resizing recalculates wrapping and pagination while preserving focus and selection. Oversized descriptions can be scrolled while their skill row remains visible. +- The note under the title is omitted when the window is too small to fit it, rather than failing. + +The live checklist is displayed at the top of a temporary terminal screen, separate from the shell's scrollback. Its initial position does not depend on where the shell cursor was before invocation. On acceptance, cancellation, or a handled error, the original shell screen is restored and receives the final report. The checklist itself is not retained in normal history, preventing host-driven resize reflow from leaving duplicate headings or partial old frames. + +| Visual cue | Meaning | +| --- | --- | +| Blue row text | Keyboard focus, including the skill name and wrapped description lines. | +| Blue `X` | Checked item: install in the install picker, or remove in the uninstall picker. Each picker does one thing, so a checked row looks the same in both, and the title and summary say what a check does. | +| Normal name/description text | Item is not focused; selection does not change the color of its name or brackets. | +| `>` and `[X]` when color is disabled | Focus and checked state, with no other marker and no color legend. `NO_COLOR` is honored. | + +| Key | Behavior | +| --- | --- | +| Up / Down | Move between skills; wrap at the beginning/end. | +| Left / Right or PageUp / PageDown | Move between pages. | +| Home / End | Go to the first/last skill. | +| Space | Toggle the focused skill. | +| A / C | Select all / clear all, across all pages. | +| Ctrl+Up / Ctrl+Down | Scroll an oversized description. | +| Enter | Accept. With `--dry-run`, report only. | +| Esc / Q / Ctrl+C | Cancel without changing destination skills. | + +Every keyboard-help line begins with `Press`. Paging/scrolling hints appear only when applicable. An unusably small terminal, at startup or after a resize, fails with exit code `1` and guidance to enlarge it. Destination skills are unchanged, but the in-memory selection must be made again. If ownership changes while a picker is open, acceptance also fails without applying the stale choice. + +Descriptions are read from YAML frontmatter in `SKILL.md`. Missing descriptions show `No description provided.` Invalid or unreadable descriptive metadata produces a visible warning, but does not prevent selecting the skill. Descriptions are not rewritten, executed, or added to reports or the ownership manifest. + +Cancellation output: + +```text +Cancelled. Nothing was copied or removed. +``` + +## 6. Remove installed skills: `uninstall` + +```powershell +dotnet-package-skills uninstall +``` + +This removes all skills tracked by the destination's ownership manifest. It does not remove NuGet packages or hand-written skills, and does not require a solution or the NuGet cache. + +Sample output: + +```text +Destination: C:\src\MyApp\.agents\skills + +Removed 2 skills: + contoso.widgets-widget-testing (contoso.widgets 2.3.0) + contoso.widgets-widget-usage (contoso.widgets 2.3.0) +``` + +Removal reports name each skill's package by the lowercase ID the manifest records. + +Common variants: + +```powershell +dotnet-package-skills uninstall --package Contoso.Widgets +dotnet-package-skills uninstall --package Contoso.Widgets@2.3.0 +dotnet-package-skills uninstall --destination .claude\skills +dotnet-package-skills uninstall --dry-run +dotnet-package-skills uninstall --interactive +dotnet-package-skills uninstall --stale +``` + +A bare package ID matches the package's tracked skills; `ID@VERSION` matches them only if that version is the one installed. The manifest records one version per package, so there is never more than one to choose from. Both modes use the same case-insensitive package matching and normalized version comparison: for example, `1.10` matches `1.10.0`. An explicitly blank, whitespace-only, or missing filter value is an error, never an instruction to remove everything. Uninstall accepts the package option only once; repeated occurrences and aliases are rejected even if the final value is absent. Only omitting `--package` means no filter. A dry run reports `Would remove` and preserves the files. + +The interactive picker lists only manifest-owned skills and reads descriptions from their installed copies. Nothing starts checked. A check means removal, not retention: + +```text +Which skills should be uninstalled? + +> [X] contoso.widgets-widget-testing - Testing patterns for code that uses + Contoso.Widgets. Use when writing unit or integration tests involving + widgets. + [ ] contoso.widgets-widget-usage - Correct usage patterns for the + Contoso.Widgets library, including lifetime rules and the batching API. + Use whenever code creates, configures, or disposes a Widget. + +1 of 2 selected; 1 to remove +(Press to select, to accept) +(Press / to move, / for first/last) +(Press to select all, to clear all, // to cancel) +Blue X: selected +``` + +Accepting removes only the checked skill. If ownership changes while the picker is open or while waiting for another operation, acceptance fails without applying that stale selection. A missing installed `SKILL.md` does not prevent removing its tracked folder. With no tracked skills, the command succeeds without opening a picker: + +```text +Destination: C:\src\MyApp\.agents\skills + +Nothing to remove. No skills installed by this tool were found there. +``` + +### Remove stale skills: `uninstall --stale` + +`install` never removes the skills of a package that left the project. `uninstall --stale` removes every stale skill: a tracked skill whose package the target no longer references, or references at a different version than the manifest records. + +```powershell +dotnet-package-skills uninstall --stale --dry-run +dotnet-package-skills uninstall --stale +dotnet-package-skills uninstall --stale --target C:\src\MyApp\MyApp.slnx --interactive +``` + +Sample output: + +```text +Target: C:\src\MyApp\MyApp.slnx +Destination: C:\src\MyApp\.agents\skills + +Removed 2 skills: + fabrikam.testing-fakes (fabrikam.testing 1.4.0) + fabrikam.testing-fixtures (fabrikam.testing 1.4.0) +``` + +With nothing stale, the command succeeds and says so: + +```text +Target: C:\src\MyApp\MyApp.slnx +Destination: C:\src\MyApp\.agents\skills + +Nothing to remove. No stale skills were found. +``` + +- `--stale` reads the target's package references, so it requires a solution or project, found as in section 3 or named with `--target`. Without one, it fails with `No solution or project found under ...`. Like `install` and `list`, it runs `dotnet list package`, which the .NET SDK can restore the target for; when that fails, the command stops and shows what it reported. +- It needs only the target's package references, not the packages, so it doesn't look in the NuGet cache for skills. +- It works when the target resolves more than one version of a package: a skill is stale only if no project references its installed version. +- With `--interactive`, only stale skills are listed, under the note `Only skills that don't match the target are listed.` +- `--stale` cannot be combined with `--package`. `--target` is accepted by `uninstall` only together with `--stale`. + +## 7. Complete option reference + +| Option | Applies to | Contract | +| --- | --- | --- | +| `-t, --target ` | `list`, `install`; `uninstall` with `--stale` | Solution, project, or directory to search. Supported files: `.slnx`, `.sln`, `.csproj`, `.fsproj`, `.vbproj`. Defaults to discovery from the current directory. | +| `-p, --package ` | `list`, `install` | Exact package coordinates instead of a target. Repeatable, with one version per package; equivalent repeated coordinates are deduplicated. Floating versions and ranges are rejected. | +| `-p, --package ` | `uninstall` | One occurrence of a nonempty package filter, optionally restricted to a normalized version. Same matching in both modes. | +| `-d, --destination ` | All three | Skills destination; default `.agents\skills`. Uninstall must use the same destination used for installation. | +| `--global-packages ` | `list`, `install` | Existing extracted-package cache to use. Overrides `NUGET_PACKAGES` and the NuGet-configured cache. | +| `--dry-run` | `install`, `uninstall` | Report planned destination changes without applying them. | +| `-i, --interactive` | `install`, `uninstall` | Open the paginated picker. On install, only skills that aren't installed are listed, and accepting only adds. On uninstall, checked skills are removed. Requires a terminal when there are rows to choose. | +| `--stale` | `uninstall` | Remove only stale skills: those whose package the target no longer references, or references at a different version. Requires a solution or project. | +| `-?, -h, --help` | Root and all three | Display usage and supported options. | +| `--version` | Root | Display version information. | + +**Combination rules:** `--target` cannot be combined with `--package`. `--stale` cannot be combined with `--package`, and `uninstall` accepts `--target` only with `--stale`. Interactive selection can be combined with `--dry-run`, package filters, and `--stale`. `list` has neither `--interactive` nor `--dry-run`. No command has a JSON output option or a restore option; `--json` and `--no-restore` are rejected as unrecognized arguments. + +**Path rule:** a relative destination is based on the invocation directory, not automatically on the directory containing `--target`. A relative `--global-packages` override is resolved from the target's directory in target mode and the invocation directory in named-package mode. That override selects the read cache; it does not reconfigure NuGet restore. + +## 8. Ownership manifest + +The destination's `.dotnet-package-skills.json` records what the tool installed. It is the tool's only machine-readable output, so its format is a public contract, guarded by its format `version`. Its shape follows the .NET local tool manifest, `dotnet-tools.json`: + +```json +{ + "version": 1, + "packages": { + "contoso.widgets": { + "version": "2.3.0", + "skills": [ + "contoso.widgets-widget-testing", + "contoso.widgets-widget-usage" + ] + } + } +} +``` + +| Element | Required | Contract | +| --- | --- | --- | +| `version` | Yes | Format version, a whole number. This release reads and writes `1`. | +| `packages` | Yes | Object keyed by package ID. The tool writes lowercase IDs; IDs are matched case-insensitively, and two keys that differ only in case are invalid. | +| `packages..version` | Yes | The one package version the skills were installed from, normalized as NuGet does (`1.10` is written `1.10.0`). | +| `packages..skills` | Yes | Skill folder names directly under the destination that this package owns. Each name is claimed once across the whole manifest. | + +Writing rules: UTF-8 without a byte order mark, two-space indentation, LF line endings with a final newline on every platform, packages and skills in a stable order. A repository can commit the file without line-ending churn between Windows and Unix checkouts. Properties the tool doesn't recognize are ignored when reading and are not preserved when the file is rewritten. + +Reading rules: + +- A newer format version fails with guidance to update the tool. +- A manifest written by a pre-release build of the tool, which has an `installed` array and no `packages` object, is not converted. The tool asks the user to move the skills folder aside and install again. +- Other damage, such as a merge conflict, a missing `version` or `packages`, an invalid package ID, a package without a version, a duplicate claim, or an unsafe skill name, fails as described in section 9. + +Scripts should rely on exit codes and read this file for what's installed. The human-readable reports are for people and can change between releases. + +Human-readable reports and diagnostics, including argument-validation errors and parser suggestions, remove terminal escape sequences and unsafe control characters from metadata, paths, and diagnostic text. This affects presentation only: arguments are validated as supplied, and stored identities remain unchanged. + +## 9. Safety, empty results, and errors + +| Situation | User-visible behavior | +| --- | --- | +| No package ships a discoverable skill | Successful report: `No bundled skills found.` | +| Skills exist but none are accepted | Report `Copied no skills.` or `Would copy no skills.`, with any skipped-item details. | +| Every discovered skill is already installed (`install -i`) | `Nothing new to install.`, with the explanation or the skipped warning; no checklist; exit code `0`. | +| A resolved package is absent from the cache | A target-based `install` fails before changes, in every mode. `list` skips it silently. With `--package`, it contributes no skills. `uninstall --stale` is unaffected. | +| Packages resolve to more than one version | Every `install` mode fails before changes and names the versions to align. `list` shows each version; `uninstall --stale` still works. | +| A package left the target | `install` keeps its skills and lists them with a pointer to `uninstall --stale`. `install -i` fails until they're removed. | +| A package moved to a new version | `install` refreshes its skills and removes the ones the new version doesn't ship. `install -i` fails with a target (the skills are stale) and with `--package` (another version is installed). | +| A destination name conflicts with another skill, an untracked folder, or a different installed owner | Warn and skip the conflicting copy. Preserve the current owner, including when the run leaves its package out. | +| The owner's new version drops a skill that another package in the run ships | `install` fails before changes and suggests `uninstall --package ` for the owner (section 4). | +| A package filter is explicitly blank or missing its value | Fail; never broaden a selective uninstall to all tracked skills. | +| `uninstall --stale` finds no solution or project | Fail with `No solution or project found under ...`; nothing is removed. | +| The ownership manifest is absent | Existing folders are not assumed to belong to the tool. | +| The ownership manifest is unreadable, malformed, or unsafe | Fail before modifying destination skills; preserve the manifest and explain how to repair/restore it. Missing `version` or `packages`, duplicate JSON properties (including case variants), invalid package IDs, packages without a version, duplicate case-insensitive skill claims, and unsafe skill folder names are invalid. Names ending in a dot or space, including `...`, are rejected because Windows can resolve them to another folder or the destination itself. This also applies to interactive and dry-run modes. `list` remains available. | +| The manifest has a newer format version | Fail before changes and ask the user to update the tool. | +| The manifest was written by a pre-release build | Fail before changes and ask the user to move the skills folder aside and install again. | +| `dotnet list package` fails, for example because the restore it runs fails, or an earlier SDK says the target needs restoring | Stop before changes with exit code `1`, show the problems it reported, and ask the user to resolve them and run the command again. The tool never restores. | +| Invalid option combination, missing target, failed restore, or filesystem error | Report an actionable error and return a non-zero exit code. | + +The manifest is written after a successful installation or removal, not by `list`, a cancelled picker, or a dry run. Removing the last tracked entry deletes the manifest. The destination folder is deleted only if it is empty; hand-written skills keep that folder alive. Removing a tracking entry is reported even when its skill folder had already been deleted. + +Cooperating tool processes serialize reads and changes for a destination. Ownership is loaded and checked inside that critical section, which remains held through copying, removal, and manifest persistence. A busy destination produces retry guidance rather than overlapping mutations. This is local-process coordination, not a distributed filesystem transaction. Equivalent Windows path spellings share that coordination. If a destination alias changes while an operation waits for access, the operation fails before modifying the newly resolved location. + +Successful operations, empty results, and cancellation return exit code `0`. Command failures return `1`. Warnings/skipped skills can still accompany exit code `0`; automation should inspect the report when completeness matters. + +Skill metadata is not a security review of the instructions. Developers remain responsible for deciding what their agent should trust. Unexpected filesystem failures are reported, but transactional rollback of a partially completed copy/removal is not provided in this version. Such failures can leave copied folders absent from the manifest. Restore a verified backup or move affected folders aside before retrying; do not blindly delete untracked guidance. + +## 10. Product review checklist + +| Scenario | Expected customer outcome | +| --- | --- | +| Discover before deciding | `list` shows available skills without installing them. | +| Try the picker safely | `install -i --dry-run` previews a selection without writing skills or a manifest. | +| Make an informed choice | Read descriptions, navigate pages, select skills, and accept. | +| Add a few more skills later | `install -i` lists only skills that aren't installed; accepting adds them and changes nothing else. With nothing new, it says so without a checklist. | +| Resize during selection | Text reflows and selections are retained while the window remains large enough; the note under the title gives way first; an unusable size fails without changing files. | +| Upgrade a package | `install` refreshes its skills and removes the ones the new version dropped. | +| Remove a package from the project | `install` keeps its skills and says which command removes them; `uninstall --stale` (with `--dry-run` or `-i`) removes them. | +| Mix package versions in one repository | `install` stops before changes and asks for the versions to be aligned, for example with Central Package Management. | +| Encounter incomplete discovery | Target-based install fails before copying or removing anything; restore and retry. | +| Encounter another package's owned name | Preserve the installed owner and warn; replacement requires explicit removal first. | +| Remove selectively | `uninstall -i` offers tracked skills only and removes only checked items. | +| Use package filters in scripts | Blank filters fail, and normalized version matching is identical with and without `-i`. | +| Keep locally authored guidance | Keep guidance in separate, untracked skill folders. | +| Automate reliably | Check exit codes, read the ownership manifest, and run `install` followed by `uninstall --stale`. | +| Commit the skills folder | The manifest has the same bytes on every platform, so commits don't churn line endings. | +| Recover from a damaged ownership record | Receive an explicit error; repair or restore the preserved manifest before retrying. | diff --git a/dotnet-package-skills/docs/scenarios.md b/dotnet-package-skills/docs/scenarios.md new file mode 100644 index 0000000..e4cf7d5 --- /dev/null +++ b/dotnet-package-skills/docs/scenarios.md @@ -0,0 +1,292 @@ +# dotnet-package-skills v1: scenarios and expected behavior + +This document describes how v1 behaves, scenario by scenario. Rows marked **Changed in v1** or **New in v1** differ from the preview build (commit `7effb41`); everything else describes behavior that the preview build already had. + +## 1. The idea + +NuGet packages can ship agent skills: folders that contain a `SKILL.md` file, under the package's `skills/` folder. `dotnet-package-skills` copies those folders into your repository, where coding agents can read them. A manifest in the skills folder records which folders the tool copied, so it can refresh or remove them later without touching anything you wrote yourself. + +| Term | Meaning | +| --- | --- | +| Skill | A folder containing `SKILL.md`, shipped in a package's `skills/` folder. | +| Skills folder | Where skills are copied. The default is `.agents/skills` under the current folder; change it with `--destination`. | +| Manifest | `.dotnet-package-skills.json` in the skills folder. It lists the folders the tool manages, by package and version. | +| Tracked skill | A folder listed in the manifest. The tool may refresh or remove it. | +| Stale skill | A tracked skill that doesn't match the project: the project no longer references its package, or it uses a different version of that package. | +| Your own skill | Any other folder in the skills folder. The tool never changes or deletes it. | + +The examples use these packages: + +| Package | Ships | +| --- | --- | +| Mockly 1.10.0 | `mockly-usage`, `mockly-migration` | +| Mockly 1.11.0 | `mockly-usage` only; it dropped `mockly-migration` | +| Contoso.Widgets 2.3.0 | `contoso.widgets-usage` | +| Newtonsoft.Json 13.0.3 | No skills | + +## 2. Commands and options + +| Command | Use it to | Changes files | +| --- | --- | --- | +| `list` | See which skills your packages ship. | Never. | +| `install` | Copy new skills and bring installed ones up to date. | The skills folder and manifest, unless `--dry-run` is used. | +| `uninstall` | Remove skills that the tool installed. | The skills folder and manifest, unless `--dry-run` is used. | + +| Option | `list` | `install` | `uninstall` | Meaning | +| --- | --- | --- | --- | --- | +| `-t, --target ` | Yes | Yes | With `--stale` | Solution or project to read. Without it, the tool looks for one in the current folder, and then in its subfolders, preferring a solution. Can't be combined with `--package`. | +| `-p, --package ` | Yes | Yes | | Name packages directly instead of reading a project. Repeatable, with one version per package. | +| `-p, --package ` | | | Yes | Remove only this package's skills, or only if that version is installed. | +| `--stale` | | | Yes | Remove only stale skills. Requires a project or solution: the one passed with `--target`, or the one the tool finds. Can't be combined with `--package`. **New in v1.** | +| `-d, --destination ` | Yes | Yes | Yes | The skills folder. | +| `-i, --interactive` | | Yes | Yes | Choose skills from a paged checklist. | +| `--dry-run` | | Yes | Yes | Show what would happen; change nothing. | +| `--global-packages ` | Yes | Yes | | Read a different NuGet cache. | + +**Changed in v1:** the `--json` option is removed from every command. Each command reports its results as text only. The `--no-restore` option is removed too: the tool never restores (F3). + +Only a project's direct package references are read. Repositories are expected to manage package versions with [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management), so that each package resolves to one version. `uninstall` needs neither a project nor the NuGet cache, except that `uninstall --stale` reads the project's package references. It never reads the packages themselves. + +## 3. What each command can change + +Read these tables first. *Refresh* means that the tracked folder is deleted and copied again from the package. Adding `--dry-run` to any row shows the same result without changing anything. + +| Mode | Copies | Refreshes | Removes | Stops without changing anything when | +| --- | --- | --- | --- | --- | +| `install` for the project | Every new skill | Every installed skill whose package the project references, from the version the project uses | Skills that a package's new version no longer ships | Any package resolves to two versions, a package that the project references isn't in the NuGet cache, or a package's new version drops a skill whose name another package ships (E6) | +| `install --package X@V` | X's new skills | X's installed skills, from version V | X's skills that version V no longer ships | The same package is named with two versions, or version V drops a skill whose name another named package ships (E6) | +| `install -i` for the project | The new skills you check | Nothing | Nothing | Any package resolves to two versions, a package that the project references isn't in the NuGet cache, or any installed skill is stale (run `uninstall --stale` first) | +| `install -i --package X@V` | The new skills from X that you check | Nothing | Nothing | The same package is named with two versions, or X is installed at another version (run `uninstall --package X` first) | + +No install mode removes skills whose package left the project. A plain `install` keeps them and suggests `uninstall --stale`. + +**Changed in v1:** `install` no longer removes skills of packages that left the project. `install --package` now removes the skills that its version no longer ships. `install -i` only adds skills, and stops when installed skills are stale. + +| Mode | Removes | +| --- | --- | +| `uninstall` | Every tracked skill. | +| `uninstall --package Mockly` | Mockly's tracked skills. | +| `uninstall --package Mockly@1.10.0` | Mockly's tracked skills, only if 1.10.0 is the installed version. | +| `uninstall --stale` | Stale skills, including skills added with `install --package` for packages that the project doesn't reference. **New in v1.** | +| `uninstall -i` | The tracked skills you check. Nothing starts checked. | +| `uninstall -i --stale` | The stale skills you check. Only stale skills are listed, and nothing starts checked. **New in v1.** | + +To move a package's skills to the version that the project now uses, instead of removing them, run a plain `install`. + +## 4. Scenarios + +### A. Getting started + +| # | Situation | You run | What happens | +| --- | --- | --- | --- | +| A1 | The project references Mockly 1.10.0 and Newtonsoft.Json. | `install` | Copies `mockly-usage` and `mockly-migration` into `.agents/skills` and creates the manifest. Newtonsoft.Json is scanned and has nothing to copy. | +| A2 | No referenced package ships skills. | `install` | Reports that no bundled skills were found. Creates no folder and no manifest. | +| A3 | You want to look before copying anything. | `list`, or `install --dry-run` | Shows what would be copied. Nothing changes. | +| A4 | You want only some of the skills. | `install -i` | Opens a paged checklist of the skills that aren't installed yet, with their descriptions. The line under the title says that installed skills aren't listed. Everything starts unchecked, and the skills you check are copied. **Changed in v1:** the preview build listed installed skills too, checked, and unchecking one removed it. | + +### B. Keeping skills up to date + +| # | Situation | You run | What happens | +| --- | --- | --- | --- | +| B1 | Nothing has changed since the last install. | `install` | Tracked skills are copied again. The files and manifest come out identical, so git shows no changes. | +| B2 | You edited `mockly-usage` by hand. | `install` | Your edits are replaced with the package's copy. Keep your own guidance in your own folders. | +| B3 | You deleted the `mockly-usage` folder by hand. | `install` | It's copied again. `install -i` doesn't list it, because the manifest still tracks it. | +| B4 | Nothing is stale, and you want to add a skill. | `install -i` | Installed skills are left exactly as they are, including any edits. Only the skills you check are copied. **Changed in v1.** | + +### C. A package changes version + +Mockly 1.10.0 is installed with `mockly-usage` and `mockly-migration`, and the project now uses Mockly 1.11.0. + +| # | Situation | You run | What happens | +| --- | --- | --- | --- | +| C1 | 1.11.0 ships the same skills. | `install` | Both skills are refreshed from 1.11.0, and the manifest records 1.11.0. | +| C2 | 1.11.0 also adds `mockly-testing`. | `install` | `mockly-testing` is copied too. | +| C3 | 1.11.0 dropped `mockly-migration`. | `install` | `mockly-usage` is refreshed, and `mockly-migration` is removed. | +| C4 | Same as C3. | `install --package Mockly@1.11.0` | Same as C3. **Changed in v1:** the preview build kept `mockly-migration`, still recorded under 1.10.0. | +| C5 | Any version change. | `install -i` | Stops with exit code 1, because Mockly's installed skills are stale. After `uninstall --stale` removes them, `install -i` lists 1.11.0's skills as new. To move to 1.11.0 in one step instead, run a plain `install`; it also copies any new skills. **Changed in v1.** | +| C6 | Any version change. | `install -i --package Mockly@1.11.0` | Stops with exit code 1, because Mockly 1.10.0 is installed, and asks you to run `uninstall --package Mockly` first. A plain `install --package Mockly@1.11.0` moves the skills to 1.11.0 instead. **Changed in v1.** | +| C7 | Mockly moves to an older version. | Any command | The same rules as an upgrade apply. | + +### D. A package is removed from the project + +Contoso.Widgets was installed, and the project no longer references it. + +| # | Situation | You run | What happens | +| --- | --- | --- | --- | +| D1 | As described. | `install` | `contoso.widgets-usage` is kept. The report says that its package is no longer referenced and suggests `uninstall --stale`. **Changed in v1:** the preview build removed it without asking. | +| D2 | As described. | `install -i` | Stops with exit code 1 and asks you to run `uninstall --stale` first. **Changed in v1:** the preview build listed it, and removed it if you unchecked it. | +| D3 | As described. | `install --package Mockly@1.11.0` | `contoso.widgets-usage` is left alone. | +| D4 | As described. | `uninstall --stale` | Removes `contoso.widgets-usage`. Add `--dry-run` to preview, or `-i` to choose. **New in v1.** | +| D5 | Alpha's skills were added with `install --package Alpha@1.0.0`, and the project doesn't reference Alpha. | `install`, `install -i`, or `uninstall --stale` | `install` keeps them and prints the hint. `install -i` stops until they're removed. `uninstall --stale` removes them. Use one source per skills folder: the project or `--package`. | + +### E. Conflicts: the tool never overwrites + +| # | Situation | You run | What happens | +| --- | --- | --- | --- | +| E1 | You already have your own `mockly-usage` folder. | `install` | Mockly's copy is skipped with the reason "the destination folder already exists and is not managed by this tool". Your folder is untouched. | +| E2 | Packages Alpha and Beta both ship a skill named `shared`. | `install` | The first package in a fixed order gets it: alphabetical by package ID for a project, or command-line order with `--package`. The other package's copy is skipped with a reason. | +| E3 | `shared` is installed from Alpha, and Beta also ships it. | `install` | Alpha keeps it, including when the project no longer references Alpha; Beta's copy is skipped. To switch owners, uninstall Alpha's skill first. | +| E4 | A file named `shared` is in the skills folder. | `install` | The skill is skipped. | +| E5 | Two projects reference different versions of the same package, for example through `VersionOverride`. This applies to every package, including ones that ship no skills, such as Newtonsoft.Json. | `install` in any mode, or `install --package` naming one package with two versions | Stops with exit code 1, changes nothing, and names the package and its versions. Align the versions with Central Package Management, and then try again. `list` still works. **Changed in v1:** the preview build installed from both versions. | +| E6 | `shared` is installed from Alpha 1.0.0. The project moves to Alpha 2.0.0, which doesn't ship `shared`, and Beta also ships it. | `install`, including `--dry-run` | Stops with exit code 1 and changes nothing. Removing Alpha's copy would hand the name to Beta, and keeping it would record Alpha 1.0.0's copy as 2.0.0's. The error suggests `uninstall --package Alpha`; after it, `install` copies Alpha 2.0.0's skills and Beta's `shared`. **New in v1.** | + +Skipped skills don't fail the command; it still exits with code 0. With `install -i`, skills that can't be installed because of a conflict aren't listed, and the report shows them as skipped. + +### F. The NuGet cache + +| # | Situation | You run | What happens | +| --- | --- | --- | --- | +| F1 | A package that the project references isn't in the NuGet cache that the tool reads, for example because `--global-packages` names a different folder than the one restore uses. | `install` or `install -i`, including with `--dry-run` | Stops with exit code 1, changes nothing, and asks you to run `dotnet restore`. | +| F2 | A package isn't in the NuGet cache. | `list`, `install --package`, or `install -i --package` | The package is treated like one without skills, with no warning. A version that isn't in the cache never causes a removal: with `install --package Mockly@1.11.0` and 1.11.0 missing, Mockly's installed skills stay as they are. **Changed in v1:** the preview build warned that the package was "resolved but not extracted" and listed it in the JSON `notOnDisk` field. | +| F3 | `dotnet list package` fails: the restore that the .NET 10 SDK runs for it fails, or an earlier SDK says the project needs restoring. | `install`, `list`, or `uninstall --stale` | Stops with exit code 1, changes nothing, and shows what `dotnet list package` reported. Restore or fix the project, and then run the command again. The tool never restores. **Changed in v1:** the preview build ran `dotnet restore` itself when the project wasn't restored, and had a `--no-restore` option to prevent that. | +| F4 | Packages are missing from the NuGet cache. | `uninstall --stale` | Not affected, because it reads only the project's package references. | + +### G. Removing skills + +| # | Situation | You run | What happens | +| --- | --- | --- | --- | +| G1 | Mockly and Contoso.Widgets skills are installed, plus your own `team-notes` folder. | `uninstall` | Removes the tracked skills. `team-notes` stays. The manifest is deleted, and the skills folder too if it's empty. | +| G2 | Same as G1. | `uninstall --package Mockly` | Removes only Mockly's skills. | +| G3 | Same as G1. | `uninstall -i` | Lists only the tracked skills, none checked. The ones you check are removed. | +| G4 | Nothing is tracked. | `uninstall` | Reports that there's nothing to remove, and exits with code 0. | +| G5 | Same as G1. | `uninstall --dry-run` | Shows what would be removed. Nothing changes. | +| G6 | Mockly 1.10.0 is installed, and the project now uses 1.11.0. | `uninstall --stale` | Removes Mockly's skills, because they don't match the project. To move them to 1.11.0 instead, run a plain `install`. **New in v1.** | +| G7 | Nothing is stale. | `uninstall --stale` | Reports "Nothing to remove. No stale skills were found." and exits with code 0. **New in v1.** | +| G8 | There's no solution or project in the current folder or its subfolders, and `--target` isn't given. | `uninstall --stale` | Stops with exit code 1 and asks for a target. Nothing is removed. **New in v1.** | + +### H. Teams and source control + +| # | Situation | You run | What happens | +| --- | --- | --- | --- | +| H1 | The skills folder and manifest are committed, and a teammate runs `install` with the same packages. | `install` | The same files are produced, so there's no diff. The manifest is sorted and uses LF line endings on every operating system. **Changed in v1:** LF line endings. | +| H2 | A merge leaves conflict markers in the manifest. | `install` or `uninstall` | Stops with exit code 1 and changes nothing until you fix the file. `list` still works. | +| H3 | The manifest says `"version": 2`, written by a newer tool. | `install` or `uninstall` | Stops and asks you to update the tool. **New in v1.** | +| H4 | The manifest uses the pre-release format, with an `installed` list. | `install` or `uninstall` | Stops and asks you to move the skills folder aside and reinstall. **New in v1.** | +| H5 | Two runs use the same skills folder at the same time. | Any command that changes files | The second run waits up to 30 seconds, then stops with "Another operation is using the skills destination … Wait for it to finish and try again." | + +### I. Scripts and CI + +| # | Situation | What happens | +| --- | --- | --- | +| I1 | A script needs to know which skills are installed. | It reads the manifest, which is versioned JSON and the tool's only machine-readable output. | +| I2 | CI runs `install`. | The job checks the exit code. `install` exits with code 0 even when skills are skipped; skips appear as warnings in the text report. | +| I3 | CI should keep the skills folder exactly in sync with the project. | Run `install`, then `uninstall --stale`, and then `git status` to see what changed. **Changed in v1:** in the preview build, `install` alone did both. | +| I4 | A command fails. | Exit code 1 and an error on standard error. | +| I5 | A script passes `--json` to any command. | Rejected as an unrecognized argument, with exit code 1. **Changed in v1:** the preview build accepted it on every command. | + +The text report is for people and isn't a stable format for scripts to parse. + +### J. The interactive checklist + +| # | Situation | What happens | +| --- | --- | --- | +| J1 | You open the checklist. | Skills are shown a page at a time, with descriptions. It uses a temporary screen, so it doesn't stay in your scrollback. | +| J2 | Every skill that the packages ship is already installed. | `install -i` reports "Nothing new to install." and exits with code 0, without opening the checklist. **New in v1.** | +| J3 | The terminal is too small. | The line under the title is dropped first. If the checklist still doesn't fit, the command exits with code 1 and asks you to enlarge the window. Nothing changes. **New in v1:** the line under the title. | +| J4 | You cancel. | "Cancelled. Nothing was copied or removed." | +| J5 | Another run changes the installed skills while the checklist is open. | Accepting fails, and nothing changes. | + +## 5. How `install` decides + +What `install` checks before it changes anything, and then what happens to each skill that a package offers. `install -i` also stops when installed skills are stale, and applies the per-skill checks only to the skills you check. + +```mermaid +flowchart TD + S["install starts"] --> P{"Does any package resolve to two versions,
or is a package the project references
missing from the NuGet cache?"} + P -->|"yes"| X["The command stops with exit code 1
and changes nothing"] + P -->|"no"| A["For each skill that a package offers"] + A --> C{"Does another package in this run
get the same folder name?"} + C -->|"yes"| S1["Skipped: name conflict"] + C -->|"no"| D{"What is already at that path
in the skills folder?"} + D -->|"a file"| S2["Skipped"] + D -->|"a folder the manifest doesn't track"| S3["Skipped: your own folder"] + D -->|"a folder tracked for another package"| S4["Skipped: owned by another package"] + D -->|"nothing, or a folder tracked for this package"| OK["Copied or refreshed"] +``` + +What a plain `install` for the project does with each installed skill: + +```mermaid +flowchart TD + T["An installed skill"] --> R{"Does the project reference
its package?"} + R -->|"no"| K["Kept; the report suggests
uninstall --stale"] + R -->|"yes, at the installed version"| F1["Refreshed"] + R -->|"yes, at another version"| S{"Does that version
still ship the skill?"} + S -->|"yes"| F2["Refreshed from that version"] + S -->|"no"| X["Removed"] +``` + +- `install --package X@V` follows this chart for X's skills only, and keeps every other skill. +- `install -i` doesn't follow this chart. If any installed skill would take the "no" or "another version" branch, it stops and asks you to run `uninstall --stale`. Otherwise, it leaves installed skills as they are. +- An installed skill involved in a conflict is never removed by the same run. If its package moved to a version that no longer ships it, `install` stops instead (E6). A version that isn't in the NuGet cache never causes a removal. + +## 6. What the tool writes + +### Manifest + +```json +{ + "version": 1, + "packages": { + "contoso.widgets": { + "version": "2.3.0", + "skills": [ + "contoso.widgets-usage" + ] + }, + "mockly": { + "version": "1.10.0", + "skills": [ + "mockly-migration", + "mockly-usage" + ] + } + } +} +``` + +- The top-level `version` is the file format version, as in `dotnet-tools.json`. The `version` inside each package is the package version. +- Package IDs are written in lowercase, as the .NET SDK does for tool manifests. Each package has one version. +- There's no `isRoot`, because the tool never searches parent folders, and no `note`. +- The file is UTF-8 without a byte order mark, with LF line endings and packages and skills in a stable order. + +The manifest is the only file the tool writes besides the copied skills, and its only machine-readable output. + +## 7. Decisions + +| # | Decision | +| --- | --- | +| 1 | No command has a `--json` option. Passing it fails as an unrecognized argument. | +| 2 | Commands report their results as text only. The manifest, which records what's installed, is the tool's only machine-readable output. | +| 3 | `list` and `install --package` never report missing packages. A project `install`, including `-i`, still stops and asks you to run `dotnet restore`. | +| 4 | The manifest follows the `dotnet-tools.json` layout: a format `version` and a `packages` map keyed by lowercase package ID. | +| 5 | Each package has exactly one version in the manifest. | +| 6 | When a package changes version, `install` and `install --package` refresh its skills and remove the ones that the new version dropped. | +| 7 | `install` never removes skills of packages that left the project. It keeps them and prints a hint. | +| 8 | `uninstall --stale` removes stale skills: skills whose package the project no longer references, or uses at a different version. It requires a project or solution, from `--target` or found by the tool. | +| 9 | `install -i` only adds skills. It lists only skills that aren't installed, all unchecked, and leaves installed skills as they are. | +| 10 | A project `install -i` stops with exit code 1 when any installed skill is stale, and asks you to run `uninstall --stale` first. | +| 11 | Repositories are expected to use Central Package Management. `install`, in every mode, never proceeds when it finds two versions of the same package, whether or not that package ships skills. It stops with exit code 1, changes nothing, and names the package and its versions. Only direct references count, because those are all the tool reads. `list` still works. | +| 12 | The manifest is always written with LF line endings, so it's byte-for-byte identical on every operating system, whatever the repository's git line-ending settings. | +| 13 | Manifests in the pre-release format aren't converted. `install` and `uninstall` stop and ask you to move the skills folder aside. | +| 14 | Package IDs keep NuGet's casing when they come from packages, and are lowercase when they come from the manifest, as in the `uninstall` report and the stale hint. | +| 15 | A version that isn't in the NuGet cache never causes a removal. | +| 16 | `install -i --package X@V` stops when X is installed at another version, and asks you to run `uninstall --package X` first. | +| 17 | `uninstall --stale` can't be combined with `--package`, and `uninstall` accepts `--target` only together with `--stale`. | +| 18 | `uninstall --stale` still runs when a package resolves to two versions. A skill counts as stale only if the project doesn't reference its installed version at all. | +| 19 | `uninstall --stale --dry-run` is the way to see stale skills; there's no machine-readable list of them. | +| 20 | When nothing new is available, `install -i` says so and exits with code 0. | +| 21 | The line under the checklist title gives way when the terminal is too small to fit it, rather than the checklist refusing to open. | +| 22 | When a package moves to a version that no longer ships an installed skill, and another package in the same run ships a skill with that name, `install` stops and suggests `uninstall --package`. It doesn't hand the name over, and it doesn't keep the old copy under the new version (E6). | +| 23 | The commands that reports and errors suggest repeat the `--target` and `--destination` of the command that was run, so they can be run as printed. | +| 24 | Package IDs follow NuGet's own rule, which allows letters outside ASCII, on the command line and in the manifest. The tool never writes a manifest that it would refuse to read. | +| 25 | Both checklists draw a checked skill the same way, with a blue X, because each does only one thing: the title and the summary say whether a check installs or removes. There's no separate removal cue, and without color, `[X]` alone marks a checked skill. | +| 26 | The tool never restores, and there's no `--no-restore` option. It runs `dotnet list package` as it is; the .NET 10 SDK restores during that when it needs to. When `dotnet list package` fails, the tool shows what it reported, and the customer restores or fixes the project and runs the command again. | + +Known consequence: skills added with `install --package` for packages outside the project count as stale for project commands, so a project `install -i` stops until they're removed. + +## 8. See also + +- [README](../README.md) +- [Functional specification](functional-spec.md) +- [`dotnet-package-skills` command reference](dotnet-package-skills.md) diff --git a/dotnet-package-skills/global.json b/dotnet-package-skills/global.json new file mode 100644 index 0000000..3dbe096 --- /dev/null +++ b/dotnet-package-skills/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "10.0.400", + "rollForward": "latestPatch", + "allowPrerelease": false + } +} diff --git a/dotnet-package-skills/samples/Contoso.Widgets/Contoso.Widgets.csproj b/dotnet-package-skills/samples/Contoso.Widgets/Contoso.Widgets.csproj new file mode 100644 index 0000000..a8e8737 --- /dev/null +++ b/dotnet-package-skills/samples/Contoso.Widgets/Contoso.Widgets.csproj @@ -0,0 +1,35 @@ + + + + + + netstandard2.0 + Contoso.Widgets + 2.3.0 + Sample package that bundles an agent skill. + true + + false + false + + + + + + + + + + + + diff --git a/dotnet-package-skills/samples/Contoso.Widgets/Widget.cs b/dotnet-package-skills/samples/Contoso.Widgets/Widget.cs new file mode 100644 index 0000000..4e204cf --- /dev/null +++ b/dotnet-package-skills/samples/Contoso.Widgets/Widget.cs @@ -0,0 +1,7 @@ +namespace Contoso.Widgets; + +/// Sample type so the package has some code in it. +public sealed class Widget +{ + public string Name { get; set; } = string.Empty; +} diff --git a/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-testing/SKILL.md b/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-testing/SKILL.md new file mode 100644 index 0000000..3a9f969 --- /dev/null +++ b/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-testing/SKILL.md @@ -0,0 +1,8 @@ +--- +name: contoso.widgets-widget-testing +description: Testing patterns for code that uses Contoso.Widgets. Use when writing unit or integration tests involving widgets. +--- + +# Testing Contoso.Widgets + +Use `WidgetFactory.CreateForTest()` to isolate tests from the production batching pipeline. diff --git a/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/SKILL.md b/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/SKILL.md new file mode 100644 index 0000000..1ed5674 --- /dev/null +++ b/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/SKILL.md @@ -0,0 +1,11 @@ +--- +name: contoso.widgets-widget-usage +description: Correct usage patterns for the Contoso.Widgets library, including lifetime rules and the batching API. Use whenever code creates, configures, or disposes a Widget. +--- + +# Using Contoso.Widgets + +Create widgets through `WidgetFactory`, never with `new Widget()` directly — the +factory is what registers the instance with the batching pipeline. + +See `references/batching.md` for the batching rules. diff --git a/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/references/batching.md b/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/references/batching.md new file mode 100644 index 0000000..d472198 --- /dev/null +++ b/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/references/batching.md @@ -0,0 +1,3 @@ +# Batching rules + +Batches flush at 500 items or 200ms, whichever comes first. diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/CommandLineDiagnostics.cs b/dotnet-package-skills/src/DotnetPackageSkills/Cli/CommandLineDiagnostics.cs new file mode 100644 index 0000000..5267761 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Cli/CommandLineDiagnostics.cs @@ -0,0 +1,47 @@ +using System.CommandLine; + +namespace DotnetPackageSkills.Cli; + +/// Sanitizes framework diagnostics without changing arguments or application console output. +internal static class CommandLineDiagnostics +{ + public static int Invoke(ParseResult result, TextWriter output, TextWriter error) + { + // Suggestions use Output, not Error. Buffer both through the whole invocation so + // split escape sequences, surrogate pairs, and Flush calls cannot bypass sanitizing. + using var capturedOutput = new StringWriter(output.FormatProvider) { NewLine = output.NewLine }; + using var capturedError = new StringWriter(error.FormatProvider) { NewLine = error.NewLine }; + + try + { + return result.Invoke(new InvocationConfiguration + { + Output = capturedOutput, + Error = capturedError, + }); + } + finally + { + Write(error, capturedError.ToString()); + Write(output, capturedOutput.ToString()); + } + } + + private static void Write(TextWriter writer, string text) + { + if (text.Length == 0) + { + return; + } + + var clean = TerminalText.Sanitize(text, multiline: true, trim: false); + + if (text.EndsWith('\n') && !clean.EndsWith('\n')) + { + // An unterminated control string can also consume the framework's final newline. + clean += "\n"; + } + + writer.Write(clean.Replace("\n", writer.NewLine, StringComparison.Ordinal)); + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/ConsoleViewport.cs b/dotnet-package-skills/src/DotnetPackageSkills/Cli/ConsoleViewport.cs new file mode 100644 index 0000000..7f8980c --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Cli/ConsoleViewport.cs @@ -0,0 +1,132 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace DotnetPackageSkills.Cli; + +internal static class ConsoleViewport +{ + public static (int Width, int Height) Size() + { + if (!OperatingSystem.IsWindows()) + { + return (Console.WindowWidth, Console.WindowHeight); + } + + var buffer = ReadBuffer(); + return (buffer.Window.Right - buffer.Window.Left + 1, buffer.Window.Bottom - buffer.Window.Top + 1); + } + + public static void SetCursorPosition(int left, int top) + { + if (!OperatingSystem.IsWindows()) + { + Console.SetCursorPosition(left, top); + return; + } + + var buffer = ReadBuffer(); + var position = new Coordinate + { + X = (short)Math.Clamp(buffer.Window.Left + left, buffer.Window.Left, buffer.Window.Right), + Y = (short)Math.Clamp(buffer.Window.Top + top, buffer.Window.Top, buffer.Window.Bottom), + }; + if (!SetConsoleCursorPosition(GetStdHandle(-11), position)) + { + throw ConsoleError("Could not position the interactive terminal cursor."); + } + } + + public static void Clear() + { + if (OperatingSystem.IsWindows()) + { + ClearWindows(); + } + else + { + Console.Write("\x1b[2J\x1b[H"); + } + } + + [SupportedOSPlatform("windows")] + private static void ClearWindows() + { + var output = GetStdHandle(-11); + var buffer = ReadBuffer(); + + // Clear cells in place. Printing blank lines instead pushes stale picker frames + // into scrollback, where the terminal can rewrap them independently after a resize. + var width = (uint)(buffer.Window.Right - buffer.Window.Left + 1); + for (var row = (int)buffer.Window.Top; row <= buffer.Window.Bottom; row++) + { + var position = new Coordinate { X = buffer.Window.Left, Y = (short)row }; + if (!FillConsoleOutputCharacter(output, ' ', width, position, out _) || + !FillConsoleOutputAttribute(output, buffer.Attributes, width, position, out _)) + { + throw ConsoleError("Could not clear the interactive terminal viewport."); + } + } + } + + [SupportedOSPlatform("windows")] + private static ScreenBufferInfo ReadBuffer() + { + if (!GetConsoleScreenBufferInfo(GetStdHandle(-11), out var buffer)) + { + throw ConsoleError("Could not read the interactive terminal viewport."); + } + + return buffer; + } + + private static IOException ConsoleError(string message) => + new(message, new Win32Exception(Marshal.GetLastPInvokeError())); + + [StructLayout(LayoutKind.Sequential)] + private struct Coordinate + { + public short X; + public short Y; + } + + [StructLayout(LayoutKind.Sequential)] + private struct WindowRectangle + { + public short Left; + public short Top; + public short Right; + public short Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct ScreenBufferInfo + { + public Coordinate Size; + public Coordinate Cursor; + public ushort Attributes; + public WindowRectangle Window; + public Coordinate MaximumWindowSize; + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle(int handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetConsoleScreenBufferInfo(nint output, out ScreenBufferInfo info); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetConsoleCursorPosition(nint output, Coordinate position); + + [DllImport("kernel32.dll", EntryPoint = "FillConsoleOutputCharacterW", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FillConsoleOutputCharacter( + nint output, char character, uint length, Coordinate position, out uint written); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FillConsoleOutputAttribute( + nint output, ushort attributes, uint length, Coordinate position, out uint written); +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/ITerminal.cs b/dotnet-package-skills/src/DotnetPackageSkills/Cli/ITerminal.cs new file mode 100644 index 0000000..f5a4461 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Cli/ITerminal.cs @@ -0,0 +1,281 @@ +using System.Diagnostics; +using System.Text; + +namespace DotnetPackageSkills.Cli; + +internal enum TerminalStyle +{ + Default, + Focus, + Selected, + Muted, +} + +internal readonly record struct TerminalState( + bool CursorVisible, + bool TreatControlCAsInput, + ConsoleColor? Foreground, + ConsoleColor? Background, + TerminalStyle Style, + Encoding OutputEncoding); + +/// The console operations the interactive picker needs, in viewport coordinates. +internal interface ITerminal +{ + bool IsRedirected { get; } + + bool SupportsColor { get; } + + int WindowHeight { get; } + + int WindowWidth { get; } + + (int Width, int Height) GetWindowSize() => (WindowWidth, WindowHeight); + + int CursorTop { get; } + + bool CursorVisible { set; } + + bool TreatControlCAsInput { set; } + + TerminalState CaptureState(); + + void RestoreState(TerminalState state); + + /// Uses lossless Unicode output for this interaction; RestoreState restores the encoding. + void UseUtf8Output(); + + IDisposable EnterInteractiveScreen(); + + void SetStyle(TerminalStyle style); + + void ResetStyle(); + + void SetCursorPosition(int left, int top); + + void Write(string text); + + void WriteLine(string text = ""); + + /// Starts a fresh viewport for the initial frame or a resize, without discarding scrollback. + void ClearViewport(); + + /// Waits at most the timeout; false lets the picker observe an idle resize. + bool TryReadKey(TimeSpan timeout, out ConsoleKeyInfo key); + + ConsoleKeyInfo ReadKey(); +} + +/// An over the real console. +internal sealed class SystemTerminal : ITerminal +{ + private const int FallbackHeight = 24; + private const int FallbackWidth = 80; + private TerminalStyle _style; + private TerminalStyle? _appliedStyle; + + public bool IsRedirected => Console.IsInputRedirected || Console.IsOutputRedirected; + + public bool SupportsColor => CanUseColor( + IsRedirected, + Environment.GetEnvironmentVariable("NO_COLOR"), + Environment.GetEnvironmentVariable("TERM"), + OperatingSystem.IsWindows()); + + public int WindowHeight => GetWindowSize().Height; + + public int WindowWidth => GetWindowSize().Width; + + public (int Width, int Height) GetWindowSize() + { + var (width, height) = Read(ConsoleViewport.Size, (FallbackWidth, FallbackHeight)); + return (width > 0 ? width : FallbackWidth, height > 0 ? height : FallbackHeight); + } + + public int CursorTop => Math.Clamp( + Read(static () => Console.CursorTop, 0) - Read(static () => Console.WindowTop, 0), + 0, + WindowHeight - 1); + + public bool CursorVisible + { + set => Ignoring(() => Console.CursorVisible = value); + } + + public bool TreatControlCAsInput + { + set => Ignoring(() => Console.TreatControlCAsInput = value); + } + + public TerminalState CaptureState() => new( + Read(static () => OperatingSystem.IsWindows() ? Console.CursorVisible : true, true), + Read(static () => Console.TreatControlCAsInput, false), + ReadColor(static () => Console.ForegroundColor), + ReadColor(static () => Console.BackgroundColor), + _style, + Console.OutputEncoding); + + public void UseUtf8Output() => Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + + public IDisposable EnterInteractiveScreen() => InteractiveScreen.Enter(); + + public void RestoreState(TerminalState state) + { + try + { + ResetStyle(); + if (SupportsColor) + { + if (state.Foreground is { } foreground) + { + Ignoring(() => Console.ForegroundColor = foreground); + } + + if (state.Background is { } background) + { + Ignoring(() => Console.BackgroundColor = background); + } + } + + _style = state.Style; + _appliedStyle = null; + } + finally + { + try + { + CursorVisible = state.CursorVisible; + } + finally + { + try + { + TreatControlCAsInput = state.TreatControlCAsInput; + } + finally + { + Console.OutputEncoding = state.OutputEncoding; + } + } + } + } + + public void SetStyle(TerminalStyle style) + { + var supportsColor = SupportsColor; + _style = supportsColor ? style : TerminalStyle.Default; + if (!supportsColor || _appliedStyle == style) + { + return; + } + + Ignoring(Console.ResetColor); + var color = style switch + { + TerminalStyle.Focus => ConsoleColor.Blue, + TerminalStyle.Selected => ConsoleColor.Blue, + TerminalStyle.Muted => ConsoleColor.DarkGray, + _ => (ConsoleColor?)null, + }; + + if (color is { } foreground) + { + Ignoring(() => Console.ForegroundColor = foreground); + } + + _appliedStyle = style; + } + + public void ResetStyle() => SetStyle(TerminalStyle.Default); + + public void SetCursorPosition(int left, int top) => ConsoleViewport.SetCursorPosition(left, top); + + public void Write(string text) => Console.Write(text); + + public void WriteLine(string text = "") => Console.WriteLine(text); + + public void ClearViewport() + { + ConsoleViewport.Clear(); + SetCursorPosition(0, 0); + } + + public ConsoleKeyInfo ReadKey() => Console.ReadKey(intercept: true); + + public bool TryReadKey(TimeSpan timeout, out ConsoleKeyInfo key) + { + ArgumentOutOfRangeException.ThrowIfLessThan(timeout, TimeSpan.Zero); + var started = Stopwatch.GetTimestamp(); + while (true) + { + if (Console.KeyAvailable) + { + key = ReadKey(); + return true; + } + + var remaining = timeout - Stopwatch.GetElapsedTime(started); + if (remaining <= TimeSpan.Zero) + { + key = default; + return false; + } + + // Keep key latency low without spinning, including the final fractional millisecond. + Thread.Sleep((int)Math.Clamp(Math.Ceiling(remaining.TotalMilliseconds), 1, 25)); + } + } + + internal static bool CanUseColor(bool redirected, string? noColor, string? term, bool windows) + { + if (redirected || noColor is not null) + { + return false; + } + + var capability = term?.ToLowerInvariant(); + if (capability is "dumb" or "unknown" or "vt100" or "vt102" or "vt220") + { + return false; + } + + return windows || + capability is "linux" or "ansi" or "cygwin" || + capability is not null && + (capability.Contains("color", StringComparison.Ordinal) || + capability.StartsWith("xterm", StringComparison.Ordinal) || + capability.StartsWith("screen", StringComparison.Ordinal) || + capability.StartsWith("tmux", StringComparison.Ordinal) || + capability.StartsWith("rxvt", StringComparison.Ordinal)); + } + + private static ConsoleColor? ReadColor(Func read) + { + var color = Read(read, (ConsoleColor)(-1)); + return (int)color is >= 0 and <= 15 ? color : null; + } + + private static T Read(Func read, T fallback) + { + try + { + return read(); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or InvalidOperationException) + { + return fallback; + } + } + + private static void Ignoring(Action action) + { + try + { + action(); + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or + ArgumentOutOfRangeException or InvalidOperationException) + { + } + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveScreen.cs b/dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveScreen.cs new file mode 100644 index 0000000..afa9cbf --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveScreen.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; + +namespace DotnetPackageSkills.Cli; + +/// Keeps transient picker frames out of the shell's reflowable scrollback. +internal sealed class InteractiveScreen : IDisposable +{ + private const uint EnableProcessedOutput = 0x0001; + private const uint EnableVirtualTerminalProcessing = 0x0004; + private readonly nint _output; + private readonly uint? _originalMode; + private bool _disposed; + + private InteractiveScreen(nint output, uint? originalMode) + { + _output = output; + _originalMode = originalMode; + } + + public static InteractiveScreen Enter() + { + nint output = nint.Zero; + uint? originalMode = null; + if (OperatingSystem.IsWindows()) + { + output = GetStdHandle(-11); + if (!GetConsoleMode(output, out var mode) || + !SetConsoleMode(output, mode | EnableProcessedOutput | EnableVirtualTerminalProcessing)) + { + throw new PackageSkillsException( + "This terminal cannot open an interactive screen. Use a terminal with virtual-terminal support " + + "or run the command without --interactive.", + new Win32Exception(Marshal.GetLastPInvokeError())); + } + + originalMode = mode; + } + + var screen = new InteractiveScreen(output, originalMode); + try + { + Console.Write("\x1b[?1049h"); + Console.Out.Flush(); + return screen; + } + catch + { + screen.Dispose(); + throw; + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + try + { + Console.Write("\x1b[?1049l"); + Console.Out.Flush(); + } + finally + { + if (_originalMode is { } mode && !SetConsoleMode(_output, mode)) + { + throw new IOException("Could not restore the terminal output mode.", + new Win32Exception(Marshal.GetLastPInvokeError())); + } + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern nint GetStdHandle(int handle); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetConsoleMode(nint handle, out uint mode); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetConsoleMode(nint handle, uint mode); +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveSkills.cs b/dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveSkills.cs new file mode 100644 index 0000000..d5cd537 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveSkills.cs @@ -0,0 +1,73 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Cli; + +internal sealed record UninstallChoice( + IReadOnlyCollection Selected, + IReadOnlyCollection ExpectedInstalled); + +internal static class InteractiveSkills +{ + /// + /// Checklist items for install, which only adds. Installed skills are never offered, so + /// nothing on the list can refresh, replace or remove a skill the user already has. + /// + public static IReadOnlyList ForInstall( + IReadOnlyList candidates, + IReadOnlyCollection installed) + { + var tracked = installed.Select(entry => entry.Skill).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return + [ + .. candidates + .Where(skill => !tracked.Contains(skill.RelativePath)) + .Select(skill => Describe(skill.RelativePath, skill.PackageId, skill.PackageVersion, skill.SourcePath)) + .OrderBy(item => item.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(item => item.Name, StringComparer.Ordinal), + ]; + } + + public static IReadOnlyList ForUninstall( + IReadOnlyList skills, + string destination) => + [ + .. skills.Select(skill => Describe( + skill.Skill, + skill.Package, + skill.Version, + Path.Combine(destination, skill.Skill))), + ]; + + /// The checked skills that were actually shown. Nothing else is installed or changed. + public static SkillChoice InstallChoice( + IReadOnlyList candidates, + IReadOnlyCollection installed, + IReadOnlyList shown, + IReadOnlySet selected) => + new( + [ + .. candidates.Where(skill => selected.Contains(skill.RelativePath) && + shown.Any(item => + item.Name.Equals(skill.RelativePath, StringComparison.OrdinalIgnoreCase) && + item.Package.Equals(skill.PackageId, StringComparison.OrdinalIgnoreCase))), + ]) + { + ExpectedInstalled = installed, + }; + + private static SkillPickerItem Describe( + string name, + string package, + string version, + string skillDirectory) + { + var metadata = SkillDescriptionReader.Read(skillDirectory); + return new SkillPickerItem( + name, + package, + version, + metadata.Description, + metadata.Warning); + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/OutputWriter.cs b/dotnet-package-skills/src/DotnetPackageSkills/Cli/OutputWriter.cs new file mode 100644 index 0000000..97ca208 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Cli/OutputWriter.cs @@ -0,0 +1,203 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Cli; + +/// Renders results for people. There is no machine-readable report. +public sealed class OutputWriter(TextWriter output, TextWriter? errorOutput = null) +{ + /// + /// False for list, which discovers without writing, so the report says "Found" + /// rather than claiming files were placed. + /// + public void WriteInstallReport(InstallResult result, bool copied) + { + WriteContext(result); + + var verb = copied + ? result.DryRun ? "Would copy" : "Copied" + // list discovers without writing, and it always runs as a dry run, so asking + // DryRun first would make this branch unreachable and claim a copy was pending. + : "Found"; + + if (result.Skills.Count > 0) + { + output.WriteLine($"{verb} {Count(result.Skills.Count, "skill")}:"); + + foreach (var skill in result.Skills) + { + output.WriteLine($" {Describe(skill.RelativePath, skill.PackageId, skill.PackageVersion)}"); + } + } + else if (result.NothingNewToInstall) + { + // An interactive install lists only skills that are not installed. With nothing left + // to list there was no checklist, and the skipped section below says why when some + // skills could not be offered. + output.WriteLine(result.Skipped.Count == 0 + ? "Nothing new to install. Every skill that these packages ship is already installed." + : "Nothing new to install."); + } + else if (result.SkillsDiscovered > 0) + { + // Packages did ship skills; none of them ended up installed, because they were + // deselected or skipped. Saying nobody ships a skill here would be a lie, and the + // sections below already explain what happened to each one. + output.WriteLine($"{verb} no skills."); + } + else + { + // Packages missing from the cache look exactly like packages without skills, so + // claim nothing about why the list is empty. + output.WriteLine("No bundled skills found."); + } + + if (result.Removed.Count > 0) + { + output.WriteLine(); + output.WriteLine( + $"{(result.DryRun ? "Would remove" : "Removed")} {Count(result.Removed.Count, "skill")}:"); + + foreach (var entry in result.Removed) + { + output.WriteLine($" {Describe(entry.Skill, entry.Package, entry.Version)}"); + } + } + + WriteUnreferenced(result); + WriteSkipped(result); + + if (result.Skills.Count > 0 && copied && !result.DryRun) + { + output.WriteLine(); + + // One line, however long. Any break we choose is a guess at the reader's width, + // and the terminal already knows theirs. + output.WriteLine( + "These skills are instructions written by the package authors, " + + "and your coding agent will follow them. Review them before relying on them."); + } + } + + private void WriteContext(InstallResult result) + { + output.WriteLine($"Target: {TerminalText.Sanitize(result.Target ?? "(packages named on the command line)")}"); + output.WriteLine($"NuGet cache: {TerminalText.Sanitize(result.GlobalPackagesFolder)}"); + output.WriteLine($"Destination: {TerminalText.Sanitize(result.Destination)}"); + + var scope = result.Target is null ? "named explicitly" : "direct"; + + output.WriteLine($"Scanned {Count(result.PackagesScanned, "package")} ({scope})."); + output.WriteLine(); + } + + /// + /// Install never removes a skill because its package left the project, so say which ones + /// stayed and which command removes them. + /// + private void WriteUnreferenced(InstallResult result) + { + if (result.Unreferenced.Count == 0) + { + return; + } + + var one = result.Unreferenced.Count == 1; + var packages = result.Unreferenced + .Select(entry => entry.Package) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Count() == 1 + ? "a package" + : "packages"; + + output.WriteLine(); + output.WriteLine( + $"{Count(result.Unreferenced.Count, "installed skill")} {(one ? "belongs" : "belong")} to " + + $"{packages} that the target no longer references:"); + + foreach (var entry in result.Unreferenced) + { + output.WriteLine($" {Describe(entry.Skill, entry.Package, entry.Version)}"); + } + + output.WriteLine( + $"Run '{TerminalText.Sanitize(result.StaleCommand)}' to remove {(one ? "it" : "them")}."); + } + + private void WriteSkipped(InstallResult result) + { + if (result.Skipped.Count == 0) + { + return; + } + + output.WriteLine(); + output.WriteLine($"Warning: skipped {Count(result.Skipped.Count, "colliding skill")}:"); + + foreach (var skill in result.Skipped) + { + output.WriteLine($" {Describe(skill.RelativePath, skill.PackageId, skill.PackageVersion)}"); + output.WriteLine($" {TerminalText.Sanitize(skill.Reason)}"); + } + } + + /// + /// The solution or project that uninstall --stale compared against, or null for a + /// plain uninstall. + /// + public void WriteUninstallReport( + IReadOnlyList removed, + string destination, + bool dryRun, + string? target = null) + { + if (target is not null) + { + output.WriteLine($"Target: {TerminalText.Sanitize(target)}"); + } + + output.WriteLine($"Destination: {TerminalText.Sanitize(destination)}"); + output.WriteLine(); + + if (removed.Count == 0) + { + output.WriteLine(target is null + ? "Nothing to remove. No skills installed by this tool were found there." + : "Nothing to remove. No stale skills were found."); + return; + } + + output.WriteLine($"{(dryRun ? "Would remove" : "Removed")} {Count(removed.Count, "skill")}:"); + + foreach (var entry in removed) + { + output.WriteLine($" {Describe(entry.Skill, entry.Package, entry.Version)}"); + } + } + + public void WriteError(string message) + { + var text = TerminalText.Sanitize(message, multiline: true).Replace("\n", Environment.NewLine); + (errorOutput ?? Console.Error).WriteLine($"error: {text}"); + } + + /// + /// Reported when the user leaves the interactive picker without confirming. Nothing failed, + /// so this is a statement of fact rather than an error. + /// + public void WriteCancelled() + { + output.WriteLine("Cancelled. Nothing was copied or removed."); + } + + private static string Count(int value, string noun) => $"{value} {noun}{(value == 1 ? string.Empty : "s")}"; + + /// One skill on one line: the folder name, then who it came from. + /// + /// This used to be two lines, with "from Package Version" indented underneath. That doubled + /// the length of every report to carry a word — "from" — that the brackets say for free, and + /// twelve skills read far more easily as twelve lines than as twenty-four. + /// Sanitize fields separately so an unterminated control in one cannot hide the next. + /// + private static string Describe(string skill, string package, string version) => + $"{TerminalText.Sanitize(skill)} ({TerminalText.Sanitize(package)} {TerminalText.Sanitize(version)})"; +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/PickerLayout.cs b/dotnet-package-skills/src/DotnetPackageSkills/Cli/PickerLayout.cs new file mode 100644 index 0000000..e752e5d --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Cli/PickerLayout.cs @@ -0,0 +1,296 @@ +namespace DotnetPackageSkills.Cli; + +/// A selection-independent layout, rebuilt only when the viewport changes. +internal sealed class PickerLayout +{ + internal const string PrimaryHelp = "(Press to select, to accept)"; + + internal sealed record Entry(string Label, int DescriptionColumn, IReadOnlyList Description) + { + public int Height => Description.Count; + } + + internal sealed record Page(int First, int Count, int VisibleRows, bool Scrollable); + + private readonly string _title; + private readonly string? _note; + private readonly int _headerRows; + private readonly int _footerRows; + private readonly int[] _itemPages; + + private PickerLayout( + int windowWidth, + int windowHeight, + int width, + bool supportsColor, + string title, + string? note, + IReadOnlyList entries, + IReadOnlyList pages, + IReadOnlyList help, + int headerRows, + int footerRows) + { + WindowWidth = windowWidth; + WindowHeight = windowHeight; + Width = width; + SupportsColor = supportsColor; + _title = title; + _note = note; + Entries = entries; + Pages = pages; + Help = help; + _headerRows = headerRows; + _footerRows = footerRows; + _itemPages = new int[entries.Count]; + for (var page = 0; page < pages.Count; page++) + { + Array.Fill(_itemPages, page, pages[page].First, pages[page].Count); + } + } + + public int WindowWidth { get; } + + public int WindowHeight { get; } + + public int Width { get; } + + public bool SupportsColor { get; } + + /// + /// Where a wrapped description continues: past the cursor, the checkbox, and a space. Both + /// pickers draw a row the same way, with or without color. + /// + public int ContinuationColumn => RowPrefix; + + private const int RowPrefix = 6; + + public IReadOnlyList Entries { get; } + + public IReadOnlyList Pages { get; } + + public IReadOnlyList Help { get; } + + public int MaxFrameHeight => Pages.Max(page => + _headerRows + 2 + _footerRows + page.VisibleRows + + (page.Scrollable ? ScrollHelpRows(Entries[page.First].Height, Width) : 0)); + + public int PageIndexFor(int item) => _itemPages[item]; + + public int MaxScroll(int item) + { + var page = Pages[PageIndexFor(item)]; + return page.Scrollable ? Entries[item].Height - page.VisibleRows : 0; + } + + public IReadOnlyList Header(int page) => Header(_title, _note, page + 1, Pages.Count, Width); + + public static PickerLayout For( + IReadOnlyList items, + string title, + PickerMode mode, + int windowWidth, + int windowHeight, + bool supportsColor, + string? note = null) + { + if (note is null) + { + return Build(items, title, mode, windowWidth, windowHeight, supportsColor, note: null); + } + + try + { + return Build(items, title, mode, windowWidth, windowHeight, supportsColor, note); + } + catch (PackageSkillsException) + { + // The note only explains what the list leaves out. A window without room for it + // keeps the checklist and loses the note, rather than refusing to open. + return Build(items, title, mode, windowWidth, windowHeight, supportsColor, note: null); + } + } + + private static PickerLayout Build( + IReadOnlyList items, + string title, + PickerMode mode, + int windowWidth, + int windowHeight, + bool supportsColor, + string? note) + { + var labels = items.Select(item => TerminalText.Sanitize(item.Name)).ToArray(); + var descriptions = items.Select(DescriptionFor).ToArray(); + var cleanTitle = TerminalText.Sanitize(title); + var cleanNote = note is null ? null : TerminalText.Sanitize(note); + var longestName = labels.Max(TerminalText.Width); + var longestDescription = descriptions.Max(description => description.Split('\n').Max(TerminalText.Width)); + var prefix = RowPrefix; + var summaryBounds = new[] { Summary(items.Count, items.Count, mode) }; + var helpWidths = HelpFor(items.Count, items.Count, supportsColor).Select(TerminalText.Width); + var naturalWidth = new[] + { + labels.Select((label, index) => prefix + TerminalText.Width(label) + 3 + + descriptions[index].Split('\n').Max(TerminalText.Width)).Max(), + TerminalText.Width(cleanTitle) + (items.Count > 1 ? 2 + Counter(items.Count, items.Count).Length : 0), + cleanNote is null ? 0 : TerminalText.Width(cleanNote), + summaryBounds.Max(TerminalText.Width), + helpWidths.Max(), + }.Max(); + + // Leave a column and a row untouched so neither padding nor a final newline can + // force an automatic wrap in the middle of a frame. + var width = Math.Min(naturalWidth, windowWidth - 1); + var available = width - prefix - 3; + var widestGrapheme = descriptions.SelectMany(TerminalText.Elements).Max(TerminalText.CellWidth); + if (available < 1 + widestGrapheme) + { + throw TooSmall(windowWidth, windowHeight, $"at least {prefix + 3 + widestGrapheme + 2} columns"); + } + + // When both columns want more than the window, give the description at least half + // the remaining cells. Short descriptions give that space back to a long name. + var descriptionReserve = Math.Max(widestGrapheme, Math.Min(longestDescription, available / 2)); + var nameWidth = Math.Min(longestName, available - descriptionReserve); + var entries = labels.Select((label, index) => + { + var displayName = TerminalText.Clip(label, nameWidth); + var descriptionColumn = prefix + TerminalText.Width(displayName) + 3; + return new Entry( + displayName, + descriptionColumn, + TerminalText.Wrap(descriptions[index], width - descriptionColumn, width - prefix)); + }).ToArray(); + + var pageCount = 1; + while (true) + { + var headerRows = Header(cleanTitle, cleanNote, pageCount, pageCount, width).Count; + var help = HelpFor(items.Count, pageCount, supportsColor) + .SelectMany(line => TerminalText.Wrap(line, width)).ToArray(); + var footerRows = summaryBounds.Max(summary => TerminalText.Wrap(summary, width).Count) + help.Length; + var budget = windowHeight - 1 - headerRows - 2 - footerRows; + if (budget < 1) + { + throw TooSmall(windowWidth, windowHeight, $"at least {windowHeight + 1 - budget} rows at this width"); + } + + var pages = Paginate(entries, budget, width, windowWidth, windowHeight); + if (pages.Count == pageCount) + { + return new PickerLayout( + windowWidth, windowHeight, width, supportsColor, cleanTitle, cleanNote, + entries, pages, help, headerRows, footerRows); + } + + // Only paging chrome and counter digit growth can shrink the row budget. + // Iterating to a fixed point avoids guessing how many rows that chrome uses. + pageCount = pages.Count; + } + } + + /// + /// Nothing on an install list is installed, so every tick is one install and the count needs + /// no second number. Every tick on an uninstall list is one removal, which is worth saying. + /// The widest summary is the one with every row ticked, so that is what layout measures. + /// + public static string Summary(int selected, int total, PickerMode mode) => + mode == PickerMode.Uninstall + ? $"{selected} of {total} selected; {selected} to remove" + : $"{selected} of {total} selected"; + + public static string ScrollHelp(int first, int last, int total) => + $"(Press / to scroll description: {first}-{last}/{total})"; + + private static int ScrollHelpRows(int lines, int width) => + TerminalText.Wrap(ScrollHelp(lines, lines, lines), width).Count; + + private static List Paginate( + IReadOnlyList entries, + int budget, + int width, + int windowWidth, + int windowHeight) + { + var pages = new List(); + for (var first = 0; first < entries.Count;) + { + if (entries[first].Height > budget) + { + var visible = budget - ScrollHelpRows(entries[first].Height, width); + // Keep the skill row visible alongside at least one scrolling continuation. + if (visible < 2) + { + throw TooSmall(windowWidth, windowHeight, $"at least {windowHeight + 2 - visible} rows at this width"); + } + + pages.Add(new Page(first++, 1, visible, Scrollable: true)); + continue; + } + + var rows = 0; + var end = first; + while (end < entries.Count && rows + entries[end].Height <= budget) + { + rows += entries[end++].Height; + } + + pages.Add(new Page(first, end - first, rows, Scrollable: false)); + first = end; + } + + return pages; + } + + private static string DescriptionFor(SkillPickerItem item) + { + if (!string.IsNullOrWhiteSpace(item.DescriptionWarning)) + { + var warning = TerminalText.Sanitize(item.DescriptionWarning); + return $"Description unavailable: {(TerminalText.Width(warning) > 0 ? warning : "unreadable metadata.")}"; + } + + var description = TerminalText.Sanitize(item.Description, multiline: true); + return TerminalText.Width(description) > 0 ? description : "No description provided."; + } + + private static IReadOnlyList Header(string title, string? note, int page, int pages, int width) + { + var text = pages > 1 ? $"{title} {Counter(page, pages)}" : title; + IReadOnlyList lines = TerminalText.Width(text) <= width ? [text] : TerminalText.Wrap(text, width); + return note is null ? lines : [.. lines, .. TerminalText.Wrap(note, width)]; + } + + private static string Counter(int page, int pages) => $"page {page} of {pages}"; + + private static IEnumerable HelpFor(int items, int pages, bool supportsColor) + { + yield return PrimaryHelp; + if (items > 1) + { + yield return "(Press / to move, / for first/last)"; + } + + if (pages > 1) + { + yield return "(Press /, / to change page)"; + } + + yield return items > 1 + ? "(Press
to select all, to clear all, // to cancel)" + : "(Press // to cancel)"; + + // Each checklist does one thing, so a tick needs no cue for what it does: the title and + // the summary say that. The legend only explains the color, so without color it goes. + if (supportsColor) + { + yield return "Blue X: selected"; + } + } + + private static PackageSkillsException TooSmall(int width, int height, string minimum) => new( + $"The terminal is too small for the interactive checklist ({width}x{height}). " + + $"Enlarge the window to {minimum}, or use the command without --interactive " + + "and with --package to limit the operation."); +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/SkillPicker.cs b/dotnet-package-skills/src/DotnetPackageSkills/Cli/SkillPicker.cs new file mode 100644 index 0000000..4dffe99 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Cli/SkillPicker.cs @@ -0,0 +1,379 @@ +namespace DotnetPackageSkills.Cli; + +internal enum PickerMode +{ + Install, + Uninstall, +} + +/// Picker metadata supplied by the caller, never loaded by the UI. +internal sealed record SkillPickerItem( + string Name, + string Package, + string Version, + string? Description = null, + string? DescriptionWarning = null); + +/// +/// A paged checklist. Nothing starts ticked: a tick means install in install mode and remove in +/// uninstall mode, so accepting without a choice changes nothing either way. +/// +internal sealed class SkillPicker(ITerminal terminal) +{ + private static readonly TimeSpan InputPollInterval = TimeSpan.FromMilliseconds(100); + + /// An optional line shown under the title, such as what the list leaves out. + public IReadOnlySet? Choose( + IReadOnlyList items, + string title, + PickerMode mode = PickerMode.Install, + string? note = null) + { + if (items.Count == 0) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + + if (terminal.IsRedirected) + { + throw new PackageSkillsException( + mode == PickerMode.Install + ? "--interactive needs a terminal, but input or output is redirected. " + + "Drop --interactive to install every discovered skill, or name the ones you want " + + "with --package." + : "--interactive needs a terminal, but input or output is redirected. " + + "Drop --interactive to remove every skill that the command matches, and add " + + "--dry-run to see which ones first."); + } + + var selected = new HashSet(); + var layout = Measure(); + var cursor = 0; + int? pageOffset = null; + var scroll = new int[items.Count]; + var frameTop = 0; + var height = 0; + var frameStarted = false; + var resetViewport = true; + var original = terminal.CaptureState(); + IDisposable? screen = null; + + try + { + terminal.UseUtf8Output(); + screen = terminal.EnterInteractiveScreen(); + terminal.CursorVisible = false; + terminal.TreatControlCAsInput = true; + terminal.ResetStyle(); + frameStarted = true; + + while (true) + { + Reflow(); + DrawFrame(); + ConsoleKeyInfo key; + while (!terminal.TryReadKey(InputPollInterval, out key)) + { + if (Reflow()) + { + DrawFrame(); + } + } + + // A key can arrive after a resize. Page keys must use the new boundaries, + // and accept/cancel must not leave the old, differently sized frame behind. + if (Reflow()) + { + DrawFrame(); + } + + if ((key.Modifiers & ConsoleModifiers.Control) != 0) + { + if (key.Key == ConsoleKey.C) + { + return null; + } + + if (key.Key is ConsoleKey.UpArrow or ConsoleKey.DownArrow) + { + scroll[cursor] = Math.Clamp( + scroll[cursor] + (key.Key == ConsoleKey.UpArrow ? -1 : 1), + 0, + layout.MaxScroll(cursor)); + continue; + } + } + + switch (key.Key) + { + case ConsoleKey.UpArrow: + cursor = (cursor - 1 + items.Count) % items.Count; + pageOffset = null; + break; + case ConsoleKey.DownArrow: + cursor = (cursor + 1) % items.Count; + pageOffset = null; + break; + case ConsoleKey.LeftArrow or ConsoleKey.PageUp: + MovePage(-1); + break; + case ConsoleKey.RightArrow or ConsoleKey.PageDown: + MovePage(1); + break; + case ConsoleKey.Home: + cursor = 0; + pageOffset = null; + break; + case ConsoleKey.End: + cursor = items.Count - 1; + pageOffset = null; + break; + case ConsoleKey.Spacebar: + if (!selected.Add(cursor)) + { + selected.Remove(cursor); + } + + break; + case ConsoleKey.A: + selected.UnionWith(Enumerable.Range(0, items.Count)); + break; + case ConsoleKey.C: + selected.Clear(); + break; + case ConsoleKey.Enter: + return items.Where((_, index) => selected.Contains(index)) + .Select(item => item.Name).ToHashSet(StringComparer.OrdinalIgnoreCase); + case ConsoleKey.Escape or ConsoleKey.Q: + return null; + } + } + } + finally + { + try + { + try + { + terminal.ResetStyle(); + } + finally + { + if (frameStarted) + { + var bottom = Math.Clamp(frameTop + height, 0, terminal.WindowHeight - 1); + terminal.SetCursorPosition(0, bottom); + if (bottom < terminal.WindowHeight - 1) + { + terminal.WriteLine(); + } + } + } + } + finally + { + try + { + screen?.Dispose(); + } + finally + { + terminal.RestoreState(original); + } + } + } + + PickerLayout Measure() + { + var size = terminal.GetWindowSize(); + return PickerLayout.For(items, title, mode, size.Width, size.Height, terminal.SupportsColor, note); + } + + void DrawFrame() + { + while (true) + { + Reflow(); + var previousHeight = height; + try + { + if (resetViewport) + { + terminal.ResetStyle(); + terminal.ClearViewport(); + frameTop = 0; + height = 0; + resetViewport = false; + } + + Render(items, selected, cursor, scroll, layout, frameTop, mode, ref height); + EnsureViewport(layout); + return; + } + catch (Exception ex) when (ex is ViewportChangedException || + (ex is IOException or ArgumentOutOfRangeException or InvalidOperationException) && ViewportChanged(layout)) + { + height = Math.Max(previousHeight, height); + resetViewport = true; + } + catch + { + // A failed redraw can leave the lower part of the previous frame intact. + height = Math.Max(previousHeight, height); + throw; + } + } + } + + bool Reflow() + { + if (!ViewportChanged(layout)) + { + return false; + } + + layout = Measure(); + resetViewport = true; + pageOffset = null; + for (var item = 0; item < scroll.Length; item++) + { + scroll[item] = Math.Min(scroll[item], layout.MaxScroll(item)); + } + + return true; + } + + void MovePage(int direction) + { + var page = layout.PageIndexFor(cursor); + var target = Math.Clamp(page + direction, 0, layout.Pages.Count - 1); + if (target == page) + { + return; + } + + pageOffset ??= cursor - layout.Pages[page].First; + var next = layout.Pages[target]; + cursor = next.First + Math.Min(pageOffset.Value, next.Count - 1); + } + } + + private void Render( + IReadOnlyList items, + HashSet selected, + int cursor, + int[] scroll, + PickerLayout layout, + int frameTop, + PickerMode mode, + ref int height) + { + var previousHeight = height; + height = 0; + var pageIndex = layout.PageIndexFor(cursor); + var page = layout.Pages[pageIndex]; + foreach (var line in layout.Header(pageIndex)) + { + WriteRow(layout, frameTop, ref height, new Span(line)); + } + + WriteRow(layout, frameTop, ref height); + for (var index = page.First; index < page.First + page.Count; index++) + { + var entry = layout.Entries[index]; + var isSelected = selected.Contains(index); + var rowStyle = index == cursor ? TerminalStyle.Focus : TerminalStyle.Default; + var offset = page.Scrollable ? scroll[index] : 0; + var rows = page.Scrollable ? page.VisibleRows : entry.Height; + // Continuations are wider than the space after the name, so scroll them below + // the fixed skill row rather than placing one into its narrower first-line slot. + WriteRow( + layout, frameTop, ref height, + new Span(index == cursor ? ">" : " ", rowStyle), + new Span(" ", rowStyle), + new Span("[", rowStyle), + new Span(isSelected ? "X" : " ", isSelected ? TerminalStyle.Selected : rowStyle), + new Span("]", rowStyle), + new Span($" {entry.Label}", rowStyle), + new Span($" - {entry.Description[0]}", rowStyle)); + for (var line = 1; line < rows; line++) + { + WriteRow(layout, frameTop, ref height, + new Span(new string(' ', layout.ContinuationColumn) + entry.Description[offset + line], rowStyle)); + } + } + + WriteRow(layout, frameTop, ref height); + foreach (var line in TerminalText.Wrap( + PickerLayout.Summary(selected.Count, items.Count, mode), layout.Width)) + { + WriteRow(layout, frameTop, ref height, new Span(line)); + } + + foreach (var line in layout.Help) + { + WriteRow(layout, frameTop, ref height, new Span(line, TerminalStyle.Muted)); + } + + if (page.Scrollable) + { + foreach (var line in TerminalText.Wrap( + PickerLayout.ScrollHelp(scroll[cursor] + 2, scroll[cursor] + page.VisibleRows, + layout.Entries[cursor].Height), + layout.Width)) + { + WriteRow(layout, frameTop, ref height, new Span(line, TerminalStyle.Muted)); + } + } + + // Erase old content, but park at the actual footer, not at the end of the erased + // rectangle. A short final page should not strand the eventual shell prompt. + var erased = height; + while (erased < previousHeight) + { + WriteRow(layout, frameTop, ref erased); + } + + terminal.SetCursorPosition(0, frameTop + height); + } + + private void WriteRow(PickerLayout layout, int frameTop, ref int height, params Span[] spans) + { + EnsureViewport(layout); + var row = height++; + terminal.SetCursorPosition(0, frameTop + row); + var cells = 0; + foreach (var span in spans) + { + terminal.SetStyle(layout.SupportsColor ? span.Style : TerminalStyle.Default); + EnsureViewport(layout); + terminal.Write(span.Text); + EnsureViewport(layout); + cells += TerminalText.Width(span.Text); + } + + terminal.ResetStyle(); + EnsureViewport(layout); + terminal.Write(new string(' ', layout.Width - cells)); + EnsureViewport(layout); + } + + private bool ViewportChanged(PickerLayout layout) + { + var size = terminal.GetWindowSize(); + return layout.WindowWidth != size.Width || layout.WindowHeight != size.Height || + layout.SupportsColor != terminal.SupportsColor; + } + + private void EnsureViewport(PickerLayout layout) + { + if (ViewportChanged(layout)) + { + throw new ViewportChangedException(); + } + } + + private readonly record struct Span(string Text, TerminalStyle Style = TerminalStyle.Default); + + private sealed class ViewportChangedException : Exception; +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/TerminalText.cs b/dotnet-package-skills/src/DotnetPackageSkills/Cli/TerminalText.cs new file mode 100644 index 0000000..ab12e14 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Cli/TerminalText.cs @@ -0,0 +1,256 @@ +using System.Globalization; +using System.Text; + +namespace DotnetPackageSkills.Cli; + +/// Plain terminal text, measured and split at grapheme and display-cell boundaries. +internal static class TerminalText +{ + public static string Sanitize(string? text, bool multiline = false, bool trim = true) + { + if (string.IsNullOrEmpty(text)) + { + return string.Empty; + } + + var clean = new StringBuilder(text.Length); + for (var index = 0; index < text.Length;) + { + var character = text[index]; + if (character is '\x1b' or '\x9b' or '\x9d' or '\x90' or '\x98' or '\x9e' or '\x9f') + { + index = SkipEscape(text, index); + continue; + } + + if (character is '\r' or '\n' or '\u2028' or '\u2029') + { + clean.Append(multiline ? '\n' : ' '); + index += character == '\r' && index + 1 < text.Length && text[index + 1] == '\n' ? 2 : 1; + continue; + } + + if (character == '\t') + { + clean.Append(' '); + index++; + continue; + } + + var status = Rune.DecodeFromUtf16(text.AsSpan(index), out var rune, out var consumed); + if (status != System.Buffers.OperationStatus.Done) + { + rune = Rune.ReplacementChar; + consumed = 1; + } + + index += consumed; + var category = Rune.GetUnicodeCategory(rune); + if (category == UnicodeCategory.Control || + category == UnicodeCategory.Format && rune.Value is not (0x200c or 0x200d or >= 0xe0020 and <= 0xe007f)) + { + continue; + } + + clean.Append(Rune.IsWhiteSpace(rune) ? " " : rune.ToString()); + } + + var result = clean.ToString(); + return trim ? result.Trim() : result; + } + + public static int Width(string text) => Elements(text).Sum(element => CellWidth(element)); + + public static IEnumerable Elements(string text) + { + var elements = StringInfo.GetTextElementEnumerator(text); + while (elements.MoveNext()) + { + yield return elements.GetTextElement(); + } + } + + public static int CellWidth(string element) + { + var width = 0; + var emojiPresentation = false; + foreach (var rune in element.EnumerateRunes()) + { + emojiPresentation |= rune.Value is 0xfe0f or 0x20e3; + if (Rune.GetUnicodeCategory(rune) is UnicodeCategory.NonSpacingMark or + UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark or + UnicodeCategory.Format or UnicodeCategory.Control) + { + continue; + } + + width = Math.Max(width, IsWide(rune.Value) ? 2 : 1); + } + + return width > 0 && emojiPresentation ? 2 : width; + } + + public static string PadRight(string text, int width) => + text + new string(' ', Math.Max(0, width - Width(text))); + + public static string Clip(string text, int width) + { + if (width <= 0) + { + return string.Empty; + } + + if (Width(text) <= width) + { + return text; + } + + var suffix = new string('.', Math.Min(3, width)); + var clipped = new StringBuilder(); + var available = width - suffix.Length; + foreach (var element in Elements(text)) + { + var cells = CellWidth(element); + if (cells > available) + { + break; + } + + clipped.Append(element); + available -= cells; + } + + return clipped.Append(suffix).ToString(); + } + + public static IReadOnlyList Wrap(string text, int width) => Wrap(text, width, width); + + public static IReadOnlyList Wrap(string text, int firstLineWidth, int continuationWidth) + { + ArgumentOutOfRangeException.ThrowIfLessThan(firstLineWidth, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(continuationWidth, 1); + var width = firstLineWidth; + var lines = new List(); + foreach (var paragraph in text.Split('\n')) + { + var line = new StringBuilder(); + var cells = 0; + foreach (var word in paragraph.Split(' ', StringSplitOptions.RemoveEmptyEntries)) + { + var wordWidth = Width(word); + if (cells > 0 && cells + 1 + wordWidth <= width) + { + line.Append(' ').Append(word); + cells += 1 + wordWidth; + continue; + } + + if (cells > 0) + { + lines.Add(line.ToString()); + line.Clear(); + cells = 0; + width = continuationWidth; + } + + foreach (var element in Elements(word)) + { + var elementWidth = CellWidth(element); + if (cells > 0 && cells + elementWidth > width) + { + lines.Add(line.ToString()); + line.Clear(); + cells = 0; + width = continuationWidth; + } + + if (elementWidth > width) + { + throw new ArgumentException("The column is narrower than a single display grapheme.", nameof(width)); + } + + line.Append(element); + cells += elementWidth; + } + } + + lines.Add(line.ToString()); + width = continuationWidth; + } + + return lines; + } + + private static int SkipEscape(string text, int index) + { + var kind = text[index++]; + if (kind == '\x1b') + { + if (index == text.Length) + { + return index; + } + + kind = text[index++]; + } + + if (kind is '[' or '\x9b') + { + while (index < text.Length) + { + if (text[index++] is >= '\x40' and <= '\x7e') + { + break; + } + } + } + else if (kind is ']' or 'P' or 'X' or '^' or '_' or '\x9d' or '\x90' or '\x98' or '\x9e' or '\x9f') + { + while (index < text.Length) + { + if (text[index++] is '\a' or '\x9c') + { + break; + } + + if (text[index - 1] == '\x1b' && index < text.Length && text[index] == '\\') + { + return index + 1; + } + } + } + else if (kind is >= '\x20' and <= '\x2f') + { + while (index < text.Length && text[index] is >= '\x20' and <= '\x2f') + { + index++; + } + + if (index < text.Length && text[index] is >= '\x30' and <= '\x7e') + { + index++; + } + } + + return index; + } + + private static bool IsWide(int value) => + value != 0x303f && value is >= 0x1100 and <= 0x115f or + 0x231a or 0x231b or 0x2329 or 0x232a or + >= 0x23e9 and <= 0x23ec or 0x23f0 or 0x23f3 or 0x25fd or 0x25fe or + 0x2614 or 0x2615 or >= 0x2648 and <= 0x2653 or 0x267f or 0x2693 or + 0x26a1 or 0x26aa or 0x26ab or 0x26bd or 0x26be or 0x26c4 or 0x26c5 or + 0x26ce or 0x26d4 or 0x26ea or 0x26f2 or 0x26f3 or 0x26f5 or 0x26fa or + 0x26fd or 0x2705 or 0x270a or 0x270b or 0x2728 or 0x274c or 0x274e or + >= 0x2753 and <= 0x2755 or 0x2757 or >= 0x2795 and <= 0x2797 or + 0x27b0 or 0x27bf or 0x2b1b or 0x2b1c or 0x2b50 or 0x2b55 or + >= 0x2e80 and <= 0xa4cf or >= 0xac00 and <= 0xd7a3 or + >= 0xf900 and <= 0xfaff or >= 0xfe10 and <= 0xfe19 or + >= 0xfe30 and <= 0xfe6f or >= 0xff00 and <= 0xff60 or + >= 0xffe0 and <= 0xffe6 or >= 0x16fe0 and <= 0x18dff or + >= 0x1aff0 and <= 0x1b2ff or 0x1f004 or 0x1f0cf or 0x1f18e or + >= 0x1f191 and <= 0x1f19a or >= 0x1f1e6 and <= 0x1f1ff or + >= 0x1f200 and <= 0x1f251 or >= 0x1f300 and <= 0x1faff or + >= 0x20000 and <= 0x3fffd; +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/DotnetPackageSkills.csproj b/dotnet-package-skills/src/DotnetPackageSkills/DotnetPackageSkills.csproj new file mode 100644 index 0000000..bdcb1b4 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/DotnetPackageSkills.csproj @@ -0,0 +1,44 @@ + + + + Exe + net8.0;net10.0 + dotnet-package-skills + DotnetPackageSkills + + + Major + + + + true + + dotnet-package-skills + dotnet-package-skills + 0.1.0 + dev + $(DotnetPackageSkillsVersion) + dotnet-package-skills contributors + Copies agent skills bundled inside NuGet packages out of the global packages folder and into a repository's skills directory, where coding agents can actually find them. + dotnet-tool;nuget;ai;agent;skills;claude;copilot + README.md + MIT + https://github.com/NuGet/Client.Tools/tree/main/dotnet-package-skills + https://github.com/NuGet/Client.Tools + git + true + + + + + + + + + + + + + diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/DotnetCli.cs b/dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/DotnetCli.cs new file mode 100644 index 0000000..bc0167c --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/DotnetCli.cs @@ -0,0 +1,20 @@ +namespace DotnetPackageSkills.Infrastructure; + +/// Invokes the dotnet CLI. +public sealed class DotnetCli(IProcessRunner runner) +{ + /// + /// The dotnet host to invoke. DOTNET_HOST_PATH is set by the SDK when the tool + /// runs inside a build or from another dotnet command, and points at the exact + /// host in use — preferring it avoids picking a different dotnet off PATH. + /// + private static string Executable => + Environment.GetEnvironmentVariable("DOTNET_HOST_PATH") is { Length: > 0 } host && File.Exists(host) + ? host + : "dotnet"; + + public ProcessResult Run(params string[] arguments) => runner.Run(Executable, arguments); + + public ProcessResult Run(IReadOnlyList arguments, string? workingDirectory) => + runner.Run(Executable, arguments, workingDirectory); +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/ProcessRunner.cs b/dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/ProcessRunner.cs new file mode 100644 index 0000000..0bb5ca1 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/ProcessRunner.cs @@ -0,0 +1,95 @@ +using System.Diagnostics; + +namespace DotnetPackageSkills.Infrastructure; + +/// Result of running an external process to completion. +public sealed record ProcessResult(int ExitCode, string StandardOutput, string StandardError) +{ + /// + /// Diagnostics text for error messages: tools write failures to stderr, but the + /// dotnet CLI frequently reports MSBuild and NuGet errors on stdout instead. + /// + public string Diagnostics => + string.IsNullOrWhiteSpace(StandardError) ? StandardOutput.Trim() : StandardError.Trim(); +} + +/// Runs external processes. Abstracted so command logic is testable without spawning dotnet. +public interface IProcessRunner +{ + ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null); +} + +/// Thrown when a process cannot be started or does not finish in time. +public sealed class ProcessExecutionException(string message, Exception? inner = null) + : Exception(message, inner); + +public sealed class ProcessRunner(TimeSpan? timeout = null) : IProcessRunner +{ + private readonly TimeSpan _timeout = timeout ?? TimeSpan.FromMinutes(5); + + public ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + foreach (var argument in arguments) + { + startInfo.ArgumentList.Add(argument); + } + + if (!string.IsNullOrEmpty(workingDirectory)) + { + startInfo.WorkingDirectory = workingDirectory; + } + + using var process = new Process { StartInfo = startInfo }; + + try + { + process.Start(); + } + catch (Exception ex) + { + throw new ProcessExecutionException( + $"Could not start '{fileName}'. Make sure the .NET SDK is installed and on PATH " + + "(https://dotnet.microsoft.com/download).", ex); + } + + // Read both streams concurrently before waiting. Draining one to completion + // first deadlocks as soon as the other fills its pipe buffer, which dotnet + // restore output does routinely. + var standardOutput = process.StandardOutput.ReadToEndAsync(); + var standardError = process.StandardError.ReadToEndAsync(); + + if (!process.WaitForExit((int)_timeout.TotalMilliseconds)) + { + TryKill(process); + throw new ProcessExecutionException( + $"'{fileName} {string.Join(' ', arguments)}' did not finish within {_timeout.TotalSeconds:0} seconds."); + } + + // The overload that takes a timeout does not wait for the async output + // readers to drain, so the parameterless call is needed for complete output. + process.WaitForExit(); + + return new ProcessResult(process.ExitCode, standardOutput.Result, standardError.Result); + } + + private static void TryKill(Process process) + { + try + { + process.Kill(entireProcessTree: true); + } + catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException or System.ComponentModel.Win32Exception) + { + // The process already exited or cannot be killed; nothing useful to do. + } + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/NuGet/GlobalPackagesLocator.cs b/dotnet-package-skills/src/DotnetPackageSkills/NuGet/GlobalPackagesLocator.cs new file mode 100644 index 0000000..e991b24 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/NuGet/GlobalPackagesLocator.cs @@ -0,0 +1,99 @@ +using DotnetPackageSkills.Infrastructure; + +namespace DotnetPackageSkills.NuGet; + +/// Locates the NuGet global packages folder, where restore extracts packages. +public sealed class GlobalPackagesLocator(DotnetCli dotnet) +{ + private const string Label = "global-packages:"; + + /// + /// Resolves the folder, honouring an explicit override, then NUGET_PACKAGES, then + /// whatever the CLI reports (which is the only thing that accounts for a + /// globalPackagesFolder set in a nuget.config). + /// + /// + /// Where to ask from. This matters: nuget.config discovery walks up from the current + /// directory, so asking from outside the repo silently ignores a repo-level config. + /// + public string Locate(string? overridePath, string workingDirectory) + { + if (!string.IsNullOrWhiteSpace(overridePath)) + { + var resolved = Path.GetFullPath(overridePath, workingDirectory); + return Directory.Exists(resolved) + ? resolved + : throw new PackageSkillsException($"--global-packages does not exist: {resolved}"); + } + + if (Environment.GetEnvironmentVariable("NUGET_PACKAGES") is { Length: > 0 } fromEnvironment && + Directory.Exists(fromEnvironment)) + { + return Path.GetFullPath(fromEnvironment); + } + + return FromCli(workingDirectory); + } + + private string FromCli(string workingDirectory) + { + var result = dotnet.Run(["nuget", "locals", "global-packages", "--list"], workingDirectory); + + if (result.ExitCode != 0) + { + throw new PackageSkillsException( + $""" + Could not determine the NuGet global packages folder. + 'dotnet nuget locals global-packages --list' failed with exit code {result.ExitCode}: + {result.Diagnostics} + """); + } + + var path = ParseListOutput(result.StandardOutput); + + if (path is null) + { + throw new PackageSkillsException( + $""" + Could not find the global packages path in the output of 'dotnet nuget locals global-packages --list': + {result.StandardOutput.Trim()} + """); + } + + if (!Directory.Exists(path)) + { + throw new PackageSkillsException( + $""" + NuGet reports its global packages folder as '{path}', but that directory does not exist. + Restore the project first — restore is what creates it. + """); + } + + return path; + } + + /// + /// Extracts the path from CLI output. The shape has drifted across SDK versions + /// ("global-packages: /path" today, "info : global-packages: /path" on older ones), + /// so this keys off the label rather than the line's position or prefix. + /// + internal static string? ParseListOutput(string output) + { + foreach (var line in output.Split('\n')) + { + var index = line.IndexOf(Label, StringComparison.OrdinalIgnoreCase); + if (index < 0) + { + continue; + } + + var value = line[(index + Label.Length)..].Trim(); + if (value.Length > 0) + { + return Path.GetFullPath(value); + } + } + + return null; + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageCoordinate.cs b/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageCoordinate.cs new file mode 100644 index 0000000..91767ce --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageCoordinate.cs @@ -0,0 +1,104 @@ +using System.Text.RegularExpressions; + +namespace DotnetPackageSkills.NuGet; + +/// An exact package identity, written as Id@Version on the command line. +public sealed partial record PackageCoordinate(string Id, string Version) +{ + public const char Separator = '@'; + + /// + /// Parses Id@Version, rejecting anything that is not a single concrete version. + /// + /// + /// Version ranges and floating versions are refused rather than resolved. Resolving one + /// means picking a version, and the only correct answer to "which version" comes from a + /// project's restore — which is what --target is for. Guessing here would copy + /// skills that describe a version the user does not actually reference. + /// + public static PackageCoordinate Parse(string value) + { + var input = value?.Trim() ?? string.Empty; + + if (input.Length == 0) + { + throw new PackageSkillsException("--package needs a value in the form Id@Version, for example Mockly@1.10.0."); + } + + var separator = input.IndexOf(Separator); + + if (separator < 0) + { + throw new PackageSkillsException( + $"'{input}' is missing a version. Write --package as Id@Version, for example {input}@1.10.0. " + + "To take versions from a project instead, use --target."); + } + + if (input.IndexOf(Separator, separator + 1) >= 0) + { + throw new PackageSkillsException($"'{input}' has more than one '{Separator}'. Expected Id@Version."); + } + + var id = input[..separator].Trim(); + var version = input[(separator + 1)..].Trim(); + + if (id.Length == 0) + { + throw new PackageSkillsException($"'{input}' is missing a package id before the '{Separator}'."); + } + + ValidateId(id); + + if (version.Length == 0) + { + throw new PackageSkillsException($"'{input}' is missing a version after the '{Separator}'."); + } + + if (IsFloatingOrRange(version)) + { + throw new PackageSkillsException( + $""" + '{version}' is a floating version or a version range, and this tool needs an exact version. + Write it out, for example --package {id}@1.10.0. + To let restore choose the version, point at a project or solution with --target instead. + """); + } + + if (!ExactVersionPattern().IsMatch(version)) + { + throw new PackageSkillsException( + $"'{version}' is not a version this tool recognises. Expected something like 1.10.0 or 2.0.0-beta.1."); + } + + return new PackageCoordinate(id, version); + } + + internal static void ValidateId(string id) + { + if (!IsValidId(id)) + { + throw new PackageSkillsException( + $"'{id}' is not a valid package id. Ids are letters, digits and '_', joined by single '.' or '-' characters."); + } + } + + /// + /// NuGet's own rule for package ids, so any id that restore accepts is accepted here too, + /// including letters outside ASCII. + /// + internal static bool IsValidId(string id) => PackageIdPattern().IsMatch(id); + + /// Wildcards and NuGet interval notation: 4.*, [1.0,2.0), (,3.0]. + private static readonly char[] RangeCharacters = ['*', '[', ']', '(', ')', ',']; + + private static bool IsFloatingOrRange(string version) => version.IndexOfAny(RangeCharacters) >= 0; + + public override string ToString() => $"{Id}{Separator}{Version}"; + + // NuGet's PackageIdValidator pattern, with \z so a trailing newline can't end a match. + [GeneratedRegex(@"^\w+([.-]\w+)*\z", RegexOptions.CultureInvariant)] + private static partial Regex PackageIdPattern(); + + [GeneratedRegex(@"^\d+(\.\d+){0,3}(-[0-9A-Za-z][0-9A-Za-z.-]*)?(\+[0-9A-Za-z][0-9A-Za-z.-]*)?$")] + private static partial Regex ExactVersionPattern(); +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageLister.cs b/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageLister.cs new file mode 100644 index 0000000..2a73fd5 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageLister.cs @@ -0,0 +1,214 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using DotnetPackageSkills.Infrastructure; + +namespace DotnetPackageSkills.NuGet; + +/// A package the target resolves to, after de-duplication across projects and frameworks. +public sealed record PackageReferenceInfo(string Id, string Version); + +/// +/// Lists the packages a solution or project resolves to, by way of +/// dotnet list <target> package --format json. +/// +public sealed class PackageLister(DotnetCli dotnet) +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + /// + /// Runs dotnet list package as it is. Whether it restores is the SDK's call: the .NET 10 + /// SDK restores when it needs to, and earlier SDKs say the target has to be restored first. + /// + /// + /// This tool never restores. A failure is reported with what the SDK said, so the customer can + /// restore or fix whatever else it names, and then run the command again. + /// + public IReadOnlyList List(string target) + { + // The target goes *before* the `package` verb: `dotnet list package`. + var arguments = new List { "list", target, "package", "--format", "json" }; + var result = dotnet.Run(arguments, workingDirectory: Path.GetDirectoryName(target)); + + if (result.ExitCode != 0) + { + throw new PackageSkillsException( + $""" + 'dotnet list "{target}" package' failed with exit code {result.ExitCode}: + {ReportedProblems(result.StandardOutput) ?? result.Diagnostics} + + Resolve what it reports, for example by restoring the target, and then run this command again. + """); + } + + return Parse(result.StandardOutput); + } + + /// + /// The problems that a JSON listing reports, one per line, or null when there are none. The + /// .NET 10 SDK reports a failed restore this way, on standard output. + /// + private static string? ReportedProblems(string output) + { + var start = output.IndexOf('{'); + if (start < 0) + { + return null; + } + + try + { + using var document = JsonDocument.Parse(output[start..]); + if (document.RootElement.ValueKind != JsonValueKind.Object || + !document.RootElement.TryGetProperty("problems", out var problems) || + problems.ValueKind != JsonValueKind.Array) + { + return null; + } + + var lines = problems.EnumerateArray() + .Where(problem => problem.ValueKind == JsonValueKind.Object) + .Select(problem => (Level: Text(problem, "level"), Text: Text(problem, "text"))) + .Where(problem => !string.IsNullOrWhiteSpace(problem.Text)) + .Select(problem => string.IsNullOrWhiteSpace(problem.Level) + ? problem.Text! + : $"{problem.Level}: {problem.Text}") + .ToList(); + + return lines.Count == 0 ? null : string.Join(Environment.NewLine, lines); + } + catch (JsonException) + { + return null; + } + + static string? Text(JsonElement problem, string name) => + problem.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + internal static IReadOnlyList Parse(string json) + { + var report = Deserialize(json); + + // Key on (id, version) because each resolved version has its own folder in the global + // packages cache. Keeping all versions also lets skill discovery report name collisions. + var found = new Dictionary<(string Id, string Version), PackageReferenceInfo>(); + + foreach (var framework in report.Projects?.SelectMany(p => p.Frameworks ?? []) ?? []) + { + foreach (var entry in framework.TopLevelPackages ?? []) + { + var id = entry.Id?.Trim(); + + // The resolved version is what exists on disk: it is the concrete value behind a + // floating version or a version managed through Central Package Management. + var version = Coalesce(entry.ResolvedVersion, entry.RequestedVersion); + + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(version)) + { + continue; + } + + var key = (id.ToLowerInvariant(), version.ToLowerInvariant()); + found[key] = new PackageReferenceInfo(id, version); + } + } + + return [.. found.Values.OrderBy(p => p.Id, StringComparer.OrdinalIgnoreCase).ThenBy(p => p.Version, StringComparer.Ordinal)]; + + static string? Coalesce(string? first, string? second) => + string.IsNullOrWhiteSpace(first) ? second?.Trim() : first.Trim(); + } + + private static ListPackageReport Deserialize(string json) + { + // MSBuild sometimes writes warnings ahead of the payload, so fall back to the + // first '{' rather than assuming the whole stream is JSON. + foreach (var candidate in Candidates(json)) + { + try + { + var report = JsonSerializer.Deserialize(candidate, JsonOptions); + if (report is not null) + { + return report; + } + } + catch (JsonException) + { + // Try the next candidate. + } + } + + throw new PackageSkillsException( + $""" + Could not parse the output of 'dotnet list package --format json'. + + If the error above mentions an unrecognized '--format' option, the installed SDK predates 7.0.200 and needs upgrading. + Raw output: + {json.Trim()} + """); + + static IEnumerable Candidates(string text) + { + var trimmed = text.Trim(); + if (trimmed.Length == 0) + { + yield break; + } + + yield return trimmed; + + var start = trimmed.IndexOf('{'); + if (start > 0) + { + yield return trimmed[start..]; + } + } + } + + private sealed class ListPackageReport + { + [JsonPropertyName("version")] + public int Version { get; set; } + + [JsonPropertyName("projects")] + public List? Projects { get; set; } + } + + private sealed class ListPackageProject + { + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("frameworks")] + public List? Frameworks { get; set; } + } + + private sealed class ListPackageFramework + { + [JsonPropertyName("framework")] + public string? Framework { get; set; } + + [JsonPropertyName("topLevelPackages")] + public List? TopLevelPackages { get; set; } + } + + private sealed class ListPackageEntry + { + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("requestedVersion")] + public string? RequestedVersion { get; set; } + + [JsonPropertyName("resolvedVersion")] + public string? ResolvedVersion { get; set; } + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackagePathResolver.cs b/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackagePathResolver.cs new file mode 100644 index 0000000..e0a826e --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackagePathResolver.cs @@ -0,0 +1,101 @@ +namespace DotnetPackageSkills.NuGet; + +/// +/// Maps a package id and version to its folder inside the global packages cache. +/// +/// +/// Restore extracts each package to <global-packages>/<id>/<version>/ +/// with both segments lowercased and the version normalized. This mirrors NuGet's own +/// normalization rules; where they are ambiguous, a directory scan settles it, so a +/// mismatch degrades into a slower lookup rather than a missed package. +/// +public static class PackagePathResolver +{ + /// Returns the extracted package folder, or null when it is not on disk. + public static string? Resolve(string globalPackagesFolder, string packageId, string version) + { + var packageDirectory = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant()); + + if (!Directory.Exists(packageDirectory)) + { + return null; + } + + var normalized = NormalizeVersion(version); + + var candidate = Path.Combine(packageDirectory, normalized); + if (Directory.Exists(candidate)) + { + return candidate; + } + + // Fall back to a case-insensitive scan. NuGet's normalization has corner cases + // (SemVer 2 build metadata, unusual padding) that are not worth reimplementing + // exactly, and the directory itself is the authoritative answer. + foreach (var directory in Directory.EnumerateDirectories(packageDirectory)) + { + var name = Path.GetFileName(directory); + if (name.Equals(normalized, StringComparison.OrdinalIgnoreCase) || + name.Equals(version, StringComparison.OrdinalIgnoreCase)) + { + return directory; + } + } + + return null; + } + + /// + /// Normalizes a version the way NuGet does for folder names: lowercased, build + /// metadata dropped, padded to three parts, and a fourth part dropped when zero. + /// So 1.2 becomes 1.2.0 and 1.2.3.0 becomes 1.2.3. + /// + public static string NormalizeVersion(string version) + { + var value = version.Trim(); + + // Build metadata is not part of package identity and never appears in the path. + var plus = value.IndexOf('+'); + if (plus >= 0) + { + value = value[..plus]; + } + + var dash = value.IndexOf('-'); + var core = dash >= 0 ? value[..dash] : value; + var prerelease = dash >= 0 ? value[(dash + 1)..] : string.Empty; + + var parts = core.Split('.'); + var numbers = new List(4); + + foreach (var part in parts) + { + if (!int.TryParse(part, out var number) || number < 0) + { + // Not a version shape this tool understands; leave it to the directory scan. + return version.Trim().ToLowerInvariant(); + } + + numbers.Add(number); + } + + while (numbers.Count < 3) + { + numbers.Add(0); + } + + if (numbers.Count >= 4 && numbers[3] == 0) + { + numbers.RemoveRange(3, numbers.Count - 3); + } + + var normalized = string.Join('.', numbers); + + if (prerelease.Length > 0) + { + normalized = $"{normalized}-{prerelease}"; + } + + return normalized.ToLowerInvariant(); + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/NuGet/TargetLocator.cs b/dotnet-package-skills/src/DotnetPackageSkills/NuGet/TargetLocator.cs new file mode 100644 index 0000000..1d6b649 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/NuGet/TargetLocator.cs @@ -0,0 +1,119 @@ +namespace DotnetPackageSkills.NuGet; + +/// Finds the solution or project to inspect when the user does not name one. +public static class TargetLocator +{ + private static readonly string[] SolutionExtensions = [".slnx", ".sln"]; + private static readonly string[] ProjectExtensions = [".csproj", ".fsproj", ".vbproj"]; + private static readonly string[] IgnoredDirectories = ["bin", "obj", ".git", "node_modules", "artifacts"]; + + /// + /// Resolves an explicit target, or auto-detects one under . + /// A directory is accepted and searched. + /// + public static string Resolve(string? requested, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(requested)) + { + return Detect(workingDirectory); + } + + var path = Path.GetFullPath(requested, workingDirectory); + + if (Directory.Exists(path)) + { + return Detect(path); + } + + if (!File.Exists(path)) + { + throw new PackageSkillsException($"--target does not exist: {path}"); + } + + var extension = Path.GetExtension(path); + if (!SolutionExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase) && + !ProjectExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase)) + { + throw new PackageSkillsException( + $"--target must be a solution or project file, but got '{Path.GetFileName(path)}'. " + + "Supported extensions: .sln, .slnx, .csproj, .fsproj, .vbproj."); + } + + return path; + } + + /// + /// Searches for a target, preferring a solution over a project and the top level + /// over nested directories. A solution covers every project in one pass, which is + /// almost always what someone means by "my repo". + /// + public static string Detect(string directory) + { + if (!Directory.Exists(directory)) + { + throw new PackageSkillsException($"Directory does not exist: {directory}"); + } + + foreach (var extensions in new[] { SolutionExtensions, ProjectExtensions }) + { + var match = EnumerateFiles(directory, extensions, SearchOption.TopDirectoryOnly).FirstOrDefault(); + if (match is not null) + { + return match; + } + } + + foreach (var extensions in new[] { SolutionExtensions, ProjectExtensions }) + { + var match = EnumerateFiles(directory, extensions, SearchOption.AllDirectories) + .Where(path => !IsIgnored(path, directory)) + .FirstOrDefault(); + if (match is not null) + { + return match; + } + } + + throw new PackageSkillsException( + $"No solution or project found under {directory}. " + + "Pass one explicitly, for example: --target src/MyApp.sln"); + } + + private static IEnumerable EnumerateFiles(string directory, string[] extensions, SearchOption option) + { + IEnumerable files; + try + { + files = Directory.EnumerateFiles(directory, "*", option); + } + catch (UnauthorizedAccessException) + { + return []; + } + + // Rank by the extension's position in the list, so preference between formats + // (.slnx ahead of .sln) is not left to how the file names happen to sort. + return files + .Select(file => new + { + File = file, + Rank = Array.FindIndex( + extensions, + extension => extension.Equals(Path.GetExtension(file), StringComparison.OrdinalIgnoreCase)), + }) + .Where(candidate => candidate.Rank >= 0) + .OrderBy(candidate => candidate.Rank) + .ThenBy(candidate => candidate.File, StringComparer.Ordinal) + .Select(candidate => candidate.File); + } + + private static bool IsIgnored(string path, string root) + { + var relative = Path.GetRelativePath(root, path); + var segments = relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + // The file name itself is never a directory match. + return segments.Take(segments.Length - 1) + .Any(segment => IgnoredDirectories.Contains(segment, StringComparer.OrdinalIgnoreCase)); + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/PackageSkillsException.cs b/dotnet-package-skills/src/DotnetPackageSkills/PackageSkillsException.cs new file mode 100644 index 0000000..6106713 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/PackageSkillsException.cs @@ -0,0 +1,8 @@ +namespace DotnetPackageSkills; + +/// +/// A failure the user needs to act on. The message is printed verbatim without a +/// stack trace, so it must read as guidance rather than as a diagnostic. +/// +public sealed class PackageSkillsException(string message, Exception? inner = null) + : Exception(message, inner); diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Program.cs b/dotnet-package-skills/src/DotnetPackageSkills/Program.cs new file mode 100644 index 0000000..21bb1fa --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Program.cs @@ -0,0 +1,394 @@ +using System.CommandLine; +using DotnetPackageSkills; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +return CommandLineBuilder.Invoke(args); + +namespace DotnetPackageSkills.Cli +{ + /// Wires up the command line surface. + internal static class CommandLineBuilder + { + /// + /// Vendor-neutral default. Agents that follow another convention are one + /// --destination away, which is why this is a default rather than a hard-coded path. + /// + private const string DefaultDestination = InstallRequest.DefaultDestination; + + public static int Invoke(string[] args, TextWriter? output = null, TextWriter? error = null) => + CommandLineDiagnostics.Invoke(Build().Parse(args), output ?? Console.Out, error ?? Console.Error); + + public static RootCommand Build() + { + var target = new Option("--target", "-t") + { + Description = "Solution or project to inspect. Defaults to searching the current directory.", + HelpName = "PATH", + }; + + var package = new Option("--package", "-p") + { + Description = + "Take skills from an exact package instead of a project, as Id@Version " + + "(for example Mockly@1.10.0). Repeatable. Floating versions are not accepted.", + HelpName = "ID@VERSION", + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = true, + }; + package.Validators.Add(result => + { + // Taking several values means the parser hands --package any unknown option that + // follows it, such as a --json left in an old script. No package ID starts with + // '-', so report it the way the parser reports an unknown option anywhere else. + foreach (var token in result.Tokens.Where(token => token.Value.StartsWith('-'))) + { + result.AddError($"Unrecognized command or argument '{token.Value}'."); + } + }); + + var destination = new Option("--destination", "-d") + { + Description = $"Folder to copy skills into. Default: {DefaultDestination}", + HelpName = "PATH", + DefaultValueFactory = _ => DefaultDestination, + }; + + var globalPackages = new Option("--global-packages") + { + Description = "Override the NuGet global packages folder instead of asking the CLI.", + HelpName = "PATH", + }; + + var dryRun = new Option("--dry-run") + { + Description = "Report what would change without writing anything.", + }; + + var interactive = new Option("--interactive", "-i") + { + Description = + "Choose which skills to add, with descriptions, one page at a time. " + + "Only skills that aren't installed are listed; installed skills are left as they are.", + }; + + var uninstallPackage = new Option("--package", "-p") + { + Description = + "Remove only this package's skills. Accepts Id, or Id@Version to remove them " + + "only if that version is the one installed.", + HelpName = "ID[@VERSION]", + Arity = ArgumentArity.ExactlyOne, + }; + uninstallPackage.Validators.Add(result => + { + if (result.IdentifierTokenCount > 1) + { + result.AddError("--package can be specified only once for uninstall."); + return; + } + + if (result.Tokens.Count == 1) + { + try + { + ParseUninstallFilter(result.Tokens[0].Value); + } + catch (PackageSkillsException error) + { + result.AddError(error.Message); + } + } + }); + + // Its own option rather than the one install uses, because "copy skills into" is + // nonsense on a command that only deletes. It still has to exist: skills installed + // to somewhere other than the default are unreachable without it. + var uninstallDestination = new Option("--destination", "-d") + { + Description = $"Folder to remove skills from. Default: {DefaultDestination}", + HelpName = "PATH", + DefaultValueFactory = _ => DefaultDestination, + }; + + var install = new Command("install", "Copy skills bundled in NuGet packages into the repository.") + { + target, package, destination, globalPackages, dryRun, interactive, + }; + install.Validators.Add(RejectTargetWithPackage); + install.SetAction(parseResult => Run(() => + { + var request = BuildRequest(parseResult); + var service = new SkillInstallService(new ProcessRunner()); + + var result = parseResult.GetValue(interactive) + ? InstallInteractively(service, request) + : service.Install(request); + + if (result is null) + { + new OutputWriter(Console.Out).WriteCancelled(); + return; + } + + new OutputWriter(Console.Out).WriteInstallReport(result, copied: true); + })); + + var list = new Command("list", "Show which packages ship skills, without copying anything.") + { + target, package, destination, globalPackages, + }; + list.Validators.Add(RejectTargetWithPackage); + list.SetAction(parseResult => Run(() => + { + var request = BuildRequest(parseResult) with { DryRun = true }; + var result = new SkillInstallService(new ProcessRunner()).Discover(request); + new OutputWriter(Console.Out).WriteInstallReport(result, copied: false); + })); + + var uninstallInteractive = new Option("--interactive", "-i") + { + Description = + "Choose which installed skills to remove, with descriptions, one page at a time. " + + "Only skills this tool installed are listed.", + }; + + var stale = new Option("--stale") + { + Description = + "Remove only stale skills: skills whose package the target no longer references, " + + "or references at a different version. Reads the target's package references, " + + "so it needs a solution or project.", + }; + + var staleTarget = new Option("--target", "-t") + { + Description = + "With --stale, the solution or project to compare against. " + + "Defaults to searching the current directory.", + HelpName = "PATH", + }; + + var uninstall = new Command("uninstall", "Remove skills this tool previously copied in.") + { + uninstallDestination, uninstallPackage, stale, staleTarget, dryRun, uninstallInteractive, + }; + uninstall.Validators.Add(result => + { + var isStale = result.GetResult(stale) is not null; + + if (isStale && result.GetResult(uninstallPackage) is not null) + { + result.AddError( + "--stale and --package cannot be combined. --stale removes the skills that no longer " + + "match the target; --package removes one package's skills."); + } + + if (!isStale && result.GetResult(staleTarget) is not null) + { + result.AddError("--target can be used with uninstall only together with --stale."); + } + }); + uninstall.SetAction(parseResult => Run(() => + { + var workingDirectory = Directory.GetCurrentDirectory(); + var destinationValue = parseResult.GetValue(uninstallDestination) ?? DefaultDestination; + var isDryRun = parseResult.GetValue(dryRun); + var (id, version) = ParseUninstallFilter(parseResult.GetValue(uninstallPackage)); + var root = Path.GetFullPath(destinationValue, workingDirectory); + var service = new SkillInstallService(new ProcessRunner()); + var references = parseResult.GetValue(stale) + ? service.ReadReferences(parseResult.GetValue(staleTarget), workingDirectory) + : null; + + UninstallChoice? choice = null; + + if (parseResult.GetValue(uninstallInteractive)) + { + choice = ChooseWhatToRemove(destinationValue, workingDirectory, id, version, references); + + if (choice is null) + { + new OutputWriter(Console.Out).WriteCancelled(); + return; + } + } + + var removed = service.Uninstall(destinationValue, workingDirectory, id, version, isDryRun, + choice?.Selected, choice?.ExpectedInstalled, references?.Packages); + + new OutputWriter(Console.Out).WriteUninstallReport(removed, root, isDryRun, references?.Target); + })); + + return new RootCommand( + """ + Copies agent skills bundled inside NuGet packages into a folder your coding agent reads. + + Package authors ship skills at skills/-/SKILL.md inside the package. Restore extracts them to the NuGet global packages folder, which is outside your repository and which no coding agent scans. This tool bridges that gap. + """) + { + install, list, uninstall, + }; + + InstallRequest BuildRequest(ParseResult parseResult) => new() + { + Target = parseResult.GetValue(target), + Packages = [.. (parseResult.GetValue(package) ?? []).Select(PackageCoordinate.Parse)], + Destination = parseResult.GetValue(destination) ?? DefaultDestination, + WorkingDirectory = Directory.GetCurrentDirectory(), + GlobalPackagesOverride = parseResult.GetValue(globalPackages), + DryRun = parseResult.GetValue(dryRun), + }; + + void RejectTargetWithPackage(System.CommandLine.Parsing.CommandResult result) + { + // Both would answer "which packages", and combining them hides which one won. + if (result.GetResult(target) is not null && result.GetResult(package) is not null) + { + result.AddError( + "--target and --package cannot be combined. Use --target to take versions " + + "from a project, or --package to name exact packages yourself."); + } + } + } + + /// + /// Discovers skills, lets the user pick from the ones not installed yet a page at a time, + /// then copies the picks. Returns null when the user cancelled. + /// + /// + /// Every check that could stop the install runs before the checklist opens, so a choice is + /// never made only to be refused. With nothing new to offer there is no checklist at all. + /// + private static InstallResult? InstallInteractively(SkillInstallService service, InstallRequest request) + { + var discovered = service.Discover(request); + var installed = SkillInstallService.InstalledSkills(discovered.Destination, request.WorkingDirectory); + var prepared = service.PrepareInteractiveInstall(request, discovered, installed); + var items = InteractiveSkills.ForInstall(prepared.Skills, installed); + + if (items.Count == 0) + { + return prepared with { NothingNewToInstall = prepared.SkillsDiscovered > 0 }; + } + + var picked = new SkillPicker(new SystemTerminal()) + .Choose(items, PickerTitle(discovered), PickerMode.Install, InstalledSkillsNote); + + if (picked is null) + { + return null; + } + + var choice = InteractiveSkills.InstallChoice(prepared.Skills, installed, items, picked); + + return service.Install(request, prepared, choice); + } + + /// + /// Offers the installed skills for removal and returns the ones ticked, or null when + /// the user cancelled. + /// + /// + /// The list comes from the manifest, so it holds exactly what this tool put there and + /// nothing a user wrote themselves. An empty list still returns an empty selection + /// rather than prompting, so the report can say there was nothing to remove. + /// + private static UninstallChoice? ChooseWhatToRemove( + string destination, + string workingDirectory, + string? packageId, + string? packageVersion, + TargetReferences? references) + { + var installed = SkillInstallService.InstalledSkills(destination, workingDirectory); + var matching = installed + .Where(entry => SkillInstaller.Matches(entry, packageId, packageVersion)) + .Where(entry => references is null || SkillInstaller.IsStale(entry, references.Packages)) + .ToList(); + + if (matching.Count == 0) + { + return new UninstallChoice([], installed); + } + + var items = InteractiveSkills.ForUninstall( + matching, + Path.GetFullPath(destination, workingDirectory)); + + var selected = new SkillPicker(new SystemTerminal()).Choose( + items, + "Which skills should be uninstalled?", + PickerMode.Uninstall, + references is null ? null : StaleSkillsNote); + return selected is null ? null : new UninstallChoice(selected.ToList(), installed); + } + + /// Shown under the install checklist title, because the list is not everything. + internal const string InstalledSkillsNote = "Installed skills aren't listed."; + + /// Shown under the uninstall checklist title with --stale. + internal const string StaleSkillsNote = "Only skills that don't match the target are listed."; + + private static string PickerTitle(InstallResult discovered) => + discovered.Target is null + ? "Which skills should be installed?" + : $"Which skills should be installed? ({Path.GetFileName(discovered.Target)})"; + + /// + /// Splits the uninstall filter, which unlike --package on install may omit the version + /// to mean "whichever version of this package is installed". + /// + internal static (string? Id, string? Version) ParseUninstallFilter(string? value) + { + if (value is null) + { + return (null, null); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new PackageSkillsException( + "--package requires a non-empty package ID, optionally followed by @Version. " + + "Omit --package only when you intend to remove all tracked skills."); + } + + if (!value.Contains(PackageCoordinate.Separator)) + { + var id = value.Trim(); + PackageCoordinate.ValidateId(id); + return (id, null); + } + + var coordinate = PackageCoordinate.Parse(value); + return (coordinate.Id, coordinate.Version); + } + + /// + /// Turns expected failures into a plain message and a non-zero exit code. Users of a CLI + /// should get guidance, not a stack trace, for anything we anticipated. + /// + private static int Run(Action action) + { + try + { + action(); + return 0; + } + catch (Exception ex) when (ex is PackageSkillsException or ProcessExecutionException) + { + new OutputWriter(Console.Out).WriteError(ex.Message); + return 1; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + new OutputWriter(Console.Out).WriteError( + $"{ex.Message}{Environment.NewLine}" + + "Check that the destination folder is writable and not open in another program."); + return 1; + } + } + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/SkillInstallService.cs b/dotnet-package-skills/src/DotnetPackageSkills/SkillInstallService.cs new file mode 100644 index 0000000..e1af1cd --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/SkillInstallService.cs @@ -0,0 +1,505 @@ +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills; + +/// Inputs for an install or a list. +public sealed record InstallRequest +{ + /// Where skills go when no destination is given. + public const string DefaultDestination = ".agents/skills"; + + /// Solution or project to inspect. Ignored when is set. + public string? Target { get; init; } + + /// Exact packages to take skills from, instead of inspecting a project. + public IReadOnlyList Packages { get; init; } = []; + + public required string Destination { get; init; } + public required string WorkingDirectory { get; init; } + public string? GlobalPackagesOverride { get; init; } + public bool DryRun { get; init; } +} + +/// What an install or a list produced. +public sealed record InstallResult +{ + /// The solution or project inspected, or null when packages were named explicitly. + public string? Target { get; init; } + + public required string GlobalPackagesFolder { get; init; } + public required string Destination { get; init; } + public required int PackagesScanned { get; init; } + public required bool DryRun { get; init; } + public required IReadOnlyList Skills { get; init; } + + /// + /// How many skills discovery turned up, which stays put even after is + /// narrowed to what was actually installed. Without it a report cannot tell "no package ships + /// a skill" apart from "you chose none of the ones that do". + /// + public int SkillsDiscovered { get; init; } + + public IReadOnlyList Removed { get; init; } = []; + public IReadOnlyList Skipped { get; init; } = []; + + /// + /// Installed skills whose package the target no longer references. Install keeps them, and + /// the report points at uninstall --stale, the one command that removes them. + /// + public IReadOnlyList Unreferenced { get; init; } = []; + + /// + /// The command the report suggests for removing skills, spelled + /// with the target and destination this run used. + /// + public string StaleCommand { get; init; } = "dotnet-package-skills uninstall --stale"; + + /// + /// Set when an interactive install found skills but every one is installed already or + /// skipped, so there was nothing to choose from and no checklist was shown. + /// + public bool NothingNewToInstall { get; init; } + + internal IReadOnlyList ResolvedPackages { get; init; } = []; + internal IReadOnlyList? AllCandidates { get; init; } +} + +/// The skills the user checked. Installing a choice copies these and changes nothing else. +public sealed record SkillChoice(IReadOnlyList Selected) +{ + public IReadOnlyCollection? ExpectedInstalled { get; init; } +} + +/// Ties package listing, skill discovery, and installation together. +public sealed class SkillInstallService(DotnetCli dotnet, SkillInstaller installer) +{ + public SkillInstallService(IProcessRunner runner) : this(new DotnetCli(runner), new SkillInstaller()) + { + } + + /// Discovers bundled skills without writing anything. + public InstallResult Discover(InstallRequest request) + { + return request.Packages.Count > 0 + ? DiscoverFromCoordinates(request) + : DiscoverFromTarget(request); + } + + private InstallResult DiscoverFromTarget(InstallRequest request) + { + var target = TargetLocator.Resolve(request.Target, request.WorkingDirectory); + + // Ask for the global packages folder from the repository, not from wherever the user + // happened to invoke the tool: nuget.config discovery walks up from the current + // directory, and a repo-level config is exactly the case worth honouring. + var globalPackages = LocateGlobalPackages(request, Path.GetDirectoryName(target)); + + // Keep every distinct (id, version) long enough to detect unsupported multi-version + // collisions explicitly rather than silently selecting one package from the solution. + var packages = new PackageLister(dotnet).List(target); + + var (skills, skipped, candidates) = Collect(globalPackages, packages.Select(p => (p.Id, p.Version))); + + return Build(request, target, globalPackages, packages.Count, skills, skipped) + with { ResolvedPackages = packages, AllCandidates = candidates }; + } + + private InstallResult DiscoverFromCoordinates(InstallRequest request) + { + var globalPackages = LocateGlobalPackages(request, request.WorkingDirectory); + + var packages = request.Packages.DistinctBy(package => + (package.Id.ToLowerInvariant(), PackagePathResolver.NormalizeVersion(package.Version))).ToArray(); + var (skills, skipped, candidates) = Collect( + globalPackages, + packages.Select(coordinate => (coordinate.Id, coordinate.Version))); + + return Build(request, target: null, globalPackages, packages.Length, skills, skipped) + with + { + ResolvedPackages = [.. packages.Select(coordinate => new PackageReferenceInfo(coordinate.Id, coordinate.Version))], + AllCandidates = candidates, + }; + } + + private string LocateGlobalPackages(InstallRequest request, string? preferredDirectory) => + new GlobalPackagesLocator(dotnet).Locate( + request.GlobalPackagesOverride, + preferredDirectory ?? request.WorkingDirectory); + + /// + /// A package that is not in the cache contributes nothing, exactly like one that ships no + /// skills: getting packages into the cache is restore's job, not this tool's. Only a target + /// install checks for them, because it would otherwise report their skills as stale. + /// + private static (List Skills, List Skipped, List Candidates) Collect( + string globalPackages, + IEnumerable<(string Id, string Version)> packages) + { + var skills = new List(); + var skipped = new List(); + var candidates = new List(); + var destinations = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var (id, version) in packages) + { + var packageDirectory = PackagePathResolver.Resolve(globalPackages, id, version); + + if (packageDirectory is null) + { + continue; + } + + foreach (var skill in SkillDiscovery.Discover(packageDirectory, id, version)) + { + candidates.Add(skill); + if (destinations.TryAdd(skill.RelativePath, skill)) + { + skills.Add(skill); + continue; + } + + var retained = destinations[skill.RelativePath]; + skipped.Add(ToSkipped( + skill, + $"conflicts with {retained.PackageId} {retained.PackageVersion} skill " + + $"'{retained.SkillName}', which was selected first")); + } + } + + return (skills, skipped, candidates); + } + + private static InstallResult Build( + InstallRequest request, + string? target, + string globalPackages, + int packagesScanned, + IReadOnlyList skills, + IReadOnlyList skipped) => + new() + { + Target = target, + GlobalPackagesFolder = globalPackages, + Destination = Path.GetFullPath(request.Destination, request.WorkingDirectory), + PackagesScanned = packagesScanned, + DryRun = request.DryRun, + Skills = skills, + SkillsDiscovered = skills.Count, + Skipped = skipped, + }; + + /// Discovers bundled skills and copies them into the destination. + public InstallResult Install(InstallRequest request) => Install(request, Discover(request), choice: null); + + /// + /// Copies a caller-chosen subset of already-discovered skills, which is what the interactive + /// picker produces. Passing a null installs everything discovered. + /// + public InstallResult Install(InstallRequest request, InstallResult discovered, SkillChoice? choice) + { + RequireOneVersionPerPackage(request, discovered); + + if (request.Packages.Count == 0) + { + RequireEveryPackageInCache(discovered); + } + + // A choice only adds: it never refreshes or removes what it was not asked about. Without + // one, the run covers the packages it found in the cache, so a version that is not there + // never causes a removal. + var offered = choice is null + ? discovered.ResolvedPackages + .Where(package => PackagePathResolver.Resolve(discovered.GlobalPackagesFolder, package.Id, package.Version) is not null) + .GroupBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().Version, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + + var outcome = installer.Install( + discovered.Destination, + choice?.Selected ?? discovered.AllCandidates ?? discovered.Skills, + request.DryRun, + offered, + choice?.ExpectedInstalled, + arguments => UninstallCommand(request, arguments)); + + return discovered with + { + DryRun = request.DryRun, + Skills = outcome.Installed, + Removed = outcome.Removed, + Skipped = discovered.AllCandidates is null + ? [.. discovered.Skipped, .. outcome.Skipped] + : outcome.Skipped, + // A target lists every package it references, so anything it did not offer has left + // the project. Named packages say nothing about the rest, so they report nothing. + Unreferenced = request.Packages.Count == 0 && choice is null ? outcome.Untouched : [], + StaleCommand = UninstallCommand(request, "--stale", withTarget: true), + AllCandidates = null, + }; + } + + /// + /// Stops an install when a package has more than one version, whether or not it ships skills. + /// + /// + /// The manifest records one version per package, and skills describe the version they came + /// from. With two versions there is no right answer for which guidance the repository gets, + /// so rather than guess, ask for the versions to be aligned. Central Package Management keeps + /// them aligned. does not check this, so list still shows both. + /// + private static void RequireOneVersionPerPackage(InstallRequest request, InstallResult discovered) + { + var conflicts = discovered.ResolvedPackages + .GroupBy(package => package.Id, StringComparer.OrdinalIgnoreCase) + .Select(group => ( + group.First().Id, + Versions: group + .DistinctBy(package => PackagePathResolver.NormalizeVersion(package.Version)) + .Select(package => package.Version) + .ToList())) + .Where(entry => entry.Versions.Count > 1) + .Select(entry => $"{entry.Id} ({string.Join(", ", entry.Versions)})") + .ToList(); + + if (conflicts.Count == 0) + { + return; + } + + throw new PackageSkillsException(request.Packages.Count == 0 + ? "Cannot install skills because these packages resolve to more than one version: " + + $"{string.Join("; ", conflicts)}. Skills can come from only one version of each package. " + + "Align the versions, for example with Central Package Management, and then try again. " + + "No skills were changed." + : "Cannot install skills because --package names more than one version of these packages: " + + $"{string.Join("; ", conflicts)}. Skills can come from only one version of each package, " + + "so name one version per package, and then try again. No skills were changed."); + } + + /// + /// Stops a target install when a package the target references is not in the cache. + /// + /// + /// Restoring is not this tool's job, but carrying on would read an unextracted package as + /// one that ships nothing and report its installed skills as no longer referenced. Asking + /// for a restore is the honest answer. Named packages never reach this check: naming one + /// that is not in the cache simply finds no skills. + /// + private static void RequireEveryPackageInCache(InstallResult discovered) + { + var missing = discovered.ResolvedPackages + .Where(package => PackagePathResolver.Resolve(discovered.GlobalPackagesFolder, package.Id, package.Version) is null) + .Select(package => $"{package.Id} {package.Version}") + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (missing.Length > 0) + { + throw new PackageSkillsException( + $"Cannot install skills because resolved packages are missing from '{discovered.GlobalPackagesFolder}': " + + $"{string.Join(", ", missing)}. " + + "Run dotnet restore for the target using this cache, then try again. No skills were changed."); + } + } + + /// + /// Runs every check that stops an interactive install before a checklist opens, and returns + /// what it can offer: the skills that would install cleanly and are not installed yet. + /// + /// + /// An interactive install only adds, so it cannot settle a mismatch between the installed + /// skills and the packages. With a target, any stale skill stops it until + /// uninstall --stale removes them. With named packages, an installed skill from + /// another version of one of them stops it, because adding beside it would give that + /// package two versions. + /// + internal InstallResult PrepareInteractiveInstall( + InstallRequest request, + InstallResult discovered, + IReadOnlyCollection installed) + { + RequireOneVersionPerPackage(request, discovered); + + if (request.Packages.Count == 0) + { + RequireEveryPackageInCache(discovered); + RequireNoStaleSkills(request, discovered, installed); + } + else + { + RequireNoOtherInstalledVersion(request, discovered, installed); + } + + var preview = Install( + request with { DryRun = true }, + discovered, + new SkillChoice(discovered.AllCandidates ?? discovered.Skills) { ExpectedInstalled = installed }); + var tracked = installed.Select(entry => entry.Skill).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return preview with + { + DryRun = request.DryRun, + Skills = [.. preview.Skills.Where(skill => !tracked.Contains(skill.RelativePath))], + }; + } + + private static void RequireNoStaleSkills( + InstallRequest request, + InstallResult discovered, + IReadOnlyCollection installed) + { + var stale = installed + .Where(entry => SkillInstaller.IsStale(entry, discovered.ResolvedPackages)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + + if (stale.Count == 0) + { + return; + } + + throw new PackageSkillsException( + $"Cannot choose skills interactively because {stale.Count} installed " + + $"{(stale.Count == 1 ? "skill doesn't" : "skills don't")} match the target: " + + $"{string.Join(", ", stale.Select(entry => $"{entry.Skill} ({entry.Package} {entry.Version})"))}. " + + $"Run '{UninstallCommand(request, "--stale", withTarget: true)}' first, and then try again. " + + "No skills were changed."); + } + + private static void RequireNoOtherInstalledVersion( + InstallRequest request, + InstallResult discovered, + IReadOnlyCollection installed) + { + var conflicts = discovered.ResolvedPackages + .Select(package => ( + package.Id, + Installed: installed.FirstOrDefault(entry => + entry.Package.Equals(package.Id, StringComparison.OrdinalIgnoreCase) && + !SkillInstaller.SameVersion(entry.Version, package.Version)))) + .Where(conflict => conflict.Installed is not null) + .ToList(); + + if (conflicts.Count == 0) + { + return; + } + + var command = conflicts.Count == 1 + ? $"'{UninstallCommand(request, $"--package {conflicts[0].Id}")}'" + : $"'{UninstallCommand(request, "--package ")}' for each of them"; + throw new PackageSkillsException( + $"{string.Join(" and ", conflicts.Select(conflict => $"{conflict.Id} {conflict.Installed!.Version}"))} " + + $"{(conflicts.Count == 1 ? "is" : "are")} already installed, and an interactive install only adds " + + $"skills, so it can't change a package's version. Run {command} first, and then try again. " + + "No skills were changed."); + } + + /// Skill folder names the manifest in already tracks. + public static IReadOnlySet InstalledSkillNames(string destination) => + InstalledSkills(destination, Directory.GetCurrentDirectory()) + .Select(entry => entry.Skill) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + /// + /// Everything the manifest tracks, in the order a list should show it. + /// + /// + /// This is what uninstall offers to choose from. It reads the manifest rather than the + /// folder, so skills the user wrote themselves are never on the list — the same reason + /// removal is manifest-driven in the first place. + /// + public static IReadOnlyList InstalledSkills(string destination, string workingDirectory) + { + var root = Path.GetFullPath(destination, workingDirectory); + using var destinationLock = DestinationLock.Acquire(root); + return + [ + .. InstallManifest.Load(root) + .EnumerateSkills() + .OrderBy(entry => entry.Skill, StringComparer.OrdinalIgnoreCase) + .ThenBy(entry => entry.Skill, StringComparer.Ordinal), + ]; + } + + /// + /// Removes skills this tool installed, optionally limited to one package, one exact + /// version, the names the caller chose, or the skills that are stale against a target. + /// + public IReadOnlyList Uninstall( + string destination, + string workingDirectory, + string? packageId, + string? packageVersion, + bool dryRun, + IReadOnlyCollection? only = null, + IReadOnlyCollection? expectedInstalled = null, + IReadOnlyCollection? staleAgainst = null) + { + var root = Path.GetFullPath(destination, workingDirectory); + return installer.Uninstall(root, packageId, packageVersion, dryRun, only, expectedInstalled, staleAgainst); + } + + /// + /// Finds the target and lists its direct package references with dotnet list package. + /// This is all uninstall --stale reads: deciding which skills are stale needs the + /// references, not the packages, so the tool never looks in the NuGet cache for them. + /// + public TargetReferences ReadReferences(string? target, string workingDirectory) + { + var resolved = TargetLocator.Resolve(target, workingDirectory); + return new TargetReferences(resolved, new PackageLister(dotnet).List(resolved)); + } + + private static SkippedSkill ToSkipped(BundledSkill skill, string reason) => + new( + skill.RelativePath, + skill.PackageId, + skill.PackageVersion, + skill.SkillName, + reason); + + /// + /// Spells an uninstall command that a report or an error suggests, with the target and the + /// destination this run used. + /// + /// Repeat --target, which only uninstall --stale accepts. + internal static string UninstallCommand(InstallRequest request, string arguments, bool withTarget = false) + { + // A suggestion is only useful if running it as printed acts on the same skills folder, + // compared against the same project. + var command = $"dotnet-package-skills uninstall {arguments}"; + + if (withTarget && request.Target is not null) + { + command += $" --target {CommandArgument(request.Target)}"; + } + + if (!IsDefaultDestination(request)) + { + command += $" --destination {CommandArgument(request.Destination)}"; + } + + return command; + } + + private static bool IsDefaultDestination(InstallRequest request) => + Path.TrimEndingDirectorySeparator(Path.GetFullPath(request.Destination, request.WorkingDirectory)).Equals( + Path.TrimEndingDirectorySeparator(Path.GetFullPath(InstallRequest.DefaultDestination, request.WorkingDirectory)), + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + /// + /// Quotes a value that a shell would otherwise split or reinterpret. Backslashes count: + /// bash treats them as escapes outside quotes, and every shell reads them literally inside. + /// + private static string CommandArgument(string value) => + value.Length > 0 && value.All(character => char.IsAsciiLetterOrDigit(character) || "._-/:+@".Contains(character)) + ? value + : $"\"{value}\""; +} + +/// A solution or project and the package versions it references directly. +public sealed record TargetReferences(string Target, IReadOnlyList Packages); diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/BundledSkill.cs b/dotnet-package-skills/src/DotnetPackageSkills/Skills/BundledSkill.cs new file mode 100644 index 0000000..0cdff99 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Skills/BundledSkill.cs @@ -0,0 +1,25 @@ +namespace DotnetPackageSkills.Skills; + +/// A skill found inside an extracted NuGet package. +/// Package id as reported by NuGet, in its original casing. +/// Resolved version as reported by NuGet. +/// Folder name of the skill inside the package's skills/ directory. +/// Absolute path to the skill folder in the global packages cache. +/// +/// Destination path relative to the skills root, always with forward slashes so the manifest is +/// stable across operating systems. +/// +public sealed record BundledSkill( + string PackageId, + string PackageVersion, + string SkillName, + string SourcePath, + string RelativePath); + +/// A package skill that was not copied because its destination path collided. +public sealed record SkippedSkill( + string RelativePath, + string PackageId, + string PackageVersion, + string SkillName, + string Reason); diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/DestinationLock.cs b/dotnet-package-skills/src/DotnetPackageSkills/Skills/DestinationLock.cs new file mode 100644 index 0000000..9f74773 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Skills/DestinationLock.cs @@ -0,0 +1,193 @@ +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace DotnetPackageSkills.Skills; + +/// Serializes cooperating tool processes from ownership checks through manifest persistence. +internal sealed class DestinationLock : IDisposable +{ + private readonly Mutex _mutex; + private bool _disposed; + + private DestinationLock(Mutex mutex) => _mutex = mutex; + + public static DestinationLock Acquire(string destination, TimeSpan? timeout = null) + { + var name = NameFor(destination); + var mutex = new Mutex(initiallyOwned: false, name); + var acquired = false; + try + { + try + { + acquired = mutex.WaitOne(timeout ?? TimeSpan.FromSeconds(30)); + } + catch (AbandonedMutexException error) + { + acquired = true; + throw new PackageSkillsException( + $"A previous operation on '{destination}' was interrupted. " + + "Check the destination and its manifest before trying again; no changes were made by this operation.", + error); + } + + if (!acquired) + { + throw new PackageSkillsException( + $"Another operation is using the skills destination '{destination}'. " + + "Wait for it to finish and try again. No skills were changed."); + } + + if (!name.Equals(NameFor(destination), StringComparison.Ordinal)) + { + throw new PackageSkillsException( + $"The skills destination '{destination}' changed while waiting for another operation. " + + "Run the command again to review its current location. No skills were changed."); + } + + return new DestinationLock(mutex); + } + catch + { + if (acquired) + { + mutex.ReleaseMutex(); + } + + mutex.Dispose(); + throw; + } + } + + internal static string NameFor(string destination) + { + var full = Path.TrimEndingDirectorySeparator(Path.GetFullPath(destination)); + if (OperatingSystem.IsWindows()) + { + return MutexName(CanonicalWindowsPath(full).ToUpperInvariant(), windows: true); + } + + var root = Path.GetPathRoot(full)!; + var canonical = root; + foreach (var part in full[root.Length..].Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries)) + { + canonical = Path.Combine(canonical, part); + var directory = new DirectoryInfo(canonical); + if (directory.Exists && directory.LinkTarget is not null) + { + canonical = directory.ResolveLinkTarget(returnFinalTarget: true)?.FullName + ?? throw new PackageSkillsException($"Could not resolve the skills destination '{destination}'."); + } + } + + canonical = Path.TrimEndingDirectorySeparator(canonical); + return MutexName(canonical, windows: false); + } + + private static string MutexName(string canonical, bool windows) + { + var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(canonical))); + return (windows ? @"Global\" : string.Empty) + "dotnet-package-skills-" + hash; + } + + [SupportedOSPlatform("windows")] + private static string CanonicalWindowsPath(string full) + { + var missing = new Stack(); + var existing = new DirectoryInfo(full); + while (!existing.Exists) + { + missing.Push(existing.Name); + existing = existing.Parent + ?? throw new PackageSkillsException($"Could not resolve the skills destination '{full}'."); + } + + // The OS resolves device prefixes, short names, drive mappings, and junctions to + // the same path. Resolve only the existing parent so previews create no directories. + var openPath = existing.FullName; + if (!openPath.StartsWith(@"\\?\", StringComparison.Ordinal) && + !openPath.StartsWith(@"\\.\", StringComparison.Ordinal)) + { + openPath = openPath.StartsWith(@"\\", StringComparison.Ordinal) + ? @"\\?\UNC\" + openPath[2..] + : @"\\?\" + openPath; + } + + using var handle = CreateFile( + openPath, 0, 7, nint.Zero, 3, 0x02000000, nint.Zero); + if (handle.IsInvalid) + { + throw PathError(full); + } + + var path = new StringBuilder(256); + var length = GetFinalPathNameByHandle(handle, path, (uint)path.Capacity, 0); + if (length == 0) + { + throw PathError(full); + } + + if (length >= path.Capacity) + { + path = new StringBuilder(checked((int)length + 1)); + length = GetFinalPathNameByHandle(handle, path, (uint)path.Capacity, 0); + if (length == 0 || length >= path.Capacity) + { + throw PathError(full); + } + } + + var canonical = path.ToString(); + if (canonical.StartsWith(@"\\?\UNC\", StringComparison.OrdinalIgnoreCase)) + { + canonical = @"\\" + canonical[8..]; + } + else if (canonical.StartsWith(@"\\?\", StringComparison.OrdinalIgnoreCase)) + { + canonical = canonical[4..]; + } + + foreach (var component in missing) + { + canonical = Path.Combine(canonical, component); + } + + return Path.TrimEndingDirectorySeparator(canonical); + } + + private static IOException PathError(string path) => new( + $"Could not resolve the skills destination '{path}'.", + new Win32Exception(Marshal.GetLastPInvokeError())); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string path, uint access, uint share, nint security, uint disposition, uint flags, nint template); + + [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle handle, StringBuilder path, uint size, uint flags); + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + try + { + _mutex.ReleaseMutex(); + } + finally + { + _mutex.Dispose(); + } + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/InstallManifest.cs b/dotnet-package-skills/src/DotnetPackageSkills/Skills/InstallManifest.cs new file mode 100644 index 0000000..5f25df8 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Skills/InstallManifest.cs @@ -0,0 +1,348 @@ +using System.Text; +using System.Text.Json; +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Skills; + +/// One installed skill with its owning package metadata. +public sealed record TrackedSkill(string Package, string Version, string Skill); + +/// The skills this tool installed from one package, and the one version they came from. +public sealed record ManifestPackage(string Version, IReadOnlyList Skills); + +/// +/// Record of what this tool put in the destination folder. +/// +/// +/// The manifest is what makes removal safe. Refreshing, removal and uninstall act only on skill +/// folder names recorded under their owning package, never on whatever happens to be in the +/// destination, so hand-authored skills living alongside package-provided ones are never at risk. +/// +/// The layout follows dotnet-tools.json: a format version, then a packages map +/// keyed by lowercase package id, each with the one version its skills came from. Repositories +/// commit this file, so the bytes are deterministic: sorted, normalized, and LF-only on every +/// operating system. +/// +public sealed class InstallManifest +{ + public const string FileName = ".dotnet-package-skills.json"; + + /// + /// The only format this build reads and writes. A newer tool that changes the format raises + /// this number, and this build then refuses the file rather than dropping what it can't read. + /// + public const int FormatVersion = 1; + + private SortedDictionary _packages = new(StringComparer.Ordinal); + + /// Tracked packages by lowercase id. + public IReadOnlyDictionary Packages => _packages; + + internal bool IsEmpty => _packages.Count == 0; + + /// Loads and validates the manifest without changing it. + /// + /// An unreadable manifest cannot safely mean "nothing is tracked." Doing that makes every + /// folder this tool installed look user-owned, so install refuses to update it and uninstall + /// refuses to remove it. Stop instead: ownership is unknown, and guessing could overwrite or + /// delete a hand-authored skill. + /// + public static InstallManifest Load(string destinationRoot) + { + var path = Path.Combine(destinationRoot, FileName); + + if (!File.Exists(path)) + { + return new InstallManifest(); + } + + string text; + try + { + text = File.ReadAllText(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + throw CannotRead(path, "the file could not be opened", ex); + } + + JsonDocument document; + try + { + document = JsonDocument.Parse(text); + } + catch (JsonException ex) + { + throw CannotRead(path, "it does not contain valid manifest JSON", ex); + } + + using (document) + { + return Read(document.RootElement, path); + } + } + + private static InstallManifest Read(JsonElement root, string path) + { + if (root.ValueKind != JsonValueKind.Object) + { + throw CannotRead(path, "it must contain a JSON object"); + } + + if (FindDuplicateProperty(root) is { } duplicate) + { + throw CannotRead(path, $"the property '{duplicate}' appears more than once"); + } + + if (!TryGetProperty(root, "version", out var formatVersion)) + { + throw TryGetProperty(root, "installed", out _) && !TryGetProperty(root, "packages", out _) + ? WrittenByPreRelease(path) + : CannotRead(path, "it has no format 'version'"); + } + + if (formatVersion.ValueKind != JsonValueKind.Number || !formatVersion.TryGetInt32(out var number)) + { + throw CannotRead(path, "its format 'version' must be a whole number"); + } + + if (number > FormatVersion) + { + throw WrittenByNewerTool(path, number); + } + + if (number < 1) + { + throw CannotRead(path, $"format version {number} is not supported"); + } + + if (!TryGetProperty(root, "packages", out var packages) || packages.ValueKind != JsonValueKind.Object) + { + throw CannotRead(path, "'packages' must be an object"); + } + + var manifest = new InstallManifest(); + var claimed = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var package in packages.EnumerateObject()) + { + var id = package.Name; + + if (!PackageCoordinate.IsValidId(id)) + { + throw CannotRead(path, $"'{id}' is not a valid package id"); + } + + if (package.Value.ValueKind != JsonValueKind.Object) + { + throw CannotRead(path, $"'packages.{id}' must be an object"); + } + + if (!TryGetProperty(package.Value, "version", out var version) || + version.ValueKind != JsonValueKind.String || + string.IsNullOrWhiteSpace(version.GetString())) + { + throw CannotRead(path, $"'packages.{id}.version' must be text"); + } + + if (!TryGetProperty(package.Value, "skills", out var skills) || skills.ValueKind != JsonValueKind.Array) + { + throw CannotRead(path, $"'packages.{id}.skills' must be an array"); + } + + var names = new List(); + foreach (var skill in skills.EnumerateArray()) + { + var name = skill.ValueKind == JsonValueKind.String ? skill.GetString() : null; + + if (name is null || !SkillDiscovery.IsSafeSkillName(name)) + { + throw CannotRead( + path, + $"'packages.{id}.skills[{names.Count}]' is not a safe skill folder name"); + } + + if (!claimed.Add(name)) + { + throw CannotRead(path, $"the skill folder '{name}' is claimed more than once"); + } + + names.Add(name); + } + + manifest._packages[id.ToLowerInvariant()] = new ManifestPackage(version.GetString()!, names); + } + + return manifest; + } + + /// Names are matched without regard to case, as they were in every earlier build. + private static bool TryGetProperty(JsonElement element, string name, out JsonElement value) + { + foreach (var property in element.EnumerateObject()) + { + if (property.Name.Equals(name, StringComparison.OrdinalIgnoreCase)) + { + value = property.Value; + return true; + } + } + + value = default; + return false; + } + + private static string? FindDuplicateProperty(JsonElement element) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + return property.Name; + } + + if (FindDuplicateProperty(property.Value) is { } nested) + { + return nested; + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (var item in element.EnumerateArray()) + { + if (FindDuplicateProperty(item) is { } nested) + { + return nested; + } + } + } + + return null; + } + + private static PackageSkillsException CannotRead( + string path, + string reason, + Exception? inner = null) => + new( + $"Could not read the install manifest '{path}' because {reason}. " + + "No skills were changed and the file was preserved. Resolve any merge conflict or " + + "restore the file, then try again. If it cannot be recovered, move the destination " + + "folder aside before reinstalling.", + inner); + + private static PackageSkillsException WrittenByNewerTool(string path, int formatVersion) => + new( + $"Could not read the install manifest '{path}' because it uses format version {formatVersion}, " + + $"and this version of dotnet-package-skills supports only version {FormatVersion}. " + + "No skills were changed and the file was preserved. Update dotnet-package-skills, and then try again."); + + private static PackageSkillsException WrittenByPreRelease(string path) => + new( + $"Could not read the install manifest '{path}' because it was written by a pre-release version " + + "of dotnet-package-skills. No skills were changed and the file was preserved. " + + "Move the skills folder aside, and then run install again."); + + public void Save(string destinationRoot) + { + Directory.CreateDirectory(destinationRoot); + + // Rewrite the existing file rather than replacing it, which keeps its permissions. + File.WriteAllText( + Path.Combine(destinationRoot, FileName), + Serialize(), + new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + } + + private string Serialize() + { + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions { Indented = true })) + { + writer.WriteStartObject(); + writer.WriteNumber("version", FormatVersion); + writer.WriteStartObject("packages"); + + foreach (var (id, package) in _packages) + { + writer.WriteStartObject(id); + writer.WriteString("version", package.Version); + writer.WriteStartArray("skills"); + + foreach (var skill in package.Skills) + { + writer.WriteStringValue(skill); + } + + writer.WriteEndArray(); + writer.WriteEndObject(); + } + + writer.WriteEndObject(); + writer.WriteEndObject(); + } + + // .NET 8 indents with the platform newline. Values are escaped, so every CRLF here is + // the writer's own, and replacing them makes a Windows file identical to a Linux one. + return Encoding.UTF8.GetString(buffer.ToArray()).Replace("\r\n", "\n") + "\n"; + } + + internal IEnumerable EnumerateSkills() => + _packages.SelectMany(package => + package.Value.Skills.Select(skill => new TrackedSkill(package.Key, package.Value.Version, skill))); + + /// Replaces everything tracked. Nothing is kept if the skills break a manifest rule. + internal void SetSkills(IEnumerable skills) + { + var next = new SortedDictionary(StringComparer.Ordinal); + + foreach (var group in skills.GroupBy(skill => skill.Package.ToLowerInvariant(), StringComparer.Ordinal)) + { + // The reader refuses an invalid id, so writing one would lock every later command + // out of the destination. + if (!PackageCoordinate.IsValidId(group.Key)) + { + throw new PackageSkillsException( + $"The install manifest can't record the package '{group.Key}' because it is not a valid package id. " + + "No skills were changed."); + } + + var versions = group + .Select(skill => PackagePathResolver.NormalizeVersion(skill.Version)) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (versions.Count > 1) + { + throw new PackageSkillsException( + $"The install manifest can record only one version of each package, but skills from " + + $"{group.Key} {string.Join(" and ", versions)} were about to be recorded. " + + "No skills were changed."); + } + + next[group.Key] = new ManifestPackage( + versions[0], + [ + .. group + .Select(skill => skill.Skill) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(skill => skill, StringComparer.Ordinal), + ]); + } + + _packages = next; + } + + public static void Delete(string destinationRoot) + { + var path = Path.Combine(destinationRoot, FileName); + if (File.Exists(path)) + { + File.Delete(path); + } + } +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDescriptionReader.cs b/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDescriptionReader.cs new file mode 100644 index 0000000..e093b72 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDescriptionReader.cs @@ -0,0 +1,241 @@ +using System.Text; +using SharpYaml; +using SharpYaml.Events; + +namespace DotnetPackageSkills.Skills; + +internal sealed record SkillDescriptionResult(string? Description, string? Warning); + +internal static class SkillDescriptionReader +{ + private const int MaxFrontmatterCharacters = 64 * 1024; + private const int MaxNestingDepth = 32; + + public static SkillDescriptionResult Read(string skillDirectory) + { + try + { + using var reader = new StreamReader(Path.Combine(skillDirectory, SkillDiscovery.SkillManifestFileName)); + var charactersRead = 0; + if (!ReadOpeningDelimiter(reader, ref charactersRead)) + { + return new(null, null); + } + + var frontmatter = new StringBuilder(); + while (ReadBoundedLine(reader, ref charactersRead) is { } line) + { + if (line.TrimEnd(' ', '\t') is "---" or "...") + { + return Parse(frontmatter.ToString()); + } + + frontmatter.Append(line).Append('\n'); + } + + return Warn("frontmatter has no closing delimiter; add a closing '---' line."); + } + catch (FileNotFoundException) + { + return Warn("was not found; restore the file to read its description."); + } + catch (DirectoryNotFoundException) + { + return Warn("was not found; restore the file to read its description."); + } + catch (UnauthorizedAccessException) + { + return Warn("could not be read; check file permissions."); + } + catch (IOException) + { + return Warn("could not be read; check the file path, permissions, and whether it is in use."); + } + catch (ArgumentException) + { + return Warn("has an invalid path; check the skill directory path."); + } + catch (YamlException) + { + return Warn("has malformed YAML frontmatter; fix the header."); + } + catch (FrontmatterException exception) + { + return Warn(exception.Message); + } + } + + private static bool ReadOpeningDelimiter(StreamReader reader, ref int charactersRead) + { + for (var index = 0; index < 3; index++) + { + if (ReadCharacter(reader, ref charactersRead) != '-') + { + return false; + } + } + + while (true) + { + switch (ReadCharacter(reader, ref charactersRead)) + { + case -1: + case '\n': + return true; + case '\r': + ReadLineFeed(reader, ref charactersRead); + return true; + case ' ': + case '\t': + break; + default: + return false; + } + } + } + + private static string? ReadBoundedLine(StreamReader reader, ref int charactersRead) + { + var line = new StringBuilder(); + while (true) + { + var character = ReadCharacter(reader, ref charactersRead); + switch (character) + { + case -1: + return line.Length == 0 ? null : line.ToString(); + case '\n': + return line.ToString(); + case '\r': + ReadLineFeed(reader, ref charactersRead); + return line.ToString(); + default: + line.Append((char)character); + break; + } + } + } + + private static void ReadLineFeed(StreamReader reader, ref int charactersRead) + { + if (reader.Peek() == '\n') + { + ReadCharacter(reader, ref charactersRead); + } + } + + private static int ReadCharacter(StreamReader reader, ref int charactersRead) + { + var character = reader.Read(); + if (character >= 0 && ++charactersRead > MaxFrontmatterCharacters) + { + throw new FrontmatterException("frontmatter exceeds 64 KiB of text; shorten the header."); + } + + return character; + } + + private static SkillDescriptionResult Parse(string frontmatter) + { + var parser = new EventReader(Parser.CreateParser(new StringReader(frontmatter))); + parser.Expect(); + if (parser.Allow() is not null) + { + return new(null, null); + } + + parser.Expect(); + var root = ReadNodeStart(parser); + if (root is not MappingStart) + { + throw new FrontmatterException("frontmatter must be a YAML mapping; use 'description: ...'."); + } + + string? description = null; + var foundDescription = false; + while (parser.Allow() is null) + { + var key = ReadNode(parser, 2); + var value = ReadNode(parser, 2); + if (key?.Value != "description") + { + continue; + } + + if (foundDescription) + { + throw new FrontmatterException("has duplicate description keys; keep only one top-level description."); + } + + foundDescription = true; + if (value is null) + { + throw new FrontmatterException("description must be a YAML scalar; replace the collection with text."); + } + + description = string.IsNullOrWhiteSpace(value.Value) ? null : value.Value; + } + + parser.Expect(); + parser.Expect(); + return new(description, null); + } + + private static Scalar? ReadNode(EventReader parser, int depth) + { + var node = ReadNodeStart(parser); + if (node is Scalar scalar) + { + return scalar; + } + + if (depth > MaxNestingDepth) + { + throw new FrontmatterException("frontmatter exceeds 32 levels of nesting; simplify the header."); + } + + if (node is MappingStart) + { + while (parser.Allow() is null) + { + ReadNode(parser, depth + 1); + ReadNode(parser, depth + 1); + } + } + else + { + while (parser.Allow() is null) + { + ReadNode(parser, depth + 1); + } + } + + return null; + } + + private static NodeEvent ReadNodeStart(EventReader parser) + { + if (parser.Accept()) + { + throw new FrontmatterException("uses YAML anchors or aliases; replace them with literal values."); + } + + var node = parser.Expect(); + if (!string.IsNullOrEmpty(node.Anchor)) + { + throw new FrontmatterException("uses YAML anchors or aliases; replace them with literal values."); + } + + if (!string.IsNullOrEmpty(node.Tag)) + { + throw new FrontmatterException("uses explicit YAML tags; remove the tags from its frontmatter."); + } + + return node; + } + + private static SkillDescriptionResult Warn(string reason) => + new(null, $"{SkillDiscovery.SkillManifestFileName} {reason}"); + + private sealed class FrontmatterException(string message) : Exception(message); +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDiscovery.cs b/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDiscovery.cs new file mode 100644 index 0000000..28b8edc --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDiscovery.cs @@ -0,0 +1,75 @@ +namespace DotnetPackageSkills.Skills; + +/// Finds skills that a package author bundled at skills/ in the package root. +public static class SkillDiscovery +{ + public const string SkillsFolderName = "skills"; + public const string SkillManifestFileName = "SKILL.md"; + + /// + /// Enumerates the skills inside an extracted package. + /// + /// + /// Each immediate subdirectory of skills/ that contains SKILL.md is one skill, + /// and the whole directory is copied as-is. Its authored folder name is also its destination + /// folder name. Nothing inside the skill is read or interpreted. + /// + public static IReadOnlyList Discover(string packageDirectory, string packageId, string packageVersion) + { + var skillsRoot = FindSkillsFolder(packageDirectory); + + if (skillsRoot is null) + { + return []; + } + + var candidates = Directory.EnumerateDirectories(skillsRoot) + .Select(directory => new { Directory = directory, Name = Path.GetFileName(directory) }) + .Where(candidate => + IsSafeSkillName(candidate.Name) && + File.Exists(Path.Combine(candidate.Directory, SkillManifestFileName))) + .OrderBy(candidate => candidate.Name, StringComparer.OrdinalIgnoreCase) + .ThenBy(candidate => candidate.Name, StringComparer.Ordinal) + .ToList(); + + return + [ + .. candidates.Select(candidate => new BundledSkill( + packageId, + packageVersion, + candidate.Name, + candidate.Directory, + candidate.Name)), + ]; + } + + /// + /// Finds the skills folder case-insensitively, because package contents are authored on + /// case-insensitive file systems as often as not. + /// + private static string? FindSkillsFolder(string packageDirectory) + { + if (!Directory.Exists(packageDirectory)) + { + return null; + } + + return Directory.EnumerateDirectories(packageDirectory) + .FirstOrDefault(directory => + string.Equals(Path.GetFileName(directory), SkillsFolderName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Rejects names that would write outside the destination or produce an unusable path. The + /// name comes from a third-party package, so it is untrusted input even though the file + /// system has already resolved it to a real directory. Reject trailing dots and spaces on + /// every platform: Windows normalizes them, and an all-dot name can resolve to the parent. + /// + internal static bool IsSafeSkillName(string name) => + !string.IsNullOrWhiteSpace(name) && + !name.EndsWith('.') && + !name.EndsWith(' ') && + name.IndexOfAny(Path.GetInvalidFileNameChars()) < 0 && + !name.Contains('/') && + !name.Contains('\\'); +} diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillInstaller.cs b/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillInstaller.cs new file mode 100644 index 0000000..1fbb374 --- /dev/null +++ b/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillInstaller.cs @@ -0,0 +1,460 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Skills; + +/// Outcome of an install. +public sealed record InstallOutcome( + IReadOnlyList Installed, + IReadOnlyList Removed, + IReadOnlyList Skipped) +{ + /// Tracked skills whose package this run did not offer. They were left exactly as they were. + public IReadOnlyList Untouched { get; init; } = []; +} + +/// Copies discovered skills into the destination and keeps the manifest in step. +public sealed class SkillInstaller +{ + /// + /// Copies every skill into , refreshing the ones this tool + /// already installed. + /// + /// + /// The version this run installs from for each package it covers, by package id. A tracked + /// skill is removed only when its package is offered at a different version that no longer + /// ships it, which is what lets an upgrade drop a skill instead of keeping a stale copy. + /// Tracked skills of packages that are not offered are left alone and reported as + /// untouched: a package leaving the project is not a reason to delete its skills here, and + /// that cleanup belongs to uninstall --stale. Pass an empty map to only add skills. + /// Null offers the packages of at their versions. + /// + /// + /// Spells the uninstall command that an error suggests, given its arguments, so it can name + /// the destination the caller was given. + /// + public InstallOutcome Install( + string destinationRoot, + IReadOnlyList skills, + bool dryRun, + IReadOnlyDictionary? offered = null, + IReadOnlyCollection? expectedInstalled = null, + Func? uninstallCommand = null) + { + // Package ids compare without regard to case, whatever comparer the caller's map uses: + // the manifest spells them in lowercase, and packages keep NuGet's casing. + var versions = offered is null + ? skills + .GroupBy(skill => skill.PackageId, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.First().PackageVersion, StringComparer.OrdinalIgnoreCase) + : new Dictionary(offered, StringComparer.OrdinalIgnoreCase); + + using var destinationLock = DestinationLock.Acquire(destinationRoot); + var manifest = InstallManifest.Load(destinationRoot); + var trackedSkills = manifest.EnumerateSkills().ToList(); + CheckOwnershipSnapshot(trackedSkills, expectedInstalled); + var (selected, duplicateSkips) = SelectUniqueDestinations(skills, trackedSkills); + var accepted = new List(); + var skipped = new List(duplicateSkips); + var protectedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var skill in selected) + { + var tracked = trackedSkills.FirstOrDefault(entry => + entry.Skill.Equals(skill.RelativePath, StringComparison.OrdinalIgnoreCase)); + var destination = ToAbsolute(destinationRoot, skill.RelativePath); + + if (File.Exists(destination)) + { + skipped.Add(ToSkipped(skill, "the destination path already exists as a file")); + if (tracked is not null) + { + protectedPaths.Add(tracked.Skill); + } + + continue; + } + + if (tracked is null && Directory.Exists(destination)) + { + skipped.Add(ToSkipped( + skill, + "the destination folder already exists and is not managed by this tool")); + continue; + } + + if (tracked is not null && !HasSameOwner(tracked, skill)) + { + skipped.Add(ToSkipped( + skill, + $"the destination folder is managed for {tracked.Package} {tracked.Version} " + + $"skill '{tracked.Skill}'; uninstall that skill before replacing its owner")); + protectedPaths.Add(tracked.Skill); + continue; + } + + accepted.Add(skill); + } + + // A package moving to a version without one of its skills normally loses that skill. + // When another package in this run ships a skill of the same name, removing it would + // hand the name to that package, which takes an explicit uninstall. Keeping it would + // record the old version's copy under the new version, where no later run removes it. + var stranded = trackedSkills + .Where(entry => protectedPaths.Contains(entry.Skill)) + .Where(entry => versions.TryGetValue(entry.Package, out var version) && !SameVersion(version, entry.Version)) + .Where(entry => !skills.Any(skill => HasSameOwner(entry, skill))) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + + if (stranded.Count > 0) + { + throw NameWouldChangeOwner( + stranded, + versions, + selected, + uninstallCommand ?? (arguments => $"dotnet-package-skills uninstall {arguments}")); + } + + var current = accepted.Select(skill => skill.RelativePath).ToHashSet(StringComparer.OrdinalIgnoreCase); + + // A skill involved in a conflict is never removed by the same run. + var removed = trackedSkills + .Where(entry => !current.Contains(entry.Skill) && !protectedPaths.Contains(entry.Skill)) + .Where(entry => versions.TryGetValue(entry.Package, out var version) && !SameVersion(version, entry.Version)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + var removedPaths = removed.Select(entry => ToAbsolute(destinationRoot, entry.Skill)).ToList(); + var untouched = trackedSkills + .Where(entry => !versions.ContainsKey(entry.Package)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + + foreach (var skill in accepted) + { + if (!Directory.Exists(skill.SourcePath) || + !File.Exists(Path.Combine(skill.SourcePath, SkillDiscovery.SkillManifestFileName))) + { + throw new PackageSkillsException( + $"The source for skill '{skill.SkillName}' is no longer available at '{skill.SourcePath}'. " + + "Restore its package and run the command again. No skills were changed."); + } + } + + // What stays tracked keeps its entry, moved to the offered version when its package has + // one: the manifest records a single version per package, the one this run installed. + var kept = trackedSkills + .Where(entry => !current.Contains(entry.Skill) && !removed.Contains(entry)) + .Select(entry => versions.TryGetValue(entry.Package, out var version) ? entry with { Version = version } : entry); + var installed = accepted.Select(skill => + new TrackedSkill(skill.PackageId, skill.PackageVersion, skill.SkillName)); + + // Build the new ownership record before touching any file, so a record that breaks a + // manifest rule stops the operation while the destination is still unchanged. + manifest.SetSkills(kept.Concat(installed)); + + var outcome = new InstallOutcome(accepted, removed, skipped) { Untouched = untouched }; + + if (dryRun) + { + return outcome; + } + + foreach (var path in removedPaths) + { + RemoveSkillDirectory(path); + } + + foreach (var skill in accepted) + { + CopyDirectory(skill.SourcePath, ToAbsolute(destinationRoot, skill.RelativePath)); + } + + if (manifest.IsEmpty) + { + // Nothing is tracked, so there is nothing for the manifest to be the source of truth + // about. Match uninstall rather than leaving an empty manifest, and a destination + // folder, that the user never asked for. The folder only goes if it is empty, so + // skills they wrote themselves keep it alive. + InstallManifest.Delete(destinationRoot); + TryRemoveEmptyDirectory(destinationRoot); + } + else + { + manifest.Save(destinationRoot); + } + + return outcome; + } + + internal static bool SameVersion(string left, string right) => + PackagePathResolver.NormalizeVersion(left).Equals(PackagePathResolver.NormalizeVersion(right), StringComparison.Ordinal); + + private static PackageSkillsException NameWouldChangeOwner( + IReadOnlyList stranded, + IReadOnlyDictionary versions, + IReadOnlyList selected, + Func uninstallCommand) + { + // The offered map keeps NuGet's casing, which reads better than the manifest's. + string OwnerId(TrackedSkill entry) => + versions.Keys.First(id => id.Equals(entry.Package, StringComparison.OrdinalIgnoreCase)); + + var reasons = stranded.Select(entry => + { + var other = selected.FirstOrDefault(skill => + skill.RelativePath.Equals(entry.Skill, StringComparison.OrdinalIgnoreCase)); + return $"{OwnerId(entry)} {versions[entry.Package]} no longer ships the installed skill '{entry.Skill}', " + + $"and {(other is null ? "another package" : $"{other.PackageId} {other.PackageVersion}")} " + + "ships a skill with that name"; + }); + var owners = stranded.Select(OwnerId).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var command = owners.Count == 1 + ? $"'{uninstallCommand($"--package {owners[0]}")}'" + : $"'{uninstallCommand("--package ")}' for each of {string.Join(", ", owners)}"; + + return new PackageSkillsException( + $"Cannot install skills because {string.Join("; ", reasons)}. The tool doesn't hand an installed " + + "skill to another package, and the manifest records one version per package, so it can't keep the " + + $"older copy either. Run {command} first, and then try again. No skills were changed."); + } + + /// + /// A tracked skill is stale when the target references no package at its installed version: + /// the package left the project, or the project now uses another version of it. + /// + internal static bool IsStale(TrackedSkill entry, IEnumerable referenced) => + !referenced.Any(package => + package.Id.Equals(entry.Package, StringComparison.OrdinalIgnoreCase) && + SameVersion(package.Version, entry.Version)); + + /// + /// Removes skills this tool installed, narrowed to one package, one exact version of it, + /// an explicit set of skill names, or the skills that are stale against a target. + /// + /// + /// Skill folder names to remove. Null removes everything the other filters match, which is + /// what an unattended uninstall does; a set is what the interactive picker returns. + /// + /// + /// A target's direct package references. When given, only skills whose installed version + /// the target does not reference are removed. + /// + public IReadOnlyList Uninstall( + string destinationRoot, + string? packageId, + string? packageVersion, + bool dryRun, + IReadOnlyCollection? only = null, + IReadOnlyCollection? expectedInstalled = null, + IReadOnlyCollection? staleAgainst = null) + { + using var destinationLock = DestinationLock.Acquire(destinationRoot); + var manifest = InstallManifest.Load(destinationRoot); + + var chosen = only is null + ? null + : new HashSet(only, StringComparer.OrdinalIgnoreCase); + + var trackedSkills = manifest.EnumerateSkills().ToList(); + CheckOwnershipSnapshot(trackedSkills, expectedInstalled); + var targeted = trackedSkills + .Where(entry => Matches(entry, packageId, packageVersion)) + .Where(entry => staleAgainst is null || IsStale(entry, staleAgainst)) + .Where(entry => chosen is null || chosen.Contains(entry.Skill)) + .OrderBy(entry => entry.Skill, StringComparer.Ordinal) + .ToList(); + var targetedPaths = targeted.Select(entry => ToAbsolute(destinationRoot, entry.Skill)).ToList(); + + if (targeted.Count == 0 || dryRun) + { + return targeted; + } + + manifest.SetSkills(trackedSkills.Except(targeted)); + + foreach (var path in targetedPaths) + { + RemoveSkillDirectory(path); + } + + if (manifest.IsEmpty) + { + InstallManifest.Delete(destinationRoot); + TryRemoveEmptyDirectory(destinationRoot); + } + else + { + manifest.Save(destinationRoot); + } + + // Report everything targeted, including entries whose folder a user had already + // deleted by hand: they are gone either way, and the manifest no longer claims them. + return targeted; + } + + internal static bool Matches(TrackedSkill entry, string? packageId, string? packageVersion) + { + if (packageId is not null && !entry.Package.Equals(packageId, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Compare normalized, so 1.2 and 1.2.0 identify the same installed folder. + return packageVersion is null || + PackagePathResolver.NormalizeVersion(entry.Version) + .Equals(PackagePathResolver.NormalizeVersion(packageVersion), StringComparison.OrdinalIgnoreCase); + } + + private static void CheckOwnershipSnapshot( + IReadOnlyCollection installed, + IReadOnlyCollection? expected) + { + if (expected is not null && !expected.ToHashSet().SetEquals(installed)) + { + throw new PackageSkillsException( + "Installed skill ownership changed while the picker was open. " + + "No skills were changed by this operation. Run the command again to review the current state."); + } + } + + private static string ToAbsolute(string destinationRoot, string relativePath) + { + if (!SkillDiscovery.IsSafeSkillName(relativePath)) + { + throw UnsafeSkillPath(destinationRoot, relativePath); + } + + var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(destinationRoot)); + var absolute = Path.GetFullPath(Path.Combine(root, relativePath)); + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + if (!string.Equals(Path.GetDirectoryName(absolute), root, comparison)) + { + throw UnsafeSkillPath(destinationRoot, relativePath); + } + + return absolute; + } + + private static PackageSkillsException UnsafeSkillPath(string destinationRoot, string relativePath) => + new( + $"The path '{relativePath}' is not a safe skill folder directly inside '{destinationRoot}'. " + + "Restore the package or repair the install manifest before retrying. No skills were changed."); + + private static void RemoveSkillDirectory(string absolute) + { + if (Directory.Exists(absolute)) + { + Directory.Delete(absolute, recursive: true); + } + } + + private static bool TryRemoveEmptyDirectory(string directory) + { + if (!Directory.Exists(directory) || Directory.EnumerateFileSystemEntries(directory).Any()) + { + return false; + } + + try + { + Directory.Delete(directory); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + + /// + /// Replaces the destination with a fresh copy of the source. + /// + /// + /// This copies rather than moves, and that is deliberate: the global packages folder is + /// NuGet's content-addressable cache. It is validated during restore and shared by every + /// project on the machine, so moving files out of it can make restore treat the cached + /// package as corrupt and strips the skill from every other repository using it. + /// + private static void CopyDirectory(string source, string destination) + { + if (Directory.Exists(destination)) + { + // Delete first so files removed in a newer package version do not survive. + Directory.Delete(destination, recursive: true); + } + + Directory.CreateDirectory(destination); + + foreach (var directory in Directory.EnumerateDirectories(source, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(Path.Combine(destination, Path.GetRelativePath(source, directory))); + } + + foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) + { + var target = Path.Combine(destination, Path.GetRelativePath(source, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target, overwrite: true); + + // Files in the global packages folder are marked read-only by restore. Copying + // carries that attribute over, which would make the next install fail to overwrite. + ClearReadOnly(target); + } + } + + private static void ClearReadOnly(string path) + { + var attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly); + } + } + + private static (List Selected, List Skipped) SelectUniqueDestinations( + IReadOnlyList skills, + IReadOnlyList installed) + { + var selected = new List(); + var skipped = new List(); + foreach (var group in skills.GroupBy(skill => skill.RelativePath, StringComparer.OrdinalIgnoreCase)) + { + var candidates = group.ToList(); + var owner = installed.FirstOrDefault(entry => entry.Skill.Equals(group.Key, StringComparison.OrdinalIgnoreCase)); + var retainedIndex = owner is null ? 0 : candidates.FindIndex(skill => HasSameOwner(owner, skill)); + retainedIndex = Math.Max(0, retainedIndex); + var retained = candidates[retainedIndex]; + selected.Add(retained); + for (var index = 0; index < candidates.Count; index++) + { + if (index == retainedIndex) + { + continue; + } + + skipped.Add(ToSkipped( + candidates[index], + $"conflicts with {retained.PackageId} {retained.PackageVersion} skill " + + $"'{retained.SkillName}', " + + (owner is not null && HasSameOwner(owner, retained) + ? "which belongs to the current owner" + : "which was selected first"))); + } + } + + return (selected, skipped); + } + + internal static bool HasSameOwner(TrackedSkill entry, BundledSkill skill) => + entry.Package.Equals(skill.PackageId, StringComparison.OrdinalIgnoreCase) && + entry.Skill.Equals(skill.SkillName, StringComparison.OrdinalIgnoreCase); + + private static SkippedSkill ToSkipped(BundledSkill skill, string reason) => + new( + skill.RelativePath, + skill.PackageId, + skill.PackageVersion, + skill.SkillName, + reason); +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineDiagnosticsTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineDiagnosticsTests.cs new file mode 100644 index 0000000..31e4af7 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineDiagnosticsTests.cs @@ -0,0 +1,179 @@ +using System.CommandLine; +using System.Text; +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +public class CommandLineDiagnosticsTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Diagnostic_write_overloads_share_one_buffer_until_invocation_finishes(bool standardError) + { + using var destination = new StringWriter(); + using var other = new StringWriter(); + var root = new RootCommand(); + root.SetAction(result => + { + TextWriter writer = standardError + ? result.InvocationConfiguration.Error + : result.InvocationConfiguration.Output; + writer.Write(" first\u001b"); + writer.Flush(); + writer.Write(']'); + writer.Write("52;c;".ToCharArray()); + writer.Write("SECRET".AsSpan()); + writer.WriteAsync("\a").GetAwaiter().GetResult(); + writer.Write('\ud83d'); + writer.FlushAsync().GetAwaiter().GetResult(); + Assert.Empty(destination.ToString()); + writer.Write('\udc69'); + writer.Write(new StringBuilder("🏽‍💻 e\u0301界")); + writer.Write('\r'); + writer.Flush(); + writer.Write('\n'); + writer.WriteLine(" next "); + return 23; + }); + + var exitCode = CommandLineDiagnostics.Invoke( + root.Parse([]), standardError ? other : destination, standardError ? destination : other); + + Assert.Equal(23, exitCode); + Assert.Equal( + $" first👩🏽‍💻 e\u0301界{Environment.NewLine} next {Environment.NewLine}", + destination.ToString()); + Assert.Empty(other.ToString()); + } + + [Theory] + [InlineData("before\u001b[31mRED\u001b[0mafter", "beforeREDafter")] + [InlineData("before\u001b]52;c;SECRET\u001b\\after", "beforeafter")] + [InlineData("before\u001b]52;c;\r\nSECRET\aafter", "beforeafter")] + [InlineData("before\u009d52;c;SECRET\u009cafter", "beforeafter")] + [InlineData("before\u001bPSECRET\u001b\\after", "beforeafter")] + [InlineData("before\u001b(0after", "beforeafter")] + [InlineData("before\u001b]8;;https://example.invalid\a链接\u001b]8;;\aafter", "before链接after")] + [InlineData("👩🏽‍💻 e\u0301 中文 🇨🇦", "👩🏽‍💻 e\u0301 中文 🇨🇦")] + [InlineData(" \ue000first\r\n next\ue000 \r\n", " \ue000first\n next\ue000 \n")] + public void Every_write_boundary_preserves_text_without_leaking_escape_payloads(string text, string expected) + { + for (var split = 0; split <= text.Length; split++) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var root = new RootCommand(); + root.SetAction(result => + { + var writer = result.InvocationConfiguration.Error; + writer.Write(text.AsSpan(0, split)); + writer.Flush(); + writer.Write(text.ToCharArray(), split, text.Length - split); + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([]), output, error)); + Assert.Equal(expected.Replace("\n", Environment.NewLine, StringComparison.Ordinal), error.ToString()); + Assert.Empty(output.ToString()); + } + } + + [Theory] + [InlineData("before\ue000\u001b", "before\ue000")] + [InlineData("before\ue000\u001b[", "before\ue000")] + [InlineData("before\ue000\u001b]unterminated", "before\ue000")] + [InlineData("before\ue000\u009dunterminated", "before\ue000")] + [InlineData("before\ue000\u001b]unterminated\r\n", "before\ue000\n")] + [InlineData("before\ue000\u001b(", "before\ue000")] + [InlineData(" before \u001b]unterminated\r\n", " before \n")] + [InlineData("\u001b]unterminated\r\n", "\n")] + [InlineData("before\r\n\u001b]unterminated\r\n", "before\n")] + [InlineData("before\u001b[\r\n", "before\n")] + public void Unfinished_controls_preserve_valid_text_and_the_final_line_break(string text, string expected) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var root = new RootCommand(); + root.SetAction(result => + { + foreach (var character in text) + { + result.InvocationConfiguration.Error.Write(character); + } + + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([]), output, error)); + Assert.Equal(expected.Replace("\n", Environment.NewLine, StringComparison.Ordinal), error.ToString()); + } + + [Theory] + [InlineData("\n")] + [InlineData("\r\n")] + public void Diagnostic_capture_preserves_the_callers_line_endings_and_leaves_writers_open(string newLine) + { + using var output = new StringWriter() { NewLine = newLine }; + using var error = new StringWriter() { NewLine = newLine }; + var root = new RootCommand(); + root.SetAction(result => + { + result.InvocationConfiguration.Output.WriteLine(" output"); + result.InvocationConfiguration.Output.WriteLine(); + result.InvocationConfiguration.Error.WriteLine(" error"); + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([]), output, error)); + output.Write("still open"); + error.Write("still open"); + + Assert.Equal($" output{newLine}{newLine}still open", output.ToString()); + Assert.Equal($" error{newLine}still open", error.ToString()); + } + + [Fact] + public void Default_framework_exception_diagnostics_are_sanitized_too() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + var root = new RootCommand(); + root.SetAction((Func)(_ => + throw new InvalidOperationException("first\u001b]52;c;SECRET\a\nsecond"))); + + var exitCode = CommandLineDiagnostics.Invoke(root.Parse([]), output, error); + + Assert.Equal(1, exitCode); + Assert.Contains($"first{Environment.NewLine}second", error.ToString()); + Assert.DoesNotContain('\u001b', error.ToString()); + Assert.DoesNotContain('\a', error.ToString()); + Assert.DoesNotContain("SECRET", error.ToString()); + } + + [Fact] + public void Diagnostic_capture_does_not_change_canonical_values_or_global_console_streams() + { + const string Value = "original\u001b[31mvalue\u001b[0m"; + using var output = new StringWriter(); + using var error = new StringWriter(); + string? received = null; + var consoleOutput = Console.Out; + var consoleError = Console.Error; + var argument = new Argument("value"); + var root = new RootCommand { argument }; + root.SetAction(result => + { + Assert.Same(consoleOutput, Console.Out); + Assert.Same(consoleError, Console.Error); + received = result.GetValue(argument); + return 0; + }); + + Assert.Equal(0, CommandLineDiagnostics.Invoke(root.Parse([Value]), output, error)); + + Assert.Equal(Value, received); + Assert.Empty(output.ToString()); + Assert.Empty(error.ToString()); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineTests.cs new file mode 100644 index 0000000..768fbc9 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineTests.cs @@ -0,0 +1,363 @@ +using System.CommandLine; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class CommandLineTests +{ + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t\r\n")] + public void A_supplied_blank_uninstall_filter_is_rejected(string filter) + { + var error = Assert.Throws(() => CommandLineBuilder.ParseUninstallFilter(filter)); + + Assert.Contains("non-empty package ID", error.Message); + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", "--package", filter]).Errors); + } + + [Theory] + [InlineData("--package")] + [InlineData("-p")] + public void An_uninstall_package_option_without_a_value_is_rejected(string option) + { + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", option]).Errors); + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", option, "--dry-run"]).Errors); + } + + [Fact] + public void Only_an_absent_uninstall_filter_means_all_packages() + { + Assert.Equal((null, null), CommandLineBuilder.ParseUninstallFilter(null)); + Assert.Equal(("Mockly", null), CommandLineBuilder.ParseUninstallFilter(" Mockly ")); + } + + [Theory] + [InlineData("--package", "only once")] + [InlineData("-p", "only once")] + [InlineData("--package=", "only once")] + [InlineData("--package=Alpha", "expects a single argument")] + public void Repeated_uninstall_filters_are_rejected_even_when_the_last_value_is_missing( + string repeated, string message) + { + var result = CommandLineBuilder.Build().Parse( + ["uninstall", "--dry-run", "--package", "Alpha", repeated]); + + Assert.Contains(result.Errors, error => error.Message.Contains(message, StringComparison.Ordinal)); + } + + [Theory] + [InlineData("_Acme")] + [InlineData("Acme_")] + [InlineData("_")] + public void Uninstall_accepts_valid_underscore_boundary_package_ids(string id) + { + Assert.Equal((id, null), CommandLineBuilder.ParseUninstallFilter(id)); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--package", id, "--dry-run"]).Errors); + } + + [Theory] + [InlineData("1.2", "1.2.0")] + [InlineData("1.2.0.0", "1.2.0")] + [InlineData("1.2.0-RC.1", "1.2.0-rc.1")] + public void Uninstall_filter_matching_normalizes_versions_for_both_modes(string filterVersion, string installedVersion) + { + var (id, version) = CommandLineBuilder.ParseUninstallFilter($"mockly@{filterVersion}"); + + Assert.True(SkillInstaller.Matches(new TrackedSkill("Mockly", installedVersion, "usage"), id, version)); + Assert.False(SkillInstaller.Matches(new TrackedSkill("Other", installedVersion, "usage"), id, version)); + } + + [Fact] + public void Uninstall_accepts_the_interactive_flag() + { + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "-i"]).Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--interactive"]).Errors); + } + + [Fact] + public void Uninstall_stale_accepts_a_target_and_every_other_uninstall_option_but_a_package() + { + Assert.Empty(CommandLineBuilder.Build().Parse( + ["uninstall", "--stale", "--target", "App.sln", "--dry-run", "-i", "-d", ".claude/skills"]) + .Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--stale", "-t", "src"]).Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "--stale"]).Errors); + } + + [Fact] + public void Uninstall_stale_cannot_be_combined_with_a_package_filter() + { + var result = CommandLineBuilder.Build().Parse(["uninstall", "--stale", "--package", "Mockly"]); + + Assert.Contains(result.Errors, error => error.Message.Contains("--stale and --package cannot be combined")); + } + + [Theory] + [InlineData("--target")] + [InlineData("-t")] + public void Uninstall_target_needs_stale(string option) + { + var result = CommandLineBuilder.Build().Parse(["uninstall", option, "App.sln"]); + + Assert.Contains(result.Errors, error => + error.Message.Contains("--target can be used with uninstall only together with --stale", StringComparison.Ordinal)); + } + + [Theory] + [InlineData("install", null)] + [InlineData("list", null)] + [InlineData("uninstall", "--stale")] + public void No_command_offers_no_restore(string command, string? extra) + { + // Restoring is left to dotnet list package and to the customer, so there is nothing to + // turn off here. + Assert.DoesNotContain( + CommandLineBuilder.Build().Subcommands.Single(candidate => candidate.Name == command).Options, + option => option.Name == "--no-restore"); + string[] args = extra is null ? [command, "--no-restore"] : [command, extra, "--no-restore"]; + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(args, output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unrecognized command or argument '--no-restore'", error.ToString()); + } + + [Theory] + [InlineData("install")] + [InlineData("list")] + public void Only_uninstall_has_the_stale_option(string command) + { + Assert.NotEmpty(CommandLineBuilder.Build().Parse([command, "--stale"]).Errors); + } + + [Fact] + public void The_interactive_install_help_says_installed_skills_are_not_listed() + { + var install = CommandLineBuilder.Build().Subcommands.Single(command => command.Name == "install"); + + var interactive = install.Options.Single(option => option.Name == "--interactive"); + + Assert.Contains("aren't installed", interactive.Description); + Assert.DoesNotContain("remove", interactive.Description, StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("install")] + [InlineData("list")] + [InlineData("uninstall")] + public void No_command_offers_json_output(string command) + { + // Reports are for people. The manifest is the only machine-readable output. + Assert.DoesNotContain( + CommandLineBuilder.Build().Subcommands.Single(candidate => candidate.Name == command).Options, + option => option.Name == "--json"); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke([command, "--json"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unrecognized command or argument '--json'", error.ToString()); + } + + [Theory] + [InlineData("install")] + [InlineData("list")] + public void An_unknown_option_after_package_values_is_reported_as_unrecognized(string command) + { + // --package takes several values, so the parser hands it a trailing unknown option, such + // as a --json left in an old script, as one more value. + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke( + [command, "--package", "Mockly@1.10.0", "--json"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Unrecognized command or argument '--json'", error.ToString()); + Assert.DoesNotContain("missing a version", error.ToString()); + } + + [Fact] + public void Uninstall_says_it_removes_from_the_destination_rather_than_copying_into_it() + { + var uninstall = CommandLineBuilder.Build() + .Subcommands.Single(command => command.Name == "uninstall"); + + var destination = uninstall.Options.Single(option => option.Name == "--destination"); + + // The option is shared-looking but not shared: install's wording is about copying in, + // which reads as nonsense on a command that only deletes. + Assert.Contains("remove skills from", destination.Description, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("copy", destination.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Uninstall_still_accepts_a_destination() + { + // Skills installed anywhere but the default are unreachable without it. + Assert.Empty(CommandLineBuilder.Build().Parse(["uninstall", "-d", ".claude/skills"]).Errors); + } + + [Fact] + public void Install_still_says_it_copies_into_the_destination() + { + var install = CommandLineBuilder.Build() + .Subcommands.Single(command => command.Name == "install"); + + var destination = install.Options.Single(option => option.Name == "--destination"); + + Assert.Contains("copy skills into", destination.Description, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void The_removed_sync_verb_is_rejected() + { + // Renamed to install, which pairs with uninstall. A clean break at 0.1.0 rather than + // an alias, so nothing has to carry the old name forward. + var result = CommandLineBuilder.Build().Parse(["sync"]); + + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Install_rejects_the_removed_include_transitive_option() + { + var result = CommandLineBuilder.Build().Parse(["install", "--include-transitive"]); + + Assert.NotEmpty(result.Errors); + } + + [Fact] + public void Install_accepts_the_short_interactive_alias() + { + Assert.Empty(CommandLineBuilder.Build().Parse(["install", "-i"]).Errors); + } + + [Fact] + public void Install_accepts_interactive_alongside_a_named_package() + { + // One package can ship a dozen skills, so choosing among them is exactly the case + // --package plus --interactive exists for. + Assert.Empty(CommandLineBuilder.Build().Parse(["install", "--package", "Mockly@1.10.0", "-i"]).Errors); + Assert.Empty(CommandLineBuilder.Build().Parse(["install", "-i", "--package", "Mockly@1.10.0"]).Errors); + } + + [Fact] + public void List_does_not_offer_interactive_selection() + { + // list writes nothing, so there is nothing to choose between. + Assert.NotEmpty(CommandLineBuilder.Build().Parse(["list", "--interactive"]).Errors); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Invalid_uninstall_filters_are_rejected_without_printing_terminal_controls(bool interactive) + { + const string Filter = "Safe\u001b]52;c;SECRET\aPackage"; + string[] args = interactive + ? ["uninstall", "--interactive", "--package", Filter] + : ["uninstall", "--package", Filter]; + var parsed = CommandLineBuilder.Build().Parse(args); + Assert.Contains(parsed.Errors, error => error.Message.Contains(Filter, StringComparison.Ordinal)); + Assert.Equal(Filter, parsed.GetValue("--package")); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(args, output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("'SafePackage' is not a valid package id.", error.ToString()); + Assert.DoesNotContain('\u001b', error.ToString()); + Assert.DoesNotContain('\a', error.ToString()); + Assert.DoesNotContain("SECRET", error.ToString()); + Assert.Contains("Usage:", output.ToString()); + } + + [Fact] + public void Uninstall_validation_keeps_multiline_guidance_readable() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke( + ["uninstall", "--package", "Mockly@1.*\u001b]52;c;SECRET\a"], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains( + "'1.*' is a floating version or a version range, and this tool needs an exact version.\n" + + "Write it out, for example --package Mockly@1.10.0.\n" + + "To let restore choose the version, point at a project or solution with --target instead.", + error.ToString().ReplaceLineEndings("\n")); + Assert.DoesNotContain('\u001b', error.ToString()); + Assert.DoesNotContain("SECRET", error.ToString()); + } + + [Theory] + [InlineData("--unknown\u001b]52;c;SECRET\a")] + [InlineData("--unknown\u009d52;c;SECRET\u009c")] + [InlineData("--dry-run=false\u001b]52;c;SECRET\u001b\\")] + public void Framework_argument_errors_do_not_emit_terminal_controls(string token) + { + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(["uninstall", token], output, error); + + Assert.Equal(1, exitCode); + Assert.NotEmpty(error.ToString()); + Assert.DoesNotContain("SECRET", output.ToString() + error.ToString()); + Assert.DoesNotContain(output.ToString() + error.ToString(), + character => char.IsControl(character) && character is not ('\r' or '\n')); + } + + [Fact] + public void Framework_typo_suggestions_are_sanitized_on_standard_output_too() + { + const string Token = "uninstal\u001b"; + var parsed = CommandLineBuilder.Build().Parse([Token]); + Assert.Contains(Token, parsed.UnmatchedTokens); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke([Token], output, error); + + Assert.Equal(1, exitCode); + Assert.Contains("Did you mean", output.ToString()); + Assert.Contains("uninstall", output.ToString()); + Assert.Contains("Unrecognized", error.ToString()); + Assert.DoesNotContain('\u001b', output.ToString() + error.ToString()); + } + + [Theory] + [InlineData("--help", null)] + [InlineData("--version", null)] + [InlineData("uninstall", "--help")] + [InlineData("uninstall", "--missing")] + [InlineData("uninstal", null)] + public void Ordinary_framework_output_and_exit_codes_are_unchanged(string first, string? second) + { + string[] args = second is null ? [first] : [first, second]; + using var expectedOutput = new StringWriter(); + using var expectedError = new StringWriter(); + var expectedExitCode = CommandLineBuilder.Build().Parse(args).Invoke(new InvocationConfiguration + { + Output = expectedOutput, + Error = expectedError, + }); + using var output = new StringWriter(); + using var error = new StringWriter(); + + var exitCode = CommandLineBuilder.Invoke(args, output, error); + + Assert.Equal(expectedExitCode, exitCode); + Assert.Equal(expectedOutput.ToString(), output.ToString()); + Assert.Equal(expectedError.ToString(), error.ToString()); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/DestinationLockTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/DestinationLockTests.cs new file mode 100644 index 0000000..cfbdc9b --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/DestinationLockTests.cs @@ -0,0 +1,151 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class DestinationLockTests +{ + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Install_and_uninstall_wait_for_the_destination_owner_to_finish(bool uninstall) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var package = temp.CreatePackageWithSkill("Alpha", "1.0.0", "shared"); + var skill = new BundledSkill("Alpha", "1.0.0", "shared", Path.Combine(package, "skills", "shared"), "shared"); + var installer = new SkillInstaller(); + installer.Install(destination, [skill], dryRun: false); + var before = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + using var attempting = new ManualResetEventSlim(); + using var finished = new ManualResetEventSlim(); + using var held = DestinationLock.Acquire(destination); + Exception? failure = null; + var operation = new Thread(() => + { + try + { + attempting.Set(); + if (uninstall) + { + installer.Uninstall(destination, null, null, dryRun: false); + } + else + { + // A version without the skill removes it, which empties the destination. + installer.Install( + destination, + [], + dryRun: false, + offered: new Dictionary { ["Alpha"] = "2.0.0" }); + } + } + catch (Exception error) + { + failure = error; + } + finally + { + finished.Set(); + } + }) { IsBackground = true }; + operation.Start(); + + try + { + Assert.True(attempting.Wait(TimeSpan.FromSeconds(5))); + Assert.False(finished.Wait(TimeSpan.FromMilliseconds(100))); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + } + finally + { + held.Dispose(); + Assert.True(operation.Join(TimeSpan.FromSeconds(5))); + } + + Assert.Null(failure); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void A_busy_destination_returns_an_actionable_error_without_creating_files() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + using var held = DestinationLock.Acquire(destination); + Exception? observed = null; + var operation = new Thread(() => + { + try + { + using var competing = DestinationLock.Acquire(destination, TimeSpan.Zero); + } + catch (Exception error) + { + observed = error; + } + }) { IsBackground = true }; + operation.Start(); + + Assert.True(operation.Join(TimeSpan.FromSeconds(5))); + var error = Assert.IsType(observed); + + Assert.Contains("Another operation", error.Message); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Equivalent_destination_spellings_share_the_same_lock() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + Assert.Equal(DestinationLock.NameFor(destination), + DestinationLock.NameFor(Path.Combine(destination, "..", "dest") + Path.DirectorySeparatorChar)); + if (OperatingSystem.IsWindows()) + { + Assert.Equal(DestinationLock.NameFor(destination), DestinationLock.NameFor(destination.ToUpperInvariant())); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Extended_Windows_paths_share_the_ordinary_destination_lock(bool exists) + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var temp = new TempDirectory(); + var destination = temp.Combine("nested", "skills"); + if (exists) + { + Directory.CreateDirectory(destination); + } + + Assert.Equal(DestinationLock.NameFor(destination), DestinationLock.NameFor(@"\\?\" + destination)); + Assert.Equal(exists, Directory.Exists(destination)); + } + + [Fact] + public void Existing_long_Windows_destinations_can_be_locked_with_either_spelling() + { + if (!OperatingSystem.IsWindows()) + { + return; + } + + using var temp = new TempDirectory(); + var destination = temp.Path; + for (var index = 0; index < 5; index++) + { + destination = Path.Combine(destination, new string('a', 60)); + } + + Directory.CreateDirectory(destination); + Assert.True(destination.Length > 260); + Assert.Equal(DestinationLock.NameFor(destination), DestinationLock.NameFor(@"\\?\" + destination)); + using var held = DestinationLock.Acquire(destination); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/DotnetPackageSkills.Tests.csproj b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/DotnetPackageSkills.Tests.csproj new file mode 100644 index 0000000..f67a172 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/DotnetPackageSkills.Tests.csproj @@ -0,0 +1,28 @@ + + + + net8.0;net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/FakeTerminal.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/FakeTerminal.cs new file mode 100644 index 0000000..a14f3ff --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/FakeTerminal.cs @@ -0,0 +1,426 @@ +using System.Text; +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +internal sealed record TerminalWrite( + int Left, + int Top, + string Text, + TerminalStyle Style, + int WindowWidth, + int WindowHeight, + int OutputCodePage, + byte[] Bytes); + +/// A cell-addressed screen, scripted keys/resizes, and the styles of individual writes. +internal sealed class FakeTerminal(int windowHeight = 18, int windowWidth = 100) : ITerminal +{ + private List> _screen = []; + private readonly List _frames = []; + private readonly List _writes = []; + private readonly List> _frameWrites = []; + private readonly Queue<(ConsoleKeyInfo? Key, Action? BeforeKey)> _keys = new(); + private int _cursorTop; + private int _cursorLeft; + private int _frameWriteStart; + + public bool IsRedirected { get; init; } + + public bool SupportsColor { get; init; } = true; + + public int WindowHeight { get; private set; } = windowHeight; + + public int WindowWidth { get; private set; } = windowWidth; + + public int CursorTop => _cursorTop; + + public bool IsCursorVisible { get; private set; } = true; + + public bool IsControlCTakenAsInput { get; private set; } + + public bool ControlCWasEverTakenAsInput { get; private set; } + + public TerminalStyle CurrentStyle { get; private set; } + + public ConsoleColor Foreground { get; set; } = ConsoleColor.Gray; + + public ConsoleColor Background { get; set; } = ConsoleColor.Black; + + public Encoding OutputEncoding { get; set; } = Encoding.ASCII; + + public int ViewportClears { get; private set; } + + public bool IsInteractiveScreen { get; private set; } + + public int ScreenEntries { get; private set; } + + public int ScreenExits { get; private set; } + + public string LastPickerScreen { get; private set; } = string.Empty; + + public int LastPickerCursorTop { get; private set; } + + public Action? BeforeOperation { get; set; } + + public bool CursorVisible + { + set + { + BeforeOperation?.Invoke(nameof(CursorVisible)); + IsCursorVisible = value; + } + } + + public bool TreatControlCAsInput + { + set + { + BeforeOperation?.Invoke(nameof(TreatControlCAsInput)); + IsControlCTakenAsInput = value; + ControlCWasEverTakenAsInput |= value; + } + } + + public IReadOnlyList Frames => _frames; + + public IReadOnlyList Writes => _writes; + + public IReadOnlyList> FrameWrites => _frameWrites; + + public List<(int Width, int Height)> FrameSizes { get; } = []; + + public List CursorTopsAwaitingKey { get; } = []; + + public List StyleEvents { get; } = []; + + public List InputTimeouts { get; } = []; + + public List KeysRead { get; } = []; + + public List EncodingChanges { get; } = []; + + public string Screen => string.Join(Environment.NewLine, + _screen.Take(WindowHeight).Select(row => string.Concat(row.Take(WindowWidth)))); + + public int FinalCursorTop => _cursorTop; + + public int CursorTopAwaitingKey { get; private set; } + + public TerminalState CaptureState() => new( + IsCursorVisible, IsControlCTakenAsInput, Foreground, Background, CurrentStyle, OutputEncoding); + + public void UseUtf8Output() + { + BeforeOperation?.Invoke(nameof(UseUtf8Output)); + OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + EncodingChanges.Add(OutputEncoding); + } + + public IDisposable EnterInteractiveScreen() + { + BeforeOperation?.Invoke(nameof(EnterInteractiveScreen)); + var previous = _screen; + var previousTop = _cursorTop; + var previousLeft = _cursorLeft; + var wasInteractive = IsInteractiveScreen; + // Switching buffers preserves the cursor; the picker must position its own first frame. + _screen = []; + IsInteractiveScreen = true; + ScreenEntries++; + return new ScreenScope(() => + { + LastPickerScreen = Screen; + LastPickerCursorTop = _cursorTop; + _screen = previous; + _cursorTop = Math.Clamp(previousTop, 0, WindowHeight - 1); + _cursorLeft = Math.Clamp(previousLeft, 0, WindowWidth - 1); + IsInteractiveScreen = wasInteractive; + ScreenExits++; + }); + } + + private sealed class ScreenScope(Action restore) : IDisposable + { + private bool _disposed; + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + restore(); + } + } + } + + public void RestoreState(TerminalState state) + { + CurrentStyle = state.Style; + Foreground = state.Foreground ?? ConsoleColor.Gray; + Background = state.Background ?? ConsoleColor.Black; + IsCursorVisible = state.CursorVisible; + IsControlCTakenAsInput = state.TreatControlCAsInput; + StyleEvents.Add(CurrentStyle); + OutputEncoding = state.OutputEncoding; + EncodingChanges.Add(OutputEncoding); + } + + public void SetStyle(TerminalStyle style) + { + BeforeOperation?.Invoke(nameof(SetStyle)); + CurrentStyle = SupportsColor ? style : TerminalStyle.Default; + if (SupportsColor) + { + Foreground = style switch + { + TerminalStyle.Focus => ConsoleColor.Blue, + TerminalStyle.Selected => ConsoleColor.Blue, + TerminalStyle.Muted => ConsoleColor.DarkGray, + _ => ConsoleColor.Gray, + }; + Background = ConsoleColor.Black; + } + + StyleEvents.Add(CurrentStyle); + } + + public void ResetStyle() => SetStyle(TerminalStyle.Default); + + public FakeTerminal Press(params ConsoleKey[] keys) + { + foreach (var key in keys) + { + _keys.Enqueue((new ConsoleKeyInfo('\0', key, false, false, false), null)); + } + + return this; + } + + public FakeTerminal Press(ConsoleKey key, int times) + { + for (var press = 0; press < times; press++) + { + Press(key); + } + + return this; + } + + public FakeTerminal PressWith(ConsoleModifiers modifiers, ConsoleKey key, int times = 1) + { + for (var press = 0; press < times; press++) + { + _keys.Enqueue((new ConsoleKeyInfo( + '\0', key, + shift: (modifiers & ConsoleModifiers.Shift) != 0, + alt: (modifiers & ConsoleModifiers.Alt) != 0, + control: (modifiers & ConsoleModifiers.Control) != 0), null)); + } + + return this; + } + + public FakeTerminal Resize(int windowHeight, int windowWidth) => + ResizeBeforeKey(ConsoleKey.NoName, windowHeight, windowWidth); + + public FakeTerminal ResizeBeforeKey(ConsoleKey key, int windowHeight, int windowWidth) + { + _keys.Enqueue((new ConsoleKeyInfo('\0', key, false, false, false), + () => ApplyResize(windowHeight, windowWidth))); + return this; + } + + public FakeTerminal ResizeWhileWaiting(int windowHeight, int windowWidth) + { + _keys.Enqueue((null, () => ApplyResize(windowHeight, windowWidth))); + return this; + } + + public void ResizeNow(int windowHeight, int windowWidth) => ApplyResize(windowHeight, windowWidth); + + public FakeTerminal WaitWithoutKey(int times = 1) + { + for (var wait = 0; wait < times; wait++) + { + _keys.Enqueue((null, null)); + } + + return this; + } + + public void SetCursorPosition(int left, int top) + { + BeforeOperation?.Invoke(nameof(SetCursorPosition)); + if (left < 0 || left >= WindowWidth || top < 0 || top >= WindowHeight) + { + throw new InvalidOperationException($"Cursor ({left}, {top}) is outside {WindowWidth}x{WindowHeight}."); + } + + _cursorLeft = left; + _cursorTop = top; + } + + public void Write(string text) + { + BeforeOperation?.Invoke(nameof(Write)); + var bytes = OutputEncoding.GetBytes(text); + var displayed = OutputEncoding.GetString(bytes); + _writes.Add(new TerminalWrite(_cursorLeft, _cursorTop, displayed, CurrentStyle, + WindowWidth, WindowHeight, OutputEncoding.CodePage, bytes)); + EnsureRow(); + var row = _screen[_cursorTop]; + foreach (var element in TerminalText.Elements(displayed)) + { + if (element.Any(char.IsControl)) + { + throw new InvalidOperationException("A terminal span contained an unsanitized control."); + } + + var cells = TerminalText.CellWidth(element); + if (_cursorLeft + cells > WindowWidth || _cursorTop >= WindowHeight) + { + throw new InvalidOperationException($"Write is outside {WindowWidth}x{WindowHeight}: '{text}'."); + } + + while (row.Count < _cursorLeft + cells) + { + row.Add(" "); + } + + if (cells == 0) + { + if (_cursorLeft > 0) + { + var previous = _cursorLeft - 1; + while (previous > 0 && row[previous] is null) + { + previous--; + } + + row[previous] += element; + } + + continue; + } + + for (var cell = _cursorLeft; cell < _cursorLeft + cells; cell++) + { + var start = cell; + while (start > 0 && row[start] is null) + { + start--; + } + + var oldWidth = TerminalText.CellWidth(row[start] ?? " "); + for (var old = start; old < Math.Min(row.Count, start + oldWidth); old++) + { + row[old] = " "; + } + } + + row[_cursorLeft] = element; + for (var cell = 1; cell < cells; cell++) + { + row[_cursorLeft + cell] = null; + } + + _cursorLeft += cells; + if (_cursorLeft == WindowWidth) + { + AdvanceRow(); + EnsureRow(); + row = _screen[_cursorTop]; + } + } + } + + public void WriteLine(string text = "") + { + Write(text); + AdvanceRow(); + } + + public void ClearViewport() + { + BeforeOperation?.Invoke(nameof(ClearViewport)); + ViewportClears++; + _screen.Clear(); + _cursorLeft = 0; + _cursorTop = 0; + } + + public bool TryReadKey(TimeSpan timeout, out ConsoleKeyInfo key) + { + InputTimeouts.Add(timeout); + BeforeOperation?.Invoke(nameof(TryReadKey)); + if (_keys.TryPeek(out var next) && next.Key is null) + { + CaptureFrame(); + _keys.Dequeue().BeforeKey?.Invoke(); + key = default; + return false; + } + + key = ReadKey(); + return true; + } + + public ConsoleKeyInfo ReadKey() + { + CaptureFrame(); + BeforeOperation?.Invoke(nameof(ReadKey)); + + if (_keys.Count == 0) + { + throw new InvalidOperationException( + "The picker asked for a key the test did not script. Add one, or end with Enter or Escape."); + } + + var next = _keys.Dequeue(); + next.BeforeKey?.Invoke(); + var key = next.Key ?? throw new InvalidOperationException("An idle wait requires TryReadKey, not ReadKey."); + KeysRead.Add(key); + return key; + } + + private void CaptureFrame() + { + _frames.Add(Screen); + _frameWrites.Add(_writes.Skip(_frameWriteStart).ToArray()); + _frameWriteStart = _writes.Count; + FrameSizes.Add((WindowWidth, WindowHeight)); + CursorTopAwaitingKey = _cursorTop; + CursorTopsAwaitingKey.Add(_cursorTop); + } + + private void ApplyResize(int height, int width) + { + WindowHeight = height; + WindowWidth = width; + _cursorTop = Math.Min(_cursorTop, WindowHeight - 1); + _cursorLeft = Math.Min(_cursorLeft, WindowWidth - 1); + } + + private void EnsureRow() + { + while (_screen.Count <= _cursorTop) + { + _screen.Add([]); + } + } + + private void AdvanceRow() + { + _cursorLeft = 0; + if (_cursorTop < WindowHeight - 1) + { + _cursorTop++; + } + else + { + _screen.RemoveAt(0); + _screen.Add([]); + } + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/GlobalPackagesLocatorTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/GlobalPackagesLocatorTests.cs new file mode 100644 index 0000000..e75aaa8 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/GlobalPackagesLocatorTests.cs @@ -0,0 +1,54 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class GlobalPackagesLocatorTests +{ + private static string SomeAbsolutePath => Path.Combine(Path.GetTempPath(), "nuget-packages"); + + [Fact] + public void ParseListOutput_reads_the_path_from_current_SDK_output() + { + var output = $"global-packages: {SomeAbsolutePath}"; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_reads_the_path_from_older_prefixed_output() + { + // Older SDKs prefix the line, which is why parsing keys off the label. + var output = $"info : global-packages: {SomeAbsolutePath}"; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_ignores_surrounding_lines() + { + var output = $""" + Welcome to .NET! + ---------------- + global-packages: {SomeAbsolutePath} + + """; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_tolerates_windows_line_endings() + { + var output = $"info : something\r\nglobal-packages: {SomeAbsolutePath}\r\n"; + + Assert.Equal(Path.GetFullPath(SomeAbsolutePath), GlobalPackagesLocator.ParseListOutput(output)); + } + + [Fact] + public void ParseListOutput_returns_null_when_the_label_is_absent() => + Assert.Null(GlobalPackagesLocator.ParseListOutput("http-cache: /somewhere\ntemp: /elsewhere")); + + [Fact] + public void ParseListOutput_returns_null_when_the_label_has_no_value() => + Assert.Null(GlobalPackagesLocator.ParseListOutput("global-packages: ")); +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/InstallManifestTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/InstallManifestTests.cs new file mode 100644 index 0000000..c58608d --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/InstallManifestTests.cs @@ -0,0 +1,347 @@ +using System.Security.AccessControl; +using System.Text; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class InstallManifestTests +{ + private const string Contents = """ + { + "version": 1, + "packages": { + "alpha": { "version": "1.0.0", "skills": ["alpha"] }, + "beta": { "version": "1.0.0", "skills": ["beta"] } + } + } + """; + + [Fact] + public void Saving_writes_the_versioned_packages_format_with_lf_line_endings_only() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var manifest = new InstallManifest(); + manifest.SetSkills( + [ + new TrackedSkill("Mockly", "1.10", "mockly-usage"), + new TrackedSkill("Mockly", "1.10.0", "mockly-migration"), + new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage"), + ]); + + manifest.Save(destination); + + var bytes = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + Assert.Equal( + "{\n" + + " \"version\": 1,\n" + + " \"packages\": {\n" + + " \"contoso.widgets\": {\n" + + " \"version\": \"2.3.0\",\n" + + " \"skills\": [\n" + + " \"contoso.widgets-usage\"\n" + + " ]\n" + + " },\n" + + " \"mockly\": {\n" + + " \"version\": \"1.10.0\",\n" + + " \"skills\": [\n" + + " \"mockly-migration\",\n" + + " \"mockly-usage\"\n" + + " ]\n" + + " }\n" + + " }\n" + + "}\n", + Encoding.UTF8.GetString(bytes)); + Assert.DoesNotContain((byte)'\r', bytes); + Assert.NotEqual(0xEF, bytes[0]); + } + + [Fact] + public void The_same_skills_produce_the_same_bytes_whatever_their_order_or_casing() + { + using var temp = new TempDirectory(); + var first = new InstallManifest(); + first.SetSkills( + [ + new TrackedSkill("Mockly", "1.0.0-RC.1", "b"), + new TrackedSkill("Alpha", "2.0", "a"), + new TrackedSkill("mockly", "1.0.0-rc.1", "a"), + ]); + var second = new InstallManifest(); + second.SetSkills( + [ + new TrackedSkill("alpha", "2.0.0", "a"), + new TrackedSkill("MOCKLY", "1.0.0-rc.1", "a"), + new TrackedSkill("Mockly", "1.0.0-RC.1", "b"), + ]); + + first.Save(temp.Combine("first")); + second.Save(temp.Combine("second")); + + Assert.Equal( + File.ReadAllBytes(temp.Combine("first", InstallManifest.FileName)), + File.ReadAllBytes(temp.Combine("second", InstallManifest.FileName))); + } + + [Fact] + public void A_saved_manifest_reads_back_with_lowercase_ids_and_normalized_versions() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var manifest = new InstallManifest(); + manifest.SetSkills( + [ + new TrackedSkill("Mockly", "1.10", "mockly-usage"), + new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage"), + ]); + manifest.Save(destination); + + var loaded = InstallManifest.Load(destination); + + Assert.Equal( + [ + new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage"), + new TrackedSkill("mockly", "1.10.0", "mockly-usage"), + ], + loaded.EnumerateSkills()); + Assert.Equal(["contoso.widgets", "mockly"], loaded.Packages.Keys); + Assert.Equal("1.10.0", loaded.Packages["mockly"].Version); + } + + [Fact] + public void One_package_cannot_be_recorded_at_two_versions() + { + var manifest = new InstallManifest(); + + var error = Assert.Throws(() => manifest.SetSkills( + [ + new TrackedSkill("Mockly", "1.10.0", "mockly-usage"), + new TrackedSkill("mockly", "1.11.0", "mockly-testing"), + ])); + + Assert.Contains("mockly", error.Message); + Assert.Contains("1.10.0", error.Message); + Assert.Contains("1.11.0", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Empty(manifest.EnumerateSkills()); + } + + [Fact] + public void A_package_id_that_the_reader_would_refuse_is_never_written() + { + // Whatever the tool writes, it has to be able to read back. Otherwise one install would + // lock every later command out of the destination. + var manifest = new InstallManifest(); + + var error = Assert.Throws(() => manifest.SetSkills( + [new TrackedSkill("not valid!", "1.0.0", "a")])); + + Assert.Contains("'not valid!'", error.Message); + Assert.Contains("not a valid package id", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Empty(manifest.EnumerateSkills()); + } + + [Fact] + public void Property_names_and_package_ids_are_read_without_regard_to_case() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + temp.CreateFile( + "dest/.dotnet-package-skills.json", + """{"Version":1,"Packages":{"Mockly":{"Version":"1.10.0","Skills":["mockly-usage"]}}}"""); + + Assert.Equal( + new TrackedSkill("mockly", "1.10.0", "mockly-usage"), + Assert.Single(InstallManifest.Load(destination).EnumerateSkills())); + } + + [Fact] + public void Unknown_properties_are_ignored_and_an_empty_packages_map_tracks_nothing() + { + using var temp = new TempDirectory(); + var withExtras = temp.CreateDirectory("extras"); + var empty = temp.CreateDirectory("empty"); + temp.CreateFile( + "extras/.dotnet-package-skills.json", + """ + { + "version": 1, + "comment": "not part of the format", + "packages": { "mockly": { "version": "1.10.0", "skills": ["mockly-usage"], "extra": true } } + } + """); + temp.CreateFile("empty/.dotnet-package-skills.json", """{"version":1,"packages":{}}"""); + + Assert.Equal("mockly-usage", Assert.Single(InstallManifest.Load(withExtras).EnumerateSkills()).Skill); + Assert.Empty(InstallManifest.Load(empty).EnumerateSkills()); + } + + [Theory] + [InlineData(2)] + [InlineData(10)] + public void A_manifest_from_a_newer_tool_asks_for_an_update_and_is_preserved(int formatVersion) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var contents = + "{\"version\":" + formatVersion + + ",\"packages\":{\"mockly\":{\"version\":\"1.10.0\",\"skills\":[\"mockly-usage\"]}}}"; + var path = temp.CreateFile("dest/.dotnet-package-skills.json", contents); + + var error = Assert.Throws(() => InstallManifest.Load(destination)); + + Assert.Contains($"format version {formatVersion}", error.Message); + Assert.Contains("supports only version 1", error.Message); + Assert.Contains("Update dotnet-package-skills", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(contents, File.ReadAllText(path)); + } + + [Fact] + public void A_manifest_in_the_pre_release_format_says_how_to_start_over_and_is_preserved() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + const string PreRelease = """ + { + "note": "Written by the dotnet-package-skills tool.", + "installed": [ { "package": "Mockly", "version": "1.10.0", "skills": ["mockly-usage"] } ] + } + """; + var path = temp.CreateFile("dest/.dotnet-package-skills.json", PreRelease); + + var error = Assert.Throws(() => InstallManifest.Load(destination)); + + Assert.Contains("pre-release version of dotnet-package-skills", error.Message); + Assert.Contains("Move the skills folder aside", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(PreRelease, File.ReadAllText(path)); + } + + [Theory] + [InlineData("null")] + [InlineData("[]")] + [InlineData("{}")] + [InlineData("""{"packages":{}}""")] + [InlineData("""{"version":"1","packages":{}}""")] + [InlineData("""{"version":1.5,"packages":{}}""")] + [InlineData("""{"version":0,"packages":{}}""")] + [InlineData("""{"version":-1,"packages":{}}""")] + [InlineData("""{"version":1}""")] + [InlineData("""{"version":1,"packages":null}""")] + [InlineData("""{"version":1,"packages":[]}""")] + [InlineData("""{"version":1,"version":1,"packages":{}}""")] + [InlineData("""{"version":1,"packages":{},"Packages":{}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":["a"]},"Mockly":{"version":"1.0.0","skills":["b"]}}}""")] + [InlineData("""{"version":1,"packages":{"not valid!":{"version":"1.0.0","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"contoso..widgets":{"version":"1.0.0","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly\n":{"version":"1.0.0","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":null}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":" ","skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":1,"skills":["a"]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0"}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":"a"}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":[null]}}}""")] + [InlineData("""{"version":1,"packages":{"mockly":{"version":"1.0.0","skills":["../outside"]}}}""")] + [InlineData("""{"version":1,"packages":{"alpha":{"version":"1.0.0","skills":["shared"]},"beta":{"version":"1.0.0","skills":["SHARED"]}}}""")] + [InlineData("""{"version":1,"packages":{"alpha":{"version":"1.0.0","skills":["shared","shared"]}}}""")] + public void A_manifest_with_an_unusable_shape_fails_before_anything_uses_it(string contents) + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", contents); + + var error = Assert.Throws(() => InstallManifest.Load(destination)); + + Assert.Contains("Could not read the install manifest", error.Message); + Assert.Contains("preserved", error.Message); + Assert.Equal(contents, File.ReadAllText(path)); + } + + [Fact] + public void The_first_save_creates_an_ordinary_manifest_file() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var path = Path.Combine(destination, InstallManifest.FileName); + var manifest = new InstallManifest(); + manifest.SetSkills([new TrackedSkill("New", "2.0.0", "new")]); + + manifest.Save(destination); + + Assert.True(File.Exists(path)); + Assert.Null(new FileInfo(path).LinkTarget); + Assert.False(File.GetAttributes(path).HasFlag(FileAttributes.ReparsePoint)); + Assert.Equal("new", Assert.Single(InstallManifest.Load(destination).EnumerateSkills()).Skill); + Assert.Equal(path, Assert.Single(Directory.EnumerateFileSystemEntries(destination))); + } + + [Fact] + public void Saving_a_regular_manifest_updates_it_without_creating_temporary_files() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", Contents); + var manifest = InstallManifest.Load(destination); + manifest.SetSkills([new TrackedSkill("New", "2.0.0", "new")]); + + manifest.Save(destination); + + Assert.Equal("new", Assert.Single(InstallManifest.Load(destination).EnumerateSkills()).Skill); + Assert.Equal(path, Assert.Single(Directory.EnumerateFileSystemEntries(destination))); + Assert.Null(new FileInfo(path).LinkTarget); + } + + [Fact] + public void A_read_only_manifest_is_not_silently_overwritten() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", Contents); + var attributes = File.GetAttributes(path); + File.SetAttributes(path, attributes | FileAttributes.ReadOnly); + try + { + Assert.Throws(() => new InstallManifest().Save(destination)); + Assert.Equal(Contents, File.ReadAllText(path)); + Assert.Equal(path, Assert.Single(Directory.EnumerateFileSystemEntries(destination))); + } + finally + { + File.SetAttributes(path, attributes); + } + } + + [Fact] + public void Saving_a_manifest_preserves_its_access_permissions() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var path = temp.CreateFile("dest/.dotnet-package-skills.json", Contents); + if (OperatingSystem.IsWindows()) + { + var file = new FileInfo(path); + var access = file.GetAccessControl(AccessControlSections.Access); + access.SetAccessRuleProtection(isProtected: true, preserveInheritance: true); + file.SetAccessControl(access); + var before = file.GetAccessControl(AccessControlSections.Access) + .GetSecurityDescriptorSddlForm(AccessControlSections.Access); + + InstallManifest.Load(destination).Save(destination); + + Assert.Equal(before, new FileInfo(path).GetAccessControl(AccessControlSections.Access) + .GetSecurityDescriptorSddlForm(AccessControlSections.Access)); + } + else + { + var mode = UnixFileMode.UserRead | UnixFileMode.UserWrite; + File.SetUnixFileMode(path, mode); + + InstallManifest.Load(destination).Save(destination); + + Assert.Equal(mode, File.GetUnixFileMode(path)); + } + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/InteractiveSkillsTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/InteractiveSkillsTests.cs new file mode 100644 index 0000000..4ebc683 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/InteractiveSkillsTests.cs @@ -0,0 +1,186 @@ +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class InteractiveSkillsTests +{ + [Fact] + public void Install_offers_only_skills_that_are_not_installed_with_their_descriptions() + { + using var temp = new TempDirectory(); + var first = Skill(temp, "first", "The first package skill."); + var second = Skill(temp, "second", "The second package skill."); + var source = File.ReadAllBytes(Path.Combine(first.SourcePath, "SKILL.md")); + + var items = InteractiveSkills.ForInstall([first, second], Owners("SECOND")); + + var item = Assert.Single(items); + Assert.Equal("first", item.Name); + Assert.Equal("Example.Package", item.Package); + Assert.Equal("1.0.0", item.Version); + Assert.Equal("The first package skill.", item.Description); + Assert.Null(item.DescriptionWarning); + Assert.Equal(source, File.ReadAllBytes(Path.Combine(first.SourcePath, "SKILL.md"))); + } + + [Fact] + public void Uninstall_reads_the_installed_copy_and_only_offers_manifest_owned_skills() + { + using var temp = new TempDirectory(); + var skill = Skill(temp, "example", "Description shipped with the installed version."); + var destination = temp.Combine("destination"); + new SkillInstaller().Install(destination, [skill], dryRun: false); + File.WriteAllText( + Path.Combine(skill.SourcePath, "SKILL.md"), + "---\ndescription: A different description now in the cache.\n---\n"); + var handwritten = Path.Combine(destination, "team-conventions"); + Directory.CreateDirectory(handwritten); + File.WriteAllText(Path.Combine(handwritten, "SKILL.md"), "---\ndescription: Our own skill.\n---\n"); + var manifest = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var tracked = SkillInstallService.InstalledSkills(destination, temp.Path); + var items = InteractiveSkills.ForUninstall(tracked, destination); + + var item = Assert.Single(items); + Assert.Equal("example", item.Name); + Assert.Equal("Description shipped with the installed version.", item.Description); + Assert.Equal(manifest, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + Assert.True(File.Exists(Path.Combine(handwritten, "SKILL.md"))); + } + + [Fact] + public void Missing_or_invalid_descriptions_do_not_remove_skills_from_the_picker() + { + using var temp = new TempDirectory(); + var missing = Skill(temp, "missing", "unused"); + var malformed = Skill(temp, "malformed", "unused"); + File.WriteAllText(Path.Combine(missing.SourcePath, "SKILL.md"), "# No frontmatter\n"); + File.WriteAllText(Path.Combine(malformed.SourcePath, "SKILL.md"), "---\ndescription: [broken\n---\n"); + + var items = InteractiveSkills.ForInstall([missing, malformed], []); + + Assert.Equal(2, items.Count); + var absent = Assert.Single(items, item => item.Name == "missing"); + Assert.Null(absent.Description); + Assert.Null(absent.DescriptionWarning); + var invalid = Assert.Single(items, item => item.Name == "malformed"); + Assert.Null(invalid.Description); + Assert.False(string.IsNullOrWhiteSpace(invalid.DescriptionWarning)); + } + + [Fact] + public void An_installed_skill_whose_file_is_missing_can_still_be_selected_for_removal() + { + using var temp = new TempDirectory(); + var skill = Skill(temp, "example", "An installed skill."); + var destination = temp.Combine("destination"); + var installer = new SkillInstaller(); + installer.Install(destination, [skill], dryRun: false); + File.Delete(Path.Combine(destination, "example", "SKILL.md")); + + var items = InteractiveSkills.ForUninstall( + SkillInstallService.InstalledSkills(destination, temp.Path), + destination); + + var item = Assert.Single(items); + Assert.Equal("example", item.Name); + Assert.Null(item.Description); + Assert.False(string.IsNullOrWhiteSpace(item.DescriptionWarning)); + var removed = installer.Uninstall(destination, null, null, dryRun: false, only: [item.Name]); + Assert.Equal("example", Assert.Single(removed).Skill); + } + + [Fact] + public void Description_loading_does_not_bypass_a_corrupt_ownership_manifest() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("destination"); + var manifest = Path.Combine(destination, InstallManifest.FileName); + const string conflict = "<<<<<<< HEAD\n{}\n=======\n{}\n>>>>>>> branch"; + File.WriteAllText(manifest, conflict); + + Assert.Throws(() => + InteractiveSkills.ForUninstall( + SkillInstallService.InstalledSkills(destination, temp.Path), + destination)); + + Assert.Equal(conflict, File.ReadAllText(manifest)); + } + + [Fact] + public void An_install_choice_never_refreshes_or_removes_an_installed_skill() + { + using var temp = new TempDirectory(); + var installedSkill = Skill(temp, "installed", "Already installed."); + var fresh = Skill(temp, "fresh", "Not installed yet."); + var destination = temp.Combine("destination"); + var installer = new SkillInstaller(); + installer.Install(destination, [installedSkill], dryRun: false); + var edited = temp.CreateFile("destination/installed/SKILL.md", "edited locally"); + var installed = SkillInstallService.InstalledSkills(destination, temp.Path); + + var items = InteractiveSkills.ForInstall([installedSkill, fresh], installed); + var choice = InteractiveSkills.InstallChoice( + [installedSkill, fresh], installed, items, Names("installed", "fresh")); + var outcome = installer.Install( + destination, + choice.Selected, + dryRun: false, + offered: new Dictionary(), + expectedInstalled: choice.ExpectedInstalled); + + Assert.Equal("fresh", Assert.Single(items).Name); + Assert.Equal("fresh", Assert.Single(choice.Selected).SkillName); + Assert.Empty(outcome.Removed); + Assert.Equal("edited locally", File.ReadAllText(edited)); + Assert.Equal( + ["fresh", "installed"], + InstallManifest.Load(destination).EnumerateSkills().Select(entry => entry.Skill).Order(StringComparer.Ordinal)); + } + + [Fact] + public void An_install_choice_takes_only_ticked_skills_that_were_shown() + { + using var temp = new TempDirectory(); + var first = Skill(temp, "first", "One."); + var second = Skill(temp, "second", "Two."); + + var installed = Owners("first", "unshown"); + var items = InteractiveSkills.ForInstall([first, second], installed); + var choice = InteractiveSkills.InstallChoice( + [first, second], installed, items, Names("FIRST", "second", "unknown")); + + Assert.Equal([second], choice.Selected); + Assert.Same(installed, choice.ExpectedInstalled); + } + + [Fact] + public void Another_packages_same_named_skill_is_not_offered() + { + using var temp = new TempDirectory(); + var candidate = Skill(temp, "shared", "A candidate from Example.Package."); + var installed = new[] { new TrackedSkill("other.package", "2.0.0", "shared") }; + + var items = InteractiveSkills.ForInstall([candidate], installed); + var choice = InteractiveSkills.InstallChoice([candidate], installed, items, Names("shared")); + + Assert.Empty(items); + Assert.Empty(choice.Selected); + } + + private static IReadOnlyList Owners(params string[] names) => + [.. names.Select(name => new TrackedSkill("Example.Package", "1.0.0", name))]; + + private static HashSet Names(params string[] names) => new(names, StringComparer.OrdinalIgnoreCase); + + private static BundledSkill Skill(TempDirectory temp, string name, string description) + { + var package = temp.CreatePackageWithSkill("Example.Package", "1.0.0", name); + var directory = Path.Combine(package, "skills", name); + File.WriteAllText( + Path.Combine(directory, "SKILL.md"), + $"---\nname: {name}\ndescription: {description}\n---\n# Body\n"); + return new BundledSkill("Example.Package", "1.0.0", name, directory, name); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputLayoutTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputLayoutTests.cs new file mode 100644 index 0000000..7ff58a6 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputLayoutTests.cs @@ -0,0 +1,199 @@ +using DotnetPackageSkills; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +/// +/// Renders every report shape and checks its blank lines. +/// +/// +/// Vertical whitespace is invisible in a diff and obvious on a projector, so the rules that +/// keep it tidy are asserted rather than left to whoever edits the writer next. +/// +public class OutputLayoutTests +{ + private static readonly BundledSkill[] TwoSkills = + [ + new("Contoso.Widgets", "2.3.0", "contoso.widgets-usage", "/p/usage", "contoso.widgets-usage"), + new("Mockly", "1.10.0", "mockly-usage", "/p/mockly", "mockly-usage"), + ]; + + public static TheoryData EveryReport() + { + var data = new TheoryData(); + + foreach (var (name, report) in Reports()) + { + data.Add(name, report); + } + + return data; + } + + private static IEnumerable<(string Name, string Report)> Reports() + { + yield return ("list", Render(Result() with { DryRun = true }, copied: false)); + yield return ("install", Render(Result(), copied: true)); + yield return ("install dry run", Render(Result() with { DryRun = true }, copied: true)); + yield return ("install nothing found", Render( + Result() with { Skills = [], SkillsDiscovered = 0 }, copied: true)); + yield return ("install nothing chosen", Render( + Result() with { Skills = [], SkillsDiscovered = 2 }, copied: true)); + yield return ("install with removals", Render( + Result() with { Removed = [new TrackedSkill("Old.Package", "1.0.0", "old-skill")] }, + copied: true)); + yield return ("install with collisions", Render( + Result() with + { + Skipped = + [ + new SkippedSkill("shared", "Beta", "2.0.0", "shared", "conflicts with Alpha 1.0.0"), + ], + }, + copied: true)); + yield return ("install with unreferenced skills", Render( + Result() with { Unreferenced = [new TrackedSkill("left.package", "3.0.0", "left-skill")] }, + copied: true)); + yield return ("install with every section", Render( + Result() with + { + Removed = [new TrackedSkill("old.package", "1.0.0", "old-skill")], + Unreferenced = + [ + new TrackedSkill("left.package", "3.0.0", "left-skill"), + new TrackedSkill("gone.package", "1.0.0", "gone-skill"), + ], + Skipped = + [ + new SkippedSkill("shared", "Beta", "2.0.0", "shared", "conflicts with Alpha 1.0.0"), + ], + }, + copied: true)); + yield return ("interactive install with nothing new", Render( + Result() with { Skills = [], NothingNewToInstall = true }, copied: true)); + yield return ("interactive install with nothing new and collisions", Render( + Result() with + { + Skills = [], + NothingNewToInstall = true, + Skipped = + [ + new SkippedSkill("shared", "Beta", "2.0.0", "shared", "conflicts with Alpha 1.0.0"), + ], + }, + copied: true)); + yield return ("uninstall", RenderUninstall( + [new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage")], dryRun: false)); + yield return ("uninstall dry run", RenderUninstall( + [new TrackedSkill("Contoso.Widgets", "2.3.0", "contoso.widgets-usage")], dryRun: true)); + yield return ("uninstall nothing to do", RenderUninstall([], dryRun: false)); + yield return ("uninstall stale", RenderUninstall( + [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")], dryRun: false, + target: @"C:\repo\App.slnx")); + yield return ("uninstall stale nothing to do", RenderUninstall([], dryRun: false, target: @"C:\repo\App.slnx")); + yield return ("cancelled", RenderCancelled()); + } + + [Theory] + [MemberData(nameof(EveryReport))] + public void No_report_starts_or_ends_with_a_blank_line(string name, string report) + { + var lines = Lines(report); + + Assert.False(lines[0].Length == 0, $"{name} opens with a blank line"); + Assert.False(lines[^1].Length == 0, $"{name} closes with a blank line"); + } + + [Theory] + [MemberData(nameof(EveryReport))] + public void No_report_has_two_blank_lines_together(string name, string report) + { + var lines = Lines(report); + + for (var index = 1; index < lines.Count; index++) + { + Assert.False( + lines[index].Length == 0 && lines[index - 1].Length == 0, + $"{name} has a double blank line at {index + 1}"); + } + } + + [Theory] + [MemberData(nameof(EveryReport))] + public void No_report_wraps_a_sentence_onto_the_next_line(string name, string report) + { + // A line ending without terminal punctuation, followed by one starting lower case, + // is prose someone hard-wrapped at a width the reader never asked for. + var lines = Lines(report).Where(line => line.Length > 0 && !line.StartsWith(' ')).ToList(); + + for (var index = 0; index < lines.Count - 1; index++) + { + var ends = lines[index].TrimEnd(); + var next = lines[index + 1]; + + Assert.False( + ends.Length > 0 && ends[^1] is not ('.' or ':' or '!' or '?') && char.IsLower(next[0]), + $"{name}: '{ends}' looks wrapped into '{next}'"); + } + } + + [Fact] + public void A_skill_and_the_package_it_came_from_share_one_line() + { + var report = Render(Result(), copied: true); + + Assert.Contains("contoso.widgets-usage (Contoso.Widgets 2.3.0)", report); + // The old shape put "from Package Version" on its own indented line, doubling the + // length of every list to say something the brackets say for free. + Assert.DoesNotContain(" from ", report); + } + + [Fact] + public void A_twelve_skill_list_is_twelve_lines_of_skills() + { + var skills = Enumerable.Range(1, 12) + .Select(number => new BundledSkill( + "Contoso.Widgets", "2.3.0", $"skill-{number:00}", $"/p/{number}", $"skill-{number:00}")) + .ToList(); + + var report = Render(Result() with { Skills = skills }, copied: true); + + Assert.Equal(12, Lines(report).Count(line => line.StartsWith(" skill-", StringComparison.Ordinal))); + } + + private static List Lines(string report) => + [.. report.TrimEnd('\r', '\n').Split(Environment.NewLine)]; + + private static string Render(InstallResult result, bool copied) + { + using var output = new StringWriter(); + new OutputWriter(output).WriteInstallReport(result, copied); + return output.ToString(); + } + + private static string RenderUninstall(IReadOnlyList removed, bool dryRun, string? target = null) + { + using var output = new StringWriter(); + new OutputWriter(output).WriteUninstallReport(removed, @"C:\repo\.agents\skills", dryRun, target); + return output.ToString(); + } + + private static string RenderCancelled() + { + using var output = new StringWriter(); + new OutputWriter(output).WriteCancelled(); + return output.ToString(); + } + + private static InstallResult Result() => new() + { + Target = @"C:\repo\App.slnx", + GlobalPackagesFolder = @"C:\packages", + Destination = @"C:\repo\.agents\skills", + PackagesScanned = 3, + DryRun = false, + Skills = TwoSkills, + SkillsDiscovered = TwoSkills.Length, + }; +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputWriterTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputWriterTests.cs new file mode 100644 index 0000000..95f969d --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputWriterTests.cs @@ -0,0 +1,448 @@ +using System.Text.Json; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class OutputWriterTests +{ + private const string ClipboardControl = "\u001b]52;c;ZWNobyBleGFtcGxl\u0007"; + + [Fact] + public void The_trust_notice_is_a_single_line() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteInstallReport(ResultWithCollision(), copied: true); + + // Any break we pick is a guess at the reader's width. It is one thought, so it goes + // out as one line and the terminal wraps it wherever it needs to. + var notice = output.ToString() + .Split(Environment.NewLine) + .Single(line => line.StartsWith("These skills are", StringComparison.Ordinal)); + + Assert.EndsWith("Review them before relying on them.", notice); + } + + [Fact] + public void No_reported_line_breaks_in_the_middle_of_a_sentence() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteInstallReport(ResultWithCollision(), copied: true); + + // A line that ends without terminal punctuation, followed by one starting lower + // case, is prose someone hard-wrapped. Indented lines are data, not prose. + var lines = output.ToString() + .Split(Environment.NewLine) + .Where(line => line.Length > 0 && !line.StartsWith(' ')) + .ToList(); + + for (var index = 0; index < lines.Count - 1; index++) + { + var ends = lines[index].TrimEnd(); + var next = lines[index + 1]; + + Assert.False( + ends.Length > 0 && ends[^1] is not ('.' or ':' or '!' or '?') && char.IsLower(next[0]), + $"'{ends}' looks hard-wrapped into '{next}'"); + } + } + + [Fact] + public void Install_report_warns_about_skipped_collisions() + { + using var output = new StringWriter(); + var result = ResultWithCollision(); + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + var report = output.ToString(); + Assert.Contains("Warning: skipped 1 colliding skill:", report); + Assert.Contains("shared-skill (Beta.Widgets 2.0.0)", report); + Assert.Contains("selected first", report); + } + + [Fact] + public void Skills_whose_package_left_the_target_are_listed_with_the_command_that_removes_them() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Unreferenced = [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")], + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + Assert.Contains( + "1 installed skill belongs to a package that the target no longer references:" + Environment.NewLine + + " contoso.widgets-usage (contoso.widgets 2.3.0)" + Environment.NewLine + + "Run 'dotnet-package-skills uninstall --stale' to remove it.", + output.ToString()); + } + + [Theory] + [InlineData("other.package", "packages")] + [InlineData("contoso.widgets", "a package")] + public void The_stale_hint_counts_skills_and_packages_separately(string secondPackage, string packages) + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Unreferenced = + [ + new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage"), + new TrackedSkill(secondPackage, "2.3.0", "second-skill"), + ], + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + Assert.Contains($"2 installed skills belong to {packages} that the target no longer references:", output.ToString()); + Assert.Contains("Run 'dotnet-package-skills uninstall --stale' to remove them.", output.ToString()); + } + + [Fact] + public void The_stale_hint_prints_the_command_for_the_destination_and_target_that_were_used() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Unreferenced = [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")], + StaleCommand = "dotnet-package-skills uninstall --stale --destination \"my\u001b[2Jskills\"", + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + Assert.Contains( + "Run 'dotnet-package-skills uninstall --stale --destination \"myskills\"' to remove it.", + output.ToString()); + Assert.DoesNotContain('\u001b', output.ToString()); + } + + [Fact] + public void Deselecting_everything_does_not_claim_the_packages_ship_no_skills() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with { Skills = [], SkillsDiscovered = 2 }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + var report = output.ToString(); + Assert.Contains("Copied no skills.", report); + Assert.DoesNotContain("ship a skills/ folder", report); + } + + [Fact] + public void A_scan_that_discovered_nothing_says_so_plainly() + { + using var output = new StringWriter(); + var result = ResultWithCollision() with { Skills = [], Skipped = [], SkillsDiscovered = 0 }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + // A package missing from the cache is indistinguishable from one without skills, so the + // report claims nothing about why nothing was found. + Assert.Contains($"{Environment.NewLine}No bundled skills found.{Environment.NewLine}", output.ToString()); + Assert.DoesNotContain("ship a skills/ folder", output.ToString()); + Assert.DoesNotContain("not extracted", output.ToString()); + } + + [Theory] + [InlineData(false, "Nothing new to install. Every skill that these packages ship is already installed.")] + [InlineData(true, "Nothing new to install.")] + public void An_interactive_install_with_nothing_to_offer_says_so(bool skipped, string expected) + { + using var output = new StringWriter(); + var result = ResultWithCollision() with + { + Skills = [], + SkillsDiscovered = 1, + NothingNewToInstall = true, + Skipped = skipped ? ResultWithCollision().Skipped : [], + }; + + new OutputWriter(output).WriteInstallReport(result, copied: true); + + var lines = output.ToString().Split(Environment.NewLine); + Assert.Contains(expected, lines); + Assert.DoesNotContain("Copied no skills.", lines); + Assert.Equal(skipped, output.ToString().Contains("Warning: skipped 1 colliding skill:", StringComparison.Ordinal)); + } + + [Fact] + public void List_reports_what_it_found_rather_than_a_pending_copy() + { + using var output = new StringWriter(); + + // list always runs as a dry run internally, but it is a query: it was never going + // to copy anything, so "Would copy" would misdescribe it. + new OutputWriter(output).WriteInstallReport(ResultWithCollision() with { DryRun = true }, copied: false); + + var report = output.ToString(); + Assert.Contains("Found 1 skill:", report); + Assert.DoesNotContain("Would copy", report); + } + + [Fact] + public void An_install_dry_run_still_says_what_it_would_copy() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteInstallReport(ResultWithCollision() with { DryRun = true }, copied: true); + + Assert.Contains("Would copy 1 skill:", output.ToString()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Install_and_list_reports_sanitize_every_untrusted_display_field(bool copied, bool dryRun) + { + var clean = ResultWithCollision() with + { + DryRun = dryRun, + Removed = [new TrackedSkill("Old.Package", "1.0.0", "old-skill")], + Unreferenced = [new TrackedSkill("left.package", "3.0.0", "left-skill")], + }; + var untrusted = clean with + { + Target = WithControls(clean.Target!), + GlobalPackagesFolder = WithControls(clean.GlobalPackagesFolder), + Destination = WithControls(clean.Destination), + Skills = + [ + .. clean.Skills.Select(skill => skill with + { + SkillName = WithControls(skill.SkillName), + RelativePath = WithControls(skill.RelativePath), + PackageId = WithControls(skill.PackageId), + PackageVersion = WithControls(skill.PackageVersion), + }), + ], + Removed = + [ + .. clean.Removed.Select(skill => new TrackedSkill( + WithControls(skill.Package), WithControls(skill.Version), WithControls(skill.Skill))), + ], + Unreferenced = + [ + .. clean.Unreferenced.Select(skill => new TrackedSkill( + WithControls(skill.Package), WithControls(skill.Version), WithControls(skill.Skill))), + ], + Skipped = + [ + .. clean.Skipped.Select(skill => skill with + { + SkillName = WithControls(skill.SkillName), + RelativePath = WithControls(skill.RelativePath), + PackageId = WithControls(skill.PackageId), + PackageVersion = WithControls(skill.PackageVersion), + Reason = WithControls(skill.Reason), + }), + ], + }; + using var expected = new StringWriter(); + using var actual = new StringWriter(); + + new OutputWriter(expected).WriteInstallReport(clean, copied); + new OutputWriter(actual).WriteInstallReport(untrusted, copied); + + AssertPlainText(actual.ToString()); + Assert.Equal(expected.ToString(), actual.ToString()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Uninstall_reports_strip_clipboard_and_other_terminal_sequences(bool dryRun) + { + string[] controls = + [ + ClipboardControl, + "\u001b]52;c;ZWNobyBleGFtcGxl\u001b\\", + "\u009d52;c;ZWNobyBleGFtcGxl\u009c", + "\u001b[2J\u001b[H", + "\u009b2J", + "\u001b]8;;https://invalid.example\u001b\\", + "\u001bPignored\u001b\\", + "\u0007\u0008\u007f\u202e\u2066", + ]; + using var expected = new StringWriter(); + new OutputWriter(expected).WriteUninstallReport( + [new TrackedSkill("Example.Package", "1.0.0", "example-skill")], @"C:\repo", dryRun); + + foreach (var control in controls) + { + using var actual = new StringWriter(); + new OutputWriter(actual).WriteUninstallReport( + [new TrackedSkill("Example" + control + ".Package", "1.0" + control + ".0", + "example" + control + "-skill")], + @"C:\re" + control + "po", dryRun); + + AssertPlainText(actual.ToString()); + Assert.Equal(expected.ToString(), actual.ToString()); + } + } + + [Theory] + [InlineData(false, "Removed 1 skill:")] + [InlineData(true, "Would remove 1 skill:")] + public void A_stale_uninstall_report_names_the_target_it_compared_against(bool dryRun, string heading) + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage")], @"C:\repo\.agents\skills", dryRun, + target: @"C:\repo\App.sln"); + + Assert.Equal( + [ + @"Target: C:\repo\App.sln", + @"Destination: C:\repo\.agents\skills", + string.Empty, + heading, + " widget-usage (contoso.widgets 2.3.0)", + string.Empty, + ], + output.ToString().Split(Environment.NewLine)); + } + + [Fact] + public void A_stale_uninstall_with_nothing_stale_says_so() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport([], @"C:\repo\.agents\skills", dryRun: false, target: @"C:\repo\App.sln"); + + Assert.Contains("Nothing to remove. No stale skills were found.", output.ToString()); + Assert.DoesNotContain("No skills installed by this tool", output.ToString()); + } + + [Fact] + public void An_unterminated_control_in_one_identity_field_cannot_hide_the_following_fields() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("Example\u001b]52;c;unterminated", "1.0.0", "example-skill")], + @"C:\repo", dryRun: true); + + AssertPlainText(output.ToString()); + Assert.Contains("example-skill (Example 1.0.0)", output.ToString()); + } + + [Fact] + public void A_manifest_clipboard_payload_is_safe_to_preview_without_rewriting_identity_or_files() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var skillFile = temp.CreateFile("dest/example-skill/SKILL.md", "installed guidance"); + var handwritten = temp.CreateFile("dest/our-own-skill/SKILL.md", "handwritten guidance"); + // Package ids are validated when the manifest is read, so the version carries the payload. + var version = "1.0.0" + ClipboardControl; + var manifest = temp.CreateFile("dest/.dotnet-package-skills.json", JsonSerializer.Serialize(new + { + version = 1, + packages = new Dictionary + { + ["example"] = new { version, skills = new[] { "example-skill" } }, + }, + })); + var before = File.ReadAllBytes(manifest); + var removed = new SkillInstaller().Uninstall(destination, null, null, dryRun: true); + using var text = new StringWriter(); + + new OutputWriter(text).WriteUninstallReport(removed, destination, dryRun: true); + + AssertPlainText(text.ToString()); + Assert.Contains("example-skill (example 1.0.0)", text.ToString()); + Assert.Equal("example", Assert.Single(removed).Package); + Assert.Equal(version, Assert.Single(removed).Version); + Assert.Equal(before, File.ReadAllBytes(manifest)); + Assert.Equal("installed guidance", File.ReadAllText(skillFile)); + Assert.Equal("handwritten guidance", File.ReadAllText(handwritten)); + } + + [Theory] + [InlineData("\r\n")] + [InlineData("\n")] + [InlineData("\r")] + public void Operational_errors_are_sanitized_without_losing_line_breaks_or_stderr_routing(string newline) + { + using var output = new StringWriter(); + using var errors = new StringWriter(); + + new OutputWriter(output, errors).WriteError( + WithControls("Could not read the package.") + newline + WithControls("Restore it and try again.")); + + Assert.Empty(output.ToString()); + AssertPlainText(errors.ToString()); + Assert.Equal( + $"error: Could not read the package.{Environment.NewLine}Restore it and try again.{Environment.NewLine}", + errors.ToString()); + } + + [Fact] + public void Newlines_in_report_metadata_cannot_insert_additional_report_rows() + { + using var output = new StringWriter(); + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("Example\r\nPackage", "1.0.0", "example-skill")], @"C:\repo", dryRun: true); + + AssertPlainText(output.ToString()); + Assert.Contains(" example-skill (Example Package 1.0.0)", output.ToString()); + Assert.Equal(3, output.ToString().Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries).Length); + } + + [Fact] + public void Human_reports_preserve_ordinary_unicode_names() + { + using var output = new StringWriter(); + const string Skill = "\u6280\u80fd-\U0001f9ea"; + + new OutputWriter(output).WriteUninstallReport( + [new TrackedSkill("Caf\u00e9.Tools", "1.0.0", Skill)], @"C:\repo", dryRun: true); + + AssertPlainText(output.ToString()); + Assert.Contains($"{Skill} (Caf\u00e9.Tools 1.0.0)", output.ToString()); + } + + private static string WithControls(string text) => ClipboardControl + text + "\u001b[0m"; + + private static void AssertPlainText(string text) => + Assert.True( + !text.Any(character => char.IsControl(character) && character is not ('\r' or '\n') || + character is '\u202e' or '\u2066'), + "Captured human-readable output contains unsafe terminal controls."); + + private static InstallResult ResultWithCollision() => new() + { + Target = @"C:\repo\App.sln", + GlobalPackagesFolder = @"C:\packages", + Destination = @"C:\repo\.agents\skills", + PackagesScanned = 2, + DryRun = false, + Skills = + [ + new BundledSkill( + "Alpha.Widgets", + "1.0.0", + "shared-skill", + @"C:\packages\alpha.widgets\1.0.0\skills\shared-skill", + "shared-skill"), + ], + Skipped = + [ + new SkippedSkill( + "shared-skill", + "Beta.Widgets", + "2.0.0", + "shared-skill", + "conflicts with Alpha.Widgets 1.0.0 skill 'shared-skill', which was selected first"), + ], + }; +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageCoordinateTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageCoordinateTests.cs new file mode 100644 index 0000000..0b1b890 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageCoordinateTests.cs @@ -0,0 +1,70 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class PackageCoordinateTests +{ + [Theory] + [InlineData("Mockly@1.10.0", "Mockly", "1.10.0")] + [InlineData("Contoso.Widgets@2.3.0", "Contoso.Widgets", "2.3.0")] + [InlineData("My_Package-Name@1.0.0-beta.1", "My_Package-Name", "1.0.0-beta.1")] + [InlineData("_Acme@1.0.0", "_Acme", "1.0.0")] + [InlineData("Acme_@1.0.0", "Acme_", "1.0.0")] + [InlineData("Widgets@2.0", "Widgets", "2.0")] + [InlineData("Widgets@1.2.3.4", "Widgets", "1.2.3.4")] + [InlineData("Widgets@1.2.3+sha.abc", "Widgets", "1.2.3+sha.abc")] + [InlineData(" Mockly@1.10.0 ", "Mockly", "1.10.0")] + [InlineData("Contoso.Überlib@1.0.0", "Contoso.Überlib", "1.0.0")] + public void Parse_accepts_an_exact_package_and_version(string input, string id, string version) + { + var coordinate = PackageCoordinate.Parse(input); + + Assert.Equal(id, coordinate.Id); + Assert.Equal(version, coordinate.Version); + } + + [Theory] + [InlineData("Mockly@4.*")] + [InlineData("Mockly@*")] + [InlineData("Mockly@1.2.*")] + [InlineData("Mockly@[1.0,2.0)")] + [InlineData("Mockly@(,3.0]")] + [InlineData("Mockly@[1.0]")] + public void Parse_refuses_floating_versions_and_ranges(string input) + { + var exception = Assert.Throws(() => PackageCoordinate.Parse(input)); + + // Guessing a version would copy skills describing a release the user does not use, + // so the message has to point at the option that resolves versions properly. + Assert.Contains("exact version", exception.Message); + Assert.Contains("--target", exception.Message); + } + + [Fact] + public void Parse_tells_the_user_how_to_add_a_missing_version() + { + var exception = Assert.Throws(() => PackageCoordinate.Parse("Mockly")); + + Assert.Contains("Mockly@1.10.0", exception.Message); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("@1.0.0")] + [InlineData("Mockly@")] + [InlineData("Mockly@@1.0.0")] + [InlineData("Mockly@not-a-version")] + [InlineData("Mockly@v1.0.0")] + [InlineData("../evil@1.0.0")] + [InlineData("path/to/thing@1.0.0")] + [InlineData("Contoso..Widgets@1.0.0")] + [InlineData("Contoso.-Widgets@1.0.0")] + [InlineData("Contoso\u200B.Widgets@1.0.0")] + public void Parse_rejects_malformed_input(string input) => + Assert.Throws(() => PackageCoordinate.Parse(input)); + + [Fact] + public void ToString_round_trips() => + Assert.Equal("Mockly@1.10.0", PackageCoordinate.Parse("Mockly@1.10.0").ToString()); +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageListerTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageListerTests.cs new file mode 100644 index 0000000..d7e2502 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageListerTests.cs @@ -0,0 +1,212 @@ +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class PackageListerTests +{ + /// Records what was asked of the CLI and replays a canned result. + private sealed class RecordingRunner(int exitCode, string standardOutput, string standardError = "") + : IProcessRunner + { + public List Invocations { get; } = []; + + public ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null) + { + Invocations.Add(string.Join(' ', arguments)); + return new ProcessResult(exitCode, standardOutput, standardError); + } + } + + private const string UnrestoredError = + "No assets file was found for 'App.csproj'. Run restore before running this command."; + + /// What the .NET 10 SDK writes when its own restore fails during a JSON listing. + private const string RestoreFailedJson = """ + { + "version": 1, + "problems": [ + { "text": "Restore failed. Run `dotnet restore` for more details on the issue.", "level": "error" } + ] + } + """; + + [Fact] + public void List_runs_dotnet_list_package_without_a_restore_option() + { + // Whether listing restores is the SDK's call: .NET 10 restores when it needs to, and + // earlier SDKs say that the target has to be restored first. + var runner = new RecordingRunner(0, TwoProjectsJson); + + new PackageLister(new DotnetCli(runner)).List("App.csproj"); + + Assert.Equal("list App.csproj package --format json", Assert.Single(runner.Invocations)); + } + + [Fact] + public void An_unrestored_target_is_reported_with_the_sdk_output_and_the_tool_never_restores_it() + { + // The customer restores and runs the command again; the tool doesn't restore for them. + var runner = new RecordingRunner(1, string.Empty, UnrestoredError); + + var error = Assert.Throws( + () => new PackageLister(new DotnetCli(runner)).List("App.csproj")); + + Assert.Contains("'dotnet list \"App.csproj\" package' failed with exit code 1", error.Message); + Assert.Contains(UnrestoredError, error.Message); + Assert.Contains("and then run this command again", error.Message); + Assert.Equal("list App.csproj package --format json", Assert.Single(runner.Invocations)); + } + + [Fact] + public void Problems_that_the_sdk_reports_in_json_are_shown_as_text() + { + var runner = new RecordingRunner(1, RestoreFailedJson); + + var error = Assert.Throws( + () => new PackageLister(new DotnetCli(runner)).List("App.csproj")); + + Assert.Contains("error: Restore failed. Run `dotnet restore` for more details on the issue.", error.Message); + Assert.DoesNotContain("\"problems\"", error.Message); + Assert.Single(runner.Invocations); + } + + private const string TwoProjectsJson = """ + { + "version": 1, + "parameters": "", + "projects": [ + { + "path": "/repo/src/Api/Api.csproj", + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Serilog", "requestedVersion": "4.1.0", "resolvedVersion": "4.1.0" }, + { "id": "Mockly", "requestedVersion": "1.10.0", "resolvedVersion": "1.10.0" } + ], + "transitivePackages": [ + { "id": "System.Text.Json", "resolvedVersion": "8.0.5" } + ] + } + ] + }, + { + "path": "/repo/src/Worker/Worker.csproj", + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Serilog", "requestedVersion": "4.1.0", "resolvedVersion": "4.1.0" } + ] + } + ] + } + ] + } + """; + + [Fact] + public void Parse_returns_direct_packages_only() + { + var packages = PackageLister.Parse(TwoProjectsJson); + + Assert.Equal(["Mockly", "Serilog"], packages.Select(p => p.Id)); + } + + [Fact] + public void Parse_deduplicates_a_package_referenced_by_several_projects() + { + var packages = PackageLister.Parse(TwoProjectsJson); + + Assert.Single(packages, package => package.Id == "Serilog"); + } + + [Fact] + public void Parse_ignores_transitive_packages() + { + Assert.DoesNotContain(PackageLister.Parse(TwoProjectsJson), package => package.Id == "System.Text.Json"); + } + + [Fact] + public void Parse_keeps_both_versions_when_frameworks_resolve_a_package_differently() + { + // Each version has its own folder in the global packages cache, so both matter. + const string json = """ + { + "projects": [ + { + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ { "id": "Widgets", "resolvedVersion": "1.0.0" } ] }, + { "framework": "net10.0", "topLevelPackages": [ { "id": "Widgets", "resolvedVersion": "2.0.0" } ] } + ] + } + ] + } + """; + + var packages = PackageLister.Parse(json); + + Assert.Equal(["1.0.0", "2.0.0"], packages.Select(p => p.Version)); + } + + [Fact] + public void Parse_prefers_the_resolved_version_over_the_requested_one() + { + // Central Package Management and floating versions leave a range in + // requestedVersion; only resolvedVersion names a folder that exists. + const string json = """ + { + "projects": [ + { + "frameworks": [ + { + "framework": "net8.0", + "topLevelPackages": [ + { "id": "Widgets", "requestedVersion": "4.*", "resolvedVersion": "4.7.2" } + ] + } + ] + } + ] + } + """; + + Assert.Equal("4.7.2", PackageLister.Parse(json).Single().Version); + } + + [Fact] + public void Parse_handles_an_unrestored_project_with_no_frameworks_array() + { + const string json = """{ "version": 1, "projects": [ { "path": "/repo/src/Api/Api.csproj" } ] }"""; + + Assert.Empty(PackageLister.Parse(json)); + } + + [Fact] + public void Parse_handles_a_project_with_no_packages() + { + const string json = """ + { "projects": [ { "frameworks": [ { "framework": "net8.0", "topLevelPackages": [] } ] } ] } + """; + + Assert.Empty(PackageLister.Parse(json)); + } + + [Fact] + public void Parse_skips_MSBuild_noise_printed_before_the_payload() + { + var noisy = "warning NU1503: Skipping restore for project.\n" + TwoProjectsJson; + + Assert.NotEmpty(PackageLister.Parse(noisy)); + } + + [Fact] + public void Parse_reports_unusable_output_as_actionable_guidance() + { + var exception = Assert.Throws( + () => PackageLister.Parse("Unrecognized option '--format'")); + + Assert.Contains("7.0.200", exception.Message); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackagePathResolverTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackagePathResolverTests.cs new file mode 100644 index 0000000..698e5cf --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackagePathResolverTests.cs @@ -0,0 +1,80 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class PackagePathResolverTests +{ + [Theory] + // Already normalized. + [InlineData("1.2.3", "1.2.3")] + // Padded to three parts. + [InlineData("1.2", "1.2.0")] + [InlineData("1", "1.0.0")] + // A zero fourth part is dropped; a non-zero one is kept. + [InlineData("1.2.3.0", "1.2.3")] + [InlineData("1.2.3.4", "1.2.3.4")] + // Leading zeros are not part of the folder name. + [InlineData("01.02.03", "1.2.3")] + // Prerelease labels are preserved but lowercased. + [InlineData("1.0.0-Beta.1", "1.0.0-beta.1")] + [InlineData("2.0-RC1", "2.0.0-rc1")] + // Build metadata is not part of package identity. + [InlineData("1.2.3+build.99", "1.2.3")] + [InlineData("1.2.3-alpha+sha.abc", "1.2.3-alpha")] + [InlineData(" 1.2.3 ", "1.2.3")] + public void NormalizeVersion_matches_NuGet_folder_naming(string input, string expected) => + Assert.Equal(expected, PackagePathResolver.NormalizeVersion(input)); + + [Fact] + public void NormalizeVersion_leaves_unparseable_versions_alone_for_the_directory_scan() => + Assert.Equal("1.x.3", PackagePathResolver.NormalizeVersion("1.X.3")); + + [Fact] + public void Resolve_finds_the_lowercased_folder_for_a_mixed_case_package_id() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + + var resolved = PackagePathResolver.Resolve(temp.Combine("packages"), "Newtonsoft.Json", "13.0.3"); + + Assert.Equal(temp.Combine("packages", "newtonsoft.json", "13.0.3"), resolved); + } + + [Fact] + public void Resolve_normalizes_the_version_before_looking() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages", "serilog", "4.1.0"); + + Assert.NotNull(PackagePathResolver.Resolve(temp.Combine("packages"), "Serilog", "4.1")); + } + + [Fact] + public void Resolve_falls_back_to_scanning_when_normalization_does_not_match() + { + using var temp = new TempDirectory(); + + // A folder name our rules would not produce, so only the scan can find it. + temp.CreateDirectory("packages", "oddball", "1.2.3.4.5"); + + Assert.NotNull(PackagePathResolver.Resolve(temp.Combine("packages"), "Oddball", "1.2.3.4.5")); + } + + [Fact] + public void Resolve_returns_null_when_the_package_is_not_extracted() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages"); + + Assert.Null(PackagePathResolver.Resolve(temp.Combine("packages"), "Missing.Package", "1.0.0")); + } + + [Fact] + public void Resolve_returns_null_when_only_a_different_version_is_extracted() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages", "serilog", "4.1.0"); + + Assert.Null(PackagePathResolver.Resolve(temp.Combine("packages"), "Serilog", "3.0.0")); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PickerLayoutTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PickerLayoutTests.cs new file mode 100644 index 0000000..1f14b82 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PickerLayoutTests.cs @@ -0,0 +1,259 @@ +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +public class PickerLayoutTests +{ + [Fact] + public void Mixed_height_entries_have_exact_whole_entry_page_boundaries() + { + var layout = Layout(80, 24, [1, 3, 2, 4, 1, 2, 3, 1, 5, 2, 1, 4, 2, 3]); + + Assert.Equal( + [ + new PickerLayout.Page(0, 6, 13, false), + new PickerLayout.Page(6, 5, 12, false), + new PickerLayout.Page(11, 3, 9, false), + ], + layout.Pages); + Assert.Equal(0, layout.PageIndexFor(5)); + Assert.Equal(1, layout.PageIndexFor(6)); + Assert.Equal(2, layout.PageIndexFor(13)); + Assert.Equal(22, layout.MaxFrameHeight); + } + + [Fact] + public void Only_an_oversized_entry_gets_a_scrolling_one_item_page() + { + var layout = Layout(80, 24, [2, 50, 2, 1]); + + Assert.Equal( + [ + new PickerLayout.Page(0, 1, 2, false), + new PickerLayout.Page(1, 1, 13, true), + new PickerLayout.Page(2, 2, 3, false), + ], + layout.Pages); + Assert.Equal(37, layout.MaxScroll(1)); + Assert.Equal(0, layout.MaxScroll(0)); + Assert.Equal(0, layout.MaxScroll(2)); + Assert.Equal(23, layout.MaxFrameHeight); + } + + [Fact] + public void Forty_six_columns_measure_wrapped_rows_and_wrapped_controls() + { + var items = Enumerable.Range(1, 24) + .Select(number => new SkillPickerItem($"skill-{number:00}", $"Package.{number}", "1.0.0")) + .ToArray(); + + var layout = PickerLayout.For(items, "Skills for App.slnx", PickerMode.Install, 46, 18, true); + + Assert.Equal(45, layout.Width); + Assert.All(layout.Entries, entry => + { + Assert.Equal(17, entry.DescriptionColumn); + Assert.Equal(["No description provided."], entry.Description); + }); + Assert.Equal([5, 5, 5, 5, 4], layout.Pages.Select(page => page.Count)); + Assert.Equal(17, layout.MaxFrameHeight); + Assert.Contains("(Press to select, to accept)", layout.Help); + Assert.Contains(" to cancel)", string.Join(" ", layout.Help)); + } + + [Fact] + public void A_normal_eighty_by_twenty_four_window_uses_its_real_row_budget() + { + var layout = Layout(80, 24, Enumerable.Repeat(1, 24).ToArray()); + + Assert.Equal(new PickerLayout.Page(0, 14, 14, false), layout.Pages[0]); + Assert.Equal(new PickerLayout.Page(14, 10, 10, false), layout.Pages[1]); + Assert.Equal(23, layout.MaxFrameHeight); + } + + [Fact] + public void A_wide_tall_window_has_no_fixed_item_or_width_ceiling() + { + var layout = Layout(240, 100, Enumerable.Repeat(1, 60).ToArray()); + + Assert.Equal(new PickerLayout.Page(0, 60, 60, false), Assert.Single(layout.Pages)); + Assert.Equal(68, layout.MaxFrameHeight); + Assert.True(layout.Width < 80); + Assert.DoesNotContain(layout.Help, line => line.Contains("change page", StringComparison.Ordinal)); + } + + [Fact] + public void A_wrapped_title_takes_space_away_from_entries_not_from_the_footer() + { + var items = Enumerable.Range(1, 24) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "short")) + .ToArray(); + var title = string.Join(" ", Enumerable.Repeat("titleword", 20)); + + var layout = PickerLayout.For(items, title, PickerMode.Install, 80, 24, true); + + Assert.Equal(3, layout.Header(0).Count); + Assert.Equal(12, layout.Pages[0].Count); + Assert.Equal(2, layout.Pages.Count); + Assert.Equal($"{title} page 1 of 2", string.Join(" ", layout.Header(0))); + Assert.Equal(23, layout.MaxFrameHeight); + } + + [Fact] + public void Page_counter_digit_growth_is_included_in_the_fixed_point() + { + var items = Enumerable.Range(1, 120) + .Select(number => new SkillPickerItem($"skill-{number:000}", "Package", "1.0.0")) + .ToArray(); + + var layout = PickerLayout.For(items, new string('t', 32), PickerMode.Install, 46, 18, true); + + Assert.Equal(30, layout.Pages.Count); + Assert.Single(layout.Header(0)); + Assert.Equal(2, layout.Header(29).Count); + Assert.Equal(29, layout.PageIndexFor(119)); + Assert.All(layout.Pages, page => Assert.Equal(4, page.Count)); + Assert.True(layout.MaxFrameHeight < 18); + } + + [Fact] + public void Summary_reservation_measures_attainable_counts_not_impossible_combinations() + { + var items = Enumerable.Range(1, 10) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "Short.")) + .ToArray(); + + var layout = PickerLayout.For(items, "Skills", PickerMode.Install, 45, 18, true); + + Assert.Equal( + [ + new PickerLayout.Page(0, 5, 5, false), + new PickerLayout.Page(5, 5, 5, false), + ], + layout.Pages); + Assert.Equal(17, layout.MaxFrameHeight); + } + + [Fact] + public void Package_metadata_does_not_consume_space_or_change_page_boundaries() + { + var items = Enumerable.Range(1, 12) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "A short description.")) + .ToArray(); + var verboseMetadata = items.Select(item => item with + { + Package = new string('p', 120), + Version = "1.0.0-a-very-long-prerelease-version", + }).ToArray(); + + var original = PickerLayout.For(items, "Skills", PickerMode.Install, 100, 24, true); + var verbose = PickerLayout.For(verboseMetadata, "Skills", PickerMode.Install, 100, 24, true); + + Assert.Equal(original.Width, verbose.Width); + Assert.Equal(original.Pages, verbose.Pages); + Assert.Equal(original.Entries.Select(entry => entry.Label), verbose.Entries.Select(entry => entry.Label)); + Assert.Equal( + original.Entries.SelectMany(entry => entry.Description), + verbose.Entries.SelectMany(entry => entry.Description)); + } + + [Fact] + public void Shorter_names_give_their_descriptions_more_space_to_wrap() + { + const string description = "One two three four five six seven eight nine ten."; + var layout = PickerLayout.For( + [ + new SkillPickerItem("alpha", "P", "1", description), + new SkillPickerItem("longer-skill", "P", "1", description), + ], + "Skills", PickerMode.Install, 46, 24, true); + + Assert.Equal(14, layout.Entries[0].DescriptionColumn); + Assert.Equal( + ["One two three four five six", "seven eight nine ten."], + layout.Entries[0].Description); + Assert.Equal(21, layout.Entries[1].DescriptionColumn); + Assert.Equal( + ["One two three four five", "six seven eight nine ten."], + layout.Entries[1].Description); + Assert.Equal(6, layout.ContinuationColumn); + Assert.Equal(new PickerLayout.Page(0, 2, 4, false), Assert.Single(layout.Pages)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Every_keyboard_hint_starts_with_Press_in_both_modes(bool uninstall) + { + var items = Enumerable.Range(1, 24) + .Select(number => new SkillPickerItem($"skill-{number:00}", "P", "1", "Short.")) + .ToArray(); + var layout = PickerLayout.For( + items, "Skills", uninstall ? PickerMode.Uninstall : PickerMode.Install, 100, 18, true); + + Assert.Contains(PickerLayout.PrimaryHelp, layout.Help); + Assert.Contains("(Press / to move, / for first/last)", layout.Help); + Assert.Contains("(Press /, / to change page)", layout.Help); + Assert.Contains("(Press to select all, to clear all, // to cancel)", layout.Help); + Assert.All(layout.Help.Where(line => line.StartsWith('(')), line => Assert.StartsWith("(Press <", line)); + Assert.Equal( + "(Press / to scroll description: 1-4/20)", + PickerLayout.ScrollHelp(1, 4, 20)); + } + + [Fact] + public void Both_pickers_share_one_legend_and_rows_start_in_the_same_column_without_color() + { + var items = new[] { new SkillPickerItem("alpha", "P", "1", "One two three four five six seven eight nine ten.") }; + + var install = PickerLayout.For(items, "Skills", PickerMode.Install, 46, 24, true); + var uninstall = PickerLayout.For(items, "Skills", PickerMode.Uninstall, 46, 24, true); + var plain = PickerLayout.For(items, "Skills", PickerMode.Uninstall, 46, 24, false); + + Assert.Equal(install.Help, uninstall.Help); + Assert.Contains("Blue X: selected", uninstall.Help); + Assert.All(plain.Help, line => Assert.StartsWith("(Press <", line)); + Assert.Equal(install.ContinuationColumn, plain.ContinuationColumn); + Assert.Equal(install.Entries[0].DescriptionColumn, plain.Entries[0].DescriptionColumn); + Assert.Equal(install.Entries[0].Description, plain.Entries[0].Description); + } + + [Fact] + public void A_window_too_small_for_the_note_drops_the_note_rather_than_the_checklist() + { + // At 46x18 the scrolling description needs every row the rest of the frame leaves, so the + // note under the title gives way. A roomier window keeps it. + const string note = "Installed skills aren't listed."; + const string title = "Which skills should be installed? (App.slnx)"; + var items = new[] + { + new SkillPickerItem("skill-01", "P", "1", "short"), + new SkillPickerItem("skill-02", "P", "1", string.Join(" ", Enumerable.Repeat("word", 200))), + }; + + var small = PickerLayout.For(items, title, PickerMode.Install, 46, 18, true, note); + var roomy = PickerLayout.For(items, title, PickerMode.Install, 100, 30, true, note); + + Assert.DoesNotContain(note, small.Header(0)); + Assert.True(small.MaxFrameHeight < 18); + Assert.Contains(note, roomy.Header(0)); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(5, 20)] + [InlineData(80, 4)] + public void Impossible_viewports_are_rejected_with_resize_guidance(int width, int height) + { + var error = Assert.Throws(() => Layout(width, height, [1, 2])); + + Assert.Contains("Enlarge the window", error.Message); + Assert.Contains($"{width}x{height}", error.Message); + } + + private static PickerLayout Layout(int width, int height, int[] rows) => PickerLayout.For( + rows.Select((count, index) => new SkillPickerItem( + $"skill-{index + 1:00}", "P", "1", + string.Join("\n", Enumerable.Range(1, count).Select(line => $"line {line:00}")))).ToArray(), + "Skills", PickerMode.Install, width, height, true); +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PipelineVersionTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PipelineVersionTests.cs new file mode 100644 index 0000000..666aab2 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PipelineVersionTests.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; + +namespace DotnetPackageSkills.Tests; + +public class PipelineVersionTests +{ + [Theory] + [InlineData(false, false, "Manual", "refs/heads/main", "", "12345", "0.1.0-ci.12345")] + [InlineData(false, false, "PullRequest", "refs/pull/42/merge", "42", "12345", "0.1.0-pr.42.12345")] + [InlineData(true, false, "IndividualCI", "refs/heads/main", "", "12345", "0.1.0-preview.12345")] + [InlineData(true, false, "Manual", "refs/heads/main", "", "12345", "0.1.0-preview.12345")] + [InlineData(true, true, "Manual", "refs/heads/main", "", "12345", "0.1.0")] + [InlineData(true, false, "IndividualCI", "refs/heads/main", "", "12346", "0.1.0-preview.12346")] + [InlineData(true, false, "IndividualCI", "refs/heads/main", "", "10000000", "0.1.0-preview.10000000")] + public async Task Pipeline_versions_distinguish_build_kinds( + bool official, bool release, string reason, string branch, string pullRequest, + string buildId, string expected) + { + var result = await Calculate("0.1.0", buildId, reason, branch, pullRequest, official, release); + + Assert.True(result.ExitCode == 0, result.Error); + Assert.Equal(expected, result.Output.Trim()); + } + + [Theory] + [InlineData("0.1.0", "12345", "Manual", "refs/heads/main", "", false, true, "manual official build")] + [InlineData("0.1.0", "12345", "IndividualCI", "refs/heads/main", "", true, true, "manual official build")] + [InlineData("0.1.0", "12345", "Manual", "refs/heads/feature", "", true, true, "manual official build")] + [InlineData("0.1.0", "12345", "PullRequest", "refs/pull/42/merge", "42", true, false, "cannot use the official signing path")] + [InlineData("0.1.0", "$(Build.BuildId)", "Manual", "refs/heads/main", "", false, false, "BuildId must be a positive integer")] + [InlineData("0.1.0", "12345", "PullRequest", "refs/pull/42/merge", "", false, false, "PullRequestNumber must be a positive integer")] + [InlineData("0.1.0", "12345", "PullRequest", "refs/pull/42/merge", "042", false, false, "PullRequestNumber must be a positive integer")] + [InlineData("01.0.0", "12345", "Manual", "refs/heads/main", "", false, false, "BaseVersion must be a three-part semantic version")] + public async Task Invalid_pipeline_versions_fail_explicitly( + string baseVersion, string buildId, string reason, string branch, string pullRequest, + bool official, bool release, string diagnostic) + { + var result = await Calculate(baseVersion, buildId, reason, branch, pullRequest, official, release); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains(diagnostic, result.Error); + Assert.Empty(result.Output.Trim()); + } + + private static async Task<(int ExitCode, string Output, string Error)> Calculate( + string baseVersion, string buildId, string reason, string branch, string pullRequest, + bool official, bool release) + { + var start = new ProcessStartInfo("pwsh") + { + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true, + }; + foreach (var argument in new[] + { + "-NoLogo", "-NoProfile", "-NonInteractive", "-File", + Path.Combine(AppContext.BaseDirectory, "Pipeline", "Get-PackageVersion.ps1"), + "-BaseVersion", baseVersion, "-BuildId", buildId, + "-BuildReason", reason, "-SourceBranch", branch, + }) + { + start.ArgumentList.Add(argument); + } + if (pullRequest.Length > 0) + { + start.ArgumentList.Add("-PullRequestNumber"); + start.ArgumentList.Add(pullRequest); + } + if (official) { start.ArgumentList.Add("-Official"); } + if (release) { start.ArgumentList.Add("-ReleaseBuild"); } + + using var process = Process.Start(start) ?? throw new InvalidOperationException("Could not start PowerShell."); + var output = process.StandardOutput.ReadToEndAsync(); + var error = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + throw new TimeoutException("Package version calculation did not finish."); + } + return (process.ExitCode, await output, await error); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDescriptionReaderTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDescriptionReaderTests.cs new file mode 100644 index 0000000..c764674 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDescriptionReaderTests.cs @@ -0,0 +1,439 @@ +using System.Text; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillDescriptionReaderTests +{ + [Theory] + [InlineData("description: Use widgets safely.", "Use widgets safely.")] + [InlineData("description: Use widgets. # Not part of the description", "Use widgets.")] + [InlineData("description: 'Use widgets: safely.'", "Use widgets: safely.")] + [InlineData("description: 'Use ''quoted'' names.'", "Use 'quoted' names.")] + [InlineData("description: \"Use \\\"quoted\\\" names and \\\\ paths.\"", "Use \"quoted\" names and \\ paths.")] + [InlineData("description: 'C:\\widgets\\skills'", "C:\\widgets\\skills")] + [InlineData("description: \"First\\nSecond\\t\\u263A\\U0001F680\"", "First\nSecond\t☺🚀")] + [InlineData("description: \" Keep surrounding spaces. \"", " Keep surrounding spaces. ")] + [InlineData("description: \"Keep\\e terminal\\r controls.\"", "Keep\u001b terminal\r controls.")] + [InlineData("description: First line\n continues here", "First line continues here")] + [InlineData("description: First paragraph\n\n second paragraph", "First paragraph\nsecond paragraph")] + [InlineData("description: \"First line\n continues here\"", "First line continues here")] + [InlineData("description: >\n First line\n continues here", "First line continues here\n")] + [InlineData("description: >-\n First paragraph\n continues here\n\n Second paragraph", "First paragraph continues here\nSecond paragraph")] + [InlineData("description: >+\n First line\n continues here\n", "First line continues here\n\n")] + [InlineData("description: >-\n First line\n indented line\n Last line", "First line\n indented line\nLast line")] + [InlineData("description: |\n First line\n Second line", "First line\nSecond line\n")] + [InlineData("description: |-\n First line\n Second line", "First line\nSecond line")] + [InlineData("description: |+\n First line\n Second line\n", "First line\nSecond line\n\n")] + [InlineData("description: |2-\n Indented line\n Last line", " Indented line\nLast line")] + [InlineData("description: |-\n ---\n ...\n description: Still scalar text", "---\n...\ndescription: Still scalar text")] + [InlineData("\"description\": A quoted key works.", "A quoted key works.")] + [InlineData("{description: 'A flow mapping works.', other: [one, two]}", "A flow mapping works.")] + [InlineData("description: 42", "42")] + [InlineData("description: true", "true")] + public void A_top_level_scalar_preserves_the_YAML_description_value(string yaml, string expected) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal(expected, result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("")] + [InlineData("# Only a comment")] + [InlineData("{}")] + [InlineData("name: widget-usage")] + [InlineData("metadata:\n description: Nested descriptions are not the skill description.")] + [InlineData("items:\n - description: Nested descriptions are not the skill description.")] + [InlineData("notes: |\n description: This is other scalar text.")] + [InlineData("? [description]\n: This is a collection key, not the description key.")] + [InlineData("Description: Keys are case-sensitive.")] + [InlineData("description:")] + [InlineData("description: # Empty")] + [InlineData("description: ''")] + [InlineData("description: \" \"")] + [InlineData("description: \"\\n\\t\\r\"")] + [InlineData("description: |\n \n ")] + public void Missing_or_blank_descriptions_have_no_warning(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Null(result.Description); + Assert.Null(result.Warning); + } + + [Fact] + public void Nested_descriptions_do_not_replace_the_top_level_description() + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, """ + before: + description: Ignore this. + description: Use this description. + after: + - description: Ignore this too. + """); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Use this description.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("")] + [InlineData("# Just Markdown\n---\ndescription: Not frontmatter.\n---")] + [InlineData("\n---\ndescription: Not at the beginning.\n---")] + [InlineData(" ---\ndescription: Indented delimiters do not open frontmatter.\n---")] + [InlineData("---not-a-delimiter\ndescription: Not frontmatter.\n---")] + public void Files_without_frontmatter_have_no_description_or_warning(string source) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", source); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Null(result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("Markdown")] + [InlineData("---not-a-delimiter")] + public void A_huge_Markdown_first_line_is_not_mistaken_for_oversized_frontmatter(string prefix) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", prefix + new string('x', 128 * 1024)); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Null(result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData(false, "\n")] + [InlineData(true, "\n")] + [InlineData(false, "\r\n")] + [InlineData(true, "\r\n")] + public void UTF8_BOM_and_line_endings_preserve_multiline_description_values(bool bom, string newline) + { + using var temp = new TempDirectory(); + var file = temp.CreateFile("SKILL.md"); + File.WriteAllText(file, $"---{newline}description: |{newline} First{newline} Second{newline}---{newline}", + new UTF8Encoding(encoderShouldEmitUTF8Identifier: bom)); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("First\nSecond\n", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("---")] + [InlineData("...")] + [InlineData("--- \t")] + [InlineData("... \t")] + public void Closing_delimiters_end_parsing_before_a_large_invalid_Markdown_body(string delimiter) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", $"--- \t\ndescription: Only the header.\n{delimiter}\n" + + "description: !invalid *alias\n" + new string('[', 128 * 1024)); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Only the header.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("---")] + [InlineData("...")] + public void A_closing_delimiter_does_not_require_a_final_newline(string delimiter) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", $"---\ndescription: Complete.\n{delimiter}"); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Complete.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData("description: [unfinished")] + [InlineData("description: \"unfinished")] + [InlineData("description: Value\n unexpected: mapping")] + [InlineData("description: Valid first\nlater: {unfinished")] + [InlineData("name: No description\nlater: [unfinished")] + [InlineData("description: Valid first\nlater:\n\tinvalid: indentation")] + public void Malformed_YAML_warns_instead_of_returning_partial_metadata(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md has malformed YAML frontmatter; fix the header."); + } + + [Theory] + [InlineData("description: [one, two]")] + [InlineData("description: []")] + [InlineData("description: {}")] + [InlineData("description:\n nested: mapping")] + public void Collection_descriptions_warn_instead_of_being_converted_to_text(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md description must be a YAML scalar; replace the collection with text."); + } + + [Theory] + [InlineData("Just a scalar")] + [InlineData("''")] + [InlineData("[description, value]")] + [InlineData("- description: Inside a sequence")] + public void Non_mapping_frontmatter_explains_the_expected_shape(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter must be a YAML mapping; use 'description: ...'."); + } + + [Fact] + public void Duplicate_top_level_descriptions_warn_instead_of_choosing_one() + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, "description: First\ndescription: Second"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md has duplicate description keys; keep only one top-level description."); + } + + [Theory] + [InlineData("description: !!str Text")] + [InlineData("!!map {description: Text}")] + [InlineData("description: !custom Text")] + [InlineData("description: ! Text")] + [InlineData("description: Otherwise valid\nother: !custom value")] + public void Explicit_tags_are_rejected_without_type_deserialization(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md uses explicit YAML tags; remove the tags from its frontmatter."); + } + + [Theory] + [InlineData("description: &text Anchored text")] + [InlineData("description: *missing")] + [InlineData("&root {description: Text}")] + [InlineData("description: Otherwise valid\nother: *missing")] + [InlineData("a: &a [text]\nb: &b [*a, *a]\ndescription: *b")] + [InlineData("a: &a [*a]\ndescription: Otherwise valid")] + public void Anchors_and_aliases_are_rejected_without_expansion(string yaml) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, yaml); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md uses YAML anchors or aliases; replace them with literal values."); + } + + [Fact] + public void The_nesting_limit_allows_32_collection_levels_including_the_root_mapping() + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, "other: " + new string('[', 31) + "leaf" + new string(']', 31) + + "\ndescription: Within the limit."); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal("Within the limit.", result.Description); + Assert.Null(result.Warning); + } + + [Theory] + [InlineData(32)] + [InlineData(5000)] + public void Excessive_nesting_warns_even_in_irrelevant_metadata(int levels) + { + using var temp = new TempDirectory(); + WriteFrontmatter(temp, "description: Valid first\nother: " + + new string('[', levels) + "leaf" + new string(']', levels)); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 32 levels of nesting; simplify the header."); + } + + [Fact] + public void The_character_limit_includes_delimiters_and_allows_exactly_64_Ki_characters() + { + using var temp = new TempDirectory(); + const string prefix = "---\ndescription: "; + const string suffix = "\n---"; + var description = new string('é', 64 * 1024 - prefix.Length - suffix.Length); + temp.CreateFile("SKILL.md", prefix + description + suffix); + + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal(description, result.Description); + Assert.Null(result.Warning); + } + + [Fact] + public void One_character_past_the_frontmatter_limit_warns() + { + using var temp = new TempDirectory(); + const string prefix = "---\ndescription: "; + const string suffix = "\n---"; + temp.CreateFile("SKILL.md", prefix + new string('x', 64 * 1024 + 1 - prefix.Length - suffix.Length) + suffix); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_huge_single_header_line_cannot_bypass_the_read_limit(bool terminated) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", "---\ndescription: " + new string('x', 256 * 1024) + + (terminated ? "\n---\n" : "")); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Fact] + public void Many_short_header_lines_share_one_read_limit() + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", "---\n" + string.Concat(Enumerable.Repeat("# Notes\n", 10_000)) + "---\n"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Fact] + public void A_huge_opening_delimiter_line_cannot_bypass_the_read_limit() + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", "---" + new string(' ', 64 * 1024)); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter exceeds 64 KiB of text; shorten the header."); + } + + [Theory] + [InlineData("---")] + [InlineData("---\n")] + [InlineData("---\ndescription: Not closed.")] + [InlineData("---\ndescription: |-\n An indented delimiter is scalar content.\n ---\n")] + public void Unterminated_frontmatter_explains_how_to_close_it(string source) + { + using var temp = new TempDirectory(); + temp.CreateFile("SKILL.md", source); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md frontmatter has no closing delimiter; add a closing '---' line."); + } + + [Fact] + public void A_missing_SKILL_file_warns_without_searching_subdirectories() + { + using var temp = new TempDirectory(); + temp.CreateFile("nested\\SKILL.md", "---\ndescription: Do not discover this.\n---\n"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md was not found; restore the file to read its description."); + } + + [Fact] + public void A_missing_skill_directory_warns() + { + using var temp = new TempDirectory(); + + AssertWarning(SkillDescriptionReader.Read(temp.Combine("missing")), + "SKILL.md was not found; restore the file to read its description."); + } + + [Fact] + public void An_unreadable_SKILL_path_warns() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("SKILL.md"); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md could not be read; check file permissions."); + } + + [Fact] + public void A_locked_SKILL_file_warns_instead_of_throwing() + { + using var temp = new TempDirectory(); + var file = WriteFrontmatter(temp, "description: Locked."); + using var locked = new FileStream(file, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + + AssertWarning(SkillDescriptionReader.Read(temp.Path), + "SKILL.md could not be read; check the file path, permissions, and whether it is in use."); + } + + [Fact] + public void An_invalid_skill_directory_path_warns() + { + using var temp = new TempDirectory(); + + AssertWarning(SkillDescriptionReader.Read(temp.Path + '\0'), + "SKILL.md has an invalid path; check the skill directory path."); + } + + [Theory] + [InlineData("---\r\ndescription: |\r\n Keep original bytes.\r\n---\r\n# Body\r\n", "Keep original bytes.\n", null)] + [InlineData("# Only Markdown", null, null)] + [InlineData("---\ndescription: [broken\n---\n", null, "SKILL.md has malformed YAML frontmatter; fix the header.")] + [InlineData("---\ndescription: *alias\n---\n", null, "SKILL.md uses YAML anchors or aliases; replace them with literal values.")] + public void Reading_leaves_every_file_byte_unchanged(string source, string? description, string? warning) + { + using var temp = new TempDirectory(); + var file = temp.CreateFile("SKILL.md"); + File.WriteAllText(file, source, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)); + var before = File.ReadAllBytes(file); + var attributes = File.GetAttributes(file); + File.SetAttributes(file, attributes | FileAttributes.ReadOnly); + try + { + var result = SkillDescriptionReader.Read(temp.Path); + + Assert.Equal(description, result.Description); + Assert.Equal(warning, result.Warning); + Assert.Equal(before, File.ReadAllBytes(file)); + } + finally + { + File.SetAttributes(file, attributes); + } + } + + private static string WriteFrontmatter(TempDirectory temp, string yaml) => + temp.CreateFile("SKILL.md", $"---\n{yaml}\n---\n"); + + private static void AssertWarning(SkillDescriptionResult result, string expected) + { + Assert.Null(result.Description); + Assert.Equal(expected, result.Warning); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDiscoveryTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDiscoveryTests.cs new file mode 100644 index 0000000..b6c09f2 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDiscoveryTests.cs @@ -0,0 +1,122 @@ +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillDiscoveryTests +{ + [Fact] + public void A_package_with_one_skill_uses_its_authored_folder_name_as_the_destination() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage"); + + var skill = Assert.Single(SkillDiscovery.Discover(package, "Mockly", "1.10.0")); + + Assert.Equal("mockly-usage", skill.RelativePath); + } + + [Fact] + public void A_package_with_several_skills_keeps_each_authored_folder_name() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage", "widget-testing"); + + var skills = SkillDiscovery.Discover(package, "Contoso.Widgets", "2.3.0"); + + Assert.Equal( + ["widget-testing", "widget-usage"], + skills.Select(s => s.RelativePath)); + } + + [Fact] + public void Discover_finds_each_subdirectory_of_the_skills_folder() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage", "widget-testing"); + + var skills = SkillDiscovery.Discover(package, "Contoso.Widgets", "2.3.0"); + + Assert.Equal(["widget-testing", "widget-usage"], skills.Select(s => s.SkillName)); + } + + [Fact] + public void Discover_keeps_package_version_as_metadata_only() + { + using var temp = new TempDirectory(); + var package = temp.CreatePackageWithSkill("Widgets", "2.0", "usage"); + + var skill = Assert.Single(SkillDiscovery.Discover(package, "Widgets", "2.0")); + + Assert.Equal("2.0", skill.PackageVersion); + Assert.Equal("usage", skill.RelativePath); + } + + [Fact] + public void Discover_returns_nothing_when_the_package_ships_no_skills_folder() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + temp.CreateFile("packages/newtonsoft.json/13.0.3/lib/net8.0/Newtonsoft.Json.dll"); + + Assert.Empty(SkillDiscovery.Discover(package, "Newtonsoft.Json", "13.0.3")); + } + + [Fact] + public void Discover_ignores_a_skill_placed_directly_in_the_skills_folder() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "widgets", "1.0.0"); + temp.CreateFile("packages/widgets/1.0.0/skills/SKILL.md", "---\nname: widgets\n---\n"); + + Assert.Empty(SkillDiscovery.Discover(package, "Widgets", "1.0.0")); + } + + [Fact] + public void Discover_ignores_subdirectories_without_a_skill_manifest() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "widgets", "1.0.0"); + temp.CreateFile("packages/widgets/1.0.0/skills/notes/readme.md", "not a skill"); + + Assert.Empty(SkillDiscovery.Discover(package, "Widgets", "1.0.0")); + } + + [Fact] + public void Discover_matches_the_skills_folder_regardless_of_casing() + { + using var temp = new TempDirectory(); + var package = temp.CreateDirectory("packages", "widgets", "1.0.0"); + temp.CreateFile("packages/widgets/1.0.0/Skills/usage/SKILL.md", "---\n---\n"); + + Assert.Single(SkillDiscovery.Discover(package, "Widgets", "1.0.0")); + } + + [Fact] + public void Discover_returns_nothing_for_a_package_that_is_not_on_disk() + { + using var temp = new TempDirectory(); + + Assert.Empty(SkillDiscovery.Discover(temp.Combine("nope"), "Ghost", "1.0.0")); + } + + [Theory] + [InlineData("widget-usage", true)] + [InlineData("Widget.Usage_2", true)] + [InlineData(".hidden-skill", true)] + [InlineData("...usage", true)] + [InlineData("skill name", true)] + [InlineData("..", false)] + [InlineData(".", false)] + [InlineData("...", false)] + [InlineData("....", false)] + [InlineData(".. ", false)] + [InlineData("... ", false)] + [InlineData("skill.", false)] + [InlineData("skill ", false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("a/b", false)] + [InlineData("a\\b", false)] + public void Skill_names_that_could_escape_the_destination_are_rejected(string name, bool expected) => + Assert.Equal(expected, SkillDiscovery.IsSafeSkillName(name)); +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallServiceTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallServiceTests.cs new file mode 100644 index 0000000..01bbc0a --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallServiceTests.cs @@ -0,0 +1,1082 @@ +using DotnetPackageSkills.Infrastructure; +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +/// +/// Exercises the whole flow with the dotnet CLI stubbed out, so the wiring between +/// listing, path resolution, discovery, and installation is covered without a restore. +/// +public class SkillInstallServiceTests +{ + private sealed class FakeDotnet(string globalPackagesFolder, string listPackageJson) : IProcessRunner + { + public List Invocations { get; } = []; + + public ProcessResult Run(string fileName, IReadOnlyList arguments, string? workingDirectory = null) + { + var line = string.Join(' ', arguments); + Invocations.Add(line); + + if (arguments.Contains("locals")) + { + return new ProcessResult(0, $"global-packages: {globalPackagesFolder}", string.Empty); + } + + if (arguments.Contains("list")) + { + return new ProcessResult(0, listPackageJson, string.Empty); + } + + if (arguments.Contains("restore")) + { + return new ProcessResult(0, "Restore succeeded.", string.Empty); + } + + throw new InvalidOperationException($"Unexpected invocation: {line}"); + } + } + + private static string Json(params (string Id, string Version)[] packages) + { + var entries = packages.Select(p => $$"""{ "id": "{{p.Id}}", "resolvedVersion": "{{p.Version}}" }"""); + + return $$""" + { + "projects": [ + { + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ {{string.Join(",", entries)}} ] } + ] + } + ] + } + """; + } + + private static InstallRequest Request(TempDirectory temp) => new() + { + Destination = ".agents/skills", + WorkingDirectory = temp.Path, + GlobalPackagesOverride = temp.Combine("packages"), + }; + + [Fact] + public void Install_copies_skills_from_packages_that_ship_them_and_ignores_the_rest() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Newtonsoft.Json", "13.0.3"))); + var result = new SkillInstallService(runner).Install(Request(temp)); + + Assert.Equal(2, result.PackagesScanned); + Assert.Equal("mockly", Assert.Single(result.Skills).RelativePath); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Target_install_rejects_incomplete_discovery_before_changing_skills(bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Existing", "1.0.0", "existing"); + var initial = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Existing", "1.0.0")))); + initial.Install(Request(temp)); + var manifestPath = temp.Combine(".agents", "skills", InstallManifest.FileName); + var before = File.ReadAllBytes(manifestPath); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"))); + var error = Assert.Throws(() => + new SkillInstallService(runner).Install(Request(temp) with { DryRun = dryRun })); + + Assert.Contains("Mockly 1.10.0", error.Message); + Assert.Contains("restore", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(before, File.ReadAllBytes(manifestPath)); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "existing", "SKILL.md"))); + } + + [Fact] + public void Discovery_treats_a_package_missing_from_the_cache_as_one_without_skills() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Missing", "1.0.0")))); + + var result = service.Discover(Request(temp)); + + Assert.Equal(1, result.PackagesScanned); + Assert.Empty(result.Skills); + Assert.Empty(result.Skipped); + Assert.False(Directory.Exists(result.Destination)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Missing_target_packages_also_block_refreshing_available_skills(bool interactive) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + var package = temp.CreatePackageWithSkill("Present", "1.0.0", "present"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Present", "1.0.0")))) + .Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var previous = File.ReadAllBytes(Path.Combine(destination, "present", "SKILL.md")); + var manifest = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + File.WriteAllText(Path.Combine(package, "skills", "present", "SKILL.md"), "new source content"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Present", "1.0.0"), ("Missing", "2.0.0")))); + var discovered = service.Discover(Request(temp)); + + Assert.Throws(() => + { + if (interactive) + { + service.PrepareInteractiveInstall( + Request(temp), discovered, SkillInstallService.InstalledSkills(destination, temp.Path)); + } + else + { + service.Install(Request(temp), discovered, null); + } + }); + + Assert.Equal(previous, File.ReadAllBytes(Path.Combine(destination, "present", "SKILL.md"))); + Assert.Equal(manifest, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + } + + [Fact] + public void Preparing_a_picker_excludes_conflicting_candidates_without_losing_the_warning() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "shared"); + temp.CreatePackageWithSkill("Beta", "2.0.0", "shared"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Alpha@1.0.0")] }); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Beta@2.0.0")] }; + var discovered = service.Discover(request); + var installed = SkillInstallService.InstalledSkills(discovered.Destination, temp.Path); + + var prepared = service.PrepareInteractiveInstall(request, discovered, installed); + var result = service.Install(request, prepared, + new SkillChoice([]) { ExpectedInstalled = installed }); + + Assert.Empty(prepared.Skills); + Assert.Contains("managed for alpha", Assert.Single(result.Skipped).Reason); + Assert.Empty(result.Removed); + Assert.False(result.DryRun); + Assert.Equal("alpha", Assert.Single(InstallManifest.Load(result.Destination).Packages).Key); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void An_interactive_target_install_stops_while_installed_skills_are_stale(bool versionChanged) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly", "mockly-testing"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))).Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + versionChanged + ? Json(("Mockly", "1.11.0"), ("Contoso.Widgets", "2.3.0")) + : Json(("Mockly", "1.10.0")))); + var discovered = service.Discover(Request(temp)); + + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + Request(temp), discovered, SkillInstallService.InstalledSkills(destination, temp.Path))); + + Assert.Contains( + versionChanged + ? "1 installed skill doesn't match the target: mockly (mockly 1.10.0)" + : "1 installed skill doesn't match the target: widget-usage (contoso.widgets 2.3.0)", + error.Message); + Assert.Contains("uninstall --stale", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void An_interactive_install_offers_only_the_skills_that_are_not_installed() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + var first = service.Discover(Request(temp)); + service.Install(Request(temp), first, new SkillChoice([.. first.Skills.Where(skill => skill.SkillName == "mockly-usage")])); + var destination = temp.Combine(".agents", "skills"); + var installed = SkillInstallService.InstalledSkills(destination, temp.Path); + + var prepared = service.PrepareInteractiveInstall(Request(temp), service.Discover(Request(temp)), installed); + + Assert.Equal("mockly-testing", Assert.Single(prepared.Skills).SkillName); + Assert.Empty(prepared.Removed); + Assert.Empty(prepared.Unreferenced); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-testing"))); + } + + [Fact] + public void An_interactive_install_with_nothing_new_offers_an_empty_list() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + var installed = SkillInstallService.InstalledSkills(temp.Combine(".agents", "skills"), temp.Path); + + var prepared = service.PrepareInteractiveInstall(Request(temp), service.Discover(Request(temp)), installed); + + Assert.Empty(prepared.Skills); + Assert.Equal(1, prepared.SkillsDiscovered); + Assert.Empty(prepared.Skipped); + } + + [Fact] + public void An_interactive_install_of_a_named_package_stops_when_another_version_is_installed() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }; + + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + request, service.Discover(request), SkillInstallService.InstalledSkills(destination, temp.Path))); + + Assert.Contains("Mockly 1.10.0 is already installed", error.Message); + Assert.Contains("'dotnet-package-skills uninstall --package Mockly'", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Theory] + [InlineData(".agents/skills", null, "--stale", true, "dotnet-package-skills uninstall --stale")] + [InlineData(".agents/skills/", null, "--stale", true, "dotnet-package-skills uninstall --stale")] + [InlineData(".claude/skills", null, "--stale", true, "dotnet-package-skills uninstall --stale --destination .claude/skills")] + [InlineData( + "my skills", "src/My App.slnx", "--stale", true, + "dotnet-package-skills uninstall --stale --target \"src/My App.slnx\" --destination \"my skills\"")] + [InlineData( + ".claude/skills", "src/App.slnx", "--package Mockly", false, + "dotnet-package-skills uninstall --package Mockly --destination .claude/skills")] + [InlineData( + @"C:\src\skills", null, "--stale", true, + "dotnet-package-skills uninstall --stale --destination \"C:\\src\\skills\"")] + public void Suggested_commands_repeat_the_target_and_destination_that_were_used( + string destination, string? target, string arguments, bool withTarget, string expected) + { + // A suggestion is only useful if running it as printed acts on the same skills folder, + // compared against the same project. + using var temp = new TempDirectory(); + var request = Request(temp) with { Destination = destination, Target = target }; + + Assert.Equal(expected, SkillInstallService.UninstallCommand(request, arguments, withTarget)); + } + + [Fact] + public void A_suggested_command_leaves_out_a_destination_that_is_the_default_spelled_in_full() + { + using var temp = new TempDirectory(); + var request = Request(temp) with { Destination = temp.Combine(".agents", "skills") }; + + Assert.Equal("dotnet-package-skills uninstall --stale", SkillInstallService.UninstallCommand(request, "--stale")); + } + + [Fact] + public void The_stale_hint_and_the_stale_stop_name_the_destination_that_was_used() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + var request = Request(temp) with { Destination = ".claude/skills" }; + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))).Install(request); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + + var result = service.Install(request); + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + request, service.Discover(request), SkillInstallService.InstalledSkills(".claude/skills", temp.Path))); + + const string Command = "dotnet-package-skills uninstall --stale --destination .claude/skills"; + Assert.Equal(Command, result.StaleCommand); + Assert.Contains($"Run '{Command}' first", error.Message); + } + + [Fact] + public void The_other_version_stop_names_the_destination_that_was_used() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with { Destination = ".claude/skills" }; + service.Install(request with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + var upgrade = request with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }; + + var error = Assert.Throws(() => service.PrepareInteractiveInstall( + upgrade, service.Discover(upgrade), SkillInstallService.InstalledSkills(".claude/skills", temp.Path))); + + Assert.Contains("'dotnet-package-skills uninstall --package Mockly --destination .claude/skills' first", error.Message); + } + + [Fact] + public void A_version_change_that_would_hand_a_skill_to_another_package_stops_with_a_command_for_this_destination() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "alpha-usage", "shared"); + temp.CreatePackageWithSkill("Alpha", "2.0.0", "alpha-usage"); + temp.CreatePackageWithSkill("Beta", "1.0.0", "shared"); + var request = Request(temp) with { Destination = ".claude/skills" }; + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Alpha", "1.0.0")))).Install(request); + var destination = temp.Combine(".claude", "skills"); + var before = Snapshot(destination); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Alpha", "2.0.0"), ("Beta", "1.0.0")))); + + var error = Assert.Throws(() => service.Install(request)); + + Assert.Contains("Alpha 2.0.0 no longer ships the installed skill 'shared'", error.Message); + Assert.Contains("'dotnet-package-skills uninstall --package Alpha --destination .claude/skills' first", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void An_interactive_install_of_a_named_package_ignores_other_installed_packages() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Contoso.Widgets@2.3.0")] }); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }; + var installed = SkillInstallService.InstalledSkills(temp.Combine(".agents", "skills"), temp.Path); + + var prepared = service.PrepareInteractiveInstall(request, service.Discover(request), installed); + + Assert.Equal("mockly", Assert.Single(prepared.Skills).SkillName); + } + + [Fact] + public void A_package_that_disappears_after_the_preview_also_blocks_acceptance() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Present", "1.0.0", "present"); + var emptyPackage = temp.CreateDirectory("packages", "empty", "1.0.0"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Present", "1.0.0"), ("Empty", "1.0.0")))); + var discovered = service.Discover(Request(temp)); + var prepared = service.PrepareInteractiveInstall(Request(temp), discovered, []); + Directory.Delete(emptyPackage); + + var error = Assert.Throws(() => + service.Install(Request(temp), prepared, new SkillChoice(prepared.Skills))); + + Assert.Contains("Empty 1.0.0", error.Message); + Assert.False(Directory.Exists(prepared.Destination)); + } + + [Fact] + public void Installation_prefers_the_current_owners_upgrade_over_an_earlier_named_collision() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Zeta", "1.0.0", "shared"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Zeta", "1.0.0")))) + .Install(Request(temp)); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "shared"); + var newer = temp.CreatePackageWithSkill("Zeta", "2.0.0", "shared"); + File.WriteAllText(Path.Combine(newer, "skills", "shared", "SKILL.md"), "updated owner"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Alpha", "1.0.0"), ("Zeta", "2.0.0")))); + + var discovered = service.Discover(Request(temp)); + Assert.Equal("Alpha", Assert.Single(discovered.Skills).PackageId); + var result = service.Install(Request(temp), discovered, choice: null); + + Assert.Equal("Zeta", Assert.Single(result.Skills).PackageId); + Assert.Equal("Alpha", Assert.Single(result.Skipped).PackageId); + Assert.Empty(result.Removed); + Assert.Equal("updated owner", File.ReadAllText(Path.Combine(result.Destination, "shared", "SKILL.md"))); + Assert.Equal("2.0.0", Assert.Single(InstallManifest.Load(result.Destination).Packages).Value.Version); + } + + [Fact] + public void Repeating_equivalent_package_coordinates_does_not_create_self_collisions() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "alpha"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + + var result = service.Install(Request(temp) with + { + Packages = [ + PackageCoordinate.Parse("Alpha@1.0"), + PackageCoordinate.Parse("alpha@1.0.0"), + PackageCoordinate.Parse("Alpha@1.0.0.0"), + ], + }); + + Assert.Equal(1, result.PackagesScanned); + Assert.Single(result.Skills); + Assert.Empty(result.Skipped); + } + + [Fact] + public void Install_auto_detects_the_solution_when_no_target_is_given() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + var result = new SkillInstallService(runner).Install(Request(temp)); + + Assert.EndsWith("MyApp.sln", result.Target); + } + + [Fact] + public void Install_honours_a_custom_destination() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"))); + new SkillInstallService(runner).Install(Request(temp) with { Destination = ".claude/skills" }); + + Assert.True(File.Exists(temp.Combine(".claude", "skills", "mockly", "SKILL.md"))); + } + + [Fact] + public void Discover_does_not_write_to_the_destination() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0"))); + var result = new SkillInstallService(runner).Discover(Request(temp)); + + Assert.Single(result.Skills); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Fact] + public void Install_never_requests_transitive_packages() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + new SkillInstallService(runner).Install(Request(temp)); + + Assert.DoesNotContain(runner.Invocations, line => line.Contains("--include-transitive")); + } + + [Fact] + public void Install_passes_the_target_before_the_package_verb() + { + // `dotnet list package` is the required order; the reverse silently + // lists the packages of whatever project is in the current directory instead. + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + new SkillInstallService(runner).Install(Request(temp)); + + var listCall = Assert.Single(runner.Invocations, line => line.StartsWith("list", StringComparison.Ordinal)); + Assert.Matches(@"^list .*MyApp\.sln package ", listCall); + } + + [Fact] + public void An_upgrade_replaces_the_previous_version_end_to_end() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + var upgraded = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.11.0")))); + var result = upgraded.Install(Request(temp)); + + Assert.Empty(result.Removed); + Assert.True(Directory.Exists(temp.Combine(".agents", "skills", "mockly"))); + Assert.Equal( + "1.11.0", + Assert.Single(InstallManifest.Load(temp.Combine(".agents", "skills")).Packages).Value.Version); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void A_solution_whose_projects_disagree_on_a_version_stops_every_install_mode(bool interactive, bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + + const string json = """ + { + "projects": [ + { + "path": "/repo/src/Api/Api.csproj", + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ { "id": "Mockly", "resolvedVersion": "1.10.0" } ] } + ] + }, + { + "path": "/repo/src/Worker/Worker.csproj", + "frameworks": [ + { "framework": "net8.0", "topLevelPackages": [ { "id": "Mockly", "resolvedVersion": "1.11.0" } ] } + ] + } + ] + } + """; + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), json)); + var request = Request(temp) with { DryRun = dryRun }; + + var error = Assert.Throws(() => + { + if (interactive) + { + service.PrepareInteractiveInstall( + request, service.Discover(request), SkillInstallService.InstalledSkills(destination, temp.Path)); + } + else + { + service.Install(request); + } + }); + + Assert.Contains("Mockly (1.10.0, 1.11.0)", error.Message); + Assert.Contains("Central Package Management", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void Two_versions_of_a_package_that_ships_no_skills_also_stop_the_install() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreateDirectory("packages", "newtonsoft.json", "12.0.3"); + temp.CreateDirectory("packages", "newtonsoft.json", "13.0.3"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + Json(("Mockly", "1.10.0"), ("Newtonsoft.Json", "12.0.3"), ("Newtonsoft.Json", "13.0.3")))); + + var error = Assert.Throws(() => service.Install(Request(temp))); + + Assert.Contains("Newtonsoft.Json (12.0.3, 13.0.3)", error.Message); + Assert.DoesNotContain("Mockly", error.Message); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Fact] + public void List_still_shows_what_each_version_ships_when_a_package_has_two() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Mockly", "1.11.0")))); + + var result = service.Discover(Request(temp)); + + Assert.Equal(["mockly", "mockly-testing"], result.Skills.Select(skill => skill.SkillName)); + Assert.Equal("1.11.0", Assert.Single(result.Skipped).PackageVersion); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Naming_one_package_with_two_versions_stops_the_install(bool interactive) + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly-testing"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with + { + Packages = [PackageCoordinate.Parse("Mockly@1.10.0"), PackageCoordinate.Parse("mockly@1.11")], + }; + + var error = Assert.Throws(() => + { + if (interactive) + { + service.PrepareInteractiveInstall(request, service.Discover(request), []); + } + else + { + service.Install(request); + } + }); + + Assert.Contains("Mockly (1.10.0, 1.11)", error.Message); + Assert.Contains("one version per package", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.False(Directory.Exists(temp.Combine(".agents"))); + } + + [Fact] + public void Skills_from_different_packages_that_share_a_name_keep_the_first_and_warn() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Alpha.Widgets", "1.0.0", "shared-skill"); + temp.CreatePackageWithSkill("Beta.Widgets", "1.0.0", "SHARED-SKILL"); + + var runner = new FakeDotnet( + temp.Combine("packages"), + Json(("Beta.Widgets", "1.0.0"), ("Alpha.Widgets", "1.0.0"))); + var result = new SkillInstallService(runner).Install(Request(temp)); + + Assert.Equal("Alpha.Widgets", Assert.Single(result.Skills).PackageId); + Assert.Equal("Beta.Widgets", Assert.Single(result.Skipped).PackageId); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "shared-skill", "SKILL.md"))); + } + + [Fact] + public void Install_takes_skills_from_an_explicitly_named_package_without_a_project() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + // No solution or project exists in the temp directory at all. + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + var result = new SkillInstallService(runner).Install( + Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + + Assert.Null(result.Target); + Assert.Equal("mockly", Assert.Single(result.Skills).RelativePath); + Assert.DoesNotContain(runner.Invocations, line => line.StartsWith("list", StringComparison.Ordinal)); + } + + [Fact] + public void Naming_a_package_explicitly_does_not_prune_skills_installed_from_a_project() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + // Naming one package says nothing about the others, so this must be additive. + var result = service.Install( + Request(temp) with { Packages = [PackageCoordinate.Parse("Contoso.Widgets@2.3.0")] }); + + Assert.Empty(result.Removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "widget-usage", "SKILL.md"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_target_install_keeps_skills_whose_package_left_the_project_and_reports_them(bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))).Install(Request(temp)); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + + var result = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp) with { DryRun = dryRun }); + + Assert.Empty(result.Removed); + Assert.Equal(new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage"), Assert.Single(result.Unreferenced)); + Assert.True(File.Exists(Path.Combine(destination, "widget-usage", "SKILL.md"))); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void A_target_upgrade_removes_the_skills_the_new_version_dropped() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-migration"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly-usage"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp)); + + var result = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.11.0")))) + .Install(Request(temp)); + + Assert.Equal("mockly-usage", Assert.Single(result.Skills).SkillName); + Assert.Equal(new TrackedSkill("mockly", "1.10.0", "mockly-migration"), Assert.Single(result.Removed)); + Assert.Empty(result.Unreferenced); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "mockly-migration"))); + Assert.Equal("1.11.0", InstallManifest.Load(result.Destination).Packages["mockly"].Version); + } + + [Fact] + public void Naming_a_newer_version_upgrades_that_package_and_leaves_the_others_alone() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-migration"); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly-usage"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))); + service.Install(Request(temp)); + + var result = service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }); + + Assert.Equal("mockly-migration", Assert.Single(result.Removed).Skill); + Assert.Empty(result.Unreferenced); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "widget-usage", "SKILL.md"))); + var manifest = InstallManifest.Load(result.Destination); + Assert.Equal("1.11.0", manifest.Packages["mockly"].Version); + Assert.Equal("2.3.0", manifest.Packages["contoso.widgets"].Version); + } + + [Fact] + public void A_named_version_missing_from_the_cache_never_removes_anything() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly-usage", "mockly-migration"); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + + var result = service.Install(Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.11.0")] }); + + Assert.Empty(result.Skills); + Assert.Empty(result.Removed); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void An_explicitly_named_package_missing_from_the_cache_installs_nothing_without_an_error() + { + using var temp = new TempDirectory(); + temp.CreateDirectory("packages"); + + var runner = new FakeDotnet(temp.Combine("packages"), Json()); + var result = new SkillInstallService(runner).Install( + Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@9.9.9")] }); + + Assert.Equal(1, result.PackagesScanned); + Assert.Empty(result.Skills); + Assert.Empty(result.Skipped); + Assert.False(Directory.Exists(result.Destination)); + } + + [Fact] + public void A_selection_can_take_only_some_of_one_packages_skills() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill( + "Mockly", + "1.10.0", + "mockly-assertions", + "mockly-testing", + "mockly-usage", + "mockly-verification"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with { Packages = [PackageCoordinate.Parse("Mockly@1.10.0")] }; + + var discovered = service.Discover(request); + Assert.Equal(4, discovered.Skills.Count); + + var chosen = discovered.Skills + .Where(skill => skill.RelativePath is "mockly-assertions" or "mockly-usage") + .ToList(); + + var result = service.Install(request, discovered, new SkillChoice(chosen)); + + Assert.Equal( + ["mockly-assertions", "mockly-usage"], + result.Skills.Select(skill => skill.RelativePath)); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "mockly-testing"))); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "mockly-verification"))); + } + + [Fact] + public void A_selection_installs_only_the_skills_that_were_chosen() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0")))); + + var request = Request(temp); + var discovered = service.Discover(request); + var chosen = discovered.Skills.Where(skill => skill.RelativePath == "mockly").ToList(); + + var result = service.Install(request, discovered, new SkillChoice(chosen)); + + Assert.Equal("mockly", Assert.Single(result.Skills).RelativePath); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills", "widget-usage"))); + } + + [Fact] + public void A_selection_never_removes_an_installed_skill_it_does_not_mention() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + var request = Request(temp) with + { + Packages = + [ + PackageCoordinate.Parse("Mockly@1.10.0"), + PackageCoordinate.Parse("Contoso.Widgets@2.3.0"), + ], + }; + + service.Install(request); + + var discovered = service.Discover(request); + var keep = discovered.Skills.Where(skill => skill.RelativePath == "mockly").ToList(); + + var result = service.Install(request, discovered, new SkillChoice(keep)); + + Assert.Empty(result.Removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "widget-usage", "SKILL.md"))); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + } + + [Fact] + public void A_selection_in_a_dry_run_writes_nothing() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + var request = Request(temp) with { DryRun = true }; + var discovered = service.Discover(request); + + var result = service.Install(request, discovered, new SkillChoice(discovered.Skills)); + + Assert.Single(result.Skills); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_selection_never_removes_skills_the_packages_no_longer_offer(bool noCandidates) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "current", "stale"); + var initial = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + initial.Install(Request(temp)); + var service = noCandidates + ? new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())) + : initial; + if (!noCandidates) + { + Directory.Delete(temp.Combine("packages", "mockly", "1.10.0", "skills", "stale"), recursive: true); + } + + var discovered = service.Discover(Request(temp)); + var result = service.Install(Request(temp), discovered, new SkillChoice(discovered.Skills)); + + Assert.Empty(result.Removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "current", "SKILL.md"))); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "stale", "SKILL.md"))); + Assert.Equal(2, InstallManifest.Load(result.Destination).EnumerateSkills().Count()); + } + + [Fact] + public void Installed_skill_names_are_read_from_the_destination_manifest() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + var installed = SkillInstallService.InstalledSkillNames(temp.Combine(".agents", "skills")); + + Assert.Contains("mockly", installed); + // Destination names compare case-insensitively everywhere else, so they must here too. + Assert.Contains("MOCKLY", installed); + } + + [Fact] + public void Installed_skill_names_are_empty_for_a_destination_that_does_not_exist_yet() + { + using var temp = new TempDirectory(); + + Assert.Empty(SkillInstallService.InstalledSkillNames(temp.Combine("nowhere"))); + } + + [Fact] + public void Uninstall_version_filter_leaves_another_version_installed() + { + using var temp = new TempDirectory(); + temp.CreatePackageWithSkill("Mockly", "1.11.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + service.Install(Request(temp) with + { + Packages = [PackageCoordinate.Parse("Mockly@1.11.0")], + }); + + service.Uninstall(".agents/skills", temp.Path, "Mockly", "1.10.0", dryRun: false); + + Assert.True(Directory.Exists(temp.Combine(".agents", "skills", "mockly"))); + } + + [Fact] + public void Uninstall_reverses_an_install() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))); + service.Install(Request(temp)); + + var removed = service.Uninstall(".agents/skills", temp.Path, packageId: null, packageVersion: null, dryRun: false); + + Assert.Single(removed); + Assert.False(Directory.Exists(temp.Combine(".agents", "skills"))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Uninstall_stale_removes_skills_whose_package_left_the_target_or_changed_version(bool dryRun) + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + temp.CreatePackageWithSkill("Contoso.Widgets", "2.3.0", "widget-usage"); + temp.CreatePackageWithSkill("Alpha", "1.0.0", "alpha"); + new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), + Json(("Mockly", "1.10.0"), ("Contoso.Widgets", "2.3.0"), ("Alpha", "1.0.0")))).Install(Request(temp)); + temp.CreateFile(".agents/skills/team-notes/SKILL.md", "ours"); + var destination = temp.Combine(".agents", "skills"); + var before = Snapshot(destination); + // The cache is gone too: deciding what is stale needs only the references. + Directory.Delete(temp.Combine("packages"), recursive: true); + var runner = new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.11.0"), ("Alpha", "1.0.0"))); + var service = new SkillInstallService(runner); + + var references = service.ReadReferences(target: null, temp.Path); + var removed = service.Uninstall( + ".agents/skills", temp.Path, packageId: null, packageVersion: null, dryRun, staleAgainst: references.Packages); + + Assert.EndsWith("MyApp.sln", references.Target); + Assert.Equal( + [ + new TrackedSkill("mockly", "1.10.0", "mockly"), + new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage"), + ], + removed); + Assert.DoesNotContain(runner.Invocations, line => line.Contains("locals", StringComparison.Ordinal)); + if (dryRun) + { + Assert.Equal(before, Snapshot(destination)); + } + else + { + Assert.False(Directory.Exists(Path.Combine(destination, "mockly"))); + Assert.False(Directory.Exists(Path.Combine(destination, "widget-usage"))); + Assert.True(File.Exists(Path.Combine(destination, "alpha", "SKILL.md"))); + Assert.Equal("ours", File.ReadAllText(Path.Combine(destination, "team-notes", "SKILL.md"))); + Assert.Equal("alpha", Assert.Single(InstallManifest.Load(destination).Packages).Key); + } + } + + [Fact] + public void Uninstall_stale_keeps_a_skill_whose_installed_version_is_still_referenced_beside_another() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreatePackageWithSkill("Mockly", "1.10.0", "mockly"); + new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json(("Mockly", "1.10.0")))) + .Install(Request(temp)); + var service = new SkillInstallService(new FakeDotnet( + temp.Combine("packages"), Json(("Mockly", "1.10.0"), ("Mockly", "1.11.0")))); + + var references = service.ReadReferences(target: null, temp.Path); + var removed = service.Uninstall( + ".agents/skills", temp.Path, null, null, dryRun: false, staleAgainst: references.Packages); + + Assert.Empty(removed); + Assert.True(File.Exists(temp.Combine(".agents", "skills", "mockly", "SKILL.md"))); + } + + [Fact] + public void Deciding_what_is_stale_requires_a_solution_or_project() + { + using var temp = new TempDirectory(); + var service = new SkillInstallService(new FakeDotnet(temp.Combine("packages"), Json())); + + var error = Assert.Throws(() => + service.ReadReferences(target: null, temp.Path)); + + Assert.Contains("--target", error.Message); + } + + private static (string Path, string Contents)[] Snapshot(string root) => + Directory.Exists(root) + ? + [ + .. Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => (Path.GetRelativePath(root, path), Convert.ToHexString(File.ReadAllBytes(path)))), + ] + : []; +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallerTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallerTests.cs new file mode 100644 index 0000000..bab35a0 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallerTests.cs @@ -0,0 +1,943 @@ +using DotnetPackageSkills.NuGet; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillInstallerTests +{ + private readonly SkillInstaller _installer = new(); + + private static BundledSkill Skill( + TempDirectory temp, + string packageId, + string version, + string skillName) + { + var packageDirectory = temp.CreatePackageWithSkill(packageId, version, skillName); + + return new BundledSkill( + packageId, + version, + skillName, + Path.Combine(packageDirectory, "skills", skillName), + skillName); + } + + [Fact] + public void Install_copies_a_skill_to_its_authored_folder_name() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + Assert.True(File.Exists(Path.Combine(destination, "mockly", "SKILL.md"))); + } + + [Fact] + public void Install_copies_nested_files_such_as_references() + { + using var temp = new TempDirectory(); + var skill = Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"); + temp.CreateFile("packages/contoso.widgets/2.3.0/skills/widget-usage/references/batching.md", "rules"); + + _installer.Install(temp.Combine("dest"), [skill], dryRun: false); + + Assert.Equal( + "rules", + File.ReadAllText(temp.Combine("dest", "widget-usage", "references", "batching.md"))); + } + + [Fact] + public void Manifest_groups_skill_names_by_package_and_version() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install( + destination, + [ + Skill(temp, "Contoso.Widgets", "2.3.0", "contoso.widgets-widget-testing"), + Skill(temp, "Contoso.Widgets", "2.3.0", "contoso.widgets-widget-usage"), + Skill(temp, "Mockly", "1.10.0", "mockly"), + ], + dryRun: false); + + var manifest = InstallManifest.Load(destination); + Assert.Equal( + ["contoso.widgets-widget-testing", "contoso.widgets-widget-usage"], + manifest.Packages["contoso.widgets"].Skills); + + var json = File.ReadAllText(Path.Combine(destination, InstallManifest.FileName)); + Assert.Contains("\"packages\":", json); + Assert.Contains("\"skills\":", json); + Assert.DoesNotContain("\"path\":", json); + Assert.DoesNotContain("\"skill\":", json); + } + + [Fact] + public void Install_with_dryRun_writes_nothing() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: true); + + Assert.Single(outcome.Installed); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Install_removes_the_previous_version_when_a_package_is_upgraded() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.11.0", "mockly")], dryRun: false); + + Assert.True(Directory.Exists(Path.Combine(destination, "mockly"))); + Assert.Empty(outcome.Removed); + Assert.Equal("1.11.0", Assert.Single(InstallManifest.Load(destination).Packages).Value.Version); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void An_upgrade_removes_the_skills_the_new_version_no_longer_ships(bool dryRun) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly-usage"), Skill(temp, "Mockly", "1.10.0", "mockly-migration")], + dryRun: false); + var before = Snapshot(destination); + + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.11.0", "mockly-usage")], dryRun); + + Assert.Equal("mockly-usage", Assert.Single(outcome.Installed).SkillName); + Assert.Equal(new TrackedSkill("mockly", "1.10.0", "mockly-migration"), Assert.Single(outcome.Removed)); + if (dryRun) + { + Assert.Equal(before, Snapshot(destination)); + } + else + { + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-migration"))); + var package = Assert.Single(InstallManifest.Load(destination).Packages); + Assert.Equal("1.11.0", package.Value.Version); + Assert.Equal(["mockly-usage"], package.Value.Skills); + } + } + + [Fact] + public void An_upgrade_to_a_version_without_skills_removes_every_skill_of_that_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly-usage"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + + var outcome = _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "1.11.0"))); + + Assert.Equal("mockly-usage", Assert.Single(outcome.Removed).Skill); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-usage"))); + Assert.Equal("contoso.widgets", Assert.Single(InstallManifest.Load(destination).Packages).Key); + } + + [Fact] + public void The_same_version_never_removes_a_tracked_skill_it_does_not_ship() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly-usage"), Skill(temp, "Mockly", "1.10.0", "mockly-migration")], + dryRun: false); + + // A package version never changes, so a skill missing from it means the cache is not + // what it was. Guessing that the author removed the skill would delete it on a hunch. + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly-usage")], dryRun: false); + + Assert.Empty(outcome.Removed); + Assert.True(File.Exists(Path.Combine(destination, "mockly-migration", "SKILL.md"))); + Assert.Equal( + ["mockly-migration", "mockly-usage"], + InstallManifest.Load(destination).Packages["mockly"].Skills); + } + + [Fact] + public void Skills_of_packages_the_run_does_not_offer_are_kept_and_reported_as_untouched() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + + var outcome = _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + Assert.Empty(outcome.Removed); + Assert.Equal(new TrackedSkill("contoso.widgets", "2.3.0", "widget-usage"), Assert.Single(outcome.Untouched)); + Assert.True(File.Exists(Path.Combine(destination, "widget-usage", "SKILL.md"))); + Assert.Equal(["contoso.widgets", "mockly"], InstallManifest.Load(destination).Packages.Keys); + } + + [Fact] + public void Install_keeps_the_first_skill_when_destination_names_collide() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var outcome = _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "shared-skill"), + Skill(temp, "Contoso.Widgets", "2.3.0", "shared-skill"), + ], + dryRun: false); + + Assert.Equal("Mockly", Assert.Single(outcome.Installed).PackageId); + Assert.Equal("Contoso.Widgets", Assert.Single(outcome.Skipped).PackageId); + Assert.True(File.Exists(Path.Combine(destination, "shared-skill", "SKILL.md"))); + } + + [Fact] + public void Install_detects_destination_collisions_case_insensitively() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var outcome = _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "shared-skill"), + Skill(temp, "Contoso.Widgets", "2.3.0", "SHARED-SKILL"), + ], + dryRun: false); + + Assert.Single(outcome.Installed); + Assert.Single(outcome.Skipped); + } + + [Fact] + public void Install_with_nothing_to_record_leaves_no_folder_behind() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + // Most packages ship no skills, so this is the common outcome. It should not leave an + // empty skills folder in a repository that never had one. + _installer.Install(destination, [], dryRun: false); + + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Removing_the_last_tracked_skill_on_an_upgrade_removes_the_manifest_like_uninstall_does() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "1.11.0"))); + + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Removing_everything_still_keeps_a_folder_holding_hand_authored_skills() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var handAuthored = temp.CreateFile("dest/our-own-skill/SKILL.md", "ours"); + + _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "1.11.0"))); + + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + Assert.Equal("ours", File.ReadAllText(handAuthored)); + } + + [Fact] + public void Install_never_touches_skills_it_did_not_install() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + // Removal is driven by the manifest, so a hand-authored skill sitting alongside + // package-provided ones has to survive every install. + Directory.CreateDirectory(destination); + var handAuthored = Path.Combine(destination, "our-own-skill"); + Directory.CreateDirectory(handAuthored); + File.WriteAllText(Path.Combine(handAuthored, "SKILL.md"), "ours"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + _installer.Install(destination, [], dryRun: false, offered: Offer(("Mockly", "2.0.0"))); + + Assert.True(File.Exists(Path.Combine(handAuthored, "SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly"))); + } + + [Fact] + public void Install_skips_an_existing_untracked_destination_folder() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var existing = temp.CreateFile("dest/mockly/SKILL.md", "ours"); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly")], + dryRun: false); + + Assert.Empty(outcome.Installed); + Assert.Single(outcome.Skipped); + Assert.Equal("ours", File.ReadAllText(existing)); + } + + [Fact] + public void Install_skips_an_existing_file_at_the_destination_path() + { + using var temp = new TempDirectory(); + var destination = temp.CreateDirectory("dest"); + var existing = temp.CreateFile("dest/mockly", "ours"); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly")], + dryRun: false); + + Assert.Empty(outcome.Installed); + Assert.Single(outcome.Skipped); + Assert.Equal("ours", File.ReadAllText(existing)); + } + + [Fact] + public void Additive_install_skips_a_path_tracked_for_another_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "shared-skill")], + dryRun: false); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "shared-skill")], + dryRun: false, + offered: Offer()); + + Assert.Empty(outcome.Installed); + Assert.Single(outcome.Skipped); + Assert.Equal("contoso.widgets", Assert.Single(InstallManifest.Load(destination).Packages).Key); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Complete_install_preserves_a_path_owned_by_another_package(bool dryRun) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "shared-skill")], + dryRun: false); + var contents = File.ReadAllBytes(Path.Combine(destination, "shared-skill", "SKILL.md")); + var manifest = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "shared-skill")], + dryRun); + + Assert.Empty(outcome.Installed); + Assert.Empty(outcome.Removed); + Assert.Contains("managed for contoso.widgets", Assert.Single(outcome.Skipped).Reason); + Assert.Equal("contoso.widgets", Assert.Single(InstallManifest.Load(destination).Packages).Key); + Assert.Equal(contents, File.ReadAllBytes(Path.Combine(destination, "shared-skill", "SKILL.md"))); + Assert.Equal(manifest, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Moving_a_package_to_a_version_without_a_skill_that_another_package_ships_stops_before_any_change( + bool dryRun) + { + // Removing the old copy would hand its name to the other package, which takes an explicit + // uninstall. Keeping it would record the old version's copy under the new version, where + // no later run would ever remove it. + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Alpha", "1.0.0", "alpha-usage"), Skill(temp, "Alpha", "1.0.0", "shared")], + dryRun: false); + var before = Snapshot(destination); + + var error = Assert.Throws(() => _installer.Install( + destination, + [Skill(temp, "Alpha", "2.0.0", "alpha-usage"), Skill(temp, "Beta", "2.0.0", "shared")], + dryRun, + offered: Offer(("Alpha", "2.0.0"), ("Beta", "2.0.0")))); + + Assert.Contains( + "Alpha 2.0.0 no longer ships the installed skill 'shared', and Beta 2.0.0 ships a skill with that name", + error.Message); + Assert.Contains("'dotnet-package-skills uninstall --package Alpha' first", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Fact] + public void A_conflicting_copy_keeps_its_owner_when_the_owners_package_is_not_part_of_the_run() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Alpha", "1.0.0", "shared")], dryRun: false); + + var result = _installer.Install( + destination, + [Skill(temp, "Beta", "2.0.0", "shared")], + dryRun: false, + offered: Offer(("Beta", "2.0.0"))); + + Assert.Empty(result.Installed); + Assert.Empty(result.Removed); + Assert.Contains("managed for alpha 1.0.0", Assert.Single(result.Skipped).Reason); + Assert.Equal(new TrackedSkill("alpha", "1.0.0", "shared"), Assert.Single(result.Untouched)); + var alpha = InstallManifest.Load(destination).Packages["alpha"]; + Assert.Equal("1.0.0", alpha.Version); + Assert.Equal(["shared"], alpha.Skills); + } + + [Fact] + public void A_package_id_with_letters_outside_ascii_is_recorded_in_a_manifest_that_reads_back() + { + // NuGet accepts any Unicode letter in a package ID. A manifest that the tool's own reader + // refused would stop every later install and uninstall with advice that can't help. + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + _installer.Install(destination, [Skill(temp, "Contoso.Überlib", "1.0.0", "uber-usage")], dryRun: false); + + Assert.Equal("contoso.überlib", Assert.Single(InstallManifest.Load(destination).Packages).Key); + Assert.Single(_installer.Uninstall(destination, "Contoso.Überlib", null, dryRun: false)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_changed_owner_invalidates_an_interactive_selection_before_writing(bool uninstall) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Alpha", "1.0.0", "shared")], dryRun: false); + var observed = InstallManifest.Load(destination).EnumerateSkills().ToList(); + _installer.Uninstall(destination, null, null, dryRun: false); + _installer.Install(destination, [Skill(temp, "Beta", "2.0.0", "shared")], dryRun: false); + var before = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var error = Assert.Throws(() => + { + if (uninstall) + { + _installer.Uninstall(destination, null, null, false, ["shared"], observed); + } + else + { + _installer.Install(destination, [], dryRun: false, offered: Offer(), expectedInstalled: observed); + } + }); + + Assert.Contains("ownership changed", error.Message); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + Assert.True(File.Exists(Path.Combine(destination, "shared", "SKILL.md"))); + } + + [Fact] + public void Upgrading_one_package_leaves_the_other_packages_untouched() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Alpha", "1.0.0", "shared"), Skill(temp, "Beta", "1.0.0", "beta")], + dryRun: false); + + var result = _installer.Install(destination, [Skill(temp, "Alpha", "2.0.0", "shared")], dryRun: false); + + Assert.Empty(result.Skipped); + Assert.Single(result.Installed); + var manifest = InstallManifest.Load(destination); + Assert.Equal("2.0.0", manifest.Packages["alpha"].Version); + Assert.Equal("1.0.0", manifest.Packages["beta"].Version); + } + + [Fact] + public void An_only_adding_install_cannot_record_a_second_version_of_a_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly-usage")], dryRun: false); + var before = Snapshot(destination); + + var error = Assert.Throws(() => _installer.Install( + destination, [Skill(temp, "Mockly", "1.11.0", "mockly-testing")], dryRun: false, offered: Offer())); + + Assert.Contains("only one version", error.Message); + Assert.Equal(before, Snapshot(destination)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void A_source_that_disappears_after_discovery_blocks_all_writes(bool dryRun) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Old", "1.0.0", "old")], dryRun: false); + var first = Skill(temp, "Alpha", "1.0.0", "first"); + var missing = Skill(temp, "Beta", "1.0.0", "missing"); + Directory.Delete(missing.SourcePath, recursive: true); + var before = File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName)); + + var error = Assert.Throws(() => + _installer.Install(destination, [first, missing], dryRun: dryRun)); + + Assert.Contains("no longer available", error.Message); + Assert.Equal(before, File.ReadAllBytes(Path.Combine(destination, InstallManifest.FileName))); + Assert.True(File.Exists(Path.Combine(destination, "old", "SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(destination, "first"))); + } + + [Fact] + public void An_upgrade_reports_removed_tracking_entries_even_when_the_folder_was_already_deleted() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Alpha", "1.0.0", "gone")], dryRun: false); + Directory.Delete(Path.Combine(destination, "gone"), recursive: true); + + var preview = _installer.Install(destination, [], dryRun: true, offered: Offer(("Alpha", "2.0.0"))); + var applied = _installer.Install(destination, [], dryRun: false, offered: Offer(("Alpha", "2.0.0"))); + + Assert.Equal(preview.Removed, applied.Removed); + Assert.Equal("gone", Assert.Single(applied.Removed).Skill); + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + } + + [Fact] + public void An_only_adding_install_leaves_every_other_tracked_skill_alone() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + var installedCopy = temp.CreateFile("dest/mockly/local-notes.md", "edited after install"); + + var outcome = _installer.Install( + destination, + [Skill(temp, "Gamma", "1.0.0", "gamma")], + dryRun: false, + offered: Offer()); + + Assert.Equal("gamma", Assert.Single(outcome.Installed).SkillName); + Assert.Empty(outcome.Removed); + Assert.Equal("edited after install", File.ReadAllText(installedCopy)); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + Assert.Equal( + ["contoso.widgets", "gamma", "mockly"], + InstallManifest.Load(destination).Packages.Keys); + } + + [Fact] + public void Install_replaces_files_that_a_newer_package_version_dropped() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + + var first = Skill(temp, "Mockly", "1.10.0", "mockly"); + File.WriteAllText(Path.Combine(first.SourcePath, "obsolete.md"), "gone in the next version"); + _installer.Install(destination, [first], dryRun: false); + + // Reinstalling the same version from a source that no longer has the file must + // not leave the stale copy behind. + File.Delete(Path.Combine(first.SourcePath, "obsolete.md")); + _installer.Install(destination, [first], dryRun: false); + + Assert.False(File.Exists(Path.Combine(destination, "mockly", "obsolete.md"))); + } + + [Fact] + public void Install_clears_the_read_only_flag_that_restore_puts_on_cached_files() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + var skill = Skill(temp, "Mockly", "1.10.0", "mockly"); + + var source = Path.Combine(skill.SourcePath, "SKILL.md"); + File.SetAttributes(source, File.GetAttributes(source) | FileAttributes.ReadOnly); + + try + { + _installer.Install(destination, [skill], dryRun: false); + + var copied = Path.Combine(destination, "mockly", "SKILL.md"); + Assert.False(File.GetAttributes(copied).HasFlag(FileAttributes.ReadOnly)); + + // The real point: a second install must be able to overwrite the copy. + _installer.Install(destination, [skill], dryRun: false); + } + finally + { + File.SetAttributes(source, File.GetAttributes(source) & ~FileAttributes.ReadOnly); + } + } + + [Fact] + public void Uninstall_against_references_removes_only_stale_skills_within_a_chosen_list() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "mockly-usage"), + Skill(temp, "Mockly", "1.10.0", "mockly-setup"), + Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"), + ], + dryRun: false); + PackageReferenceInfo[] references = [new("Contoso.Widgets", "2.3.0"), new("Mockly", "1.11.0")]; + + var removed = _installer.Uninstall( + destination, packageId: null, packageVersion: null, dryRun: false, + only: ["mockly-setup", "widget-usage"], staleAgainst: references); + + // widget-usage was chosen, but the target still references its version. + Assert.Equal("mockly-setup", Assert.Single(removed).Skill); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly-usage"))); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + } + + [Fact] + public void Uninstall_removes_only_the_skills_it_was_given() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "mockly-usage"), + Skill(temp, "Mockly", "1.10.0", "mockly-setup"), + Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"), + ], + dryRun: false); + + // What the interactive picker hands back: an explicit list, not a package filter. + var removed = _installer.Uninstall( + destination, + packageId: null, + packageVersion: null, + dryRun: false, + only: ["mockly-setup", "widget-usage"]); + + Assert.Equal(["mockly-setup", "widget-usage"], removed.Select(entry => entry.Skill)); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly-usage"))); + Assert.False(Directory.Exists(Path.Combine(destination, "mockly-setup"))); + Assert.Single(InstallManifest.Load(destination).EnumerateSkills()); + } + + [Fact] + public void Uninstall_given_an_empty_list_removes_nothing() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + // Confirming the picker without ticking anything must not be read as "all of them". + var removed = _installer.Uninstall( + destination, + packageId: null, + packageVersion: null, + dryRun: false, + only: []); + + Assert.Empty(removed); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly"))); + } + + [Fact] + public void Uninstall_combines_a_chosen_list_with_a_package_filter() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [ + Skill(temp, "Mockly", "1.10.0", "mockly-usage"), + Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage"), + ], + dryRun: false); + + var removed = _installer.Uninstall( + destination, + packageId: "Mockly", + packageVersion: null, + dryRun: false, + only: ["mockly-usage", "widget-usage"]); + + // widget-usage was ticked but belongs to another package, so the filter still holds. + Assert.Equal("mockly-usage", Assert.Single(removed).Skill); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + } + + [Fact] + public void Uninstall_removes_everything_it_installed_including_the_manifest() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var removed = _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: false); + + Assert.Single(removed); + Assert.False(Directory.Exists(destination)); + } + + [Fact] + public void Uninstall_can_target_a_single_package() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly"), Skill(temp, "Contoso.Widgets", "2.3.0", "widget-usage")], + dryRun: false); + + _installer.Uninstall(destination, packageId: "mockly", packageVersion: null, dryRun: false); + + Assert.False(Directory.Exists(Path.Combine(destination, "mockly"))); + Assert.True(Directory.Exists(Path.Combine(destination, "widget-usage"))); + Assert.True(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + } + + [Fact] + public void Uninstall_with_dryRun_writes_nothing() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var removed = _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: true); + + Assert.Single(removed); + Assert.True(Directory.Exists(Path.Combine(destination, "mockly"))); + } + + [Fact] + public void Uninstall_on_an_untouched_folder_reports_nothing_to_do() + { + using var temp = new TempDirectory(); + + Assert.Empty(_installer.Uninstall(temp.Combine("dest"), packageId: null, packageVersion: null, dryRun: false)); + } + + [Fact] + public void Uninstall_leaves_hand_authored_skills_in_place() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", "mockly")], dryRun: false); + + var handAuthored = Path.Combine(destination, "our-own-skill"); + Directory.CreateDirectory(handAuthored); + File.WriteAllText(Path.Combine(handAuthored, "SKILL.md"), "ours"); + + _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: false); + + Assert.True(File.Exists(Path.Combine(handAuthored, "SKILL.md"))); + } + + [Fact] + public void A_corrupt_manifest_blocks_install_and_preserves_everything() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "already-installed")], + dryRun: false); + + var manifest = Path.Combine(destination, InstallManifest.FileName); + const string corrupt = "{ not json"; + File.WriteAllText(manifest, corrupt); + + var error = Assert.Throws(() => + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "not-installed")], + dryRun: false)); + + Assert.Contains(manifest, error.Message); + Assert.Contains("preserved", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(corrupt, File.ReadAllText(manifest)); + Assert.True(File.Exists(Path.Combine(destination, "already-installed", "SKILL.md"))); + Assert.False(Directory.Exists(Path.Combine(destination, "not-installed"))); + } + + [Fact] + public void A_corrupt_manifest_blocks_uninstall_and_preserves_everything() + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Mockly", "1.10.0", "mockly")], + dryRun: false); + + var manifest = Path.Combine(destination, InstallManifest.FileName); + const string corrupt = """ + <<<<<<< HEAD + {"installed":[]} + ======= + {"installed":[]} + >>>>>>> feature + """; + File.WriteAllText(manifest, corrupt); + + var error = Assert.Throws(() => + _installer.Uninstall( + destination, + packageId: null, + packageVersion: null, + dryRun: false)); + + Assert.Contains(manifest, error.Message); + Assert.Equal(corrupt, File.ReadAllText(manifest)); + Assert.True(File.Exists(Path.Combine(destination, "mockly", "SKILL.md"))); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Unsafe_manifest_aliases_block_all_changes_and_preserve_handwritten_skills(bool uninstall, bool dryRun) + { + foreach (var unsafeName in new[] { "...", "....", ".. ", "... ", "our-own-skill.", "our-own-skill " }) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install( + destination, + [Skill(temp, "Contoso.Widgets", "2.3.0", "already-installed")], + dryRun: false); + temp.CreateFile("dest/our-own-skill/SKILL.md", "handwritten guidance"); + temp.CreateFile("dest/our-own-skill/references/details.md", "handwritten reference"); + var next = Skill(temp, "Mockly", "1.10.0", "not-installed"); + var manifest = Path.Combine(destination, InstallManifest.FileName); + File.WriteAllText(manifest, $$""" + { + "version": 1, + "packages": { + "contoso.widgets": {"version":"2.3.0","skills":["already-installed"]}, + "mockly": {"version":"1.10.0","skills":["{{unsafeName}}"]} + } + } + """); + var before = Snapshot(destination); + + var error = Assert.Throws(() => + { + if (uninstall) + { + _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun); + } + else + { + _installer.Install(destination, [next], dryRun); + } + }); + + Assert.Contains("not a safe skill folder name", error.Message); + Assert.Contains("No skills were changed", error.Message); + Assert.Contains("preserved", error.Message); + Assert.Equal(before, Snapshot(destination)); + Assert.False(Directory.Exists(Path.Combine(destination, "not-installed"))); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Unsafe_candidate_paths_cannot_bypass_manifest_validation_or_prune_existing_skills(bool dryRun) + { + foreach (var unsafeName in new[] { "...", "..", "our-own-skill.", "our-own-skill ", "../outside" }) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest"); + _installer.Install(destination, [Skill(temp, "Old", "1.0.0", "old")], dryRun: false); + temp.CreateFile("dest/our-own-skill/SKILL.md", "handwritten guidance"); + temp.CreateFile("outside/SKILL.md", "outside the destination"); + var next = Skill(temp, "Alpha", "1.0.0", "next"); + var unsafeSkill = Skill(temp, "Beta", "1.0.0", "source") with + { + SkillName = unsafeName, + RelativePath = unsafeName, + }; + var before = Snapshot(temp.Path); + + var error = Assert.Throws(() => + _installer.Install(destination, [next, unsafeSkill], dryRun)); + + Assert.Contains("safe skill folder", error.Message); + Assert.Equal(before, Snapshot(temp.Path)); + } + } + + [Theory] + [InlineData(".hidden-skill")] + [InlineData("...usage")] + [InlineData("skill name")] + public void Safe_names_with_dots_or_spaces_remain_installable_and_removable(string skillName) + { + using var temp = new TempDirectory(); + var destination = temp.Combine("dest") + Path.DirectorySeparatorChar; + var handwritten = temp.CreateFile("dest/our-own-skill/SKILL.md", "ours"); + + _installer.Install(destination, [Skill(temp, "Mockly", "1.10.0", skillName)], dryRun: false); + Assert.True(File.Exists(Path.Combine(destination, skillName, "SKILL.md"))); + + var removed = _installer.Uninstall(destination, packageId: null, packageVersion: null, dryRun: false); + + Assert.Equal(skillName, Assert.Single(removed).Skill); + Assert.False(Directory.Exists(Path.Combine(destination, skillName))); + Assert.False(File.Exists(Path.Combine(destination, InstallManifest.FileName))); + Assert.Equal("ours", File.ReadAllText(handwritten)); + } + + private static (string Path, string Contents)[] Snapshot(string root) => + Directory.Exists(root) + ? + [ + .. Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories) + .OrderBy(path => path, StringComparer.Ordinal) + .Select(path => (Path.GetRelativePath(root, path), Convert.ToHexString(File.ReadAllBytes(path)))), + ] + : []; + + private static Dictionary Offer(params (string Id, string Version)[] packages) => + packages.ToDictionary(package => package.Id, package => package.Version, StringComparer.OrdinalIgnoreCase); +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillPickerTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillPickerTests.cs new file mode 100644 index 0000000..3c59aa2 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillPickerTests.cs @@ -0,0 +1,2194 @@ +using System.Text; +using DotnetPackageSkills.Cli; +using DotnetPackageSkills.Skills; + +namespace DotnetPackageSkills.Tests; + +public class SkillPickerTests +{ + private const string Title = "Skills for App.slnx"; + + [Theory] + [InlineData(0, true, false)] + [InlineData(8, true, false)] + [InlineData(58, true, false)] + [InlineData(8, false, false)] + [InlineData(0, true, true)] + [InlineData(8, true, true)] + [InlineData(58, true, true)] + [InlineData(8, false, true)] + public void The_first_frame_starts_at_the_top_regardless_of_the_shell_cursor( + int cursorRow, bool color, bool uninstall) + { + var terminal = new FakeTerminal(windowHeight: 70, windowWidth: 140) + { + SupportsColor = color, + }.Press(ConsoleKey.Escape); + terminal.WriteLine("previous shell output"); + terminal.SetCursorPosition(12, cursorRow); + var originalScreen = terminal.Screen; + var originalState = terminal.CaptureState(); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ClearViewport)) + { + Assert.True(terminal.IsInteractiveScreen); + } + }; + + new SkillPicker(terminal).Choose(Items(3), Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.StartsWith(Title, Assert.Single(terminal.Frames)); + Assert.Equal(0, Assert.Single(terminal.Writes, write => write.Text == Title).Top); + Assert.Equal(originalScreen, terminal.Screen); + Assert.Equal(cursorRow, terminal.FinalCursorTop); + Assert.Equal(originalState, terminal.CaptureState()); + Assert.Equal(1, terminal.ViewportClears); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void Picker_frames_are_isolated_from_prior_shell_output_and_removed_on_exit(ConsoleKey exit) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, exit); + terminal.WriteLine("earlier shell output"); + var before = terminal.Screen; + var cursor = terminal.CursorTop; + var state = terminal.CaptureState(); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey)) + { + Assert.True(terminal.IsInteractiveScreen); + Assert.DoesNotContain("earlier shell output", terminal.Screen); + } + }; + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(before, terminal.Screen); + Assert.Equal(cursor, terminal.CursorTop); + Assert.Equal(state, terminal.CaptureState()); + Assert.Contains(Title, terminal.LastPickerScreen); + Assert.DoesNotContain(Title, terminal.Screen); + Assert.Equal(1, terminal.ScreenEntries); + Assert.Equal(1, terminal.ScreenExits); + Assert.False(terminal.IsInteractiveScreen); + } + + [Fact] + public void Ctrl_C_restores_the_normal_screen_without_retaining_a_picker_copy() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A).PressWith(ConsoleModifiers.Control, ConsoleKey.C); + terminal.WriteLine("prior output"); + var before = terminal.Screen; + + Assert.Null(new SkillPicker(terminal).Choose(Items(24), Title)); + + Assert.Equal(before, terminal.Screen); + Assert.False(terminal.IsInteractiveScreen); + Assert.Equal(1, terminal.ScreenExits); + } + + [Fact] + public void Rendering_failure_still_restores_the_original_screen() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + terminal.WriteLine("prior output"); + var before = terminal.Screen; + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.Write) && terminal.CurrentStyle == TerminalStyle.Selected) + { + throw new IOException("failed during selection render"); + } + }; + + Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal(before, terminal.Screen); + Assert.False(terminal.IsInteractiveScreen); + Assert.Equal(1, terminal.ScreenExits); + } + + [Fact] + public void A_note_under_the_title_is_shown_on_every_page_and_fits_the_frame() + { + const string Note = "Installed skills aren't listed."; + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.PageDown, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title, PickerMode.Install, Note); + + Assert.All(terminal.Frames, frame => + { + var lines = frame.Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + Assert.StartsWith(Title, lines[0]); + Assert.Equal(Note, lines[1]); + Assert.Equal(string.Empty, lines[2]); + }); + Assert.Contains("page 2 of", terminal.Frames[1]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Picker_shows_one_page_of_skills_at_a_time() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("skill-01", frame); + Assert.Contains("skill-08", frame); + Assert.DoesNotContain("skill-09", frame); + Assert.Contains("page 1 of 3", frame); + } + + [Fact] + public void An_install_list_starts_with_nothing_ticked() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(3), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("[ ] skill-01", frame); + Assert.Contains("[ ] skill-02", frame); + Assert.Contains("[ ] skill-03", frame); + Assert.DoesNotContain("[X]", frame); + Assert.DoesNotContain("installed", frame); + Assert.Empty(choice!); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 0, 1).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 0, 2).Style); + } + + [Fact] + public void Neutral_rows_show_the_description_instead_of_a_status_column() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(4), Title); + + var rows = Rows(terminal.Frames[0]); + + Assert.Equal("[ ] skill-01 - No description provided.", rows[0]); + Assert.Equal("[ ] skill-02 - No description provided.", rows[1]); + } + + [Fact] + public void Ticking_a_new_skill_colors_only_its_X_blue_when_it_is_not_focused() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 0, 1).Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 2, 1, "X").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "[").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "]").Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 2, 1).Style); + Assert.Equal("[X] skill-01 - No description provided.", Rows(terminal.Frames[1])[0]); + Assert.DoesNotContain("will install", terminal.Frames[1]); + } + + [Fact] + public void An_install_list_never_mentions_removal() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.A, ConsoleKey.C, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.All(terminal.Frames, frame => + Assert.DoesNotContain("remove", frame, StringComparison.OrdinalIgnoreCase)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Both_pickers_draw_a_tick_the_same_way(bool uninstall) + { + // Each checklist does one thing, so a tick needs no second cue for what it does; the + // title and the summary say that. Only the X is blue, and the brackets follow the row. + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 1, 1, "[").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 1, 1, "]").Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 2, 1, "X").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "[").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 2, 1, "]").Style); + Assert.All(terminal.Frames, frame => + { + Assert.Contains("Blue X: selected", frame); + Assert.DoesNotContain("brackets", frame, StringComparison.OrdinalIgnoreCase); + }); + } + + [Fact] + public void Package_attribution_does_not_appear_after_the_authored_skill_name() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Contains("skill-01 - No description provided.", terminal.Frames[0]); + Assert.DoesNotContain("Package.1", terminal.Frames[0]); + Assert.DoesNotContain("1.0.0", terminal.Frames[0]); + } + + /// The skill rows of a frame, trimmed of the cursor column and padding. + private static List Rows(string frame) => + [ + .. frame.Split(Environment.NewLine) + .Where(line => line.Contains('[', StringComparison.Ordinal)) + .Select(line => line[line.IndexOf('[')..].TrimEnd()), + ]; + + private static TerminalWrite SkillSpan(FakeTerminal terminal, int frame, int skill) => + Assert.Single(terminal.FrameWrites[frame], write => + write.Text == $" skill-{skill:00}"); + + private static TerminalWrite CheckboxSpan(FakeTerminal terminal, int frame, int skill, string part) + { + var row = SkillSpan(terminal, frame, skill).Top; + return Assert.Single(terminal.FrameWrites[frame], write => write.Top == row && write.Text == part); + } + + [Fact] + public void Uninstalling_starts_with_nothing_ticked() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + // Every row is installed, and a tick deletes. Pre-ticking them would make a mistaken + // enter wipe the lot. + var choice = new SkillPicker(terminal) + .Choose(Items(5), Title, PickerMode.Uninstall); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void Uninstalling_a_ticked_row_says_how_many_skills_will_be_removed() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal) + .Choose(Items(3), Title, PickerMode.Uninstall); + + Assert.Equal("skill-01", Assert.Single(choice!)); + Assert.Equal("[X] skill-01 - No description provided.", Rows(terminal.Frames[1])[0]); + Assert.Contains("1 of 3 selected; 1 to remove", terminal.Frames[1]); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 1, 1).Style); + } + + [Fact] + public void Uninstalling_says_nothing_about_rows_left_alone() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall); + + var frame = terminal.Frames[0]; + Assert.DoesNotContain("installed", frame); + Assert.DoesNotContain("will install", frame); + Assert.Equal("[ ] skill-01 - No description provided.", Rows(frame)[0]); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 0, 1).Style); + } + + [Fact] + public void Uninstalling_counts_selected_removals_without_an_install_count() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(4), Title, PickerMode.Uninstall); + + Assert.Contains("0 of 4 selected; 0 to remove", terminal.Frames[0]); + Assert.Contains("1 of 4 selected; 1 to remove", terminal.Frames[1]); + Assert.DoesNotContain("to install", terminal.Frames[1]); + } + + [Fact] + public void Uninstalling_can_take_everything_with_one_key() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal) + .Choose(Items(24), Title, PickerMode.Uninstall); + + Assert.Equal(24, choice!.Count); + } + + [Fact] + public void Uninstalling_cancelled_removes_nothing() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Escape); + + Assert.Null(new SkillPicker(terminal) + .Choose(Items(5), Title, PickerMode.Uninstall)); + } + + [Fact] + public void Uninstalling_without_a_terminal_says_what_to_do_instead() + { + var terminal = new FakeTerminal { IsRedirected = true }; + + var error = Assert.Throws( + () => new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall)); + + // The install wording tells you to drop the flag and install everything, which is the + // opposite of what this command would then do. It also can't point at --package, which + // uninstall --stale refuses. + Assert.Contains("remove every skill that the command matches", error.Message); + Assert.DoesNotContain("--package", error.Message); + } + + [Fact] + public void Pressing_enter_immediately_installs_nothing() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(5), Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void Moving_past_the_last_item_on_a_page_shows_the_next_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.DownArrow, times: 8).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[8]; + Assert.Contains("page 2 of 3", frame); + Assert.Contains("> [ ] skill-09", frame); + Assert.DoesNotContain("skill-08", frame); + } + + [Fact] + public void Moving_up_from_the_first_skill_wraps_to_the_last_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.UpArrow).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[1]; + Assert.Contains("page 3 of 3", frame); + Assert.Contains("> [ ] skill-24", frame); + } + + [Fact] + public void Right_arrow_pages_forward_without_moving_within_the_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("> [ ] skill-09", terminal.Frames[1]); + } + + [Fact] + public void Moving_down_from_the_last_skill_wraps_to_the_first_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.End, ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[2]; + Assert.Contains("page 1 of 3", frame); + Assert.Contains("> [ ] skill-01", frame); + } + + [Fact] + public void Left_arrow_pages_back() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.RightArrow, ConsoleKey.RightArrow, ConsoleKey.LeftArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("page 3 of 3", terminal.Frames[2]); + + var frame = terminal.Frames[3]; + Assert.Contains("page 2 of 3", frame); + Assert.Contains("> [ ] skill-09", frame); + } + + [Fact] + public void Page_up_and_page_down_page_like_the_arrows() + { + var terminal = new FakeTerminal().Press(ConsoleKey.PageDown, ConsoleKey.PageUp, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("page 2 of 3", terminal.Frames[1]); + Assert.Contains("page 1 of 3", terminal.Frames[2]); + } + + [Fact] + public void Home_and_end_jump_to_the_first_and_last_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.End, ConsoleKey.Home, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Contains("> [ ] skill-24", terminal.Frames[1]); + Assert.Contains("> [ ] skill-01", terminal.Frames[2]); + } + + [Fact] + public void Space_selects_the_focused_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.NotNull(choice); + Assert.Equal("skill-01", Assert.Single(choice)); + } + + [Fact] + public void Pressing_space_again_unticks_the_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void A_selects_every_skill_on_every_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.NotNull(choice); + Assert.Equal(24, choice.Count); + } + + [Fact] + public void C_clears_every_skill_on_every_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.C, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + } + + [Fact] + public void The_install_summary_counts_selections_only() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(5), Title); + + Assert.Contains("0 of 5 selected", terminal.Frames[0]); + Assert.Contains("1 of 5 selected", terminal.Frames[1]); + Assert.DoesNotContain("to install", terminal.Frames[1]); + Assert.DoesNotContain("to remove", terminal.Frames[1]); + } + + [Fact] + public void Ctrl_c_cancels_without_choosing_anything() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A).PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + Assert.Null(new SkillPicker(terminal).Choose(Items(24), Title)); + } + + [Fact] + public void Ctrl_c_is_not_mistaken_for_the_clear_all_key() + { + var terminal = new FakeTerminal().PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + // A bare 'c' clears the selection and keeps going, so the modifier has to win. + Assert.Null(new SkillPicker(terminal).Choose(Items(5), Title)); + } + + [Fact] + public void Ctrl_c_restores_the_terminal_on_the_way_out() + { + var terminal = new FakeTerminal().PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + } + + [Fact] + public void Ctrl_c_is_taken_as_a_key_rather_than_killing_the_process() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Escape); + + new SkillPicker(terminal).Choose(Items(3), Title); + + // Left to the runtime, Ctrl+C ends the process mid-frame and the cursor is never + // put back. Capturing it is what makes the restore reachable at all. + Assert.True(terminal.ControlCWasEverTakenAsInput); + Assert.False(terminal.IsControlCTakenAsInput); + } + + [Fact] + public void Cancelling_on_a_partial_page_parks_the_picker_then_restores_the_shell_cursor() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow); + terminal.PressWith(ConsoleModifiers.Control, ConsoleKey.C); + + new SkillPicker(terminal).Choose(Items(12), Title); + + // Four complete entries, nine measured chrome rows, and the closing blank line. + Assert.Equal(14, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + } + + [Fact] + public void A_partial_page_parks_the_cursor_under_the_footer_while_it_waits() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + // Where the cursor rests between keys is where a prompt lands if the process is + // killed outright, which is what Ctrl+C does on a host that will not hand it over. + // Leaving it at the bottom of the reserved rows is the whitespace bug itself. + Assert.Equal(13, terminal.CursorTopAwaitingKey); + } + + [Fact] + public void A_full_page_parks_the_cursor_under_the_footer_while_it_waits() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + Assert.Equal(17, terminal.CursorTopAwaitingKey); + } + + [Fact] + public void Cancelling_on_a_full_page_restores_the_original_screen_cursor() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Escape); + + new SkillPicker(terminal).Choose(Items(12), Title); + + Assert.Equal(17, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + } + + [Fact] + public void Escape_cancels_without_choosing_anything() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A, ConsoleKey.Escape); + + Assert.Null(new SkillPicker(terminal).Choose(Items(3), Title)); + } + + [Fact] + public void Q_cancels_without_choosing_anything() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Q); + + Assert.Null(new SkillPicker(terminal).Choose(Items(3), Title)); + } + + [Fact] + public void A_tall_window_shows_more_skills_per_page() + { + var terminal = new FakeTerminal(windowHeight: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // The entire list and its measured, non-paging footer fit in this viewport. + var frame = terminal.Frames[0]; + Assert.Contains("skill-24", frame); + Assert.DoesNotContain("page 1 of", frame); + } + + [Fact] + public void A_window_taller_than_the_list_does_not_page_at_all() + { + var terminal = new FakeTerminal(windowHeight: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // Nothing to page to, so the paging key is dropped along with the counter. + Assert.DoesNotContain("change page", terminal.Frames[0]); + } + + [Fact] + public void The_page_is_the_window_height_not_a_fixed_ceiling() + { + // Eighteen entries fit beside the measured footer. A fixed ten-row cap would hide + // eight entries that fit, even before descriptions change their rendered heights. + var terminal = new FakeTerminal(windowHeight: 28).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(60), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("skill-18", frame); + Assert.DoesNotContain("skill-19", frame); + Assert.Contains("page 1 of 4", frame); + } + + [Fact] + public void A_short_window_shrinks_the_page_rather_than_overflowing_it() + { + var terminal = new FakeTerminal(windowHeight: 12).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("skill-02", frame); + Assert.DoesNotContain("skill-03", frame); + Assert.Contains("page 1 of 12", frame); + } + + [Fact] + public void A_window_too_short_for_essential_controls_gives_actionable_guidance() + { + var terminal = new FakeTerminal(windowHeight: 4).Press(ConsoleKey.Enter); + + var error = Assert.Throws( + () => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains("too small", error.Message); + Assert.Contains("Enlarge the window", error.Message); + Assert.Contains("--package", error.Message); + Assert.Empty(terminal.Frames); + Assert.Empty(terminal.Writes); + } + + [Fact] + public void A_partial_last_page_puts_the_summary_under_its_final_skill() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + var lines = terminal.Frames[1].Split(Environment.NewLine); + var lastSkill = Array.FindIndex(lines, line => line.Contains("skill-12", StringComparison.Ordinal)); + var summary = Array.FindIndex(lines, line => line.Contains("of 12 selected", StringComparison.Ordinal)); + + Assert.True(lastSkill > 0, "the last skill should be on the page"); + // A blank separator, not padding out to a full page. + Assert.Equal(lastSkill + 2, summary); + } + + [Fact] + public void Paging_to_a_shorter_page_erases_what_the_taller_one_left_behind() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + // In-place redrawing can only erase by overwriting, so page one's rows have to be + // blanked rather than simply skipped. + var frame = terminal.Frames[1]; + Assert.DoesNotContain("skill-01", frame); + Assert.DoesNotContain("skill-08", frame); + Assert.Contains("skill-09", frame); + Assert.Contains("skill-12", frame); + } + + [Fact] + public void Paging_back_to_a_full_page_redraws_every_row() + { + var terminal = new FakeTerminal().Press(ConsoleKey.RightArrow, ConsoleKey.LeftArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(12), Title); + + var frame = terminal.Frames[2]; + Assert.Contains("skill-01", frame); + Assert.Contains("skill-08", frame); + Assert.DoesNotContain("skill-09", frame); + } + + [Fact] + public void A_full_last_page_is_unchanged_by_the_partial_page_handling() + { + var terminal = new FakeTerminal().Press(ConsoleKey.End, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(16), Title); + + var lines = terminal.Frames[1].Split(Environment.NewLine); + var lastSkill = Array.FindIndex(lines, line => line.Contains("skill-16", StringComparison.Ordinal)); + var summary = Array.FindIndex(lines, line => line.Contains("of 16 selected", StringComparison.Ordinal)); + + Assert.Equal(lastSkill + 2, summary); + Assert.Contains("page 2 of 2", terminal.Frames[1]); + } + + [Fact] + public void The_frame_is_only_as_wide_as_its_content_on_a_wide_terminal() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + // Padding every row out to the window would strand the page counter at the far edge + // and trail whitespace far past the text it belongs to. + Assert.All( + terminal.Frames[0].Split(Environment.NewLine), + line => Assert.True(line.Length < 80, $"line is {line.Length} columns wide: '{line}'")); + } + + [Fact] + public void The_page_counter_sits_beside_the_title_not_at_the_far_edge() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var header = terminal.Frames[0].Split(Environment.NewLine)[0]; + Assert.EndsWith("page 1 of 3", header.TrimEnd()); + Assert.True(header.TrimEnd().Length < 80, $"header is {header.TrimEnd().Length} columns wide"); + } + + [Fact] + public void A_long_skill_name_still_widens_the_frame_to_fit() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + var name = new string('x', 40); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(name, "Some.Package", "1.0.0"), + ], + Title); + + Assert.Contains(name, terminal.Frames[0]); + } + + [Fact] + public void A_name_far_longer_than_any_constant_survives_on_a_wide_terminal() + { + var terminal = new FakeTerminal(windowWidth: 200).Press(ConsoleKey.Enter); + + // The column used to stop at a hardcoded 44, so this lost its tail with most of the + // window still empty beside it. The terminal is the only thing that gets to decide. + var name = "contoso.widgets-extremely-long-skill-name-that-keeps-going-and-going"; + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(name, "Contoso.Widgets", "2.3.0"), + ], + Title); + + Assert.Contains(name, terminal.Frames[0]); + Assert.DoesNotContain("...", terminal.Frames[0]); + } + + [Fact] + public void A_narrow_terminal_truncates_the_name_rather_than_overflowing_the_row() + { + var terminal = new FakeTerminal(windowWidth: 50).Press(ConsoleKey.Enter); + var name = "contoso.widgets-extremely-long-skill-name-that-keeps-going"; + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(name, "Contoso.Widgets", "2.3.0"), + ], + Title); + + var frame = terminal.Frames[0]; + Assert.Contains("...", frame); + Assert.All( + frame.Split(Environment.NewLine), + line => Assert.True(line.Length < 50, $"'{line}' is {line.Length} columns wide")); + } + + [Fact] + public void The_name_column_grows_with_the_terminal() + { + const string Name = "contoso.widgets-a-name-of-some-considerable-length-indeed"; + + static int DescriptionColumnAt(int windowWidth) + { + var terminal = new FakeTerminal(windowWidth: windowWidth).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem(Name, "Contoso.Widgets", "2.3.0"), + ], + Title); + + return terminal.Frames[0] + .Split(Environment.NewLine) + .Single(line => line.StartsWith("> [", StringComparison.Ordinal)) + .IndexOf(" - ", StringComparison.Ordinal); + } + + Assert.True(DescriptionColumnAt(140) > DescriptionColumnAt(70), "a wider terminal should give the name more room"); + } + + [Fact] + public void Rows_are_padded_so_a_shorter_frame_cannot_leave_the_previous_one_behind() + { + // Changing counts or focus must not leave an earlier row showing through. + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + var lengths = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.Length).Distinct(); + Assert.Single(lengths); + } + + [Fact] + public void A_single_skill_does_not_leave_a_page_of_blank_rows_behind_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(1), Title); + + // A single entry and its useful footer, not a page's worth of reserved skill rows. + Assert.Equal(8, terminal.Frames[0].Split(Environment.NewLine).Length); + } + + [Fact] + public void A_short_list_shrinks_the_frame_to_fit_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(11, terminal.Frames[0].Split(Environment.NewLine).Length); + } + + [Fact] + public void A_list_longer_than_a_page_still_fills_the_page() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Equal(17, terminal.Frames[0].Split(Environment.NewLine).Length); + } + + [Fact] + public void One_page_of_skills_shows_no_page_counter() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(4), Title); + + var frame = terminal.Frames[0]; + Assert.Contains(Title, frame); + Assert.DoesNotContain("page 1 of 1", frame); + // Nothing to page to, so offering the key would teach a control that does nothing. + Assert.DoesNotContain("change page", frame); + Assert.Contains("/ to move", frame); + } + + [Fact] + public void A_single_skill_offers_neither_paging_nor_movement() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(1), Title); + + var frame = terminal.Frames[0]; + Assert.DoesNotContain("/", frame); + Assert.DoesNotContain("change page", frame); + // With one skill, select-all and clear-all are a slower way to press space. + Assert.DoesNotContain("select all", frame); + Assert.DoesNotContain("clear all", frame); + Assert.Contains(PickerLayout.PrimaryHelp, frame); + Assert.Contains("(Press // to cancel)", frame); + } + + [Fact] + public void The_bottom_help_uses_Aspire_key_syntax_and_subdued_spans() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("(Press to select, to accept)", frame); + Assert.Contains("(Press / to move", frame); + Assert.Contains("(Press /, / to change page)", frame); + Assert.Contains("/ for first/last", frame); + Assert.Contains("(Press to select all", frame); + Assert.Contains(" to clear all", frame); + Assert.Contains("// to cancel", frame); + Assert.All( + terminal.FrameWrites[0].Where(write => write.Text.StartsWith("(", StringComparison.Ordinal)), + write => Assert.StartsWith("(Press <", write.Text)); + var help = Assert.Single(terminal.FrameWrites[0], write => write.Text == PickerLayout.PrimaryHelp); + Assert.Equal(TerminalStyle.Muted, help.Style); + Assert.True(help.Top > SkillSpan(terminal, 0, 8).Top); + } + + [Fact] + public void More_than_one_page_still_shows_the_counter_and_the_paging_key() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + var frame = terminal.Frames[0]; + Assert.Contains("page 1 of 3", frame); + Assert.Contains("(Press /, / to change page)", frame); + } + + [Fact] + public void A_single_skill_can_still_be_toggled_and_confirmed() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var choice = new SkillPicker(terminal).Choose(Items(1), Title); + + Assert.NotNull(choice); + Assert.Equal("skill-01", Assert.Single(choice)); + } + + [Fact] + public void Moving_within_a_single_page_never_leaves_it() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.DownArrow, ConsoleKey.DownArrow, ConsoleKey.RightArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.All( + terminal.Frames, + frame => Assert.Equal(11, frame.Split(Environment.NewLine).Length)); + Assert.Contains("> [ ] skill-03", terminal.Frames[^1]); + } + + [Fact] + public void The_frame_never_grows_beyond_the_rows_it_reserved() + { + var terminal = new FakeTerminal().Press(ConsoleKey.DownArrow, times: 30).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // Eight entry rows and nine measured chrome rows, however far the cursor travels. + Assert.All(terminal.Frames, frame => Assert.Equal(17, frame.Split(Environment.NewLine).Length)); + } + + [Fact] + public void The_frame_stays_ascii_so_a_legacy_console_code_page_renders_all_of_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(24), Title); + + // Windows consoles default to an OEM code page that silently drops arrows and box + // glyphs, so a legend built from them reads as gaps on the most common terminal. + Assert.All( + terminal.Frames[0].Replace(Environment.NewLine, string.Empty), + character => Assert.InRange(character, ' ', '~')); + } + + [Fact] + public void Long_skill_names_are_truncated_rather_than_wrapped() + { + var terminal = new FakeTerminal(windowWidth: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(2), Title); + + Assert.All( + terminal.Frames[0].Split(Environment.NewLine), + line => Assert.True(line.Length < 40, $"'{line}' is {line.Length} characters wide")); + } + + [Fact] + public void The_cursor_is_put_back_when_the_picker_leaves() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Escape); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.True(terminal.IsCursorVisible); + } + + [Fact] + public void A_redirected_terminal_is_refused_with_guidance() + { + var terminal = new FakeTerminal { IsRedirected = true }; + + var error = Assert.Throws( + () => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains("--interactive needs a terminal", error.Message); + Assert.Contains("--package", error.Message); + } + + [Fact] + public void Nothing_to_choose_between_never_prompts() + { + var terminal = new FakeTerminal { IsRedirected = true }; + + var choice = new SkillPicker(terminal).Choose([], Title); + + Assert.NotNull(choice); + Assert.Empty(choice); + Assert.Empty(terminal.Frames); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Both_modes_put_the_description_immediately_after_the_skill_name(bool uninstall) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80).Press(ConsoleKey.Enter); + var mode = uninstall ? PickerMode.Uninstall : PickerMode.Install; + + new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1.2.3", "Short description.")], Title, mode); + + var row = Assert.Single(Rows(terminal.Frames[0])); + Assert.Equal("[ ] alpha - Short description.", row); + var description = Assert.Single(terminal.FrameWrites[0], write => write.Text == " - Short description."); + Assert.Equal(11, description.Left); + Assert.Equal(TerminalStyle.Focus, description.Style); + Assert.Equal(1, terminal.Frames[0].Split("Short description.", StringSplitOptions.None).Length - 1); + Assert.DoesNotContain("will install", row); + Assert.DoesNotContain("will remove", row); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" \t\r\n ")] + [InlineData("\x1b[31m\x1b[0m\u202e")] + [InlineData("\u200d")] + public void Missing_or_invisible_description_text_has_an_explicit_fallback(string? description) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", description)], Title); + + Assert.Equal("alpha", Assert.Single(chosen!)); + Assert.Contains(" - No description provided.", terminal.Frames[0]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Description_warnings_are_visible_and_do_not_change_eligibility(bool uninstall) + { + var terminal = new FakeTerminal(windowWidth: 160).Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var mode = uninstall ? PickerMode.Uninstall : PickerMode.Install; + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", "stale text", "cannot read SKILL.md.")], + Title, mode); + + Assert.Equal("alpha", Assert.Single(chosen!)); + Assert.Contains(" - Description unavailable: cannot read SKILL.md.", terminal.Frames[0]); + Assert.DoesNotContain("stale text", terminal.Frames[0]); + Assert.DoesNotContain("No description provided.", terminal.Frames[0]); + } + + [Fact] + public void Descriptions_continue_at_the_skill_text_edge_without_a_name_sized_gap() + { + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1.2.3", + "One two three four five six seven eight nine ten.")], Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + Assert.Equal("> [ ] alpha - One two three four five six", lines[2]); + Assert.Equal(new string(' ', 6) + "seven eight nine ten.", lines[3]); + Assert.Equal(string.Empty, lines[4]); + Assert.Equal("0 of 1 selected", lines[5]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Descriptions_follow_differently_sized_names_without_padding() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem("alpha", "Pkg", "1.2.3", "First."), + new SkillPickerItem("beta", "Pkg", "1.2.3", "Second."), + ], + Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine); + Assert.Equal("> [ ] alpha - First.", lines[2].TrimEnd()); + Assert.Equal(" [ ] beta - Second.", lines[3].TrimEnd()); + var descriptions = terminal.FrameWrites[0].Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal)).ToArray(); + Assert.Equal([11, 10], descriptions.Select(write => write.Left)); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Picker_names_keep_the_package_prefix_but_omit_the_metadata_suffix(bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 100) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + const string name = "contoso.widgets-batching"; + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem(name, "Contoso.Widgets", "2.3.0", "Batching widget calls.")], + Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal(name, Assert.Single(chosen!)); + Assert.All(terminal.Frames, frame => + { + Assert.Contains($"{name} - Batching widget calls.", frame); + Assert.DoesNotContain("(Contoso.Widgets 2.3.0)", frame); + }); + AssertWithinWindow(terminal); + } + + [Fact] + public void Help_wraps_instead_of_truncating_the_Aspire_prompt() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 40).Press(ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(1), Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + var first = Array.FindIndex(lines, line => line.StartsWith("(Press ", StringComparison.Ordinal)); + Assert.Equal("(Press to select, to", lines[first]); + Assert.Equal("accept)", lines[first + 1]); + Assert.Equal("(Press to select, to accept)", $"{lines[first]} {lines[first + 1]}"); + Assert.Contains("(Press // to cancel)", string.Join(" ", lines)); + Assert.All(terminal.FrameWrites[0].Where(write => write.Top == first || write.Top == first + 1), + write => Assert.True(string.IsNullOrWhiteSpace(write.Text) || write.Style == TerminalStyle.Muted)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Ticked_additions_are_counted_and_only_their_X_is_colored() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(["skill-01", "skill-02"], chosen!.Order(StringComparer.Ordinal)); + Assert.Contains("2 of 3 selected", terminal.Frames[^1]); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 3, 1).Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 3, 1, "X").Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 3, 1, "[").Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 3, 2, "X").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 3, 2, "[").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 3, 2).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 3, 3).Style); + } + + [Fact] + public void Blue_focus_covers_the_whole_row_and_a_tick_away_from_focus_keeps_only_its_blue_x() + { + var terminal = new FakeTerminal() + .Press(ConsoleKey.Spacebar, ConsoleKey.DownArrow, ConsoleKey.Spacebar, ConsoleKey.UpArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall); + + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 1, 1).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 2, 1).Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 3, 2, "[").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 4, 1).Style); + Assert.Equal(TerminalStyle.Default, SkillSpan(terminal, 4, 2).Style); + Assert.Equal(TerminalStyle.Default, CheckboxSpan(terminal, 4, 2, "]").Style); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 4, 2, "X").Style); + Assert.All(terminal.FrameWrites, writes => + { + var focus = Assert.Single(writes, write => write.Text == ">"); + Assert.Equal(TerminalStyle.Focus, focus.Style); + Assert.Equal(0, focus.Left); + Assert.All(writes.Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal)), + write => Assert.Equal(write.Top == focus.Top ? TerminalStyle.Focus : TerminalStyle.Default, write.Style)); + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Focus_colors_every_wrapped_line_and_clears_from_the_previous_skill(bool uninstall) + { + var terminal = new FakeTerminal(windowHeight: 30, windowWidth: 80) + .Press(ConsoleKey.DownArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose( + [ + new SkillPickerItem("alpha", "Pkg", "1", "First line.\nSecond line.\nThird line."), + new SkillPickerItem("beta", "Pkg", "1", "Another line.\nAnother continuation."), + ], + Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + var alpha = Assert.Single(terminal.FrameWrites[0], write => write.Text == " alpha").Top; + var beta = Assert.Single(terminal.FrameWrites[0], write => write.Text == " beta").Top; + Assert.Equal(3, beta - alpha); + for (var frame = 0; frame < 2; frame++) + { + Assert.All( + terminal.FrameWrites[frame].Where(write => write.Top >= alpha && write.Top < beta && + !string.IsNullOrWhiteSpace(write.Text)), + write => Assert.Equal(frame == 0 ? TerminalStyle.Focus : TerminalStyle.Default, write.Style)); + Assert.All( + terminal.FrameWrites[frame].Where(write => write.Top >= beta && write.Top < beta + 2 && + !string.IsNullOrWhiteSpace(write.Text)), + write => Assert.Equal(frame == 1 ? TerminalStyle.Focus : TerminalStyle.Default, write.Style)); + } + + AssertWithinWindow(terminal); + } + + [Fact] + public void Unticking_a_new_installation_returns_it_to_neutral() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.DoesNotContain(terminal.FrameWrites[2], write => write.Text == "X"); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 2, 1).Style); + Assert.Contains("0 of 3 selected", terminal.Frames[2]); + } + + [Fact] + public void Unticking_an_uninstall_row_returns_it_to_neutral() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title, PickerMode.Uninstall); + + Assert.Empty(chosen!); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 1, 1, "X").Style); + Assert.Equal(TerminalStyle.Focus, CheckboxSpan(terminal, 2, 1, "[").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 2, 1).Style); + Assert.Contains("0 of 3 selected; 0 to remove", terminal.Frames[2]); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Without_color_both_pickers_show_a_tick_with_the_checkbox_alone(bool uninstall) + { + // A tick means one thing throughout a checklist, so nothing is needed beside the + // checkbox, and there is no color left for a legend to explain. + var terminal = new FakeTerminal { SupportsColor = false }; + terminal.Press(ConsoleKey.A, ConsoleKey.C, ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal) + .Choose(Items(3), Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains("> [ ] skill-01", terminal.Frames[0]); + Assert.Contains("> [X] skill-01", terminal.Frames[1]); + Assert.Contains(" [X] skill-02", terminal.Frames[1]); + Assert.Contains(" [ ] skill-02", terminal.Frames[2]); + Assert.Contains("> [X] skill-01", terminal.Frames[3]); + Assert.Contains(" [ ] skill-02", terminal.Frames[3]); + Assert.All(terminal.StyleEvents, style => Assert.Equal(TerminalStyle.Default, style)); + Assert.All(terminal.Frames, frame => + { + Assert.DoesNotContain("+ [", frame); + Assert.DoesNotContain("- [", frame); + Assert.DoesNotContain("+ install", frame); + Assert.DoesNotContain("- remove", frame); + Assert.DoesNotContain("Blue X", frame); + }); + } + + [Theory] + [InlineData(false, null, null, true, true)] + [InlineData(true, null, "xterm-256color", true, false)] + [InlineData(false, "", "xterm-256color", true, false)] + [InlineData(false, "1", "xterm-256color", false, false)] + [InlineData(false, null, "dumb", true, false)] + [InlineData(false, null, "DuMb", false, false)] + [InlineData(false, null, "vt100", true, false)] + [InlineData(false, null, "vt220", false, false)] + [InlineData(false, null, "unknown", false, false)] + [InlineData(false, null, null, false, false)] + [InlineData(false, null, "xterm-256color", false, true)] + [InlineData(false, null, "screen", false, true)] + [InlineData(false, null, "tmux-256color", false, true)] + [InlineData(false, null, "linux", false, true)] + public void System_terminal_respects_NO_COLOR_redirection_and_color_capabilities( + bool redirected, string? noColor, string? term, bool windows, bool expected) + { + Assert.Equal(expected, SystemTerminal.CanUseColor(redirected, noColor, term, windows)); + } + + [Fact] + public void Mixed_description_pages_preserve_whole_entries_and_the_preferred_page_offset() + { + var items = MixedItems(1, 3, 2, 4, 1, 2, 3, 1, 5, 2, 1, 4, 2, 3); + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .Press(ConsoleKey.DownArrow, times: 3) + .Press(ConsoleKey.Spacebar, ConsoleKey.RightArrow, ConsoleKey.Spacebar, + ConsoleKey.RightArrow, ConsoleKey.LeftArrow, ConsoleKey.PageUp, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(items, Title); + + Assert.Equal(["skill-04", "skill-10"], chosen!); + Assert.Equal(Enumerable.Range(1, 6).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[0])); + Assert.Equal(Enumerable.Range(7, 5).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[5])); + Assert.Equal(Enumerable.Range(12, 3).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[7])); + Assert.Contains("> [ ] skill-10", terminal.Frames[5]); + Assert.Contains("> [ ] skill-14", terminal.Frames[7]); + Assert.Contains("> [X] skill-10", terminal.Frames[8]); + Assert.Contains("> [X] skill-04", terminal.Frames[9]); + Assert.Contains("desc-06-2", terminal.Frames[0]); + Assert.DoesNotContain("desc-07-1", terminal.Frames[0]); + Assert.Contains("desc-09-5", terminal.Frames[5]); + Assert.Contains("desc-11-1", terminal.Frames[5]); + Assert.Contains("desc-12-4", terminal.Frames[7]); + Assert.Contains("desc-14-3", terminal.Frames[7]); + Assert.DoesNotContain("desc-11-1", terminal.Frames[7]); + Assert.Equal(18, terminal.CursorTopsAwaitingKey[7]); + Assert.All(terminal.Frames[7].Split(Environment.NewLine).Skip(18), + line => Assert.True(string.IsNullOrWhiteSpace(line))); + AssertWithinWindow(terminal); + } + + [Fact] + public void Changing_actions_and_focus_never_changes_page_membership() + { + var items = Items(24) + .Select(item => item with { Description = "Short." }).ToArray(); + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.C, ConsoleKey.A, ConsoleKey.DownArrow, ConsoleKey.Spacebar, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(items, Title); + + Assert.All(terminal.Frames, frame => Assert.Equal(FrameSkillNames(terminal.Frames[0]), FrameSkillNames(frame))); + Assert.Equal(Enumerable.Range(1, 5).Select(number => $"skill-{number:00}"), FrameSkillNames(terminal.Frames[0])); + Assert.Equal(SkillSpan(terminal, 0, 1).Top, SkillSpan(terminal, 4, 1).Top); + Assert.Equal(TerminalStyle.Selected, CheckboxSpan(terminal, 2, 2, "X").Style); + Assert.Equal(TerminalStyle.Focus, SkillSpan(terminal, 2, 1).Style); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_growing_then_shrinking_summary_wraps_without_repaginating_or_leaving_old_footer_rows() + { + var items = Items(100).Select(item => item with { Description = "Short." }).ToArray(); + // At this width the empty uninstall summary fits on one line and the full one does not, + // so selecting everything grows the footer by a row and clearing shrinks it again. + var terminal = new FakeTerminal(windowHeight: 30, windowWidth: 33) + .Press(ConsoleKey.A, ConsoleKey.C, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(items, Title, PickerMode.Uninstall); + + var tops = terminal.CursorTopsAwaitingKey; + Assert.Equal([tops[0], tops[0] + 1, tops[0]], tops); + Assert.All(terminal.Frames, frame => Assert.Equal(FrameSkillNames(terminal.Frames[0]), FrameSkillNames(frame))); + Assert.Contains("100 of 100 selected; 100 to", terminal.Frames[1]); + Assert.Contains("0 of 100 selected; 0 to remove", terminal.Frames[2]); + Assert.True(string.IsNullOrWhiteSpace(terminal.Frames[2].Split(Environment.NewLine)[tops[0]])); + AssertWithinWindow(terminal); + } + + [Fact] + public void Normal_arrows_navigate_skills_even_when_a_description_is_scrollable() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow) + .Press(ConsoleKey.DownArrow, ConsoleKey.UpArrow, ConsoleKey.Enter); + + new SkillPicker(terminal).Choose(MixedItems(50, 1, 1), Title); + + Assert.Contains(" desc-01-3", terminal.Frames[1]); + Assert.Contains(" - desc-01-1", terminal.Frames[1]); + Assert.DoesNotContain("desc-01-2", terminal.Frames[1].Split(Environment.NewLine).Select(line => line.Trim())); + Assert.Contains("> [ ] skill-02", terminal.Frames[2]); + Assert.Contains("page 2 of 2", terminal.Frames[2]); + Assert.DoesNotContain("", terminal.Frames[2]); + Assert.Contains("> [ ] skill-01", terminal.Frames[3]); + Assert.Contains(" desc-01-3", terminal.Frames[3]); + Assert.Contains("/", terminal.Frames[3]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Description_scrolling_reaches_every_line_and_clamps_both_ends_without_selecting() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .PressWith(ConsoleModifiers.Control, ConsoleKey.UpArrow, times: 3) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow, times: 80) + .PressWith(ConsoleModifiers.Control, ConsoleKey.UpArrow, times: 80) + .Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var description = string.Join("\n", Enumerable.Range(1, 60).Select(line => $"detail-{line:00}")); + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", description)], Title); + + Assert.Equal("alpha", Assert.Single(chosen!)); + Assert.Equal(terminal.Frames[0], terminal.Frames[3]); + Assert.Contains("detail-60", terminal.Frames[83]); + Assert.Contains(" - detail-01", terminal.Frames[83]); + Assert.DoesNotContain("detail-02", terminal.Frames[83]); + Assert.Equal(terminal.Frames[82], terminal.Frames[83]); + Assert.Equal(terminal.Frames[0], terminal.Frames[163]); + for (var line = 1; line <= 60; line++) + { + Assert.Contains(terminal.Frames, frame => frame.Contains($"detail-{line:00}", StringComparison.Ordinal)); + } + + Assert.All(terminal.Frames.Take(164), frame => Assert.Contains("> [ ] alpha", frame)); + Assert.Contains("> [X] alpha", terminal.Frames[164]); + Assert.All(terminal.Frames, frame => Assert.Contains("/", frame)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Scroll_controls_are_ignored_and_not_advertised_when_every_line_fits() + { + var terminal = new FakeTerminal().PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow) + .PressWith(ConsoleModifiers.Control, ConsoleKey.UpArrow) + .Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Empty(chosen!); + Assert.All(terminal.Frames, frame => + { + Assert.Contains("> [ ] skill-01", frame); + Assert.DoesNotContain("", frame); + Assert.Equal(terminal.Frames[0], frame); + }); + } + + [Fact] + public void Resizing_down_and_up_preserves_focus_and_selections_and_erases_old_cells() + { + var terminal = new FakeTerminal(windowHeight: 40, windowWidth: 160) + .Press(ConsoleKey.End, ConsoleKey.Spacebar, ConsoleKey.Home, ConsoleKey.Spacebar, ConsoleKey.End) + .Resize(windowHeight: 18, windowWidth: 46) + .Resize(windowHeight: 40, windowWidth: 160) + .Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Equal(["skill-01", "skill-24"], chosen!); + Assert.Equal(3, terminal.ViewportClears); + Assert.Equal((46, 18), terminal.FrameSizes[6]); + Assert.Contains("> [X] skill-24", terminal.Frames[6]); + Assert.DoesNotContain("skill-01", terminal.Frames[6]); + Assert.DoesNotContain("Package.24", terminal.Frames[6]); + Assert.Contains("> [X] skill-24 - No description provided.", terminal.Frames[7]); + Assert.Contains("[X] skill-01 - No description provided.", terminal.Frames[7]); + Assert.Equal(24, Rows(terminal.Frames[7]).Count); + Assert.DoesNotContain("change page", terminal.Frames[7]); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public void Resizing_during_a_redraw_restarts_the_frame_without_losing_selection(bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 50, windowWidth: 120) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var resized = false; + terminal.BeforeOperation = operation => + { + if (!resized && operation == nameof(FakeTerminal.SetStyle) && + terminal.KeysRead.Count == 1 && terminal.Writes.LastOrDefault()?.Text.StartsWith('[') == true) + { + resized = true; + terminal.ResizeNow(windowHeight: 18, windowWidth: 46); + } + }; + + var selected = new SkillPicker(terminal).Choose( + MixedItems(8, 8, 8), Title, uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.True(resized); + Assert.Equal("skill-01", Assert.Single(selected!)); + Assert.Equal(2, terminal.ViewportClears); + Assert.Contains("page 1 of 3", terminal.Frames[^1]); + Assert.Contains(PickerLayout.PrimaryHelp, terminal.Frames[^1]); + Assert.Contains("1 of 3 selected", terminal.Frames[^1]); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Continuation_lines_use_the_width_beneath_the_skill_text(bool color) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 46) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var selected = new SkillPicker(terminal).Choose( + [new SkillPickerItem("longer-skill", "P", "1", + "One two three four five six seven eight nine ten.")], Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine).Select(line => line.TrimEnd()).ToArray(); + // Rows start in the same column with and without color, so they wrap the same way. + Assert.EndsWith("longer-skill - One two three four five", lines[2]); + Assert.Equal(new string(' ', 6) + "six seven eight nine ten.", lines[3]); + Assert.Equal(string.Empty, lines[4]); + Assert.Equal("longer-skill", Assert.Single(selected!)); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false, true)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(true, false)] + public void Resizing_without_a_key_reflows_before_input_and_preserves_focus_and_selections( + bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 32, windowWidth: 120) { SupportsColor = color }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.End, ConsoleKey.Spacebar) + .ResizeWhileWaiting(windowHeight: 18, windowWidth: 46) + .WaitWithoutKey(times: 2) + .Press(ConsoleKey.Enter); + const string focused = "> [X] skill-24"; + // Without color there is no legend line, so each page holds one more skill: all 24 fit + // on one page at 120x32, and the narrow window needs four pages rather than five. + var lastPage = color ? "page 5 of 5" : "page 4 of 4"; + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey) && terminal.KeysRead.Count == 3) + { + // Assert the resize is already visible BEFORE the next real key is consumed. + Assert.Equal(2, terminal.ViewportClears); + Assert.Contains(lastPage, terminal.Screen); + Assert.Contains(focused, terminal.Screen); + Assert.Contains("(Press to select, to accept)", terminal.Screen); + } + }; + var items = Items(24); + + var chosen = new SkillPicker(terminal).Choose(items, Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal(["skill-01", "skill-24"], chosen!); + if (color) + { + Assert.Contains("page 2 of 2", terminal.Frames[3]); + } + else + { + Assert.DoesNotContain("page", terminal.Frames[3]); + } + + Assert.Equal((46, 18), terminal.FrameSizes[4]); + Assert.Contains(lastPage, terminal.Frames[4]); + Assert.Contains(focused, terminal.Frames[4]); + Assert.DoesNotContain("Package.24", terminal.Frames[4]); + Assert.Equal(color ? TerminalStyle.Focus : TerminalStyle.Default, + SkillSpan(terminal, 4, 24).Style); + Assert.Equal(color ? TerminalStyle.Selected : TerminalStyle.Default, CheckboxSpan(terminal, 4, 24, "X").Style); + Assert.Equal(terminal.Frames[4], terminal.Frames[5]); + Assert.Equal(terminal.Frames[4], terminal.Frames[6]); + Assert.Empty(terminal.FrameWrites[5]); + Assert.Empty(terminal.FrameWrites[6]); + Assert.Equal([ConsoleKey.Spacebar, ConsoleKey.End, ConsoleKey.Spacebar, ConsoleKey.Enter], + terminal.KeysRead.Select(key => key.Key)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Idle_input_waits_are_bounded_and_do_not_repaint_unchanged_frames() + { + var terminal = new FakeTerminal().WaitWithoutKey(times: 3).Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Empty(chosen!); + Assert.Equal(4, terminal.InputTimeouts.Count); + Assert.All(terminal.InputTimeouts, timeout => Assert.Equal(TimeSpan.FromMilliseconds(100), timeout)); + Assert.All(terminal.Frames, frame => Assert.Equal(terminal.Frames[0], frame)); + Assert.All(terminal.FrameWrites.Skip(1), writes => Assert.Empty(writes)); + Assert.Equal(1, terminal.ViewportClears); + Assert.Equal(ConsoleKey.Enter, Assert.Single(terminal.KeysRead).Key); + AssertWithinWindow(terminal); + } + + [Fact] + public void An_idle_resize_can_reveal_a_complete_description_and_remove_the_scroll_hint() + { + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.Spacebar) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow, times: 5) + .ResizeWhileWaiting(windowHeight: 100, windowWidth: 120) + .WaitWithoutKey(times: 2) + .Press(ConsoleKey.Enter); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey) && terminal.KeysRead.Count == 6) + { + Assert.Contains("desc-01-60", terminal.Screen); + Assert.DoesNotContain("", terminal.Screen); + Assert.Contains("> [X] skill-01 - desc-01-1", terminal.Screen); + } + }; + + var chosen = new SkillPicker(terminal).Choose(MixedItems(60), Title); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains(" desc-01-7", terminal.Frames[6]); + Assert.Contains(" - desc-01-1", terminal.Frames[6]); + Assert.EndsWith(" - desc-01-1", terminal.Frames[7].Split(Environment.NewLine)[2].TrimEnd()); + Assert.Contains("desc-01-60", terminal.Frames[7]); + Assert.DoesNotContain("", terminal.Frames[7]); + Assert.Equal(terminal.Frames[7], terminal.Frames[8]); + Assert.Equal(terminal.Frames[7], terminal.Frames[9]); + Assert.Empty(terminal.FrameWrites[8]); + Assert.Empty(terminal.FrameWrites[9]); + Assert.Equal(7, terminal.KeysRead.Count); + Assert.Equal(2, terminal.ViewportClears); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_page_key_received_with_a_resize_uses_the_new_page_boundaries() + { + var terminal = new FakeTerminal(windowHeight: 40, windowWidth: 200) + .Press(ConsoleKey.DownArrow, times: 2) + .ResizeBeforeKey(ConsoleKey.PageDown, windowHeight: 18, windowWidth: 100) + .Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(Items(24), Title); + + Assert.Equal("skill-11", Assert.Single(chosen!)); + Assert.Contains("> [ ] skill-11", terminal.Frames[3]); + Assert.Contains("page 2 of 3", terminal.Frames[3]); + Assert.DoesNotContain("skill-01", terminal.Frames[3]); + Assert.Equal(2, terminal.ViewportClears); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void Resizing_with_an_exit_key_still_redraws_and_restores_the_terminal(ConsoleKey exit) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .ResizeBeforeKey(exit, windowHeight: 18, windowWidth: 46); + + var chosen = new SkillPicker(terminal).Choose(Items(4), Title); + + if (exit == ConsoleKey.Enter) + { + Assert.Empty(chosen!); + } + else + { + Assert.Null(chosen); + } + + Assert.Equal(2, terminal.ViewportClears); + Assert.Contains("(Press to select, to accept)", terminal.LastPickerScreen); + Assert.All(terminal.LastPickerScreen.Split(Environment.NewLine), line => Assert.True(TerminalText.Width(line) < 46)); + Assert.Empty(terminal.Screen); + Assert.InRange(terminal.FinalCursorTop, 0, 17); + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + Assert.Equal(TerminalStyle.Default, terminal.CurrentStyle); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_scrolled_description_is_clamped_after_resizing_and_fully_shown_when_it_fits() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80) + .Press(ConsoleKey.Spacebar) + .PressWith(ConsoleModifiers.Control, ConsoleKey.DownArrow, times: 5) + .Resize(windowHeight: 18, windowWidth: 46) + .Resize(windowHeight: 100, windowWidth: 160) + .Resize(windowHeight: 18, windowWidth: 46) + .Press(ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose(MixedItems(60), Title); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains(" desc-01-7", terminal.Frames[7]); + Assert.Contains(" - desc-01-1", terminal.Frames[7]); + Assert.Contains(" - desc-01-1", terminal.Frames[8]); + Assert.Contains("desc-01-60", terminal.Frames[8]); + Assert.DoesNotContain("", terminal.Frames[8]); + Assert.Contains(" - desc-01-1", terminal.Frames[9]); + Assert.DoesNotContain("desc-01-60", terminal.Frames[9]); + Assert.Contains("/", terminal.Frames[9]); + Assert.All(terminal.Frames.Skip(1), frame => Assert.Contains("> [X] skill-01", frame)); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Resizing_to_an_impossible_viewport_fails_safely_instead_of_drawing_outside_it(bool idle) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + if (idle) + { + terminal.ResizeWhileWaiting(windowHeight: 4, windowWidth: 5); + } + else + { + terminal.Resize(windowHeight: 4, windowWidth: 5); + } + + var error = Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains("5x4", error.Message); + Assert.Contains("Enlarge the window", error.Message); + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + Assert.Equal(ConsoleColor.Gray, terminal.Foreground); + Assert.Equal(ConsoleColor.Black, terminal.Background); + Assert.Equal(TerminalStyle.Default, terminal.CurrentStyle); + Assert.InRange(terminal.FinalCursorTop, 0, 3); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(46, 18, true)] + [InlineData(46, 18, false)] + [InlineData(80, 24, true)] + [InlineData(80, 24, false)] + [InlineData(160, 40, true)] + [InlineData(240, 80, true)] + public void All_navigation_and_selection_keys_stay_inside_the_viewport( + int width, int height, bool color) + { + var terminal = new FakeTerminal(windowHeight: height, windowWidth: width) { SupportsColor = color }; + terminal.Press( + ConsoleKey.DownArrow, ConsoleKey.RightArrow, ConsoleKey.PageDown, ConsoleKey.UpArrow, + ConsoleKey.PageUp, ConsoleKey.LeftArrow, ConsoleKey.End, ConsoleKey.Home, ConsoleKey.A, + ConsoleKey.C, ConsoleKey.Spacebar, ConsoleKey.Enter); + var items = MixedItems(1, 3, 2, 4, 1, 2, 3, 1, 5, 2, 1, 4, 2, 3); + + var chosen = new SkillPicker(terminal).Choose(items, Title); + + Assert.Equal("skill-01", Assert.Single(chosen!)); + Assert.Contains("[X] skill-01", terminal.Frames[^1]); + Assert.Contains("(Press to select, to accept)", terminal.Frames[^1]); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_picker_started_near_the_bottom_does_not_scroll_the_shell_buffer() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + terminal.SetCursorPosition(0, 16); + terminal.Write("previous prompt"); + var before = terminal.Screen; + + new SkillPicker(terminal).Choose(Items(3), Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine); + Assert.Equal(Title, lines[0].TrimEnd()); + Assert.Contains("> [ ] skill-01", lines[2]); + Assert.DoesNotContain("previous prompt", terminal.Frames[0]); + Assert.Equal(11, terminal.CursorTopAwaitingKey); + Assert.Equal(before, terminal.Screen); + Assert.Equal(16, terminal.FinalCursorTop); + AssertWithinWindow(terminal); + } + + [Fact] + public void A_small_frame_is_separate_from_preceding_output_and_restores_it() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Enter); + terminal.Write("earlier output"); + terminal.SetCursorPosition(0, 3); + var before = terminal.Screen; + + new SkillPicker(terminal).Choose(Items(3), Title); + + var lines = terminal.Frames[0].Split(Environment.NewLine); + Assert.Equal(Title, lines[0].TrimEnd()); + Assert.DoesNotContain("earlier output", terminal.Frames[0]); + Assert.Equal(11, terminal.CursorTopAwaitingKey); + Assert.Equal(before, terminal.Screen); + Assert.Equal(3, terminal.FinalCursorTop); + AssertWithinWindow(terminal); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void Exit_restores_the_original_colors_style_cursor_and_Ctrl_C_ownership(ConsoleKey exit) + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar, exit); + terminal.SetStyle(TerminalStyle.Muted); + terminal.Foreground = ConsoleColor.Yellow; + terminal.Background = ConsoleColor.DarkMagenta; + terminal.CursorVisible = false; + terminal.TreatControlCAsInput = true; + var original = terminal.CaptureState(); + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(original, terminal.CaptureState()); + } + + [Fact] + public void Ctrl_C_restores_colors_after_a_colored_selection() + { + var terminal = new FakeTerminal().Press(ConsoleKey.A) + .PressWith(ConsoleModifiers.Control, ConsoleKey.C); + var original = terminal.CaptureState(); + + var chosen = new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Null(chosen); + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(original, terminal.CaptureState()); + } + + [Theory] + [InlineData("UseUtf8Output")] + [InlineData("EnterInteractiveScreen")] + [InlineData("CursorVisible")] + [InlineData("TreatControlCAsInput")] + [InlineData("SetStyle")] + [InlineData("Write")] + [InlineData("SetCursorPosition")] + [InlineData("TryReadKey")] + [InlineData("ReadKey")] + [InlineData("ClearViewport")] + public void Exceptions_in_setup_render_input_or_resize_restore_all_terminal_state(string operation) + { + var terminal = new FakeTerminal() + .Resize(windowHeight: 24, windowWidth: 80) + .Press(ConsoleKey.Enter); + terminal.Foreground = ConsoleColor.Yellow; + terminal.Background = ConsoleColor.DarkMagenta; + var original = terminal.CaptureState(); + var failed = false; + terminal.BeforeOperation = current => + { + if (!failed && current == operation) + { + failed = true; + throw new IOException($"failure in {operation}"); + } + }; + + var error = Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal($"failure in {operation}", error.Message); + Assert.True(failed); + Assert.Equal(original, terminal.CaptureState()); + Assert.False(terminal.IsInteractiveScreen); + Assert.Equal(terminal.ScreenEntries, terminal.ScreenExits); + } + + [Fact] + public void An_input_exception_after_an_action_span_does_not_leak_its_color() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey) && terminal.Frames.Count == 2) + { + throw new IOException("input disappeared"); + } + }; + + Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(TerminalStyle.Default, terminal.CurrentStyle); + Assert.Equal(ConsoleColor.Gray, terminal.Foreground); + Assert.Equal(ConsoleColor.Black, terminal.Background); + Assert.True(terminal.IsCursorVisible); + Assert.False(terminal.IsControlCTakenAsInput); + Assert.Equal(12, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + } + + [Fact] + public void A_failed_colored_write_restores_style_and_parks_below_the_last_complete_frame() + { + var terminal = new FakeTerminal().Press(ConsoleKey.Spacebar); + terminal.Foreground = ConsoleColor.Yellow; + terminal.Background = ConsoleColor.DarkMagenta; + var original = terminal.CaptureState(); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.Write) && terminal.CurrentStyle == TerminalStyle.Selected) + { + throw new IOException("colored write failed"); + } + }; + + var error = Assert.Throws(() => new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal("colored write failed", error.Message); + Assert.Contains(TerminalStyle.Selected, terminal.StyleEvents); + Assert.Equal(original, terminal.CaptureState()); + Assert.Equal(12, terminal.LastPickerCursorTop); + Assert.Equal(0, terminal.FinalCursorTop); + Assert.Contains("Blue X: selected", terminal.LastPickerScreen); + Assert.Empty(terminal.Screen); + } + + [Theory] + [InlineData(ConsoleKey.Enter)] + [InlineData(ConsoleKey.Escape)] + [InlineData(ConsoleKey.Q)] + public void UTF8_output_is_scoped_to_the_picker_and_the_exact_original_encoding_is_restored(ConsoleKey exit) + { + var originalEncoding = (Encoding)Encoding.Latin1.Clone(); + originalEncoding.EncoderFallback = EncoderFallback.ExceptionFallback; + var terminal = new FakeTerminal { OutputEncoding = originalEncoding }; + terminal.Press(ConsoleKey.Spacebar, exit); + terminal.BeforeOperation = operation => + { + if (operation == nameof(FakeTerminal.ReadKey)) + { + Assert.Equal(Encoding.UTF8.CodePage, terminal.OutputEncoding.CodePage); + Assert.Empty(terminal.OutputEncoding.GetPreamble()); + } + }; + + new SkillPicker(terminal).Choose(Items(3), Title); + + Assert.Same(originalEncoding, terminal.OutputEncoding); + Assert.Same(EncoderFallback.ExceptionFallback, terminal.OutputEncoding.EncoderFallback); + Assert.Equal([Encoding.UTF8.CodePage, originalEncoding.CodePage], + terminal.EncodingChanges.Select(encoding => encoding.CodePage)); + Assert.All(terminal.Writes, write => Assert.Equal(Encoding.UTF8.CodePage, write.OutputCodePage)); + } + + [Fact] + public void Ctrl_C_restores_the_original_output_encoding() + { + var terminal = new FakeTerminal { OutputEncoding = Encoding.Latin1 }; + terminal.Press(ConsoleKey.Spacebar).PressWith(ConsoleModifiers.Control, ConsoleKey.C); + var original = terminal.CaptureState(); + + Assert.Null(new SkillPicker(terminal).Choose(Items(3), Title)); + + Assert.Equal(original, terminal.CaptureState()); + Assert.Equal([Encoding.UTF8.CodePage, Encoding.Latin1.CodePage], + terminal.EncodingChanges.Select(encoding => encoding.CodePage)); + } + + [Theory] + [InlineData(false, true)] + [InlineData(false, false)] + [InlineData(true, true)] + [InlineData(true, false)] + public void Unicode_descriptions_are_encoded_losslessly_and_cannot_emit_terminal_controls( + bool uninstall, bool color) + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 160) + { + OutputEncoding = Encoding.Latin1, + SupportsColor = color, + }; + terminal.Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + const string Description = "Café, 測試, 🧪, Cafe\u0301. \x1b[2JUNICODE-END"; + const string ExpectedSpan = " - Café, 測試, 🧪, Cafe\u0301. UNICODE-END"; + + var chosen = new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1", Description)], Title, + uninstall ? PickerMode.Uninstall : PickerMode.Install); + + Assert.Equal("alpha", Assert.Single(chosen!)); + var description = Assert.Single(terminal.FrameWrites[0], + write => write.Text.StartsWith(" - ", StringComparison.Ordinal)); + Assert.Equal(ExpectedSpan, description.Text); + Assert.Equal(Encoding.UTF8.GetBytes(ExpectedSpan), description.Bytes); + Assert.Equal(Encoding.UTF8.CodePage, description.OutputCodePage); + Assert.DoesNotContain((byte)0x1b, description.Bytes); + Assert.Equal(color ? TerminalStyle.Focus : TerminalStyle.Default, description.Style); + Assert.Contains("測試", terminal.Frames[0]); + Assert.Contains("🧪", terminal.Frames[0]); + Assert.Contains("Cafe\u0301", terminal.Frames[0]); + Assert.DoesNotContain('?', description.Text); + Assert.Same(Encoding.Latin1, terminal.OutputEncoding); + AssertWithinWindow(terminal); + } + + [Fact] + public void The_test_terminal_observes_legacy_encoding_loss_instead_of_assuming_Unicode_output() + { + var terminal = new FakeTerminal { OutputEncoding = Encoding.Latin1 }; + const string Text = "Café, 測試, 🧪, Cafe\u0301."; + + terminal.Write(Text); + + var write = Assert.Single(terminal.Writes); + Assert.Equal(Encoding.Latin1.GetBytes(Text), write.Bytes); + Assert.Equal(Encoding.Latin1.GetString(write.Bytes), terminal.Screen); + Assert.DoesNotContain("測試", terminal.Screen); + Assert.DoesNotContain("🧪", terminal.Screen); + Assert.DoesNotContain("Cafe\u0301", terminal.Screen); + Assert.NotEqual(Text, terminal.Screen); + } + + [Fact] + public void Noninteractive_and_empty_paths_do_not_change_output_encoding() + { + var redirected = new FakeTerminal { IsRedirected = true, OutputEncoding = Encoding.Latin1 }; + var empty = new FakeTerminal { IsRedirected = true, OutputEncoding = Encoding.Latin1 }; + + Assert.Throws(() => new SkillPicker(redirected).Choose(Items(1), Title)); + Assert.Empty(new SkillPicker(empty).Choose([], Title)!); + + Assert.Empty(redirected.EncodingChanges); + Assert.Empty(empty.EncodingChanges); + Assert.Same(Encoding.Latin1, redirected.OutputEncoding); + Assert.Same(Encoding.Latin1, empty.OutputEncoding); + Assert.Empty(redirected.Writes); + Assert.Empty(empty.Writes); + Assert.Equal(0, redirected.ScreenEntries); + Assert.Equal(0, empty.ScreenEntries); + } + + [Fact] + public void Authored_unicode_names_use_display_cells_for_column_alignment() + { + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 80).Press(ConsoleKey.A, ConsoleKey.Enter); + + var chosen = new SkillPicker(terminal).Choose( + [ + new SkillPickerItem("猫", "P", "1", "First."), + new SkillPickerItem("ab", "P", "1", "Second."), + new SkillPickerItem("👩🏽‍💻", "P", "1", "Third."), + ], + Title); + + Assert.Equal(["猫", "ab", "👩🏽‍💻"], chosen!); + var descriptions = terminal.FrameWrites[0].Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal)).ToArray(); + Assert.Equal([8, 8, 8], descriptions.Select(write => write.Left)); + Assert.Equal( + [TerminalStyle.Focus, TerminalStyle.Default, TerminalStyle.Default], + descriptions.Select(write => write.Style)); + Assert.Contains("> [X] 猫 - First.", terminal.Frames[1]); + Assert.Contains("[X] 👩🏽‍💻 - Third.", terminal.Frames[1]); + AssertWithinWindow(terminal); + } + + [Fact] + public void Wrapped_unicode_descriptions_keep_every_grapheme_and_do_not_split_surrogates() + { + var terminal = new FakeTerminal(windowHeight: 18, windowWidth: 46).Press(ConsoleKey.Enter); + var description = string.Concat(Enumerable.Repeat("界e\u0301👩🏽‍💻", 12)); + + new SkillPicker(terminal).Choose( + [new SkillPickerItem("alpha", "Pkg", "1.2.3", description)], Title); + + var indent = new string(' ', 6); + var rendered = terminal.FrameWrites[0] + .Where(write => write.Text.StartsWith(" - ", StringComparison.Ordinal) || + write.Text.StartsWith(indent, StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(write.Text)) + .Select(write => write.Text.StartsWith(" - ", StringComparison.Ordinal) ? write.Text[3..] : write.Text[6..]) + .ToArray(); + Assert.Equal(description, string.Concat(rendered)); + Assert.Equal(TerminalText.Elements(description), rendered.SelectMany(TerminalText.Elements)); + Assert.All(rendered, line => Assert.DoesNotContain('\ufffd', line)); + Assert.DoesNotContain("...", terminal.Frames[0]); + AssertWithinWindow(terminal); + } + + [Fact] + public void All_author_metadata_is_sanitized_but_returned_skill_identity_is_unchanged() + { + const string Name = "unsafe\x1b[2Jskill\r\nname\u202e"; + var terminal = new FakeTerminal(windowHeight: 24, windowWidth: 120) + .Press(ConsoleKey.Spacebar, ConsoleKey.Enter); + var item = new SkillPickerItem(Name, "P\a", "\u009b2J1", + "\x1b]8;;malicious\aVisible\x1b]8;;\x1b\\\nrow\tend\u202e\0"); + + var chosen = new SkillPicker(terminal).Choose( + [item], "\x1b[31mTitle\x1b[0m\nnext\tline"); + + Assert.Equal(Name, Assert.Single(chosen!)); + Assert.Contains("Title next line", terminal.Frames[0]); + Assert.Contains("unsafeskill name - Visible", terminal.Frames[0]); + Assert.Contains("row end", terminal.Frames[0]); + Assert.DoesNotContain("malicious", terminal.Frames[0]); + Assert.DoesNotContain('\u202e', terminal.Frames[0]); + Assert.All(terminal.Frames, frame => + Assert.DoesNotContain(frame.Replace(Environment.NewLine, string.Empty), char.IsControl)); + AssertWithinWindow(terminal); + } + + [Fact] + public void Unicode_name_clipping_keeps_whole_graphemes_and_is_not_a_fixed_width_cap() + { + var name = string.Concat(Enumerable.Repeat("👩🏽‍💻e\u0301界", 16)); + var item = new SkillPickerItem(name, "P", "1", "Short."); + var narrow = new FakeTerminal(windowHeight: 24, windowWidth: 46).Press(ConsoleKey.Enter); + var wide = new FakeTerminal(windowHeight: 24, windowWidth: 240).Press(ConsoleKey.Enter); + + new SkillPicker(narrow).Choose([item], Title); + new SkillPicker(wide).Choose([item], Title); + + Assert.Contains("...", narrow.Frames[0]); + Assert.DoesNotContain('\ufffd', narrow.Frames[0]); + Assert.Contains(name, wide.Frames[0]); + Assert.DoesNotContain("...", wide.Frames[0]); + AssertWithinWindow(narrow); + AssertWithinWindow(wide); + } + + private static IReadOnlyList MixedItems(params int[] heights) => + Items(heights.Length).Select((item, index) => item with + { + Description = string.Join("\n", + Enumerable.Range(1, heights[index]).Select(line => $"desc-{index + 1:00}-{line}")), + }).ToArray(); + + private static string[] FrameSkillNames(string frame) => Rows(frame) + .Select(row => System.Text.RegularExpressions.Regex.Match(row, @"\bskill-\d+\b").Value).ToArray(); + + private static void AssertWithinWindow(FakeTerminal terminal) + { + Assert.All(terminal.Writes, write => + { + Assert.InRange(write.Left, 0, write.WindowWidth - 1); + Assert.InRange(write.Top, 0, write.WindowHeight - 1); + Assert.True(write.Left + TerminalText.Width(write.Text) < write.WindowWidth, + $"Write used the wrap column of {write.WindowWidth}x{write.WindowHeight}: '{write.Text}'"); + }); + for (var frame = 0; frame < terminal.Frames.Count; frame++) + { + var (width, height) = terminal.FrameSizes[frame]; + var lines = terminal.Frames[frame].Split(Environment.NewLine); + Assert.True(lines.Length <= height); + Assert.All(lines, line => Assert.True(TerminalText.Width(line) < width)); + var lastContent = Array.FindLastIndex(lines, line => !string.IsNullOrWhiteSpace(line)); + Assert.True(lastContent < height - 1); + Assert.Equal(lastContent + 1, terminal.CursorTopsAwaitingKey[frame]); + } + } + + private static IReadOnlyList Items(int count) => + [ + .. Enumerable.Range(1, count).Select(number => + new SkillPickerItem($"skill-{number:00}", $"Package.{number}", "1.0.0")), + ]; +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TargetLocatorTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TargetLocatorTests.cs new file mode 100644 index 0000000..52db0b3 --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TargetLocatorTests.cs @@ -0,0 +1,118 @@ +using DotnetPackageSkills.NuGet; + +namespace DotnetPackageSkills.Tests; + +public class TargetLocatorTests +{ + [Fact] + public void Detect_prefers_a_solution_over_a_project() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateFile("MyApp.csproj"); + + Assert.EndsWith("MyApp.sln", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_prefers_slnx_over_sln() + { + using var temp = new TempDirectory(); + temp.CreateFile("MyApp.sln"); + temp.CreateFile("MyApp.slnx"); + + Assert.EndsWith("MyApp.slnx", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_prefers_the_top_level_over_a_nested_solution() + { + using var temp = new TempDirectory(); + temp.CreateFile("Root.sln"); + temp.CreateFile("nested/Inner.sln"); + + Assert.EndsWith("Root.sln", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_descends_when_nothing_is_at_the_top_level() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp/MyApp.csproj"); + + Assert.EndsWith("MyApp.csproj", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_ignores_build_output_directories() + { + using var temp = new TempDirectory(); + + // Project files copied into obj/ during restore would otherwise win by sort order. + temp.CreateFile("obj/Aaa.csproj"); + temp.CreateFile("src/Real.csproj"); + + Assert.EndsWith("Real.csproj", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_finds_fsproj_and_vbproj_too() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp.fsproj"); + + Assert.EndsWith("MyApp.fsproj", TargetLocator.Detect(temp.Path)); + } + + [Fact] + public void Detect_explains_what_to_do_when_there_is_no_target() + { + using var temp = new TempDirectory(); + + var exception = Assert.Throws(() => TargetLocator.Detect(temp.Path)); + + Assert.Contains("--target", exception.Message); + } + + [Fact] + public void Resolve_accepts_a_path_relative_to_the_working_directory() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp/MyApp.csproj"); + + var resolved = TargetLocator.Resolve("src/MyApp/MyApp.csproj", temp.Path); + + Assert.True(Path.IsPathRooted(resolved)); + Assert.True(File.Exists(resolved)); + } + + [Fact] + public void Resolve_searches_within_a_directory_that_was_passed_as_the_target() + { + using var temp = new TempDirectory(); + temp.CreateFile("src/MyApp/MyApp.csproj"); + + Assert.EndsWith("MyApp.csproj", TargetLocator.Resolve("src", temp.Path)); + } + + [Fact] + public void Resolve_rejects_a_file_that_is_not_a_project_or_solution() + { + using var temp = new TempDirectory(); + temp.CreateFile("notes.txt"); + + var exception = Assert.Throws(() => TargetLocator.Resolve("notes.txt", temp.Path)); + + Assert.Contains(".csproj", exception.Message); + } + + [Fact] + public void Resolve_reports_a_missing_target_by_full_path() + { + using var temp = new TempDirectory(); + + var exception = Assert.Throws(() => TargetLocator.Resolve("Ghost.sln", temp.Path)); + + Assert.Contains("Ghost.sln", exception.Message); + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TempDirectory.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TempDirectory.cs new file mode 100644 index 0000000..fa4373f --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TempDirectory.cs @@ -0,0 +1,61 @@ +namespace DotnetPackageSkills.Tests; + +/// A scratch directory that cleans itself up, for tests that touch the file system. +public sealed class TempDirectory : IDisposable +{ + public TempDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "dps-tests-" + Guid.NewGuid().ToString("N")[..12]); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public string Combine(params string[] parts) => System.IO.Path.Combine([Path, .. parts]); + + /// Creates a directory under the temp root and returns its full path. + public string CreateDirectory(params string[] parts) + { + var full = Combine(parts); + Directory.CreateDirectory(full); + return full; + } + + /// Creates a file (and its parent directories) under the temp root. + public string CreateFile(string relativePath, string content = "") + { + var full = Combine(relativePath.Split('/')); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full)!); + File.WriteAllText(full, content); + return full; + } + + /// Builds an extracted-package layout with a bundled skill, mirroring the NuGet cache. + public string CreatePackageWithSkill(string packageId, string version, params string[] skillNames) + { + var packageDirectory = CreateDirectory("packages", packageId.ToLowerInvariant(), version); + + foreach (var skillName in skillNames) + { + CreateFile($"packages/{packageId.ToLowerInvariant()}/{version}/skills/{skillName}/SKILL.md", + $"---\nname: {skillName}\n---\n"); + } + + return packageDirectory; + } + + public void Dispose() + { + try + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } + catch (IOException) + { + // A locked file in a temp directory is not worth failing a test over. + } + } +} diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TerminalTextTests.cs b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TerminalTextTests.cs new file mode 100644 index 0000000..11b5cde --- /dev/null +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TerminalTextTests.cs @@ -0,0 +1,211 @@ +using DotnetPackageSkills.Cli; + +namespace DotnetPackageSkills.Tests; + +public class TerminalTextTests +{ + [Theory] + [InlineData("", 0)] + [InlineData("plain", 5)] + [InlineData("界", 2)] + [InlineData("A", 2)] + [InlineData("e\u0301", 1)] + [InlineData("😀", 2)] + [InlineData("👩🏽‍💻", 2)] + [InlineData("🇺🇸", 2)] + [InlineData("❤️", 2)] + [InlineData("♥", 1)] + [InlineData("1️⃣", 2)] + [InlineData("𐍈", 1)] + [InlineData("가", 2)] + [InlineData("\u0301", 0)] + [InlineData("a\u200d", 1)] + [InlineData("\u303f", 1)] + public void Width_counts_display_cells_not_UTF16_code_units(string text, int cells) + { + Assert.Equal(cells, TerminalText.Width(text)); + } + + [Fact] + public void Wrapping_prefers_word_boundaries() + { + Assert.Equal(["One two three", "four five"], TerminalText.Wrap("One two three four five", 13)); + } + + [Fact] + public void Wrapping_keeps_explicit_paragraph_breaks() + { + Assert.Equal(["one", "", "two", "three"], TerminalText.Wrap("one\n\ntwo three", 6)); + } + + [Fact] + public void Long_words_are_split_without_losing_their_tail() + { + Assert.Equal(["abcde", "fghij", "kl"], TerminalText.Wrap("abcdefghijkl", 5)); + } + + [Fact] + public void Wrapping_never_splits_a_combining_sequence_flag_or_joined_emoji() + { + const string Text = "ab👩🏽‍💻e\u0301🇺🇸界"; + + var lines = TerminalText.Wrap(Text, 3); + + Assert.Equal(["ab", "👩🏽‍💻e\u0301", "🇺🇸", "界"], lines); + Assert.Equal(Text, string.Concat(lines)); + Assert.All(lines, line => Assert.InRange(TerminalText.Width(line), 1, 3)); + } + + [Fact] + public void An_exactly_fitting_grapheme_is_not_replaced_or_split() + { + Assert.Equal(["👩🏽‍💻", "🇺🇸", "界"], TerminalText.Wrap("👩🏽‍💻🇺🇸界", 2)); + } + + [Fact] + public void A_column_that_cannot_fit_one_grapheme_is_rejected_instead_of_losing_it() + { + Assert.Throws(() => TerminalText.Wrap("界", 1)); + Assert.Throws(() => TerminalText.Wrap("text", 0)); + } + + [Fact] + public void Continuation_lines_can_use_more_space_than_the_first_line() + { + Assert.Equal( + ["One two", "three four five six", "seven eight nine"], + TerminalText.Wrap("One two three four five six seven eight nine", 7, 19)); + } + + [Fact] + public void Wider_continuations_keep_paragraph_breaks_and_long_word_tails() + { + Assert.Equal( + ["abc", "defgh", "", "one two three"], + TerminalText.Wrap("abcdefgh\n\none two three", 3, 13)); + } + + [Fact] + public void Wider_continuations_preserve_unicode_graphemes() + { + const string text = "界👩🏽‍💻e\u0301界"; + + var lines = TerminalText.Wrap(text, 2, 5); + + Assert.Equal(["界", "👩🏽‍💻e\u0301界"], lines); + Assert.Equal(TerminalText.Elements(text), lines.SelectMany(TerminalText.Elements)); + Assert.Equal(["a", "界"], TerminalText.Wrap("a界", 1, 2)); + } + + [Fact] + public void Both_wrapping_widths_must_be_positive() + { + Assert.Throws(() => TerminalText.Wrap("text", 0, 10)); + Assert.Throws(() => TerminalText.Wrap("text", 10, 0)); + } + + [Theory] + [InlineData(6, "e\u0301界...")] + [InlineData(4, "e\u0301...")] + [InlineData(3, "...")] + [InlineData(2, "..")] + [InlineData(1, ".")] + [InlineData(0, "")] + public void Clipping_names_preserves_graphemes_and_reserves_the_ellipsis(int width, string expected) + { + var clipped = TerminalText.Clip("e\u0301界👩🏽‍💻rest", width); + + Assert.Equal(expected, clipped); + Assert.True(TerminalText.Width(clipped) <= width); + Assert.DoesNotContain('\ufffd', clipped); + } + + [Fact] + public void Fitting_text_is_not_given_an_ellipsis() + { + Assert.Equal("👩🏽‍💻e\u0301", TerminalText.Clip("👩🏽‍💻e\u0301", 3)); + } + + [Fact] + public void Padding_aligns_cells_instead_of_surrogates_or_combining_marks() + { + Assert.Equal("界e\u0301 ", TerminalText.PadRight("界e\u0301", 6)); + } + + [Fact] + public void Sanitizing_strips_ANSI_commands_hyperlinks_controls_and_direction_overrides() + { + const string Text = + "before\x1b[31mRED\x1b[0m\x1b]8;;https://invalid.example\a" + + "link\x1b]8;;\x1b\\\r\nnext\tpart\0\a\u007f\u009b2J\u202eafter\u2066\u2069"; + + Assert.Equal("beforeREDlink\nnext partafter", TerminalText.Sanitize(Text, multiline: true)); + Assert.Equal("beforeREDlink next partafter", TerminalText.Sanitize(Text)); + } + + [Fact] + public void Sanitizing_trims_boundaries_by_default_but_can_preserve_them() + { + const string Text = " \t\u001b[31mfirst\u001b[0m\r\n second \r\n"; + const string Preserved = " first\n second \n"; + + Assert.Equal("first\n second", TerminalText.Sanitize(Text, multiline: true)); + Assert.Equal("first\n second", TerminalText.Sanitize(Text, multiline: true, trim: true)); + Assert.Equal(Preserved, TerminalText.Sanitize(Text, multiline: true, trim: false)); + Assert.Equal(Preserved.Replace('\n', ' ').Trim(), TerminalText.Sanitize(Text)); + Assert.Equal(Preserved.Replace('\n', ' '), TerminalText.Sanitize(Text, trim: false)); + } + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" \r\n\t", " \n ")] + public void Preserving_boundaries_keeps_blank_text_without_inventing_content(string? text, string expected) + { + Assert.Equal(string.Empty, TerminalText.Sanitize(text, multiline: true)); + Assert.Equal(expected, TerminalText.Sanitize(text, multiline: true, trim: false)); + } + + [Fact] + public void Preserving_boundaries_still_discards_unterminated_escape_payloads() + { + const string Text = " before \u001b]52;c;SECRET\r\n"; + + Assert.Equal("before", TerminalText.Sanitize(Text, multiline: true)); + Assert.Equal(" before ", TerminalText.Sanitize(Text, multiline: true, trim: false)); + } + + [Theory] + [InlineData("a\x1bPignored\x1b\\b", "ab")] + [InlineData("a\u009dignored\u009cb", "ab")] + [InlineData("a\x1b(0b", "ab")] + [InlineData("a\x1b[999mvisible", "avisible")] + [InlineData("a\x1b]unterminated", "a")] + [InlineData("a\x1b", "a")] + public void Terminal_escape_strings_do_not_leak_their_payload(string input, string expected) + { + Assert.Equal(expected, TerminalText.Sanitize(input)); + } + + [Fact] + public void Authored_unicode_is_not_forced_to_ASCII() + { + const string Text = "👩🏽‍💻 Café e\u0301 中文 🇨🇦"; + + Assert.Equal(Text, TerminalText.Sanitize(Text)); + } + + [Fact] + public void Unpaired_surrogates_become_visible_replacement_characters() + { + Assert.Equal("before\ufffdafter\ufffd", TerminalText.Sanitize("before\ud800after\udfff")); + } + + [Fact] + public void Line_separators_are_normalized_without_allowing_terminal_control_characters() + { + Assert.Equal("one\ntwo\nthree\nfour", + TerminalText.Sanitize("one\r\ntwo\rthree\u2028four", multiline: true)); + Assert.Equal("one two three four", TerminalText.Sanitize("one\r\ntwo\rthree\u2028four")); + } +} diff --git a/eng/pipelines/dotnet-package-skills/Get-PackageVersion.ps1 b/eng/pipelines/dotnet-package-skills/Get-PackageVersion.ps1 new file mode 100644 index 0000000..11d7be9 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Get-PackageVersion.ps1 @@ -0,0 +1,50 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $BaseVersion, + + [Parameter(Mandatory)] + [AllowEmptyString()] + [string] $BuildId, + + [Parameter(Mandatory)] + [string] $BuildReason, + + [Parameter(Mandatory)] + [string] $SourceBranch, + + [string] $PullRequestNumber, + + [switch] $Official, + + [switch] $ReleaseBuild +) + +$ErrorActionPreference = 'Stop' + +if ($BaseVersion -cnotmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { + throw 'BaseVersion must be a three-part semantic version without leading zeros or a suffix.' +} +if ($BuildId -cnotmatch '^[1-9][0-9]*$') { + throw 'BuildId must be a positive integer supplied by Azure Pipelines.' +} +if ($Official -and $BuildReason -eq 'PullRequest') { + throw 'Pull requests cannot use the official signing path.' +} +if ($ReleaseBuild) { + if (-not $Official -or $BuildReason -ne 'Manual' -or $SourceBranch -ne 'refs/heads/main') { + throw 'A stable release requires a manual official build from refs/heads/main.' + } + return $BaseVersion +} +if ($Official) { + return "$BaseVersion-preview.$BuildId" +} +if ($BuildReason -eq 'PullRequest') { + if ($PullRequestNumber -cnotmatch '^[1-9][0-9]*$') { + throw 'PullRequestNumber must be a positive integer supplied by the GitHub PR build.' + } + return "$BaseVersion-pr.$PullRequestNumber.$BuildId" +} + +return "$BaseVersion-ci.$BuildId" diff --git a/eng/pipelines/dotnet-package-skills/README.md b/eng/pipelines/dotnet-package-skills/README.md new file mode 100644 index 0000000..82f41ec --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/README.md @@ -0,0 +1,145 @@ +# dotnet-package-skills pipeline + +`stage.yml` contributes one independent `DotnetPackageSkills` stage to each root pipeline. +All tool-specific build, test, version, signing, package-verification, and artifact steps live +here. The source, SDK pin, restore configuration, and C# tests live in the repository's +`dotnet-package-skills` folder. There is no Python or Node dependency. + +| Entry point | Source | Outputs | +| --- | --- | --- | +| `eng\pipelines\pr.yml` | GitHub, PRs targeting `main`, in `dnceng-public/public` | Unsigned validation package, C# results, logs | +| `eng\pipelines\official.yml` | Trusted `dnceng/internal/NuGet-Client.Tools` Azure Repos mirror, `main` only | Signed package with 1ES-governed outputs, C# results, logs | + +The official definition must select the trusted repository as its source. `checkout: self` +does not change a definition that was incorrectly configured to use GitHub. + +## Stage contract + +The stage accepts `isOfficialBuild` and `releaseBuild`, both defaulting to false. +The root PR pipeline fixes both to false. The official entry point fixes `isOfficialBuild` +to true and passes its `DotnetPackageSkillsReleaseBuild` queue parameter, which defaults to false. +Do not expose real signing as a PR queue option. + +The stage has `dependsOn: []`. Other tools must not consume its variables/artifacts or +implicitly depend on its position in the stage list. Pipeline-wide pools, triggers, the trusted +mirror, and the official 1ES wrapper remain repository-owned. Platform-injected 1ES governance +may add stages; do not disable it to force the expanded official pipeline to contain one stage. + +Within the tool stage: + +1. Install the SDK from the tool's `global.json` and the .NET 8 runtime. +2. Calculate and validate the package version. +3. Restore and build the solution in Release, then run C# tests for net8.0 and net10.0. +4. Official only: sign and verify the tool-owned DLL in each target framework's build output. +5. Pack only the tool project with `--no-build --no-restore`. +6. Official only: scan, sign, and verify the exact `.nupkg`. +7. Verify the package payload, install it for each framework, and smoke-test the installed commands. +8. Publish the successful package. Retain test results and diagnostic logs when a step fails. + +All dotnet commands run from the tool folder so SDK selection honors its scoped `global.json`. +The sample builds with the solution but is not included in the shipping package output. +PRs do not require the 1ES template repository or any production credential. + +## Package versions + +The tool project's `VersionPrefix` is the single base-version setting, initially `0.1.0`. +Local builds use the `dev` suffix. CI computes a version with `Get-PackageVersion.ps1` and +passes `DotnetPackageSkillsVersion` to both build and pack. This override is consumed only by +the tool project; it does not change the sample's `2.3.0` version. + +| Run | Example | +| --- | --- | +| Local | `0.1.0-dev` | +| GitHub PR 42, build 12345 | `0.1.0-pr.42.12345` | +| Manually queued public definition, build 12345 | `0.1.0-ci.12345` | +| Ordinary official build 12345 | `0.1.0-preview.12345` | +| Manual official release on trusted main | `0.1.0` | + +To produce a stable artifact, review/merge the intended base version, wait for trusted-mirror +parity, and manually queue the official pipeline from `main` with +`DotnetPackageSkillsReleaseBuild=true`. CI-triggered, PR, and non-main release requests fail. +Changing major/minor/patch remains a reviewed source change; CI does not create version commits +or tags. A new BuildId yields a new prerelease version, while retrying the same build retains it. + +Package/informational versions carry the suffix and source provenance. The build ID is not a +numeric assembly-version component. Do not override the version only during pack: the packaged +assembly and NuGet metadata must describe the same build. + +## Official signing setup + +Official builds require owner-approved ESRP v6 WIF/MSI configuration. No connection, identity, +certificate, or signing profile is created or selected automatically. Configure these +non-secret Azure Pipeline variables on the official definition before its first signing run: + +| Variable | Required value | +| --- | --- | +| `DotnetPackageSkillsEsrpServiceConnection` | Name of the approved WIF connection, authorized specifically for this pipeline | +| `DotnetPackageSkillsEsrpManagedIdentityClientId` | Managed identity client ID used by that connection | +| `DotnetPackageSkillsEsrpTenantId` | Tenant ID of the managed identity | +| `DotnetPackageSkillsEsrpClientId` | ESRP account's registered client/application ID | +| `DotnetPackageSkillsEsrpKeyVault` | Key vault containing the ESRP request-signing certificate | +| `DotnetPackageSkillsEsrpRequestSigningCertificate` | Request-signing certificate name, not certificate material | +| `DotnetPackageSkillsEsrpBinaryKeyCode` | Approved code-signing profile for the tool's DLLs | +| `DotnetPackageSkillsEsrpNuGetKeyCode` | Approved NuGet author-signing profile | + +The service-connection variable must be available during pipeline resource authorization, not +created by a runtime step. Keep secrets and certificate material in the approved service, never +in YAML or pipeline logs. Do not authorize all pipelines as a shortcut. + +The owner must also authorize the internal pool, confirm access to the organization-local 1ES +template repository, install/enable the ESRP v6 signing and scanning tasks, and complete required +signing approvals and branch controls. The stage checks that it is running on `main` from the +trusted repository. Missing settings or access fail the run; there is no unsigned official mode. +Azure DevOps may reject a missing/unauthorized service connection before any job starts. + +`steps-sign.yml` targets only `dotnet-package-skills.dll` in net8.0 and net10.0, followed by the +single generated tool `.nupkg`. Tool packing publishes the intermediate assemblies under +`obj\Release`, not the copies under `bin\Release`: the signing step first checks that these +match the tested build outputs, signs the intermediate assemblies, then copies the verified +signed bytes back to `bin` for final payload comparison. Signing only `bin` would allow pack +to replace the signed payload. No test/sample assemblies or third-party dependencies are +signed, and strong-name identities are unchanged. + +`Verify-Package.ps1 -RequireSigned` requires a NuGet signature, checks `dotnet nuget verify --all`, +validates assembly signatures, and compares each packaged DLL with its signed build output. +Packing must not rebuild or replace those assemblies. + +The first successful signed run remains an onboarding acceptance gate. Local and public PR +validation alone do not establish that ESRP permissions are configured. + +## Artifacts and local verification + +Artifacts are named `dotnet-package-skills-packages`, `dotnet-package-skills-testresults`, and +`dotnet-package-skills-logs`. Their staging directory is +`$(Build.ArtifactStagingDirectory)\dotnet-package-skills`. Official artifacts use 1ES +`templateContext.outputs`; only the public path uses ordinary publish tasks. Nothing is pushed +to an internal feed or nuget.org. + +From the tool source folder, after building and packing: + +```powershell +pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 ` + -PackagePath .\artifacts\packages\dotnet-package-skills.0.1.0-dev.nupkg ` + -ExpectedVersion 0.1.0-dev ` + -BuildOutputPath .\src\DotnetPackageSkills\bin\Release +``` + +Use the actual package version when verifying CI artifacts. The helper uses a local-only +temporary feed, separate NuGet cache/CLI home, and isolated tool paths. It exercises both +target frameworks with patch-only runtime roll-forward, preserves handwritten fixture skills, +and removes only the temporary directory it created. + +## Retirement when the tool moves into the .NET SDK + +1. Delete the repository-root `dotnet-package-skills` source folder and this + `eng\pipelines\dotnet-package-skills` pipeline folder. +2. Remove their single stage-template reference from `pr.yml` and `official.yml`, and remove + `DotnetPackageSkillsReleaseBuild` from `official.yml`. +3. Remove the tool entry from the root README. No root SDK pin, NuGet configuration, or MSBuild + hook was added for this tool. +4. Verify that remaining stages reference none of the removed files, variables, or artifacts. + Other tools should require no implementation changes. +5. Have the pipeline owner remove tool-specific settings/permissions where appropriate. Keep + shared pools, connections, the trusted mirror, and the official 1ES wrapper. +6. If no workload remains, retire the unused pipeline definitions/entry points with their + owner's approval instead of leaving invalid empty-stage YAML. diff --git a/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 b/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 new file mode 100644 index 0000000..4708025 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/Verify-Package.ps1 @@ -0,0 +1,186 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string] $PackagePath, + + [Parameter(Mandatory)] + [string] $ExpectedVersion, + + [Parameter(Mandatory)] + [string] $BuildOutputPath, + + [switch] $RequireSigned +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Invoke-CheckedCommand { + param([string] $Command, [string[]] $Arguments) + + $output = & $Command @Arguments 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "'$Command' failed with exit code ${LASTEXITCODE}:`n$($output -join "`n")" + } + return $output -join "`n" +} + +function Read-ArchiveText { + param([IO.Compression.ZipArchive] $Archive, [string] $Name) + + $entry = $Archive.GetEntry($Name) + if ($null -eq $entry) { throw "Package is missing $Name." } + $reader = [IO.StreamReader]::new($entry.Open()) + try { return $reader.ReadToEnd() } + finally { $reader.Dispose() } +} + +$package = (Resolve-Path -LiteralPath $PackagePath).Path +$buildOutput = (Resolve-Path -LiteralPath $BuildOutputPath).Path +$archive = [IO.Compression.ZipFile]::OpenRead($package) +try { + [xml] $nuspec = Read-ArchiveText $archive 'dotnet-package-skills.nuspec' + if ($nuspec.package.metadata.id -cne 'dotnet-package-skills') { + throw 'Package ID must be dotnet-package-skills.' + } + if ($nuspec.package.metadata.version -cne $ExpectedVersion) { + throw "Package version '$($nuspec.package.metadata.version)' does not match '$ExpectedVersion'." + } + if ($nuspec.package.metadata.packageTypes.packageType.name -cne 'DotnetTool') { + throw 'Package must declare the DotnetTool package type.' + } + if ($nuspec.package.metadata.license.type -cne 'expression' -or + $nuspec.package.metadata.license.InnerText -cne 'MIT') { + throw 'Package must retain its MIT license expression.' + } + if ([string]::IsNullOrWhiteSpace((Read-ArchiveText $archive 'README.md'))) { + throw 'Package README must not be empty.' + } + if ($RequireSigned -and $null -eq $archive.GetEntry('.signature.p7s')) { + throw 'Package is not signed; official artifacts must have a NuGet signature.' + } + + foreach ($framework in @('net8.0', 'net10.0')) { + [xml] $settings = Read-ArchiveText $archive "tools/$framework/any/DotnetToolSettings.xml" + $command = $settings.DotNetCliTool.Commands.Command + if ($command.Name -cne 'dotnet-package-skills' -or $command.EntryPoint -cne 'dotnet-package-skills.dll') { + throw "Incorrect tool command settings for $framework." + } + + $entry = $archive.GetEntry("tools/$framework/any/dotnet-package-skills.dll") + if ($null -eq $entry) { throw "Package is missing the $framework assembly." } + $assembly = Join-Path $buildOutput "$framework\dotnet-package-skills.dll" + $stream = $entry.Open() + try { $packageHash = (Get-FileHash -InputStream $stream -Algorithm SHA256).Hash } + finally { $stream.Dispose() } + if ($packageHash -cne (Get-FileHash -LiteralPath $assembly -Algorithm SHA256).Hash) { + throw "The $framework package payload does not match the built assembly." + } + if ($RequireSigned) { + $signature = Get-AuthenticodeSignature -LiteralPath $assembly + if ($signature.Status -ne 'Valid') { + throw "The $framework assembly signature is not valid: $($signature.StatusMessage)" + } + } + } +} +finally { + $archive.Dispose() +} + +if ($RequireSigned) { + Write-Host (Invoke-CheckedCommand dotnet @('nuget', 'verify', $package, '--all')) +} + +$temporary = [IO.Directory]::CreateTempSubdirectory('dotnet-package-skills-verify-').FullName +$originalEnvironment = @{} +foreach ($name in @( + 'NUGET_PACKAGES', 'DOTNET_CLI_HOME', 'DOTNET_ROLL_FORWARD', + 'DOTNET_GENERATE_ASPNET_CERTIFICATE', 'DOTNET_ADD_GLOBAL_TOOLS_TO_PATH', + 'DOTNET_CLI_TELEMETRY_OPTOUT', 'DOTNET_NOLOGO' +)) { + $originalEnvironment[$name] = [Environment]::GetEnvironmentVariable($name) +} + +try { + $feed = [IO.Directory]::CreateDirectory((Join-Path $temporary 'feed')).FullName + Copy-Item -LiteralPath $package -Destination $feed + $config = Join-Path $temporary 'NuGet.config' + $escapedFeed = [Security.SecurityElement]::Escape($feed) + [IO.File]::WriteAllText($config, @" + + + + + +"@) + $env:NUGET_PACKAGES = Join-Path $temporary 'nuget-cache' + $env:DOTNET_CLI_HOME = Join-Path $temporary 'cli-home' + $env:DOTNET_ROLL_FORWARD = 'LatestPatch' + $env:DOTNET_GENERATE_ASPNET_CERTIFICATE = 'false' + $env:DOTNET_ADD_GLOBAL_TOOLS_TO_PATH = 'false' + $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' + $env:DOTNET_NOLOGO = '1' + + $fixtureCache = Join-Path $temporary 'fixture-cache' + $skill = Join-Path $fixtureCache 'contoso.widgets\2.3.0\skills\contoso.widgets-widget-usage' + [IO.Directory]::CreateDirectory($skill) | Out-Null + [IO.File]::WriteAllText((Join-Path $skill 'SKILL.md'), "---`nname: contoso.widgets-widget-usage`ndescription: Local package verification fixture.`n---`n") + + foreach ($framework in @('net8.0', 'net10.0')) { + $toolPath = Join-Path $temporary "tools-$framework" + Write-Host (Invoke-CheckedCommand dotnet @( + 'tool', 'install', 'dotnet-package-skills', '--tool-path', $toolPath, + '--version', $ExpectedVersion, '--framework', $framework, + '--configfile', $config, '--no-http-cache', '--verbosity', 'quiet' + )) + + $tool = Join-Path $toolPath 'dotnet-package-skills.exe' + $version = (Invoke-CheckedCommand $tool @('--version')).Trim() + if (($version -split '\+', 2)[0] -cne $ExpectedVersion) { + throw "Installed $framework tool version '$version' does not match '$ExpectedVersion'." + } + $help = Invoke-CheckedCommand $tool @('--help') + if ($help -notmatch 'install' -or $help -notmatch 'uninstall') { + throw "Installed $framework tool does not expose its expected commands." + } + + $destination = Join-Path $temporary "skills-$framework" + $handwritten = Join-Path $destination 'handwritten' + [IO.Directory]::CreateDirectory($handwritten) | Out-Null + [IO.File]::WriteAllText((Join-Path $handwritten 'SKILL.md'), 'Preserve this skill.') + $packageArguments = @('--package', 'Contoso.Widgets@2.3.0', '--global-packages', $fixtureCache, '--destination', $destination) + + $listing = Invoke-CheckedCommand $tool (@('list') + $packageArguments) + if ($listing -notmatch 'contoso.widgets-widget-usage') { + throw "Installed $framework tool did not discover the local fixture." + } + Write-Host (Invoke-CheckedCommand $tool (@('install') + $packageArguments)) + $installedSkill = Join-Path $destination 'contoso.widgets-widget-usage\SKILL.md' + if (-not (Test-Path -LiteralPath $installedSkill)) { + throw "Installed $framework tool did not copy the fixture skill." + } + $manifestPath = Join-Path $destination '.dotnet-package-skills.json' + $manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json + if ($manifest.packages.'contoso.widgets'.version -cne '2.3.0') { + throw "Installed $framework tool did not record the fixture package version." + } + Write-Host (Invoke-CheckedCommand $tool @('uninstall', '--package', 'Contoso.Widgets', '--destination', $destination)) + if (Test-Path -LiteralPath $installedSkill) { + throw "Installed $framework tool did not remove the fixture skill." + } + if ([IO.File]::ReadAllText((Join-Path $handwritten 'SKILL.md')) -cne 'Preserve this skill.') { + throw "Installed $framework tool changed a handwritten skill." + } + } +} +finally { + foreach ($name in $originalEnvironment.Keys) { + $value = $originalEnvironment[$name] + if ($null -eq $value) { $value = [NullString]::Value } + [Environment]::SetEnvironmentVariable($name, $value) + } + Remove-Item -LiteralPath $temporary -Recurse -Force +} + +Write-Host "Verified dotnet-package-skills $ExpectedVersion on .NET 8 and .NET 10." diff --git a/eng/pipelines/dotnet-package-skills/stage.yml b/eng/pipelines/dotnet-package-skills/stage.yml new file mode 100644 index 0000000..bd615ab --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/stage.yml @@ -0,0 +1,215 @@ +parameters: +- name: isOfficialBuild + type: boolean + default: false +- name: releaseBuild + type: boolean + default: false + +stages: +- stage: DotnetPackageSkills + displayName: dotnet-package-skills + dependsOn: [] + variables: + - name: DotnetPackageSkillsDirectory + value: $(Build.SourcesDirectory)\dotnet-package-skills + - name: DotnetPackageSkillsPipelineDirectory + value: $(Build.SourcesDirectory)\eng\pipelines\dotnet-package-skills + - name: DotnetPackageSkillsArtifacts + value: $(Build.ArtifactStagingDirectory)\dotnet-package-skills + - name: DOTNET_NOLOGO + value: '1' + - name: DOTNET_CLI_TELEMETRY_OPTOUT + value: '1' + - name: DOTNET_GENERATE_ASPNET_CERTIFICATE + value: 'false' + - name: DOTNET_ADD_GLOBAL_TOOLS_TO_PATH + value: 'false' + jobs: + - job: BuildWindows + displayName: Build, test, and package + timeoutInMinutes: 60 + ${{ if eq(parameters.isOfficialBuild, true) }}: + templateContext: + outputs: + - output: pipelineArtifact + targetPath: $(DotnetPackageSkillsArtifacts)\packages + artifactName: dotnet-package-skills-packages + - output: pipelineArtifact + targetPath: $(DotnetPackageSkillsArtifacts)\logs + artifactName: dotnet-package-skills-logs + condition: succeededOrFailed() + isProduction: false + - output: pipelineArtifact + targetPath: $(DotnetPackageSkillsArtifacts)\testresults + artifactName: dotnet-package-skills-testresults + condition: succeededOrFailed() + isProduction: false + steps: + - checkout: self + clean: true + fetchDepth: 1 + fetchTags: false + + - pwsh: | + foreach ($directory in @('logs', 'testresults', 'packages')) { + New-Item -ItemType Directory -Force -Path "$(DotnetPackageSkillsArtifacts)\$directory" | Out-Null + } + displayName: Prepare tool artifact directories + + - ${{ if eq(parameters.isOfficialBuild, true) }}: + - pwsh: | + if ($env:BUILD_REPOSITORY_PROVIDER -ne 'TfsGit' -or + $env:BUILD_REPOSITORY_NAME -ne 'NuGet-Client.Tools' -or + $env:SYSTEM_TEAMPROJECT -ne 'internal' -or + $env:SYSTEM_COLLECTIONURI -ne 'https://dev.azure.com/dnceng/' -or + $env:BUILD_SOURCEBRANCH -ne 'refs/heads/main' -or + $env:BUILD_REASON -eq 'PullRequest') { + throw 'Official signing requires main from the trusted dnceng/internal/NuGet-Client.Tools repository.' + } + foreach ($name in @( + 'ESRP_SERVICE_CONNECTION', 'ESRP_MANAGED_IDENTITY', 'ESRP_TENANT', 'ESRP_CLIENT', + 'ESRP_KEY_VAULT', 'ESRP_REQUEST_CERTIFICATE', 'ESRP_BINARY_KEY', 'ESRP_NUGET_KEY' + )) { + $value = [Environment]::GetEnvironmentVariable($name) + if ([string]::IsNullOrWhiteSpace($value) -or $value.Contains('$(')) { + throw "Missing official signing setting $name. Configure and authorize the pipeline as described in eng/pipelines/dotnet-package-skills/README.md." + } + } + foreach ($name in @('ESRP_MANAGED_IDENTITY', 'ESRP_TENANT', 'ESRP_CLIENT')) { + $parsed = [guid]::Empty + if (-not [guid]::TryParse([Environment]::GetEnvironmentVariable($name), [ref] $parsed)) { + throw "Signing setting $name must be a GUID." + } + } + foreach ($name in @('ESRP_BINARY_KEY', 'ESRP_NUGET_KEY')) { + if ([Environment]::GetEnvironmentVariable($name) -cnotmatch '^[A-Za-z0-9-]+$') { + throw "Signing setting $name must be an approved ESRP key code." + } + } + displayName: Validate trusted source and required signing configuration + env: + ESRP_SERVICE_CONNECTION: $(DotnetPackageSkillsEsrpServiceConnection) + ESRP_MANAGED_IDENTITY: $(DotnetPackageSkillsEsrpManagedIdentityClientId) + ESRP_TENANT: $(DotnetPackageSkillsEsrpTenantId) + ESRP_CLIENT: $(DotnetPackageSkillsEsrpClientId) + ESRP_KEY_VAULT: $(DotnetPackageSkillsEsrpKeyVault) + ESRP_REQUEST_CERTIFICATE: $(DotnetPackageSkillsEsrpRequestSigningCertificate) + ESRP_BINARY_KEY: $(DotnetPackageSkillsEsrpBinaryKeyCode) + ESRP_NUGET_KEY: $(DotnetPackageSkillsEsrpNuGetKeyCode) + + - task: UseDotNet@2 + displayName: Install pinned .NET SDK + inputs: + packageType: sdk + useGlobalJson: true + workingDirectory: $(DotnetPackageSkillsDirectory) + + - task: UseDotNet@2 + displayName: Install .NET 8 runtime for compatibility tests + inputs: + packageType: runtime + version: 8.0.x + + - pwsh: | + $baseVersion = dotnet msbuild .\src\DotnetPackageSkills\DotnetPackageSkills.csproj -nologo -getProperty:VersionPrefix + if ($LASTEXITCODE -ne 0) { throw 'Could not evaluate the tool base version.' } + $official = [bool]::Parse('${{ parameters.isOfficialBuild }}') + $release = [bool]::Parse('${{ parameters.releaseBuild }}') + $version = & "$(DotnetPackageSkillsPipelineDirectory)\Get-PackageVersion.ps1" ` + -BaseVersion $baseVersion.Trim() -BuildId $env:BUILD_BUILDID ` + -BuildReason $env:BUILD_REASON -SourceBranch $env:BUILD_SOURCEBRANCH ` + -PullRequestNumber $env:SYSTEM_PULLREQUEST_PULLREQUESTNUMBER ` + -Official:$official -ReleaseBuild:$release + Write-Host "##vso[task.setvariable variable=DotnetPackageSkillsVersion]$version" + Write-Host "dotnet-package-skills package version: $version" + displayName: Resolve package version + workingDirectory: $(DotnetPackageSkillsDirectory) + + - pwsh: | + dotnet restore .\DotnetPackageSkills.slnx --configfile .\NuGet.config ` + "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` + "-bl:$(DotnetPackageSkillsArtifacts)\logs\restore.binlog" + if ($LASTEXITCODE -ne 0) { throw 'Restore failed.' } + displayName: Restore tool, samples, and tests + workingDirectory: $(DotnetPackageSkillsDirectory) + + - pwsh: | + dotnet build .\DotnetPackageSkills.slnx --configuration Release --no-restore ` + "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` + "-p:SourceRevisionId=$(Build.SourceVersion)" ` + "-bl:$(DotnetPackageSkillsArtifacts)\logs\build.binlog" + if ($LASTEXITCODE -ne 0) { throw 'Build failed.' } + displayName: Build tool and C# tests + workingDirectory: $(DotnetPackageSkillsDirectory) + + - pwsh: | + dotnet test .\tests\DotnetPackageSkills.Tests\DotnetPackageSkills.Tests.csproj ` + --configuration Release --no-build --no-restore ` + "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` + --logger "trx;LogFilePrefix=dotnet-package-skills" ` + --results-directory "$(DotnetPackageSkillsArtifacts)\testresults" + if ($LASTEXITCODE -ne 0) { throw 'C# tests failed.' } + displayName: Run C# tests on .NET 8 and .NET 10 + workingDirectory: $(DotnetPackageSkillsDirectory) + + - task: PublishTestResults@2 + displayName: Publish C# test results + condition: succeededOrFailed() + inputs: + testResultsFormat: VSTest + testResultsFiles: $(DotnetPackageSkillsArtifacts)\testresults\**\*.trx + testRunTitle: dotnet-package-skills + failTaskOnFailedTests: true + failTaskOnMissingResultsFile: true + failTaskOnFailureToPublishResults: true + + - ${{ if eq(parameters.isOfficialBuild, true) }}: + - template: steps-sign.yml + parameters: + target: assemblies + + - pwsh: | + dotnet pack .\src\DotnetPackageSkills\DotnetPackageSkills.csproj ` + --configuration Release --no-build --no-restore ` + "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` + "-p:SourceRevisionId=$(Build.SourceVersion)" ` + --output "$(DotnetPackageSkillsArtifacts)\packages" ` + "-bl:$(DotnetPackageSkillsArtifacts)\logs\pack.binlog" + if ($LASTEXITCODE -ne 0) { throw 'Tool packaging failed.' } + displayName: Pack the tool without rebuilding + workingDirectory: $(DotnetPackageSkillsDirectory) + + - ${{ if eq(parameters.isOfficialBuild, true) }}: + - template: steps-sign.yml + parameters: + target: package + + - pwsh: | + $signed = [bool]::Parse('${{ parameters.isOfficialBuild }}') + & "$(DotnetPackageSkillsPipelineDirectory)\Verify-Package.ps1" ` + -PackagePath "$(DotnetPackageSkillsArtifacts)\packages\dotnet-package-skills.$(DotnetPackageSkillsVersion).nupkg" ` + -ExpectedVersion "$(DotnetPackageSkillsVersion)" ` + -BuildOutputPath "$(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\bin\Release" ` + -RequireSigned:$signed + displayName: Verify and install the exact package on both runtimes + workingDirectory: $(DotnetPackageSkillsDirectory) + + - ${{ if eq(parameters.isOfficialBuild, false) }}: + - task: PublishPipelineArtifact@1 + displayName: Publish unsigned PR package + inputs: + targetPath: $(DotnetPackageSkillsArtifacts)\packages + artifact: dotnet-package-skills-packages + - task: PublishPipelineArtifact@1 + displayName: Publish diagnostic logs + condition: succeededOrFailed() + inputs: + targetPath: $(DotnetPackageSkillsArtifacts)\logs + artifact: dotnet-package-skills-logs + - task: PublishPipelineArtifact@1 + displayName: Publish test result files + condition: succeededOrFailed() + inputs: + targetPath: $(DotnetPackageSkillsArtifacts)\testresults + artifact: dotnet-package-skills-testresults diff --git a/eng/pipelines/dotnet-package-skills/steps-sign.yml b/eng/pipelines/dotnet-package-skills/steps-sign.yml new file mode 100644 index 0000000..77debc1 --- /dev/null +++ b/eng/pipelines/dotnet-package-skills/steps-sign.yml @@ -0,0 +1,121 @@ +parameters: +- name: target + type: string + values: + - assemblies + - package + +steps: +- ${{ if eq(parameters.target, 'assemblies') }}: + - pwsh: | + foreach ($framework in @('net8.0', 'net10.0')) { + $assembly = Get-Item -LiteralPath "$(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\obj\Release\$framework\dotnet-package-skills.dll" + if ($assembly.Length -eq 0) { throw "Empty signing input for $framework." } + $built = "$(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\bin\Release\$framework\dotnet-package-skills.dll" + if ((Get-FileHash -LiteralPath $assembly.FullName).Hash -cne (Get-FileHash -LiteralPath $built).Hash) { + throw "The $framework pack input does not match the tested build output." + } + } + displayName: Check expected tool assembly signing inputs + - task: EsrpCodeSigning@6 + displayName: Sign and verify tool assemblies + inputs: + ConnectedServiceName: $(DotnetPackageSkillsEsrpServiceConnection) + AppRegistrationClientId: $(DotnetPackageSkillsEsrpManagedIdentityClientId) + AppRegistrationTenantId: $(DotnetPackageSkillsEsrpTenantId) + EsrpClientId: $(DotnetPackageSkillsEsrpClientId) + AuthAKVName: $(DotnetPackageSkillsEsrpKeyVault) + AuthSignCertName: $(DotnetPackageSkillsEsrpRequestSigningCertificate) + UseMSIAuthentication: true + # PackAsTool publishes the intermediate assembly, not the copy in bin. + FolderPath: $(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\obj\Release + Pattern: | + net8.0/dotnet-package-skills.dll + net10.0/dotnet-package-skills.dll + UseMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "keyCode": "$(DotnetPackageSkillsEsrpBinaryKeyCode)", + "operationCode": "SigntoolSign", + "parameters": { + "OpusName": "Microsoft", + "OpusInfo": "http://www.microsoft.com", + "FileDigest": "/fd \"SHA256\"", + "PageHash": "/NPH", + "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" + }, + "toolName": "sign", + "toolVersion": "1.0" + }, + { + "keyCode": "$(DotnetPackageSkillsEsrpBinaryKeyCode)", + "operationCode": "SigntoolVerify", + "parameters": {}, + "toolName": "sign", + "toolVersion": "1.0" + } + ] + - pwsh: | + foreach ($framework in @('net8.0', 'net10.0')) { + $assembly = "$(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\obj\Release\$framework\dotnet-package-skills.dll" + $signature = Get-AuthenticodeSignature -LiteralPath $assembly + if ($signature.Status -ne 'Valid') { + throw "The $framework assembly signature is not valid: $($signature.StatusMessage)" + } + Copy-Item -LiteralPath $assembly -Destination "$(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\bin\Release\$framework\dotnet-package-skills.dll" -Force + } + displayName: Verify assembly signatures before packing + +- ${{ if eq(parameters.target, 'package') }}: + - pwsh: | + $packages = @(Get-ChildItem -LiteralPath "$(DotnetPackageSkillsArtifacts)\packages" -Filter '*.nupkg') + $expected = "dotnet-package-skills.$(DotnetPackageSkillsVersion).nupkg" + if ($packages.Count -ne 1 -or $packages[0].Name -cne $expected -or $packages[0].Length -eq 0) { + throw "Expected exactly one nonempty tool package: $expected" + } + displayName: Check exact NuGet signing input + - task: EsrpMalwareScanning@6 + displayName: Scan the tool package before signing + inputs: + ConnectedServiceName: $(DotnetPackageSkillsEsrpServiceConnection) + AppRegistrationClientId: $(DotnetPackageSkillsEsrpManagedIdentityClientId) + AppRegistrationTenantId: $(DotnetPackageSkillsEsrpTenantId) + EsrpClientId: $(DotnetPackageSkillsEsrpClientId) + UseMSIAuthentication: true + FolderPath: $(DotnetPackageSkillsArtifacts)\packages + Pattern: dotnet-package-skills.$(DotnetPackageSkillsVersion).nupkg + UseMinimatch: true + CleanupTempStorage: true + - task: EsrpCodeSigning@6 + displayName: Sign and verify the NuGet package + inputs: + ConnectedServiceName: $(DotnetPackageSkillsEsrpServiceConnection) + AppRegistrationClientId: $(DotnetPackageSkillsEsrpManagedIdentityClientId) + AppRegistrationTenantId: $(DotnetPackageSkillsEsrpTenantId) + EsrpClientId: $(DotnetPackageSkillsEsrpClientId) + AuthAKVName: $(DotnetPackageSkillsEsrpKeyVault) + AuthSignCertName: $(DotnetPackageSkillsEsrpRequestSigningCertificate) + UseMSIAuthentication: true + FolderPath: $(DotnetPackageSkillsArtifacts)\packages + Pattern: dotnet-package-skills.$(DotnetPackageSkillsVersion).nupkg + UseMinimatch: true + signConfigType: inlineSignParams + inlineOperation: | + [ + { + "keyCode": "$(DotnetPackageSkillsEsrpNuGetKeyCode)", + "operationSetCode": "NuGetSign", + "parameters": [], + "toolName": "sign", + "toolVersion": "1.0" + }, + { + "keyCode": "$(DotnetPackageSkillsEsrpNuGetKeyCode)", + "operationSetCode": "NuGetVerify", + "parameters": [], + "toolName": "sign", + "toolVersion": "1.0" + } + ] diff --git a/eng/pipelines/official.yml b/eng/pipelines/official.yml index a101be1..8d73a6c 100644 --- a/eng/pipelines/official.yml +++ b/eng/pipelines/official.yml @@ -3,6 +3,12 @@ trigger: pr: none +parameters: +- name: DotnetPackageSkillsReleaseBuild + displayName: Produce a stable dotnet-package-skills package (manual main builds only) + type: boolean + default: false + resources: repositories: - repository: 1esPipelines @@ -18,8 +24,7 @@ extends: demands: ImageOverride -equals windows.vs2026.amd64 os: windows stages: - - stage: Build - jobs: - - job: HelloWorld - steps: - - script: echo Hello world! + - template: /eng/pipelines/dotnet-package-skills/stage.yml@self + parameters: + isOfficialBuild: true + releaseBuild: ${{ parameters.DotnetPackageSkillsReleaseBuild }} diff --git a/eng/pipelines/pr.yml b/eng/pipelines/pr.yml index fac0871..418ae31 100644 --- a/eng/pipelines/pr.yml +++ b/eng/pipelines/pr.yml @@ -11,9 +11,7 @@ pool: - ImageOverride -equals windows.vs2026.amd64.open stages: -- stage: Build - jobs: - - job: HelloWorld - steps: - - script: echo Hello world! - displayName: Run PR smoke check +- template: dotnet-package-skills/stage.yml + parameters: + isOfficialBuild: false + releaseBuild: false From 9d3c46875546195706991756f446b425811f49ea Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 11:47:17 -0700 Subject: [PATCH 03/12] docs(dotnet-package-skills): rewrite prose in ASD-STE100 controlled language Rewrite README.md, CONTRIBUTING.md, and the three docs/*.md files to follow ASD Simplified Technical English (STE100) rules: - Short, active-voice, single-clause sentences - No em dashes or semicolons in prose (split into separate sentences) - No contractions (does not, cannot, is not, etc.) - Only approved modals (can, must, may, will, would); replace 'should' - Imperative verb-form headings instead of gerunds, where not anchor-linked Code blocks, CLI syntax, and literal tool-output/error strings are left unchanged, since they represent exact program behavior rather than prose. All cross-file anchor links (#manifest-file, #remove-stale-skills, #choose-skills-interactively, #what-you-get, #removal-is-manifest-driven) are verified to still resolve. Verified: dotnet build and dotnet test (807 tests x net8.0/net10.0) still pass, confirming no non-prose content was altered. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet-package-skills/CONTRIBUTING.md | 635 +++++++++-------- dotnet-package-skills/README.md | 648 ++++++++++-------- .../docs/dotnet-package-skills.md | 60 +- dotnet-package-skills/docs/functional-spec.md | 83 +-- dotnet-package-skills/docs/scenarios.md | 88 +-- 5 files changed, 824 insertions(+), 690 deletions(-) diff --git a/dotnet-package-skills/CONTRIBUTING.md b/dotnet-package-skills/CONTRIBUTING.md index e4ea93f..4cc1262 100644 --- a/dotnet-package-skills/CONTRIBUTING.md +++ b/dotnet-package-skills/CONTRIBUTING.md @@ -1,12 +1,13 @@ -# Contributing +# How to contribute -Thanks for helping out. This is a small, deliberately boring tool — the bar for changes is that -they keep it small and boring. +Thank you for your help. This tool is small and simple on purpose. Keep changes small and simple +too. -## Getting set up +## Set up your computer -Use the .NET 10 SDK selected by this folder's `global.json`, the .NET 8 runtime, and -PowerShell 7. The tool and its C# test suite target both net8.0 and net10.0; CI runs on Windows. +Install the .NET 10 SDK that this folder's `global.json` selects. Install the .NET 8 runtime too. +Install PowerShell 7. The tool and its C# test suite target both net8.0 and net10.0. The CI system +runs tests on Windows only. ```powershell git clone https://github.com/NuGet/Client.Tools.git @@ -34,315 +35,391 @@ pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 ## Origin -This folder was imported from -[`kartheekp-ms/dotnet-package-skills` at `59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3`](https://github.com/kartheekp-ms/dotnet-package-skills/tree/59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3) -as a source snapshot, without rewriting or transferring the original repository's history. -The source README and package metadata declare MIT; Client.Tools retains its root MIT license. -The source repository is unchanged. +This folder came from +[`kartheekp-ms/dotnet-package-skills` at `59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3`](https://github.com/kartheekp-ms/dotnet-package-skills/tree/59d3bc0d4fc80188d33bcc257d2a83c99a4cbfa3). +We copied the source files at that commit. We did not copy or change the original repository's +history. The source repository is unchanged. -The Python/Node terminal regression harness was intentionally excluded. The C# picker tests -and interactive tool behavior remain. Build configuration and pipeline integration are scoped -to this tool so it can be retired independently when its functionality moves into the .NET SDK. +The source README and package metadata name the MIT license. Client.Tools keeps its own root MIT +license file. We left out the Python and Node.js terminal regression tests on purpose. The C# +picker tests and the interactive tool behavior stay the same. Build configuration and pipeline +integration apply only to this tool. This lets a maintainer remove the tool on its own, for +example when its function moves into the .NET SDK. ## Layout ``` src/DotnetPackageSkills/ ├── Program.cs CLI surface: commands, options, exit codes -├── SkillInstallService.cs Orchestration — the only place the steps are sequenced -├── Cli/OutputWriter.cs Human-readable reports -├── Cli/SkillPicker.cs The --interactive picker, paged so one screen is one page -├── Cli/ITerminal.cs Console access behind an interface, so the picker can be tested +├── SkillInstallService.cs Orchestration. This is the only file that puts the steps in order. +├── Cli/OutputWriter.cs Writes reports for people to read +├── Cli/SkillPicker.cs The --interactive picker. It shows one page per screen. +├── Cli/ITerminal.cs An interface for console access, so tests can replace the console ├── Cli/InteractiveSkills.cs Picker-only metadata and selection mapping ├── Infrastructure/ Process execution and the dotnet CLI wrapper ├── NuGet/ Target detection, package listing, cache path resolution └── Skills/ Discovery, copying, version-change removal, the install manifest -tests/DotnetPackageSkills.Tests/ xunit; application tests use in-process fakes -samples/Contoso.Widgets/ Example of a package that ships a skill +tests/DotnetPackageSkills.Tests/ xunit tests. Application tests use in-process fakes. +samples/Contoso.Widgets/ An example of a package that ships a skill ``` -## Invariants - -These are the things worth being careful about. Each exists for a reason that is not obvious from -the code alone, so please don't quietly change them. - -**Copy from the global packages folder; never move.** It is NuGet's content-addressable cache, -validated during restore and shared by every project on the machine. Moving files out can make -restore treat a cached package as corrupt, and removes the skill from every other repository using -that package. - -**Removal is driven by the manifest, never by scanning the destination.** `.dotnet-package-skills.json` -records, for each package ID, the one installed version and the skill folder names it owns; -`install` (when a package changes version) and `uninstall` act only on those names. Users keep -their own hand-written skills in the same folder, and deleting one of those would be unforgivable. - -**An unreadable manifest stops every operation that needs ownership.** Never treat a malformed or -unreadable manifest as empty. Empty means the existing folders are user-owned; corrupt means their -ownership is unknown. `install` and `uninstall` must fail before changing anything and preserve the -file so the user can repair or restore it. `list` may still run because it does not read ownership -or write anything. -An existing manifest must have a whole-number format `version` that the tool supports and a -`packages` object. Package IDs must be valid by NuGet's own rule (`PackageCoordinate.IsValidId`, -which allows letters outside ASCII), every package needs a non-empty version, JSON properties must -be unique ignoring case, and case-insensitive destination claims must be unique. A newer format -version asks the user to update the tool. A pre-release manifest, which has an `installed` array, -is refused rather than converted. `SetSkills` refuses an ID that the reader would refuse, so the -tool never writes a manifest that locks the destination. -For v1, use ordinary manifest files and update them in place. The tool does not create symbolic -links, and linked or redirected manifests are outside the v1 safety guarantees. Do not replace an -existing manifest with a new inode merely to handle links: that can change Unix ownership or ACLs. - -**The manifest is a public contract.** Reports have no machine-readable form, so the manifest is -what scripts read, and teams commit it. Its shape follows `dotnet-tools.json`: a format `version` -and a `packages` object keyed by lowercase package ID. A change that an older tool would misread -needs a new format `version`, so older tools refuse the file instead of guessing. Write it the same -way on every platform: UTF-8 without a BOM, LF line endings with a final newline, packages and -skills in ordinal order, and values escaped identically on `net8.0` and `net10.0` -(`JsonWriterOptions.NewLine` doesn't exist on .NET 8, so the writer's CRLF is replaced after -serializing). A platform-dependent byte is churn in someone's pull request. - -**No tracked skills means no manifest and no folder.** When the last entry goes, `install` and -`uninstall` both delete `.dotnet-package-skills.json` and drop the destination folder if it is -empty, so a repository where nothing ships a skill never grows a stray `.agents/skills/`. The -folder only goes when it is genuinely empty — hand-written skills keep it alive. - -**Descriptions are read-only presentation metadata, not installation requirements.** Discovery -still identifies skills by folder structure. Only interactive pickers read the top-level YAML -`description` in `SKILL.md`, using a bounded frontmatter reader and an established YAML parser. -Never interpret the Markdown body, execute metadata, invent a description, or rewrite the file. -Missing metadata gets an explicit placeholder; unreadable or invalid metadata gets a visible -warning without hiding the skill. This intentionally replaces the former no-frontmatter-parsing -rule so users can make an informed selection. Reports and the manifest never include descriptions. - -**Skill names from packages are untrusted input.** They become path segments in the user's repo. -`SkillDiscovery.IsSafeSkillName` is the shared discovery/manifest gate; keep it strict. Reject names -ending in dots or spaces on every platform, since Windows can normalize them to another folder or -the destination itself. Before mutation, resolve all affected skill paths as direct children of the -destination; removing a skill must not walk up and delete its parents. - -**`install` removes a skill only when its package changes version.** A noninteractive install -offers the resolved packages it found in the cache. A tracked skill goes only if its package is -offered at a different normalized version, the new version doesn't ship it, and no ownership -conflict protects it. When a conflict does protect it, `install` stops instead: removing it would -hand its name to the other package, and relabeling it would record the old version's copy under -the new version, where no later run would remove it. A package that left the project is never a -reason to delete, because a reference can vanish for a moment: `install` reports those skills as -unreferenced, and `uninstall --stale` removes them when asked. A version missing from the cache -never causes a removal, and a target install stops before any writes, including previews, when a -resolved package is missing. An incomplete cache must never look like permission to delete skills. - -**`install -i` only adds.** The checklist lists only skills that would install cleanly and aren't -tracked, nothing starts checked, and the installer gets an empty offered-packages map, so nothing -is refreshed or removed. Every check that could stop the run happens before the checklist opens: -two versions of a package; with a target, a missing package or a stale skill; with `--package`, a -named package tracked at another version. Adding beside a stale skill would leave the manifest -disagreeing with the project, and adding beside another version would give a package two versions. - -**`uninstall --stale` reads references, not packages.** It needs a solution or project, and it -compares the manifest with the target's direct package references from `dotnet list package`. It -never asks where the NuGet cache is, so a missing or partial cache can't change what counts as -stale. A skill is stale when no referenced package has its ID and installed version, which also -keeps it working when a target resolves two versions. `--stale` can't be combined with `--package`, -and `uninstall` accepts `--target` only with `--stale`. - -**The picker pages, and that is the point.** A solution can reference many packages that ship -skills. `SkillPicker` renders a frame that fits the window and redraws it in place, so the list -can never scroll off the top unread — agreeing to skills you did not see is the failure mode worth -designing against. Page size follows rendered height, including descriptions and help, rather -than an item-count ceiling. The picker itself has no filesystem access: `InteractiveSkills` -supplies package descriptions for install and installed-file descriptions for uninstall. - -**The frame is measured from its contents and bounded by the window.** Names and descriptions -share a row with ` - ` immediately after the authored name, not a padded name column. Do not append -package/version metadata or strip the package prefix from the name. Each skill's wrapped -description continues at the skill-text edge and uses the remaining row width, not an indent -as wide as the name. Measure those lines and every -wrapped footer before assigning whole skill entries to pages. An oversized description must be -scrollable, never silently truncated. Reflow on resize while preserving focus and selections. -Page boundaries must not shift just because a checkbox or cursor changed. Partial last pages end -at their actual content rather than a run of blank rows. - -Two things follow from redrawing in place, and both are easy to break. Rows are padded to the -measured width, and rows below a shorter frame are blanked, because overwriting is the only way -to erase without ANSI. And the widest summary, with every row ticked, is measured rather than the -current one, so counts growing as you select never reflow the frame. Chrome that would do nothing -is dropped: no counter on a single page, no movement or select-all keys for a single skill. The -note under the title is optional chrome too: when the window can't fit it, the layout drops the -note rather than refusing to open. - -**Keyboard hints follow Aspire's checklist style.** The primary hint is -`(Press to select, to accept)`, below the list. Use the same angle-bracket key -notation for paging, select-all, clear-all, cancel, and description scrolling, beginning each -keyboard-help line with `Press`. Wrap help rather than clipping away the keys. Only advertise -paging and scrolling when they are useful. - -**Focus and checked state are the only cues.** The focused skill's text and all wrapped -description lines are blue. A checked item has a blue uppercase `X` in both pickers; names and -brackets do not change color because of selection. Neither picker marks what a tick does: each -does one thing, so the title and the summary say it, and the uninstall summary also says how many -will go. No-color terminals show `>` and `[X]` with no extra marker and no legend; respect -`NO_COLOR`. A dedicated status column would take space away from descriptions. - -**A tick means install in one picker and remove in the other.** Neither starts with anything -ticked, so pressing enter without touching anything changes nothing in both. `PickerMode` carries -the difference, which shows only in the summary; the caller supplies the title. The uninstall list -comes from the manifest, so a skill someone wrote by hand is never offered for deletion. - -**The picker owns the terminal, so it has to hand it back.** `Choose` hides the cursor and takes -Ctrl+C as input, and restores both in a `finally`. Ctrl+C is why: left to the runtime it ends the -process mid-frame, so the restore never runs and the user is left typing into a terminal with no -cursor. Taken as a key it cancels through the same path as `esc`. Note the modifier is tested -before the switch, because a bare `c` clears the selection. - -**An interactive choice applies only to the ownership it was made against.** Ownership snapshots -are rechecked before applying an interactive choice; concurrent ownership changes invalidate it -rather than changing which package's files are affected. -Hold the destination lock from ownership loading through the final manifest write. Both -installation and uninstallation participate, so another tool invocation cannot change ownership -between the check and mutation. Canonicalize destination aliases before choosing the lock. - -**Resizing invalidates an in-progress frame.** Read width and height together, restart a redraw -if its viewport changes, and clear cells in place rather than scrolling blank lines. Otherwise -old picker copies accumulate in terminal history and can wrap incorrectly when the host resizes. -Preserve prior scrollback, focus, and selections; never swallow unrelated rendering failures. -The picker owns an alternate screen for its entire lifetime. Clearing just the current viewport -cannot erase old rows that the host has already reflowed into normal history. Restore the original -screen and output mode on every managed exit, then write the final report on the normal screen. -Clear and home the alternate viewport before the first frame, just as after a resize. Entering the -alternate screen can preserve the shell cursor position; reserving space with blank lines then -leaves a gap above a compact checklist. Never clear the normal screen to fix that gap. - -Every render also parks the cursor directly below the last line it drew, rather than at the bottom -of the layout's maximum height. `SkillPickerTests` pins that placement for short pages; managed exits -restore the original shell cursor independently when they leave the alternate screen. - -**Picker chrome is ASCII; author text is not restricted to English.** Keep control hints and -markers ASCII so legacy console encodings do not lose them. Display Unicode descriptions without -splitting text elements, measuring terminal cells rather than UTF-16 code units. Strip unsafe -terminal control sequences from author-supplied text. All color goes through `ITerminal`, and the -picker uses BOM-less UTF-8 while prompting. Restore the original encoding and terminal styling -when it exits or fails, so ordinary command output retains its existing behavior. - -**Human-readable reports must not execute metadata as terminal commands.** Sanitize each untrusted -display field with `TerminalText.Sanitize`, including package/version metadata, paths, skipped -reasons, and operational errors. Framework parser diagnostics and suggestions use a separate output -path and must be sanitized too, including split writes. Keep multiline error guidance readable. -Never sanitize arguments before validation or persist sanitized display values; canonical -identities must remain intact. - -**Reports are for people; there is no JSON report.** The manifest is the machine-readable record, -and exit codes carry success or failure. Don't bring back a `--json` report without revisiting -that decision. Because `--package` accepts several values, the parser hands it any unknown option -that follows, such as a `--json` left in an old script; its validator reports a value starting -with `-` as an unrecognized argument rather than as a malformed package. - -**`--package` refuses floating versions and ranges.** Resolving one means choosing a version, and -the only correct answer comes from a project's restore. `PackageCoordinate.Parse` is the gate. - -**Only direct dependencies are scanned.** Applications code against their direct package -references, not implementation details brought in transitively. Do not add `--include-transitive` -or parse `transitivePackages` without revisiting that product decision. - -**The authored skill folder name is the destination folder name.** A package skill at -`skills/contoso.widgets-widget-usage/` lands at -`/contoso.widgets-widget-usage/`. Package and version remain manifest metadata; they -do not create destination path segments. A skill must be an immediate subdirectory containing -`SKILL.md`; a lone `skills/SKILL.md` is intentionally unsupported. - -**Collisions warn and skip; they never overwrite silently.** Destination names compare -case-insensitively. Package enumeration and skill discovery stay deterministic so the first match -wins reproducibly. An existing untracked destination folder is user-owned and untouchable. -Different packages cannot transfer an already-tracked destination between owners in any install -mode. A skipped conflicting path is protected from version-change removal as well as copying. -Same-package version refreshes remain allowed. A protected skill is never relabeled to a version -that doesn't ship it; that case stops the install, as described above. -Keep all discovery candidates internally until install-time ownership is known. Prefer the -current owner's candidate; `list` remains a destination-independent discovery report. -Package authors avoid collisions by prefixing skill folders with their lowercased package ID, but -the tool does not enforce that naming convention. -For v1, retain logical case-insensitive matching without reconciling distinct physical case variants. -Package authors should keep folder casing stable. Case-only renames and mixed-case physical entries -on case-sensitive filesystems are outside the v1 ownership guarantees; do not promise safe migration -or add special reconciliation logic without revisiting that scope. - -**Package filters must not broaden destructive operations.** An explicitly blank filter is an -error; only an absent `--package` means all packages. Interactive and noninteractive uninstall -use the same normalized version matcher. - -**One version per package, or no install.** The manifest records one version per package. When -the resolved packages, or the `--package` coordinates, include two normalized versions of one ID, -every install mode stops before any change and asks for the versions to be aligned; repositories -are expected to use NuGet Central Package Management. `PackageLister.Parse` keeps distinct -`(id, version)` pairs so the check can see them, and `list` still shows both. - -**Errors should read as guidance.** Throw `PackageSkillsException` with a message that tells the -user what to do next. `Program.cs` prints it without a stack trace. If a message would leave -someone stuck, it needs more words. A command in a message has to work when pasted as printed: -build it with `SkillInstallService.UninstallCommand`, which repeats the run's `--target` and -non-default `--destination`. +## Rules that must stay true + +Read this list before you change this tool. Each rule protects something that is not obvious +from the code alone. Do not change a rule without a discussion first. + +**Copy files from the global packages folder. Never move them.** NuGet validates this folder +during restore. It is a content-addressable cache, and every project on the machine shares it. +If you move a file out of the cache, restore may treat the cached package as damaged. Moving a +file also removes the skill from every other repository that uses that package. + +**Use the manifest to decide what to remove. Never scan the destination folder to decide.** +`.dotnet-package-skills.json` records, for each package ID, the one installed version and the +names of the skill folders it owns. `install` acts only on those names when a package changes +version. `uninstall` acts only on those names too. Users keep their own hand-written skills in +the same folder. Deleting one of those skills by mistake is a serious failure. + +**An unreadable manifest stops every operation that needs ownership information.** Do not treat +a damaged or unreadable manifest as an empty manifest. An empty manifest means the existing +folders belong to the user. A damaged manifest means the tool does not know who owns the +folders. `install` and `uninstall` must fail before they change anything. They must keep the +file in place so the user can repair it or restore it. `list` can still run, because `list` does +not read ownership information and does not write anything. + +An existing manifest must have these parts. It must have a format `version` property, and the +value must be a whole number that this version of the tool supports. It must have a `packages` +object. Each package ID in that object must be valid under NuGet's own rule. `PackageCoordinate.IsValidId` +checks this rule, and the rule allows letters outside ASCII. Each package needs a version that is +not empty. JSON property names must be unique when you ignore letter case. Destination names +claimed by different packages must be unique when you ignore letter case. When the manifest has +a newer format version than the tool supports, the tool asks the user to update the tool. A +pre-release manifest has an `installed` array instead of a `packages` object. The tool refuses +this old format. It does not convert the file. `SetSkills` refuses any ID that the reader would +refuse. This way, the tool never writes a manifest that it cannot later read. + +In v1, use ordinary manifest files, and update them in place. The tool does not create symbolic +links. Linked or redirected manifests fall outside the v1 safety guarantees. Do not replace an +existing manifest with a new file merely to handle links. That action can change file ownership +or access rights on Unix systems. + +**The manifest is a public contract.** Reports have no machine-readable form. Scripts read the +manifest instead, and teams commit the manifest to source control. Its shape follows +`dotnet-tools.json`. It has a format `version` property and a `packages` object. The object key +is the lowercase package ID. A change that an older version of the tool would misread needs a new +format `version` number. This way, an older tool refuses the file instead of misreading it. + +Write the manifest the same way on every platform. Use UTF-8 encoding without a byte order mark. +Use LF line endings, with a final newline at the end of the file. List packages and skills in a +fixed order. Escape values the same way on `net8.0` and `net10.0`. .NET 8 has no +`JsonWriterOptions.NewLine` property, so the writer replaces its CRLF output with LF after it +serializes the file. A byte that depends on the platform creates needless differences in a pull +request. + +**When no skill is tracked, the manifest and the folder both disappear.** When the last entry +leaves the manifest, `install` and `uninstall` both delete `.dotnet-package-skills.json`. They +also delete the destination folder, but only if the folder is empty. This way, a repository where +no package ships a skill never grows a stray `.agents/skills/` folder. The tool removes the +folder only when the folder is truly empty. Hand-written skills in that folder keep it from being +deleted. + +**Descriptions are read-only text for display. They are not a requirement for installation.** +Skill discovery still identifies a skill by its folder structure alone. Only the interactive +pickers read the top-level YAML `description` property in `SKILL.md`. They read it with a +bounded frontmatter reader and a standard YAML parser. Never interpret the Markdown body of +`SKILL.md`. Never run any part of its metadata as code. Never invent a description. Never +rewrite the file. + +When a skill has no description, the picker shows an explicit placeholder text. When the +metadata is unreadable or invalid, the picker shows a visible warning, but it still shows the +skill. This rule replaces an earlier rule that forbade reading frontmatter at all. The new rule +lets users make an informed choice. Reports and the manifest never include descriptions. + +**Treat skill names from packages as untrusted input.** A skill name becomes a path segment +inside the user's repository. `SkillDiscovery.IsSafeSkillName` is the one gate that both +discovery and the manifest use. Keep this gate strict. Reject a name that ends in a dot or a +space, on every platform. Windows can resolve such a name to a different folder, or to the +destination folder itself. Before you change any file, resolve every affected skill path as a +direct child of the destination folder. When the tool removes a skill, it must not walk up the +folder tree and delete a parent folder. + +**`install` removes a skill only when its package changes to a version that drops the skill.** A +noninteractive install offers the packages that it resolved and found in the cache. A tracked +skill is removed only when all of these conditions are true. First, the tool offers its package +at a different normalized version. Second, the new version does not ship that skill. Third, no +ownership conflict protects the skill. + +When an ownership conflict does protect the skill, `install` stops instead of removing it. +Removing the skill would hand its folder name to a different package. Relabeling the skill would +record the old version's files under the new version's name, and a later run would then never +remove those old files. A package that left the project is never a reason to delete its skills. +A package reference can disappear for a moment, for example during a refactor, so `install` +reports these skills as unreferenced instead of deleting them. `uninstall --stale` removes them +when the user asks for that. + +A version that is missing from the cache never causes a removal. When a target install is +missing a resolved package, the install stops before it writes anything, including during a +preview. An incomplete cache must never look like permission to delete skills. + +**`install -i` only adds skills. It never refreshes or removes a skill.** The checklist lists +only skills that would install cleanly and that are not already tracked. Nothing starts checked. +The installer receives an empty map of offered packages, so it cannot refresh or remove anything. + +Every check that could stop the run happens before the checklist opens. The checks look for +three problems: two versions of one package, a missing package or a stale skill when you use a +target, and a named package tracked at a different version when you use `--package`. If the tool +let you add a skill next to a stale skill, the manifest would disagree with the project. If the +tool let you add a skill next to another version of the same package, one package would have two +versions in the manifest. The tool stops before the checklist opens to prevent both problems. + +**`uninstall --stale` reads package references. It does not read packages.** This command needs +a solution or project. It compares the manifest with the target's direct package references from +`dotnet list package`. It never asks where the NuGet cache is. A missing or partial cache cannot +change what counts as stale, because the command never looks at the cache. A skill is stale when +no referenced package matches both its ID and its installed version. This rule also works +correctly when a target resolves a package to two versions. `--stale` cannot combine with +`--package`. `uninstall` accepts `--target` only together with `--stale`. + +**The picker shows one page at a time. This design choice is deliberate.** A solution can +reference many packages that ship skills. `SkillPicker` renders a frame that fits inside the +window, and it redraws that frame in place. The list can never scroll off the top of the screen +unread. We designed the picker this way to prevent the worst failure: a user who approves skills +that they never saw. Page size follows the rendered height of the content, including descriptions +and keyboard help. Page size does not follow a fixed count of items. The picker itself has no +access to the file system. `InteractiveSkills` supplies package descriptions for `install`, and it +supplies installed-file descriptions for `uninstall`. + +**The tool measures the frame from its content. The window bounds the frame's size.** A skill's +name and description share one row. The text ` - ` follows the authored name directly. The +layout does not use a padded name column. Do not append package or version metadata to a name. +Do not strip the package prefix from a name. Each skill's wrapped description text lines up with +the start of the skill's own text. The description uses the rest of the row's width. The +description does not use an indent as wide as the name. Measure every line, including every +wrapped footer line, before you assign whole skill entries to pages. A user must be able to +scroll an oversized description. The tool must never truncate a description silently. The layout +must reflow when the window resizes, and it must keep the current focus and the current +selections. A checkbox change or a cursor move must never shift a page boundary. A partial last +page ends at its actual content. It never shows a run of blank rows. + +**Keyboard hints follow the style of Aspire's checklist.** The primary hint reads +`(Press to select, to accept)`. It appears below the list. Use the same +angle-bracket key notation for paging, select-all, clear-all, cancel, and description scrolling. +Start every keyboard-help line with the word `Press`. Wrap the help text instead of cutting off +any key name. Show the paging and scrolling hints only when the user can actually use paging or +scrolling. + +**Focus and checked state are the only visual cues.** The focused skill's text turns blue, +including all of its wrapped description lines. A checked item shows a blue uppercase `X` in +both pickers. Selection never changes the color of a name or a bracket. Neither picker marks what +a check mark does with a separate cue. Each picker does only one thing, so its title and its +summary state that action. The uninstall summary also states how many skills will be removed. A +terminal without color shows `>` for focus and `[X]` for a checked item. It shows no other +marker and no color legend. The tool respects the `NO_COLOR` setting. A separate status column +would take space away from the descriptions, so the tool does not use one. + +**A check mark means install in one picker. It means remove in the other picker.** Neither +picker starts with any item checked. If a user presses enter without checking anything, both +pickers change nothing. `PickerMode` carries this difference, and the difference shows only in +the summary text. The code that calls the picker supplies the title text. The uninstall list +comes from the manifest, so the picker never offers a hand-written skill for deletion. + +**The picker takes control of the terminal, so it must give control back.** `Choose` hides the +cursor. It reads Ctrl+C as ordinary input instead of letting the runtime handle it. It restores +the cursor and the input mode inside a `finally` block. This matters because of Ctrl+C: if the +runtime handled Ctrl+C directly, it would end the process in the middle of a frame, the restore +code would never run, and the user would be left typing into a terminal with no visible cursor. +The code reads Ctrl+C as a key instead, and it cancels through the same path as the `esc` key. +The code tests for the Ctrl modifier before it checks the key itself, because a plain `c` key +clears the current selection. + +**An interactive choice applies only to the ownership state that was in effect when the user +made it.** The tool rechecks its ownership snapshot before it applies an interactive choice. When +ownership changed at the same time, the tool rejects the stale choice. It does not apply that +choice to a different package's files. + +Hold the destination lock from the moment the tool loads ownership information through the +moment it writes the final manifest. Both installation and removal take part in this lock. This +way, no other run of the tool can change ownership between the check and the change. Resolve +destination aliases to one canonical path before the tool chooses which lock to use. + +**A resize while a frame is on screen makes that frame invalid.** Read the new width and the new +height together. Restart the redraw when the viewport size changes. Clear cells in place instead +of scrolling blank lines past them. If the code scrolls blank lines instead, old picker frames +build up in the terminal's history, and they can wrap incorrectly when the host resizes again. +Keep the prior scrollback content, the current focus, and the current selections. Never swallow a +rendering failure that is not related to the resize. + +The picker owns an alternate screen for as long as it runs. Clearing only the current viewport +cannot erase old rows, because the host may have already moved those rows into its normal +history. Restore the original screen and the original output mode on every managed exit. Then +write the final report on the normal screen, not on the alternate screen. Clear the alternate +viewport and move the cursor to its home position before the first frame, and do the same thing +again after every resize. When the terminal enters the alternate screen, it can keep the shell's +old cursor position. If the code then reserves space with blank lines, those lines leave a gap +above a compact checklist. Never clear the normal screen to remove that gap. + +Every render also places the cursor directly below the last line that it drew. The cursor does +not go to the bottom of the layout's maximum possible height. `SkillPickerTests` checks this +exact placement for short pages. A managed exit restores the original shell cursor position on +its own, separately, when it leaves the alternate screen. + +**The picker's own chrome uses only ASCII characters. Author-written text can use any +language.** Keep every control hint and every marker in ASCII, so that an older console encoding +does not lose or corrupt them. Display Unicode descriptions without splitting a text element in +the middle. Measure terminal cells for this purpose, not UTF-16 code units. Remove any unsafe +terminal control sequence from text that a package author supplied. Send all color output +through `ITerminal`. The picker uses UTF-8 without a byte order mark while it prompts the user. +Restore the original text encoding and the original terminal styling when the picker exits, and +also when it fails. This way, ordinary command output keeps behaving the way it always did. + +**A report written for people must not let metadata run as a terminal command.** Sanitize every +untrusted display field with `TerminalText.Sanitize`. This includes package and version metadata, +file paths, reasons for a skip, and operational errors. Framework parser diagnostics and +suggestions use a separate output path. Sanitize that path too, including any write that is split +into parts. Keep multiline error guidance readable. + +Never sanitize an argument before the tool validates it. Never save a sanitized value as a +stored value. A canonical identity, such as a package ID, must stay intact and unmodified. + +**Reports exist for people to read. The tool has no JSON report.** The manifest is the one +machine-readable record. Exit codes carry success or failure. Do not add a `--json` report back +without a new discussion of that decision. `--package` accepts several values, so the parser +hands it any unknown option that follows it. This can happen when an old script still has a +`--json` flag left in it. The validator for `--package` reports a value that starts with `-` as +an unrecognized argument. It does not report that value as a malformed package coordinate. + +**`--package` refuses a floating version and refuses a version range.** Resolving a range means +choosing one version from it. The only correct answer to that choice comes from a project's own +restore step. `PackageCoordinate.Parse` is the one gate that enforces this rule. + +**The tool scans only direct dependencies.** Application code depends on its direct package +references. It does not depend on implementation details that come in through a transitive +reference. Do not add a `--include-transitive` option. Do not parse `transitivePackages`. Both +changes need a new product discussion first. + +**The authored skill folder name becomes the destination folder name.** A package skill at +`skills/contoso.widgets-widget-usage/` lands at `/contoso.widgets-widget-usage/`. +The package ID and the package version stay in the manifest as metadata. They do not become part +of the destination path. A skill must be an immediate subfolder that contains `SKILL.md`. A lone +`skills/SKILL.md` file, with no subfolder, is not supported. This is a deliberate choice. + +**When names collide, the tool warns and skips. It never overwrites a file silently.** The tool +compares destination names without regard to letter case. Package enumeration and skill discovery +both stay deterministic, so the same package always wins a collision, every time you run the +tool. An existing untracked destination folder belongs to the user. The tool must not touch it. +No install mode can transfer an already-tracked destination from one owner package to another +package. A conflicting path that the tool skips is also protected from removal when the owner's +version changes, in the same way that it is protected from copying. A version refresh of the +same package remains allowed. The tool never relabels a protected skill under a version that does +not ship it. That case stops the install instead, as an earlier rule in this list describes. + +Keep every discovery candidate available internally until the tool knows ownership at install +time. Prefer the current owner's candidate when more than one package offers the same name. +`list` stays a discovery report that does not depend on the destination folder's current state. + +Package authors can avoid collisions by prefixing their skill folders with their lowercased +package ID. The tool does not enforce this naming convention. + +In v1, the tool matches names case-insensitively, as a logical rule. It does not reconcile two +folders that differ only in physical case on a case-sensitive file system. Package authors must +keep their folder casing stable across versions. A case-only rename, or two physical folders +that differ only in case on a case-sensitive file system, both fall outside the v1 ownership +guarantees. Do not promise safe migration for these cases. Do not add special reconciliation +logic for them without a new discussion of this scope. + +**A package filter must never broaden a destructive operation.** An explicitly blank filter +value is an error. Only a completely absent `--package` option means all packages. Interactive +uninstall and noninteractive uninstall both use the same normalized version matcher. + +**The tool allows one version of each package, or it does not install at all.** The manifest +records exactly one version for each package. The resolved packages, or the `--package` +coordinates, can sometimes include two normalized versions of one package ID. When that happens, +every install mode stops before it changes anything, and it asks the user to align the versions. +We expect repositories to use NuGet Central Package Management for this. `PackageLister.Parse` +keeps each distinct `(id, version)` pair separate, so this check can see both versions. `list` +still shows both versions too. + +**Write every error message as guidance, not as a description of the failure alone.** Throw +`PackageSkillsException` with a message that tells the user what to do next. `Program.cs` prints +this message without a stack trace. If a message would leave a user stuck with no next step, add +more words to it. A command shown inside a message must work exactly as printed, when the user +copies and pastes it. Build that command with `SkillInstallService.UninstallCommand`. This +method repeats the run's `--target` value and its non-default `--destination` value. ## Tests -Application unit tests run offline and never invoke `dotnet`. Anything that needs the CLI goes through -`IProcessRunner`, which `SkillInstallServiceTests` fakes — see `FakeDotnet` there for the pattern. -Use `TempDirectory` for anything touching the file system; it cleans up after itself. +Application unit tests run offline. They never run the `dotnet` command. Any test code that +needs the CLI goes through `IProcessRunner`. `SkillInstallServiceTests` fakes this interface. See +`FakeDotnet` in that file for the pattern to follow. Use `TempDirectory` for any test that +touches the file system. `TempDirectory` cleans up its own files afterward. -The interactive picker goes through `ITerminal`, which `FakeTerminal` drives from a scripted key -sequence and reads back as a screen buffer. It models a buffer rather than concatenating writes -because the picker redraws in place, so appending every write would show frames stacked on top of -each other instead of the one page a user sees. +The interactive picker goes through `ITerminal`. `FakeTerminal` drives this interface from a +scripted sequence of keys, and it reads the result back as a screen buffer. `FakeTerminal` models +a buffer instead of joining writes end to end. The picker redraws its frame in place, so joining +every write together would stack frames on top of each other. A real user sees only one page at +a time, and the test model must match that. -### Pipeline and package checks +### Checks for the pipeline and the package -`PipelineVersionTests` invokes the tool's PowerShell version calculator to cover PR, preview, -manual, and stable-release versions, plus invalid identifiers and release requests. It needs -`pwsh` on PATH but no network or signing credentials. +`PipelineVersionTests` calls the tool's PowerShell version calculator. These tests cover PR +versions, preview versions, manual versions, and stable release versions. They also cover +invalid identifiers and invalid release requests. These tests need `pwsh` on the PATH. They need +no network access and no signing credentials. -The package verifier described above separately installs the produced `.nupkg` for each target -framework and checks its version, payload, and install/list/uninstall behavior in temporary -directories. Official builds additionally require valid package and assembly signatures. +A separate package verifier installs the produced `.nupkg` file for each target framework. It +checks the package version, the package payload, and the install, list, and uninstall behavior, +all inside temporary directories. Official builds also require a valid package signature and +valid assembly signatures. -Keep all tool-specific pipeline logic under `eng\pipelines\dotnet-package-skills` at the -repository root. See its [guide](../eng/pipelines/dotnet-package-skills/README.md) for versioning, -official signing setup, and the retirement checklist. +Keep all pipeline logic that is specific to this tool under `eng\pipelines\dotnet-package-skills`, +at the root of the repository. See its [guide](../eng/pipelines/dotnet-package-skills/README.md) +for version numbering, official signing setup, and the checklist for retiring this tool. -### Naming unit tests +### Name unit tests -Name tests as a sentence describing the behaviour, not the method under test: +Name each test as a sentence that describes the behavior. Do not name a test after the method +under test: ```csharp [Fact] public void Install_skips_a_later_skill_when_destination_names_collide() ``` -New behaviour needs a test. Bug fixes need a test that fails without the fix — the `.slnx` -preference bug shipped with one, and that is why it stayed fixed. +Every new behavior needs a test. Every bug fix needs a test that fails without the fix. The +`.slnx` preference bug shipped with a test like this, and that test is the reason the bug has +stayed fixed since then. ## Style -`TreatWarningsAsErrors` is on; builds must be warning-clean. Beyond that, match the surrounding -code. Comments explain *why*, not what — if a comment restates the code, delete it. +`TreatWarningsAsErrors` is on. A build must produce no warnings. Beyond that rule, match the +style of the surrounding code. Write a comment to explain why the code does something. Do not +write a comment that only restates what the code does. If you find a comment that only restates +the code, delete it. ## Compatibility -- The tool targets `net8.0` and `net10.0`. Don't drop `net8.0` without a discussion; it is the LTS - a lot of teams are still on. -- `dotnet list package --format json` requires SDK 7.0.200+. That is the floor for what the tool - can inspect, and the error message says so when it isn't met. -- **The tool never restores.** It runs `dotnet list package` as it is, without `--no-restore`, - and never runs `dotnet restore` itself. The .NET 10 SDK restores during the listing when it needs - to; earlier SDKs report that the target has to be restored first. A failed listing stops the - command with what the SDK reported, read from the JSON `problems` array when there is one, so the - customer can restore or fix the target and run the command again. `PackageListerTests` pins - both halves: the exact arguments, and that no restore is ever attempted. -- Output of `dotnet nuget locals` has changed shape across SDK versions. Parsing keys off the - `global-packages:` label rather than line position — keep it that way. +- The tool targets `net8.0` and `net10.0`. Do not drop `net8.0` without a discussion first. Many + teams still run the `net8.0` long-term support release. +- `dotnet list package --format json` needs SDK 7.0.200 or later. This is the lowest SDK version + that the tool can inspect, and the error message states this requirement when the installed SDK + does not meet it. +- **The tool never restores a project.** It runs `dotnet list package` without a `--no-restore` + flag, and it never runs `dotnet restore` on its own. The .NET 10 SDK restores the project during + this listing step, when the project needs it. An earlier SDK instead reports that the target + needs to be restored first. When the listing fails, the command stops. It shows what the SDK + reported, including the JSON `problems` array when the SDK provides one, so the customer can + restore the target, or fix the target, and run the command again. `PackageListerTests` checks + two things together: the exact arguments that the tool passes, and the fact that the tool never + attempts a restore. +- The output shape of `dotnet nuget locals` has changed across SDK versions. The parser reads the + key from the `global-packages:` label in that output. It does not read the key by line + position. Keep the parser written this way. ## Pull requests -- One change per PR. -- `dotnet build` and `dotnet test` pass. -- README updated if you changed the CLI surface. -- Say what you tested it against. "Ran `install` on a solution with 40 packages, two of which ship - skills" is worth more than a description of the diff. +- Make one change in each pull request. +- Make sure `dotnet build` and `dotnet test` both pass. +- Update the README when you change the CLI surface. +- State what you tested your change against. For example, write "Ran `install` on a solution + with 40 packages. Two of those packages ship skills." This kind of statement is worth more than + a description of the code diff. diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md index 2646ea1..430b6bd 100644 --- a/dotnet-package-skills/README.md +++ b/dotnet-package-skills/README.md @@ -1,24 +1,26 @@ # dotnet-package-skills -Copies agent skills bundled inside NuGet packages into a folder your coding agent actually reads. +This tool copies agent skills from inside NuGet packages into a folder that your coding agent +reads. -For a product-oriented command reference and sample outputs, see the -[functional specification](docs/functional-spec.md). For the expected behavior in each situation, -such as a package upgrade or a package leaving the project, see [scenarios](docs/scenarios.md). +For a command reference and sample output, see the +[functional specification](docs/functional-spec.md). For the expected result in each situation, +such as a package upgrade or a package that leaves the project, see +[scenarios](docs/scenarios.md). ## The problem -Package authors are the domain experts on their own libraries, and some of them now ship an -**agent skill** inside the package — instructions covering the conventions, gotchas, and correct -usage patterns for that library. Those files are packed at +Package authors know their own libraries best. Some package authors now ship an **agent skill** +inside the package. A skill is a set of instructions that covers the conventions, the pitfalls, +and the correct usage patterns for that library. The package stores each skill at `skills/-/SKILL.md`. -Restore extracts the package into the **NuGet global packages folder** (`~/.nuget/packages` by -default), which lives outside your repository and is shared by every project on the machine. -Coding agents only scan a skills directory *inside* the working repo. So the skill is on disk, -correct, and invisible. +Restore extracts the package into the **NuGet global packages folder**. This folder is +`~/.nuget/packages` by default. It sits outside your repository, and every project on the +machine shares it. A coding agent scans a skills folder only *inside* the working repository. +Because of this, the skill sits correctly on disk, but the agent cannot see it. -This tool bridges that gap. +This tool closes that gap. ``` ~/.nuget/packages/mockly/1.10.0/skills/mockly-usage/SKILL.md ← where restore puts it @@ -34,31 +36,33 @@ dotnet tool install --global dotnet-package-skills ## Use -From your repository root: +Run this command from your repository root: ```bash dotnet-package-skills install ``` -That is the whole workflow. It finds your solution or project, lists its packages, locates each -direct dependency in the NuGet cache, and copies any bundled skills into `.agents/skills/`. +This single command does the whole job. It finds your solution or project. It lists that +project's packages. It locates each direct dependency in the NuGet cache. It copies any bundled +skills into `.agents/skills/`. -Run it again after adding or upgrading packages. It refreshes the skills of the packages it finds, -and when a package moves to a new version, it removes the skills that version no longer ships. It -never removes skills because a package left the project. Instead, it lists them, and -`dotnet-package-skills uninstall --stale` removes them. +Run the command again after you add or upgrade a package. The command refreshes the skills of +the packages it finds. When a package moves to a new version, the command removes the skills +that version no longer ships. The command never removes a skill just because a package left the +project. Instead, it lists that skill, and you remove it later with +`dotnet-package-skills uninstall --stale`. ### Commands | Command | What it does | | --- | --- | -| `install` | Copy bundled skills into the destination. Add `--interactive` to choose which new skills to add. | -| `list` | Show which packages ship skills, without copying anything. | -| `uninstall` | Remove skills this tool copied in. Add `--stale` to remove only the skills the project no longer references, or `--interactive` to pick them. | +| `install` | Copies bundled skills into the destination. Add `--interactive` to choose which new skills to add. | +| `list` | Shows which packages ship skills. It copies nothing. | +| `uninstall` | Removes skills that this tool copied in. Add `--stale` to remove only the skills whose package the project no longer references. Add `--interactive` to pick them yourself. | -### What to point it at +### Choose what to read skills from -Three ways to say which packages to take skills from: +There are three ways to tell the tool which packages to read skills from. ```bash dotnet-package-skills install # auto-detect solution or project @@ -66,29 +70,32 @@ dotnet-package-skills install --target src/MyApp.slnx # a specific solution dotnet-package-skills install --package Mockly@1.10.0 # exact packages, no project needed ``` -`--package` is repeatable and needs an **exact version** — `Mockly@1.*` and `Mockly@[1.0,2.0)` are -refused. Resolving a range means picking a version, and the only correct answer to "which version" -comes from a project's restore, which is what `--target` is for. Guessing would copy skills -describing a release you do not actually reference. +`--package` is repeatable. It needs an **exact version**. The tool refuses `Mockly@1.*` and +`Mockly@[1.0,2.0)`. Resolving a range means picking one version from it, and the only correct +answer to "which version" comes from a project's own restore step. `--target` gives you that +answer. If the tool guessed a version instead, it could copy skills that describe a release you +do not actually reference. -`--target` and `--package` cannot be combined; both answer the same question. +`--target` and `--package` cannot combine, because both options answer the same question. -Naming packages explicitly touches only the packages you name and leaves every other installed -skill alone. A target describes the project's complete set of packages, so a target install can -also tell you which installed skills belong to packages the project no longer references. If a -package the target resolves is missing from the NuGet cache, `install` stops before changing -anything; restore first. +When you name packages explicitly, the tool touches only the packages you name. It leaves every +other installed skill alone. A target describes the project's complete set of packages. Because +of this, a target install can also tell you which installed skills belong to a package that the +project no longer references. When a package that the target resolves is missing from the NuGet +cache, `install` stops before it changes anything. Restore the project first, and then try again. -The tool expects one version of each package, which is what +The tool expects one version of each package. [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) -gives a repository. When the target resolves two versions of the same package, or `--package` -names two, `install` stops without changing anything and names the versions to align. +gives a repository that guarantee. When the target resolves a package to two versions, or when +`--package` names a package twice at two versions, `install` stops without changing anything. It +names the versions that you need to align. -### Keeping skills in step with the project +### Keep skills in step with the project -When a package moves to a new version, `install` copies the new version's skills over the old ones -and removes any skill the new version no longer ships. The manifest records one version per -package, so it always says which release the installed guidance describes. +When a package moves to a new version, `install` copies that version's skills over the old +copies. It removes any skill that the new version no longer ships. The manifest records one +version for each package, so the manifest always states which release the installed guidance +describes. When a package leaves the project, `install` keeps its skills and lists them: @@ -99,35 +106,37 @@ When a package leaves the project, `install` keeps its skills and lists them: Run 'dotnet-package-skills uninstall --stale' to remove them. ``` -The suggested command repeats the `--target` and `--destination` you passed, so you can run it as -printed. The same goes for every command that the tool's errors suggest. +The suggested command repeats the `--target` and `--destination` that you passed, so you can run +it exactly as printed. Every command that the tool's errors suggest works the same way. -A reference can disappear for a moment, for example halfway through a refactor, so removing -skills is always a command you run on purpose. `uninstall --stale` removes every **stale** skill: -one whose package the target no longer references, or references at a different version. Preview -it with `--dry-run`, or pick among the stale skills with `--interactive`: +A package reference can disappear for a moment, for example halfway through a refactor. Because +of this, removing skills is always a command that you run on purpose. `uninstall --stale` removes +every **stale** skill. A stale skill is one whose package the target no longer references, or +whose package the target references at a different version. Preview this removal with +`--dry-run`, or pick among the stale skills yourself with `--interactive`: ```bash dotnet-package-skills uninstall --stale --dry-run dotnet-package-skills uninstall --stale ``` -`--stale` reads the project's package references, so it needs a solution or project: the one in -the current directory, or the one you pass with `--target`. Skills you added with -`install --package` for packages outside the project count as stale too. +`--stale` reads the project's package references, so it needs a solution or project. The tool +uses the one in the current directory, or the one you pass with `--target`. A skill that you +added with `install --package`, for a package outside the project, also counts as stale. -### Choosing which skills to install +### Choose which skills to install -By default `install` copies everything it finds. Add `--interactive` to choose which new skills to -add: +By default, `install` copies every skill that it finds. Add `--interactive` to choose which new +skills to add: ```bash dotnet-package-skills install --interactive # everything the project references dotnet-package-skills install --package Mockly@1.10.0 --interactive # just one package's skills ``` -It composes with `--target` and `--package`, so you can narrow to a single package first and then -pick among the skills it ships — which is what you want when one package bundles a dozen of them. +`--interactive` combines with `--target` and `--package`. You can narrow the list to a single +package first, and then pick among the skills that package ships. Use this method when one +package bundles a dozen skills together. ``` Which skills should be installed? (MyApp.slnx) @@ -148,73 +157,83 @@ Installed skills aren't listed. Blue X: selected ``` -The checklist lists only skills that aren't installed, and nothing starts checked, so accepting -adds exactly the skills you ticked. An interactive install never refreshes or removes anything: -run `install` without `--interactive` to refresh, and `uninstall` to remove. When every skill the -packages ship is already installed, it prints `Nothing new to install.` and doesn't open the -checklist. Skills it can't add, such as a name already taken by another package or by a folder you -wrote yourself, aren't listed; the report names them under the skipped warning. - -Adding only makes sense when the installed skills match the packages, so before the checklist -opens, an interactive install stops without changing anything when: - -- the target resolves more than one version of a package, or `--package` names more than one; -- with a target, a package the target resolves is missing from the NuGet cache; -- with a target, an installed skill is stale. Run `dotnet-package-skills uninstall --stale` first; -- with `--package`, a named package is installed at another version. Run - `dotnet-package-skills uninstall --package ` first, or run `install --package @` - without `--interactive` to move it to the new version. - -Each description follows the authored skill name immediately after ` - `, without a padded -column or a package/version suffix. Package prefixes in authored names are kept, and continuation -lines flow beneath the skill text, using the available width rather than leaving a name-sized gap. -The focused skill's text, including wrapped description lines, is blue. Checked items have a blue -`X`; other skill names and descriptions use their normal color. The summary counts the checked -skills; there is no separate status column. With `NO_COLOR` set, or on a terminal without color -support, `>` marks the focused skill and `[X]` the checked ones, with nothing else beside them. - -The keyboard hints appear below the list, using Aspire's -`(Press to select, to accept)` style. Every keyboard-help line starts with `Press`, -including movement, paging, select-all, clear-all, cancel, and description scrolling. Controls -that do nothing are left out. These keys work: +The checklist lists only skills that are not already installed. Nothing starts checked. When you +accept, the tool adds exactly the skills that you checked. An interactive install never refreshes +a skill and never removes a skill. Run `install` without `--interactive` to refresh a skill. Run +`uninstall` to remove a skill. When the packages ship only skills that are already installed, the +command prints `Nothing new to install.` and does not open the checklist. A skill that the tool +cannot add, for example because its name is already taken by another package or by a folder you +wrote yourself, is not listed. The report names these skills under its skipped warning instead. + +Adding a skill makes sense only when the installed skills already match the packages. Because of +this, an interactive install stops before the checklist opens, and changes nothing, in these +cases: + +- The target resolves a package to more than one version, or `--package` names a package more + than once. +- With a target, a package that the target resolves is missing from the NuGet cache. +- With a target, an installed skill is stale. Run `dotnet-package-skills uninstall --stale` + first. +- With `--package`, a named package is installed at a different version. Run + `dotnet-package-skills uninstall --package ` first. You can also run + `install --package @` without `--interactive` to move the package to its new + version. + +Each description follows the authored skill name right after ` - `. There is no padded column and +no package or version suffix. The tool keeps any package prefix in an authored name. A +continuation line flows beneath the skill's own text, using the available width, instead of +leaving a name-sized gap. The focused skill's text turns blue, including every wrapped +description line. A checked item shows a blue `X`. Every other skill name and description keeps +its normal color. The summary counts only the checked skills. There is no separate status +column. When you set `NO_COLOR`, or when your terminal has no color support, `>` marks the +focused skill and `[X]` marks each checked skill, with no other symbol beside them. + +The keyboard hints appear below the list, in the style of Aspire's +`(Press to select, to accept)` message. Every keyboard-help line starts with the +word `Press`. This applies to movement, paging, select-all, clear-all, cancel, and description +scrolling. The tool leaves out a control that would do nothing. These keys work: | Key | Does | | --- | --- | -| `up` / `down` | Move, wrapping around at either end | -| `left` / `right`, `pgup` / `pgdn` | Previous / next page | -| `home` / `end` | Jump to the first / last skill | -| `space` | Toggle the highlighted skill | -| `a` / `c` | Select all / clear all, across every page | -| `ctrl+up` / `ctrl+down` | Scroll a description when one skill is taller than a page | -| `enter` | Confirm the selection | -| `esc` / `q` / `ctrl+c` | Cancel, changing nothing | - -Pages are measured in rendered lines, including wrapped descriptions and keyboard hints, rather -than a fixed number of skills. Each ordinary skill stays together on one page. A description too -long for a page can be scrolled without changing the selection. Resizing the terminal reflows the -page in place while preserving the highlighted skill and checked items, even during a redraw. -Old picker frames are not pushed into scrollback. When scrolling an oversized description, the -skill row stays visible while its continuation lines scroll. Short lists and partial final pages -do not leave a screenful of blank rows, and a single page has no page counter. The note under the -title gives way when the window is too small to fit it, so a small window keeps the checklist -rather than refusing to open. -The live picker uses a temporary terminal screen and starts at its top, regardless of the shell's -previous cursor position. Host-driven reflow cannot leave duplicate copies in normal scrollback. -Accepting, cancelling, or a handled failure restores the previous shell screen; the final report -is written there, not alongside an old checklist. - -Descriptions come from the top-level YAML `description` in each package's `SKILL.md`. Missing -descriptions say `No description provided.`; unreadable or malformed metadata shows an explicit -description warning without hiding the skill or preventing its selection. Only the interactive -checklists read this metadata; reports and the ownership manifest don't include descriptions. - -`--interactive` needs a terminal. Pair it with `--dry-run` to see what a selection would change -before committing to it. - -### Choosing what to remove - -`uninstall` takes `--interactive` too, and lists only what this tool installed — never a skill you -wrote yourself, because it reads the manifest rather than the folder: +| `up` / `down` | Moves to the next or previous skill, and wraps around at either end | +| `left` / `right`, `pgup` / `pgdn` | Moves to the previous or next page | +| `home` / `end` | Jumps to the first or last skill | +| `space` | Toggles the highlighted skill | +| `a` / `c` | Selects all skills, or clears all skills, across every page | +| `ctrl+up` / `ctrl+down` | Scrolls a description when one skill is taller than a page | +| `enter` | Confirms the selection | +| `esc` / `q` / `ctrl+c` | Cancels, and changes nothing | + +The tool measures a page in rendered lines, including wrapped descriptions and keyboard hints. It +does not measure a page by a fixed number of skills. Each ordinary skill stays together on one +page. A user can scroll a description that is too long for one page, without changing the +selection. When you resize the terminal, the tool reflows the page in place. It keeps the +highlighted skill and the checked items, even during a redraw. The tool does not push old picker +frames into your scrollback. When you scroll an oversized description, the skill's own row stays +visible while its continuation lines scroll. A short list, or a partial final page, does not +leave a screenful of blank rows. A single page shows no page counter. The note under the title +gives way first when the window is too small to fit it. This way, a small window still shows the +checklist instead of refusing to open. + +The live picker uses a temporary terminal screen. It starts at the top of that screen, regardless +of where the shell's cursor was before you ran the command. A host-driven reflow cannot leave +duplicate copies of the checklist in your normal scrollback. When you accept, cancel, or hit a +handled failure, the tool restores the previous shell screen. It writes the final report there, +not next to an old checklist. + +Descriptions come from the top-level YAML `description` property in each package's `SKILL.md` +file. A missing description shows the text `No description provided.` Unreadable or malformed +metadata shows an explicit description warning, but it does not hide the skill or block its +selection. Only the interactive checklists read this metadata. Reports and the ownership +manifest never include a description. + +`--interactive` needs a terminal. Pair it with `--dry-run` to see what a selection would change, +before you commit to that change. + +### Choose what to remove + +`uninstall` also takes `--interactive`. It lists only what this tool installed. It never lists a +skill that you wrote yourself, because it reads the manifest instead of scanning the folder: ```bash dotnet-package-skills uninstall --interactive @@ -237,10 +256,10 @@ Which skills should be uninstalled? Blue X: selected ``` -Nothing starts ticked, so a mistaken enter removes nothing. A ticked row looks the same as in the -install checklist: each checklist does only one thing, so the title and the summary say what a -tick does. Narrow the list first with `--package` if you only care about one package, or with -`--stale` to see only the skills that no longer match the project: +Nothing starts checked, so a mistaken enter removes nothing. A checked row looks the same as it +does in the install checklist. Each checklist does only one thing, so its title and its summary +state what a check mark does. Narrow the list first with `--package` when you care about only one +package. Narrow it with `--stale` to see only the skills that no longer match the project: ``` Which skills should be uninstalled? @@ -254,39 +273,40 @@ Only skills that don't match the target are listed. 1 of 2 selected; 1 to remove ``` -Add `--dry-run` to see the outcome without it happening. Descriptions are read from the installed -copies, not from the NuGet cache. A missing or damaged `SKILL.md` does not prevent removal of a -manifest-owned skill. Package matching ignores case, and version filters are normalized in both -modes (`1.10` matches `1.10.0`). Blank, missing, or repeated uninstall `--package` values are -errors, not an unfiltered uninstall. `--stale` and `--package` can't be combined. +Add `--dry-run` to see the outcome without it happening. Descriptions come from the installed +copies, not from the NuGet cache. A missing or damaged `SKILL.md` does not block removal of a +manifest-owned skill. Package matching ignores letter case. Both modes normalize version filters, +so `1.10` matches `1.10.0`. A blank, missing, or repeated `--package` value on `uninstall` is an +error. It never broadens the command to an unfiltered uninstall. `--stale` and `--package` cannot +combine. ### Options | Option | Applies to | Description | | --- | --- | --- | | `-t, --target ` | install, list | Solution or project to inspect. Defaults to searching the current directory. | -| `-p, --package ` | install, list | Take skills from an exact package instead of a project. Repeatable. No floating versions. | -| `-d, --destination ` | install, list | Where skills are copied. Default `.agents/skills`. | -| `-d, --destination ` | uninstall | Where to remove them from. Must match the one you installed to. | -| `--global-packages ` | install, list | Override the NuGet global packages folder. | -| `-i, --interactive` | install | Choose which new skills to add, with descriptions and pagination. Lists only skills that aren't installed. Combines with `--target` or `--package`. | -| `-i, --interactive` | uninstall | Choose which installed skills to remove, with descriptions and pagination. Lists only what this tool installed. | -| `-p, --package ` | uninstall | Remove only this package's skills — whichever version is installed, or only if it's the version you name. | -| `--stale` | uninstall | Remove only stale skills: those whose package the target no longer references, or references at a different version. Needs a solution or project. Not with `--package`. | +| `-p, --package ` | install, list | Take skills from an exact package instead of a project. Repeatable. Refuses a floating version. | +| `-d, --destination ` | install, list | Where the tool copies skills to. Default `.agents/skills`. | +| `-d, --destination ` | uninstall | Where the tool removes skills from. Must match the destination you installed to. | +| `--global-packages ` | install, list | Overrides the NuGet global packages folder. | +| `-i, --interactive` | install | Lets you choose which new skills to add, with descriptions and pagination. Lists only skills that are not installed. Combines with `--target` or `--package`. | +| `-i, --interactive` | uninstall | Lets you choose which installed skills to remove, with descriptions and pagination. Lists only skills that this tool installed. | +| `-p, --package ` | uninstall | Removes only this package's skills. Removes whichever version is installed, or only the version you name. | +| `--stale` | uninstall | Removes only stale skills: a skill whose package the target no longer references, or whose package the target references at a different version. Needs a solution or project. Cannot combine with `--package`. | | `-t, --target ` | uninstall | With `--stale`, the solution or project to compare against. Defaults to searching the current directory. | -| `--dry-run` | install, uninstall | Report what would change without writing anything. | +| `--dry-run` | install, uninstall | Reports what would change, and writes nothing. | -### Targeting another agent's folder +### Target another agent's folder -`.agents/skills` is the vendor-neutral default. Point `--destination` anywhere else: +`.agents/skills` is the vendor-neutral default destination. Point `--destination` anywhere else: ```bash dotnet-package-skills install --destination .claude/skills dotnet-package-skills install --destination .codex/skills ``` -`uninstall` takes the same option, and needs it: it only looks where you point it, so removing -what you put in `.claude/skills` means saying so again. +`uninstall` takes the same option, and it needs that option. `uninstall` looks only where you +point it. To remove what you put in `.claude/skills`, you must name that folder again. ```bash dotnet-package-skills uninstall --destination .claude/skills @@ -294,24 +314,27 @@ dotnet-package-skills uninstall --destination .claude/skills ### Scripts and CI -Reports are written for people, and the manifest is the only machine-readable output. In a -script, rely on the exit code: `0` when the command succeeded, and `1` when it stopped, with the -reason on stderr. A command that stops changes nothing. Argument errors also print help on stdout. +The tool writes reports for people to read. The manifest is its only machine-readable output. In +a script, rely on the exit code. The code is `0` when the command succeeded. The code is `1` when +the command stopped, and the reason appears on stderr. A command that stops changes nothing. An +argument error also prints help text on stdout. -A job that keeps a committed skills folder in step with the project can run: +A job that keeps a committed skills folder in step with the project can run these two commands: ```bash dotnet-package-skills install dotnet-package-skills uninstall --stale ``` -To see what's installed, read `.agents/skills/.dotnet-package-skills.json`, described in -[What you get](#what-you-get). `--interactive` needs a terminal, so leave it out of scripts. +To see what the tool installed, read `.agents/skills/.dotnet-package-skills.json`. [What you +get](#what-you-get) describes this file. `--interactive` needs a terminal, so leave it out of a +script. -Human-readable reports and diagnostics, including argument-validation errors and parser suggestions, -remove terminal escape sequences and unsafe control characters from metadata, paths, and diagnostic -text. This is display-only: arguments are validated as supplied, and stored identities are -unchanged. +A report and a diagnostic message are both written for people, including an argument-validation +error and a parser suggestion. The tool removes terminal escape sequences and unsafe control +characters from metadata, paths, and diagnostic text before it shows them. This change affects +only the display. The tool still validates arguments exactly as you supplied them, and a stored +identity stays unchanged. ## What you get @@ -328,12 +351,13 @@ Each authored skill folder lands directly under the destination: └── SKILL.md ``` -The tool preserves the skill folder name from the package. Package id and version remain in the -install manifest for attribution and uninstall filtering, but they are not added to the path. +The tool keeps the skill folder's name from the package. The package ID and version stay in the +install manifest, for attribution and for uninstall filtering, but the tool does not add them to +the path. -The manifest follows the shape of the .NET local tool manifest (`dotnet-tools.json`): a format -`version`, then one entry per package, keyed by its lowercase package ID, with the one version its -skills came from and the skill folders it owns: +The manifest follows the shape of the .NET local tool manifest, `dotnet-tools.json`. It has a +format `version`, and then one entry for each package, keyed by the lowercase package ID. Each +entry names the one version that its skills came from, and the skill folders it owns: ```json { @@ -350,60 +374,68 @@ skills came from and the skill folders it owns: } ``` -The file is safe to commit. The tool writes it the same way on every platform: UTF-8 without a -byte order mark, LF line endings, and entries in a stable order, so a Windows checkout and a Linux -checkout produce the same bytes. It ignores properties it doesn't recognize, and it refuses a -manifest with a newer format `version` and asks you to update the tool. +You can safely commit this file. The tool writes it the same way on every platform. It uses +UTF-8 without a byte order mark, LF line endings, and a stable order for its entries. Because of +this, a Windows checkout and a Linux checkout produce the same bytes. The tool ignores a property +that it does not recognize. It refuses a manifest with a newer format `version`, and it asks you +to update the tool instead. -Package authors should prefix every folder with their lowercased package id, as shown above. This -keeps names globally unique when skills from many packages share one destination. The convention is -documented rather than enforced, so existing safe names still work. +We recommend that a package author prefix every skill folder with the package's lowercased ID, as the +example above shows. This convention keeps names globally unique when skills from many packages +share one destination. The tool documents this convention. It does not enforce it. An existing +safe name still works. ### Name collisions -Destination names are compared case-insensitively. If two package skills choose the same name, the -first one in deterministic package order is copied and later collisions are skipped with a warning. -An existing destination folder not tracked by this tool is treated as user-owned and is also -skipped, never overwritten. -The same protection applies to a name already owned by a different package: every install mode -warns and preserves that owner rather than transferring it automatically. Explicitly uninstall -the old skill before installing its replacement. Upgrading the same package remains supported. -If both the owner and another package offer the same name, installation prefers the owner's -candidate so the conflict does not prevent a legitimate refresh. - -One combination stops `install` instead: the owner's package moves to a version that no longer -ships the skill, while another package ships a skill with that name. Removing the old copy would -hand the name over, and the manifest can't keep an older version's copy under the new version, so -`install` changes nothing and suggests `uninstall --package ` for the owner. After that, -`install` copies both packages' current skills. - -V1 does not reconcile distinct physical case variants on case-sensitive filesystems. Keep authored -skill-folder casing stable across versions and avoid folders such as `guide` and `GUIDE` in the -same destination. Case-only renames or collisions between those physical variants can leave -untracked old copies or overwrite a handwritten variant; those scenarios are outside v1 guarantees. - -Refreshing a tracked skill replaces its entire folder, including local edits and added files. -Keep hand-written guidance in separate, untracked skill folders. +The tool compares destination names without regard to letter case. When two package skills +choose the same name, the tool copies the first one in a fixed package order. It skips a later +collision and shows a warning. An existing destination folder that this tool does not track +belongs to the user. The tool skips that folder too, and never overwrites it. + +The same protection applies to a name that a different package already owns. Every install mode +warns about this and keeps the current owner. It does not transfer the name automatically. +Uninstall the old skill explicitly before you install its replacement. Upgrading the same package +still works as expected. When both the current owner and another package offer the same name, +installation prefers the owner's candidate. This way, the conflict does not block a legitimate +refresh. + +One combination stops `install` instead of skipping a file. This happens when the owner's package +moves to a version that no longer ships the skill, while a different package ships a skill with +that same name. Removing the old copy would hand the name to the other package. Keeping the old +copy would record it under the new version's number, which would be wrong. For both reasons, +`install` changes nothing in this case. It suggests `uninstall --package ` for the owner. +After you run that command, `install` copies both packages' current skills. + +V1 does not reconcile two folders that differ only in physical case on a case-sensitive file +system. Keep an authored skill folder's casing stable across versions. Avoid folders such as +`guide` and `GUIDE` in the same destination. A case-only rename, or a collision between those +physical variants, can leave an untracked old copy behind, or it can overwrite a handwritten +variant. Both outcomes fall outside the v1 guarantees. + +A refresh of a tracked skill replaces its entire folder. This includes any local edits and any +files that you added. Keep hand-written guidance in a separate, untracked skill folder instead. ### Package versions -The destination holds skills from one version of each package, and the manifest records that -version. Keep the projects in a repository on one version of each package, which is what +The destination holds skills from one version of each package, and the manifest records that one +version. [NuGet Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) -does. When a target resolves more than one version of a package, `install` stops without changing -anything and names the versions to align; `--package` with two versions of one package stops the -same way. `list` still shows every version it finds. +keeps the projects in a repository on one version of each package. We recommend it for this +reason. When a target resolves a package to more than one version, `install` stops without +changing anything, and it names the versions that you need to align. `--package` stops the same +way when you name one package at two versions. `list` still shows every version that it finds. -### Should I commit this folder? +### Commit or ignore this folder -Either is defensible. Commit it so the whole team and CI get the skills without running anything, -or gitignore it and let each machine refresh it. Pick one and say so in your contributing guide. +Both choices are reasonable. Commit the folder so the whole team and your CI system get the +skills without running anything. Or add the folder to `.gitignore` and let each machine refresh +it on its own. Pick one choice, and state that choice in your own contributing guide. -## For package authors: shipping a skill +## For package authors: ship a skill -Put each skill under `skills/-/`, with its own `SKILL.md` and any supporting -files. Prefixing the folder with your lowercased package ID keeps your skills from colliding with -other packages on the consumer's machine. +Put each skill under `skills/-/`. Give it its own `SKILL.md` file, plus +any supporting files it needs. Prefix the folder with your lowercased package ID. This keeps your +skills from colliding with another package's skills on the consumer's machine. ```xml @@ -415,12 +447,12 @@ other packages on the consumer's machine. ``` -A complete working example is in [`samples/Contoso.Widgets`](samples/Contoso.Widgets). +[`samples/Contoso.Widgets`](samples/Contoso.Widgets) contains a complete, working example. -Every skill must have its own immediate subdirectory under `skills/`; a lone `skills/SKILL.md` is -not discovered. +Every skill must have its own immediate subfolder under `skills/`. The tool does not discover a +lone `skills/SKILL.md` file. -Give each skill a useful `description` in its YAML frontmatter so customers can decide whether +Give each skill a useful `description` in its YAML frontmatter, so a customer can decide whether they need it: ```yaml @@ -432,114 +464,135 @@ description: > --- ``` -Plain, quoted, literal (`|`), and folded (`>`) descriptions are supported. The interactive picker -reads only bounded frontmatter, never interprets the Markdown instructions, and never rewrites -the file. Description metadata is informative, not an additional installation requirement. -Frontmatter is limited to 65,536 decoded characters and 32 collection levels. Explicit YAML tags, -anchors, and aliases are not supported by the description reader; they produce a visible metadata -warning rather than preventing installation. +The tool supports a plain description, a quoted description, a literal (`|`) description, and a +folded (`>`) description. The interactive picker reads only bounded frontmatter. It never +interprets the Markdown instructions in the file, and it never rewrites the file. Description +metadata exists to inform the user. It is not an additional requirement for installation. +Frontmatter is limited to 65,536 decoded characters and 32 collection levels. The description +reader does not support an explicit YAML tag, anchor, or alias. These produce a visible metadata +warning instead of blocking installation. ## How it works -1. `dotnet list package --format json` — the resolved direct packages. The tool never - restores: the .NET 10 SDK restores during this step when it needs to, and earlier SDKs say the - target has to be restored first. If the step fails, the tool shows what it reported, so you can - restore or fix the target and run the tool again. -2. `dotnet nuget locals global-packages --list` — where restore extracted them. `NUGET_PACKAGES` - and `--global-packages` take precedence, in that order. -3. `install` stops without changing anything if a package resolves to more than one version, or - if a package the target resolves is missing from the cache. -4. For each package, look in `///skills/`. -5. Copy each `skills//` folder to `//`, skipping and warning on collisions. - For a package that moved to a new version, remove the skills the new version no longer ships. -6. Record what was copied in `/.dotnet-package-skills.json`. - -`uninstall --stale` needs only step 1: it compares the manifest with the target's package -references and never looks in the NuGet cache for skills. - -Nothing inside a skill is read or interpreted. The package author decides what a skill contains; -this tool only puts it where an agent will look. - -### It copies, it never moves - -The global packages folder is NuGet's content-addressable cache. It is validated during restore -and shared by every project on the machine, so moving files out of it can make restore treat the -cached package as corrupt — and would strip the skill from every other repository using that -package. +1. `dotnet list package --format json` finds the resolved direct packages. The tool + never restores a project on its own. The .NET 10 SDK restores the project during this step, + when it needs to. An earlier SDK instead says that the target needs to be restored first. When + this step fails, the tool shows what it reported, so you can restore or fix the target and run + the tool again. +2. `dotnet nuget locals global-packages --list` finds where restore extracted those packages. + `NUGET_PACKAGES` and `--global-packages` both take precedence over this step, in that order. +3. `install` stops without changing anything when a package resolves to more than one version, or + when a package that the target resolves is missing from the cache. +4. For each package, the tool looks in `///skills/`. +5. The tool copies each `skills//` folder to `//`. It skips a collision + and shows a warning instead. For a package that moved to a new version, the tool removes the + skills that the new version no longer ships. +6. The tool records what it copied in `/.dotnet-package-skills.json`. + +`uninstall --stale` needs only step 1. It compares the manifest with the target's package +references, and it never looks in the NuGet cache for skills. + +The tool never reads or interprets anything inside a skill. The package author decides what a +skill contains. This tool only places that content where an agent will look for it. + +### The tool only copies skills + +The global packages folder is NuGet's content-addressable cache. NuGet validates this folder +during restore, and every project on the machine shares it. If you move a file out of this +folder, restore may treat the cached package as damaged. Moving a file would also remove the +skill from every other repository that uses that package. ### Removal is manifest-driven -`.dotnet-package-skills.json` records what was copied in. `install` (when a package moves to a new -version) and `uninstall` remove only paths listed there, rather than scanning arbitrary folders. -Keep hand-written guidance in separate, untracked folders, subject to the v1 case-variant and -linked-manifest limitations described here. - -If that manifest exists but cannot be read, `install` and `uninstall` stop without changing -anything and preserve the file for repair. Resolve any merge conflict or restore it from source -control before retrying. If it cannot be recovered, move the whole destination folder aside before -installing again; the tool will not guess which existing folders it owns. -A manifest is also refused when it names a newer format version (update the tool), when it was -written by a pre-release build of this tool (move the skills folder aside and install again), or -when a package is missing its version, a package ID is invalid, or a skill is claimed twice. -Skill names must identify a single folder directly inside the destination. Names ending in a dot -or space, including `...`, are rejected because Windows can resolve them to another folder or the -destination itself. A manifest containing such a name blocks install and uninstall, including -interactive and dry-run modes, before any skill files or manifest bytes are changed. -The tool creates an ordinary manifest file by default and updates an existing manifest in place. -Symbolic-link or other redirected manifests are unsupported in v1. The tool does not create those -links or protect their targets: normal filesystem operations may follow a link, including one -already present in a checked-out repository. Use a regular manifest file in the skills destination; -customers who provide links are responsible for their effects. -Concurrent tool operations on the same destination are serialized, and an interactive choice -is rejected if ownership changed before it could be applied. +`.dotnet-package-skills.json` records what the tool copied in. `install` removes only the paths +listed there, when a package moves to a new version. `uninstall` removes only those listed paths +too. Neither command scans arbitrary folders. Keep hand-written guidance in a separate, untracked +folder. This folder is still subject to the v1 case-variant and linked-manifest limitations that +this document describes. + +When that manifest exists but the tool cannot read it, `install` and `uninstall` both stop +without changing anything. They keep the file in place so you can repair it. Resolve any merge +conflict in the file, or restore it from source control, before you try again. If you cannot +recover the file, move the whole destination folder aside before you install again. The tool will +not guess which existing folders it owns. + +The tool also refuses a manifest in three other cases. It refuses a manifest that names a newer +format version. Update the tool instead. It refuses a manifest that a pre-release build of this +tool wrote. Move the skills folder aside and install again instead. It refuses a manifest where a +package is missing its version, where a package ID is invalid, or where a skill is claimed twice. + +A skill name must identify a single folder directly inside the destination. The tool rejects a +name that ends in a dot or a space, including the name `...`, because Windows can resolve such a +name to a different folder or to the destination folder itself. A manifest that contains such a +name blocks both install and uninstall, including interactive mode and dry-run mode, before the +tool changes any skill file or manifest byte. + +By default, the tool creates an ordinary manifest file, and it updates an existing manifest in +place. V1 does not support a symbolic link or another kind of redirected manifest. The tool does +not create such a link, and it does not protect a link's target. An ordinary file system +operation can follow a link, including a link that already exists in a checked-out repository. +Use a regular manifest file in your skills destination. A customer who provides a link is +responsible for that link's effects. + +The tool serializes concurrent operations on the same destination. It rejects an interactive +choice if ownership changed before the tool could apply that choice. ## A note on trust -A bundled skill is a set of instructions written by a third party that your agent will then -follow. That is a supply-chain surface. This tool only ever copies from packages your project -already depends on, and it prints every skill it copied so you can review them. Treat a new skill -the way you would treat any new dependency. +A bundled skill is a set of instructions that a third party wrote. Your agent will follow those +instructions, so a bundled skill is part of your software supply chain. This tool only copies +skills from a package that your project already depends on, and it prints every skill that it +copies, so you can review them. Treat a new skill the way you would treat any new dependency. -## Troubleshooting +## Common problems -**"No bundled skills found"** — the common and correct outcome; most packages do not ship skills. +**"No bundled skills found"** This is the common and correct outcome. Most packages do not ship +skills. -**"'dotnet list ... package' failed"** — the tool reads the target's packages with `dotnet list -package` and shows what it reported, such as a restore that failed or a target that earlier SDKs -say needs restoring. The tool never restores. Resolve what it reports, for example with -`dotnet restore`, and run the tool again. +**"'dotnet list ... package' failed"** The tool reads the target's packages with `dotnet list +package`, and it shows what that command reported. For example, a restore may have failed, or an +earlier SDK may say that the target needs restoring. The tool never restores a project on its +own. Resolve what the tool reports, for example by running `dotnet restore`, and run the tool +again. -**"resolved packages are missing from"** the NuGet cache — run `dotnet restore` for the target and -try again. This also happens when packages come from a NuGet *fallback folder* (common in -containers and on hosted build agents); point `--global-packages` at that folder. +**"resolved packages are missing from"** the NuGet cache. Run `dotnet restore` for the target and +try again. This message also appears when your packages come from a NuGet *fallback folder*, +which is common in a container or on a hosted build agent. Point `--global-packages` at that +folder. -**"resolve to more than one version"** — projects in the target reference different versions of a -package. Align them, for example with Central Package Management, and try again. +**"resolve to more than one version"** Projects in the target reference different versions of +one package. Align those versions, for example with Central Package Management, and try again. -**"installed skills don't match the target"** (from `install --interactive`) — some installed -skills are stale. Preview them with `dotnet-package-skills uninstall --stale --dry-run`, remove -them with `uninstall --stale`, and try again. +**"installed skills don't match the target"** This message comes from `install --interactive`. +Some installed skills are stale. Preview them with +`dotnet-package-skills uninstall --stale --dry-run`. Remove them with `uninstall --stale`, and +try again. -**"is already installed, and an interactive install only adds skills"** — `install --interactive ---package` named a package that is installed at another version. Run `install --package` without -`--interactive` to move it to the new version, or `uninstall --package ` first. +**"is already installed, and an interactive install only adds skills"** `install --interactive +--package` named a package that is installed at a different version. Run `install --package` +without `--interactive` to move the package to the new version, or run `uninstall --package ` +first. -**"Could not read the install manifest"** — the manifest has a merge conflict or was edited into a -shape the tool can't trust. See [Removal is manifest-driven](#removal-is-manifest-driven). +**"Could not read the install manifest"** The manifest has a merge conflict, or someone edited it +into a shape that the tool cannot trust. See +[Removal is manifest-driven](#removal-is-manifest-driven). -**"Unrecognized option '--format'"** — the SDK predates 7.0.200. Upgrade it. +**"Unrecognized option '--format'"** Your SDK is older than 7.0.200. Upgrade it. -**Wrong global packages folder** — nuget.config discovery walks up from the current directory, so -run the tool from your repository root, or pass `--global-packages` explicitly. +**The tool reads the wrong global packages folder.** `nuget.config` discovery walks up from the +current directory. Run the tool from your repository root instead, or pass `--global-packages` +explicitly. -**Solution filters (`.slnf`)** are not accepted by `dotnet list package` on all SDKs. Pass the -underlying `.sln`, or run once per project with `--target`. +**A solution filter (`.slnf`) is rejected.** Not every SDK accepts a solution filter with +`dotnet list package`. Pass the underlying `.sln` file instead, or run the tool once for each +project with `--target`. -## Building from source +## Build from source -Run from the `dotnet-package-skills` folder in a Client.Tools checkout so `global.json` -selects the pinned .NET SDK. Install the .NET 8 runtime as well as .NET 10, and PowerShell 7 -for the C# pipeline-version tests. CI currently validates on Windows. +Run these commands from the `dotnet-package-skills` folder in a Client.Tools checkout. This way, +`global.json` selects the pinned .NET SDK. Install the .NET 8 runtime as well as .NET 10. Install +PowerShell 7 too, for the C# pipeline-version tests. The CI system currently validates these +commands on Windows only. ```powershell Set-Location .\dotnet-package-skills @@ -553,14 +606,17 @@ pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 -BuildOutputPath .\src\DotnetPackageSkills\bin\Release ``` -The verification command installs the exact local package into temporary tool paths for -.NET 8 and .NET 10, exercises its non-interactive commands, and removes its temporary files. -It does not replace a globally installed tool or modify your installed skills. - -Local builds use `0.1.0-dev`. PR builds use `0.1.0-pr..`; ordinary official -builds use `0.1.0-preview.`. Only an explicit manual official release run produces -the stable base version. Build artifacts are not automatically published to a NuGet feed. -See the [pipeline and release guide](../eng/pipelines/dotnet-package-skills/README.md). +This verification command installs the exact local package into a temporary tool path, once for +.NET 8 and once for .NET 10. It runs the package's non-interactive commands, and then it removes +its own temporary files. It does not replace a tool that you installed globally, and it does not +change your installed skills. + +A local build uses the version `0.1.0-dev`. A PR build uses the version +`0.1.0-pr..`. An ordinary official build uses the version +`0.1.0-preview.`. Only an explicit manual official release run produces the stable base +version. A build does not publish its artifacts to a NuGet feed automatically. See the +[pipeline and release guide](../eng/pipelines/dotnet-package-skills/README.md) for more +information. ## License diff --git a/dotnet-package-skills/docs/dotnet-package-skills.md b/dotnet-package-skills/docs/dotnet-package-skills.md index 752e903..1feb0b8 100644 --- a/dotnet-package-skills/docs/dotnet-package-skills.md +++ b/dotnet-package-skills/docs/dotnet-package-skills.md @@ -37,7 +37,7 @@ dotnet-package-skills --version ## Description -Some NuGet packages include *agent skills*: instructions from the package author that teach coding agents how to use the package. Each skill is a folder that contains a `SKILL.md` file and any supporting files. Restore extracts these folders into the NuGet global packages folder, outside your repository, where agents don't look for them. The `dotnet-package-skills` command copies them into your repository. +Some NuGet packages include *agent skills*. An agent skill is a set of instructions from the package author that teaches a coding agent how to use the package. Each skill is a folder that contains a `SKILL.md` file and any supporting files. Restore extracts these folders into the NuGet global packages folder. This folder sits outside your repository, where an agent does not look for skills. The `dotnet-package-skills` command copies these folders into your repository. To install the skills that your packages ship, run these commands from the root of your repository: @@ -46,17 +46,17 @@ dotnet restore dotnet-package-skills install ``` -The skills are copied to `.agents/skills` under the current directory. If your agent reads skills from another folder, add `--destination`, for example `--destination .claude/skills`. If your solution or project isn't in the current directory, pass its path to both commands, for example `dotnet restore src/MyApp.slnx` and `dotnet-package-skills install --target src/MyApp.slnx`. To choose which skills to install, add `--interactive`. Run `install` again after you add or upgrade packages. +The command copies skills to `.agents/skills` under the current directory. When your agent reads skills from another folder, add `--destination`. For example, add `--destination .claude/skills`. When your solution or project is not in the current directory, pass its path to both commands. For example, run `dotnet restore src/MyApp.slnx` and then `dotnet-package-skills install --target src/MyApp.slnx`. To choose which skills to install, add `--interactive`. Run `install` again after you add a package or upgrade a package. > [!IMPORTANT] > Skills are instructions that your coding agent follows. Review them before you rely on them. -The command reads only the direct package references of your solution or project, not the packages that they depend on. It doesn't download packages or change your project files. +The command reads only the direct package references of your solution or project. It does not read the packages that those packages depend on. The command does not download a package, and it does not change your project files. This article uses these terms: - **Target**: the solution or project whose package references the command reads. Without `--target`, the command looks for one in the current directory, and then in its subdirectories. Reports show the target after `Target:`. -- **Skills folder**: the folder that skills are copied to, `.agents/skills` under the current directory unless you specify `--destination`. `--target` doesn't change it. Reports show it after `Destination:`. +- **Skills folder**: the folder that the command copies skills to. This is `.agents/skills` under the current directory, unless you specify `--destination`. `--target` does not change this folder. A report shows this folder after `Destination:`. - **Tracked skill**: a skill that the command installed, as recorded in the [manifest](#manifest-file) in the skills folder. The command updates and removes only tracked skills, never folders that you created. - **Stale skill**: a tracked skill whose package the target no longer references at the version that the skill came from. @@ -77,15 +77,15 @@ Found 4 skills: fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) ``` -`NuGet cache` is the NuGet global packages folder that skills are read from, and `Destination` is the skills folder that `install` would copy them to. `list` doesn't look in the skills folder, so it doesn't show which skills are installed; the [manifest](#manifest-file) records those. +`NuGet cache` names the NuGet global packages folder that the command reads skills from. `Destination` names the skills folder that `install` would copy them to. `list` does not look in the skills folder, so it does not show which skills are installed. The [manifest](#manifest-file) records that information instead. -Restore the target before you run the command. The command doesn't run `dotnet restore` itself, although with the .NET 10 SDK, `dotnet list package` restores the target when it needs to, even during a dry run. If `dotnet list package` fails, the command shows what it reported and changes nothing; fix the problem, for example by restoring the target, and then run the command again. If a package that the target references isn't in the NuGet global packages folder, `list` skips the package and `install` stops. +Restore the target before you run the command. The command does not run `dotnet restore` on its own. With the .NET 10 SDK, `dotnet list package` restores the target when it needs to, even during a dry run. When `dotnet list package` fails, the command shows what it reported, and it changes nothing. Fix the problem, for example by restoring the target, and then run the command again. When a package that the target references is not in the NuGet global packages folder, `list` skips that package, and `install` stops. -To read skills from specific packages instead of a target, specify `--package @`. The package must already be in the NuGet global packages folder, because the command doesn't download it. If the package isn't there, the command finds no skills in it and doesn't warn you. +To read skills from specific packages instead of a target, specify `--package @`. The package must already be in the NuGet global packages folder, because the command does not download it. When the package is not there, the command finds no skills in it, and it shows no warning. ### Install and update skills -`dotnet-package-skills install` copies the target's skills into the skills folder and records them in the manifest. Each skill keeps its folder name from the package. After the same first lines as the `list` report, the report lists the skills that were copied: +`dotnet-package-skills install` copies the target's skills into the skills folder, and it records them in the manifest. Each skill keeps its folder name from the package. The report starts with the same first lines as the `list` report. Then it lists the skills that the command copied: ```output Copied 4 skills: @@ -97,7 +97,7 @@ Copied 4 skills: These skills are instructions written by the package authors, and your coding agent will follow them. Review them before relying on them. ``` -Run `install` again whenever your packages change; there's no separate update command. Each run compares the tracked skills with the target's packages: +Run `install` again whenever your packages change. There is no separate update command. Each run compares the tracked skills with the target's packages: | If you've... | `install`... | | --- | --- | @@ -114,17 +114,17 @@ Run 'dotnet-package-skills uninstall --stale' to remove them. ``` > [!WARNING] -> `install` replaces the whole folder of each skill that it copies, including your edits and any files that you added. Keep your own instructions in separate skill folders; the command never changes folders that it didn't install. +> `install` replaces the whole folder of each skill that it copies. This includes your edits and any files that you added. Keep your own instructions in separate skill folders. The command never changes a folder that it did not install. -With `--package`, `install` does the same for the packages that you name, and leaves all other skills alone. With `--interactive`, it only adds the skills that you choose; see [Choose skills interactively](#choose-skills-interactively). To preview an installation, add `--dry-run`. The report then lists the planned changes under `Would copy` and `Would remove`, and nothing changes. +With `--package`, `install` does the same thing for the packages that you name, and it leaves every other skill alone. With `--interactive`, it adds only the skills that you choose. See [Choose skills interactively](#choose-skills-interactively). To preview an installation, add `--dry-run`. The report then lists the planned changes under `Would copy` and `Would remove`, and the command changes nothing. The skills folder holds the skills of only one version of each package. If the target references a package at more than one version, or `--package` names more than one, `install` stops and names the versions. [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) keeps all the projects in a repository on one version of each package. -Skill names are compared without regard to case. If two packages ship a skill with the same name, only one of them is copied. `install` also never replaces a tracked skill with another package's skill, or overwrites a folder that it didn't install. It skips such skills, and lists them in the report under `Warning:`. To give a skill name to another package, first remove the tracked skill, for example with `uninstall --package `. +Skill names are compared without regard to case. When two packages ship a skill with the same name, the command copies only one of them. `install` also never replaces a tracked skill with another package's skill, and it never overwrites a folder that it did not install. It skips such a skill, and it lists that skill in the report under `Warning:`. To give a skill name to another package, first remove the tracked skill, for example with `uninstall --package `. ### Remove skills -`dotnet-package-skills uninstall` removes every tracked skill from the skills folder. It doesn't remove folders that you created or any NuGet packages, and it doesn't need a target. If you installed skills into another folder, specify the same `--destination`. +`dotnet-package-skills uninstall` removes every tracked skill from the skills folder. It does not remove a folder that you created, and it does not remove any NuGet package. It does not need a target. When you installed skills into another folder, specify the same `--destination`. ```output Destination: C:\src\MyApp\.agents\skills @@ -152,7 +152,7 @@ Skills that you installed with `install --package` are stale unless the target r ### Choose skills interactively -Add `--interactive` (`-i`) to choose skills in a paged checklist. `install -i` lists the skills that aren't installed yet, and `uninstall -i` lists the tracked skills, or with `--stale`, only the stale ones. Nothing starts checked. The following example shows an install checklist after one skill is checked: +Add `--interactive` (`-i`) to choose skills in a paged checklist. `install -i` lists the skills that are not installed yet. `uninstall -i` lists the tracked skills, or with `--stale`, only the stale skills. Nothing starts checked. The following example shows an install checklist after you check one skill: ```output Which skills should be installed? (MyApp.slnx) @@ -178,16 +178,16 @@ Each row shows a skill's name and the `description` from its `SKILL.md` file. `> | Home, End | Go to the first or last skill. | | Space | Check or uncheck the focused skill. | | A, C | Check or clear all skills, on every page. | -| Ctrl+Up, Ctrl+Down | Scroll a description that's too long for the page. | +| Ctrl+Up, Ctrl+Down | Scroll a description that is too long for the page. | | Enter | Install or remove the checked skills. With `--dry-run`, only report what would change. | | Esc, Q, Ctrl+C | Cancel without changing any skills. | -`install -i` only adds the skills that you check; it never updates or removes skills. Because of that, it first checks that the tracked skills match the packages, and stops before the checklist opens if they don't: +`install -i` adds only the skills that you check. It never updates a skill, and it never removes a skill. Because of this, it first checks that the tracked skills match the packages. It stops before the checklist opens if they do not match: - With a target, it stops if any tracked skill is stale. Run `uninstall --stale`, and then try again. - With `--package`, it stops if a named package is installed at another version. Run `uninstall --package ` first, or run `install --package` without `--interactive` to switch versions. -Skills that `install` would skip because their name is in use aren't listed; the report after the checklist names them. If every skill is already installed, the checklist doesn't open, and the command reports `Nothing new to install.` +The checklist does not list a skill that `install` would skip because its name is in use. The report after the checklist names that skill instead. When every skill is already installed, the checklist does not open, and the command reports `Nothing new to install.` ### Manifest file @@ -215,27 +215,27 @@ The manifest, `.dotnet-package-skills.json` in the skills folder, records the sk | `packages..version` | The package version that the skills were installed from. | | `packages..skills` | The names of the skill folders installed from the package. | -If you commit the skills folder to source control, commit the manifest with it. The command writes the file the same way on every platform, in UTF-8 with LF line endings and a stable order, so it doesn't cause line-ending churn. Don't edit the file by hand: the command relies on it to decide which folders it can replace or remove, and drops properties that it doesn't recognize when it rewrites the file. When the last tracked skill is removed, the command deletes the manifest, and the skills folder if it's empty. +If you commit the skills folder to source control, commit the manifest with it. The command writes the file the same way on every platform. It uses UTF-8 encoding, LF line endings, and a stable order, so the file does not cause line-ending differences between checkouts. Do not edit the file by hand. The command relies on the file to decide which folders it can replace or remove. The command also drops a property that it does not recognize when it rewrites the file. When the last tracked skill is removed, the command deletes the manifest. It also deletes the skills folder, if that folder is empty. -### Exit codes and troubleshooting +### Exit codes and common problems -The command returns `0` on success, including when there's nothing to do or you cancel a checklist, and `1` on failure. Errors are written to standard error. Skipped skills are reported as warnings and don't change the exit code, so check the report when it matters that every skill was installed. Reports are meant for people: scripts should rely on the exit code, and read the manifest to find the installed skills. +The command returns `0` on success. This includes when there is nothing to do, and when you cancel a checklist. The command returns `1` on failure. An error is written to standard error. A skipped skill is reported as a warning, and a warning does not change the exit code. Check the report when it matters that every skill was installed. Reports exist for people to read. A script must rely on the exit code instead, and it must read the manifest to find the installed skills. Each of these errors stops the command before it changes any skills or the manifest: | Problem | What to do | | --- | --- | -| `dotnet list package` fails, for example because the target isn't restored. | Fix what it reports, for example by running `dotnet restore`, and then run the command again. | +| `dotnet list package` fails, for example because the target is not restored. | Fix what it reports, for example by running `dotnet restore`, and then run the command again. | | `install` reports packages that are missing from the NuGet global packages folder. | Restore the target into that folder, and then run `install` again. If you use `--global-packages`, restore into the same folder, for example with `dotnet restore --packages `. | | The target references a package at more than one version, or `--package` names more than one. | Align the versions, for example with [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management). With `--package`, name one version of each package. | -| `install --interactive` says that installed skills don't match the target, or that a named package is installed at another version. | Run the `uninstall` command that the error suggests, and then try again. To switch a package to another version instead, run `install` without `--interactive`. | -| The manifest can't be read. | Resolve any merge conflict in `.dotnet-package-skills.json`, or restore the file from source control. If the error says that the manifest uses a newer format version, update the tool. If it says that a pre-release version of the tool wrote the manifest, move the skills folder aside, and then run `install` again. | +| `install --interactive` says that installed skills do not match the target, or that a named package is installed at another version. | Run the `uninstall` command that the error suggests, and then try again. To switch a package to another version instead, run `install` without `--interactive`. | +| The manifest cannot be read. | Resolve any merge conflict in `.dotnet-package-skills.json`, or restore the file from source control. If the error says that the manifest uses a newer format version, update the tool. If it says that a pre-release version of the tool wrote the manifest, move the skills folder aside, and then run `install` again. | | Another run of the command is using the skills folder. | The command waits up to 30 seconds for the other run to finish, and then stops. Run the command again after the other run finishes. | | The terminal is too small for the checklist. | Enlarge the window, or run the command without `--interactive`. | For other errors, run the command that the error suggests. Suggested commands repeat the `--target` and `--destination` that you specified, so you can run them as printed. -If a file system error interrupts a copy or removal, the command reports it but doesn't undo the changes that it already made, and copied folders might be missing from the manifest. Move the affected skill folders aside, and then run the command again. +When a file system error interrupts a copy or removal, the command reports that error, but it does not undo the changes that it already made. A copied folder might then be missing from the manifest. Move the affected skill folders aside, and then run the command again. ## Commands @@ -263,7 +263,7 @@ If a file system error interrupts a copy or removal, the command reports it but - **`--global-packages `** - For `install` and `list`, specifies the NuGet global packages folder to read packages from. The folder must exist. This option overrides the `NUGET_PACKAGES` environment variable and the folder configured for NuGet, but it doesn't change where restore extracts packages. Unlike `--destination`, a relative path is resolved from the target's directory, or from the current directory when you use `--package`. + For `install` and `list`, specifies the NuGet global packages folder to read packages from. The folder must exist. This option overrides the `NUGET_PACKAGES` environment variable and the folder that NuGet is configured to use, but it does not change where restore extracts packages. Unlike `--destination`, the command resolves a relative path from the target's directory, or from the current directory when you use `--package`. - **`-?|-h|--help`** @@ -271,23 +271,23 @@ If a file system error interrupts a copy or removal, the command reports it but - **`-i|--interactive`** - For `install` and `uninstall`, opens a checklist for choosing the skills to install or remove. `install` lists only skills that aren't installed, and only adds skills. Requires a terminal when there's something to choose. Can be combined with `--dry-run`, and with either `--package` or, for `uninstall`, `--stale`. For more information, see [Choose skills interactively](#choose-skills-interactively). + For `install` and `uninstall`, opens a checklist for choosing the skills to install or remove. `install` lists only skills that are not installed, and it only adds skills. This option needs a terminal when there is something to choose. You can combine it with `--dry-run`, and with either `--package` or, for `uninstall`, `--stale`. For more information, see [Choose skills interactively](#choose-skills-interactively). - **`-p|--package `** - For `install` and `list`, reads skills from the specified package instead of a target. Specify an exact version, such as `Contoso.Widgets@2.3.0`; floating versions and version ranges aren't accepted. Repeat the option to name several packages. Duplicates, such as `Mockly@1.10` and `Mockly@1.10.0`, count once. `install` accepts only one version of each package, while `list` shows every version that you name. The package must already be in the NuGet global packages folder; if it isn't, the command finds no skills in it and doesn't warn you. Can't be combined with `--target`. + For `install` and `list`, reads skills from the specified package instead of a target. Specify an exact version, such as `Contoso.Widgets@2.3.0`. The command does not accept a floating version or a version range. Repeat the option to name several packages. A duplicate, such as `Mockly@1.10` and `Mockly@1.10.0` named together, counts once. `install` accepts only one version of each package. `list` shows every version that you name. The package must already be in the NuGet global packages folder. If it is not there, the command finds no skills in it, and it shows no warning. You cannot combine this option with `--target`. - **`-p|--package `** - For `uninstall`, removes only the skills of the specified package. With a version, removes them only if that version is installed. Package IDs are compared without regard to case, and `1.10` matches `1.10.0`. Specify the option at most once; a blank value is an error. Can't be combined with `--stale`. + For `uninstall`, removes only the skills of the specified package. With a version, removes them only if that version is installed. The command compares package IDs without regard to case, and `1.10` matches `1.10.0`. Specify this option at most once. A blank value is an error. You cannot combine this option with `--stale`. - **`--stale`** - For `uninstall`, removes only stale skills. Requires a target. Can be combined with `--target`, `--dry-run`, and `--interactive`, but not with `--package`. For more information, see [Remove stale skills](#remove-stale-skills). + For `uninstall`, removes only stale skills. This option requires a target. You can combine it with `--target`, `--dry-run`, and `--interactive`, but not with `--package`. For more information, see [Remove stale skills](#remove-stale-skills). - **`-t|--target `** - For `install`, `list`, and `uninstall --stale`, specifies the solution (`.slnx` or `.sln`), project (`.csproj`, `.fsproj`, or `.vbproj`), or directory to read package references from. For a directory, or when the option is omitted, the command looks for a solution or project in that directory, and then in its subdirectories. Doesn't change the skills folder. Can't be combined with `--package`. + For `install`, `list`, and `uninstall --stale`, specifies the solution (`.slnx` or `.sln`), project (`.csproj`, `.fsproj`, or `.vbproj`), or directory to read package references from. For a directory, or when you omit the option, the command looks for a solution or project in that directory, and then in its subdirectories. It does not change the skills folder. You cannot combine it with `--package`. - **`--version`** diff --git a/dotnet-package-skills/docs/functional-spec.md b/dotnet-package-skills/docs/functional-spec.md index 13aa12c..894f9a4 100644 --- a/dotnet-package-skills/docs/functional-spec.md +++ b/dotnet-package-skills/docs/functional-spec.md @@ -8,7 +8,8 @@ It copies whole skill folders, including supporting documents, from the NuGet ca The default destination is `.agents\skills` under the directory where the command runs. Developers can choose another destination, such as `.claude\skills`. Agent support for a destination remains the agent's responsibility. -Examples below use illustrative packages and paths. Interactive page sizes and line wrapping vary with the terminal dimensions; the examples are not fixed screen layouts. +The examples below use made-up packages and paths. Interactive page sizes and line wrapping both +vary with the terminal's dimensions. The examples are not fixed screen layouts. ## 2. Command structure @@ -28,7 +29,7 @@ There are three subcommands. Interactive selection, previews, and stale cleanup | `--help` | Learn the commands and options. | No changes. | | `--version` | Identify the tool build. | No changes. | -The tool requires a compatible .NET runtime; current builds target .NET 8 and .NET 10. Project discovery also uses the installed .NET SDK. +The tool requires a compatible .NET runtime. Current builds target .NET 8 and .NET 10. Project discovery also uses the installed .NET SDK. Installing the .NET tool itself is separate from installing skills. For evaluation with a supplied local tool package: @@ -70,7 +71,7 @@ Version output starts with `0.1.0` and may include build metadata after `+`. dotnet-package-skills list ``` -The tool runs [`dotnet list package`](https://learn.microsoft.com/dotnet/core/tools/dotnet-package-list) on a solution or project to find its top-level package references, and then looks for immediate subfolders of each package's `skills` directory that contain `SKILL.md`. Without `--target`, it uses a solution or project from the current directory or one of its subdirectories; the chosen path appears in `Target:`. Name a target when that choice matters. +The tool runs [`dotnet list package`](https://learn.microsoft.com/dotnet/core/tools/dotnet-package-list) on a solution or project to find its top-level package references. It then looks for an immediate subfolder of each package's `skills` directory that contains `SKILL.md`. Without `--target`, the tool uses a solution or project from the current directory, or from one of its subdirectories. The chosen path appears after `Target:`. Name a target when that choice matters. Sample output: @@ -99,7 +100,7 @@ dotnet-package-skills list --package Contoso.Widgets@2.3.0 An explicit package must already be extracted in the selected NuGet cache. Naming it does not download it or add it to a project. Such reports use `Target: (packages named on the command line)` and `Scanned N packages (named explicitly)`. -The cache directory itself must already exist. If it is absent, restore the project first. `list` skips packages that aren't extracted in the selected cache, without reporting them. When projects resolve different versions of one package, `list` shows each version; `install` refuses to proceed until they're aligned (section 4). `list` does not read destination ownership, so its first discovery candidate can differ from the owner-preferred candidate used by `install`. +The cache directory itself must already exist. If it is absent, restore the project first. `list` skips a package that is not extracted in the selected cache, and it reports no warning for that package. When projects resolve different versions of one package, `list` shows each version. `install` refuses to proceed until you align those versions (section 4). `list` does not read destination ownership. Because of this, its first discovery candidate can differ from the owner-preferred candidate that `install` uses. ## 4. Install or refresh skills: `install` @@ -125,10 +126,10 @@ These skills are instructions written by the package authors, and your coding ag | Scope | Behavior | | --- | --- | -| Any noninteractive run | Refresh the skills of the packages found in the cache. When a package's version differs from the version the manifest records, refresh its skills and remove its installed skills that the new version doesn't ship. A package at the same version never loses a skill. Paths protected by an ownership conflict are retained. | +| Any noninteractive run | Refresh the skills of the packages found in the cache. When a package's version differs from the version that the manifest records, refresh its skills, and remove its installed skills that the new version does not ship. A package at the same version never loses a skill. The tool retains a path that an ownership conflict protects. | | Target | Keep the installed skills of packages the target no longer references, and list them with a pointer to `uninstall --stale` (section 6). | | `--package` | Touch only the named packages. Other installed skills are left in place without comment. | -| Interactive install | Only add skills that aren't installed (section 5). Never refresh or remove. | +| Interactive install | Add only the skills that are not installed (section 5). Never refresh a skill, and never remove a skill. | | A different package claims an installed name | Preserve the existing owner and skip the conflicting copy with a warning in every mode. If the run also supplies the current owner's candidate, refresh that candidate rather than letting the conflict block it. Replacement requires an explicit uninstall first. | | The owner's package moves to a version that no longer ships that name | Stop before any change, including with `--dry-run`. Removing the old copy would hand the name to the other package, and keeping it would record the old version's copy under the new version. The error suggests `uninstall --package ` for the owner. | @@ -211,9 +212,9 @@ Blue X: selected ### Selection rules -- The checklist lists only skills that would install cleanly and aren't installed. Nothing starts checked. The line under the title says that installed skills aren't listed. -- Acceptance copies only the checked skills. An interactive install never refreshes or removes a skill; refreshing is what a noninteractive `install` does, and removal is what `uninstall` does. -- Candidates that would be skipped, such as a name owned by another package or used by an untracked folder, aren't listed. The final report names them under the skipped warning. +- The checklist lists only skills that would install cleanly and that are not installed. Nothing starts checked. The line under the title states that installed skills are not listed. +- Acceptance copies only the checked skills. An interactive install never refreshes a skill, and it never removes a skill. A noninteractive `install` refreshes a skill, and `uninstall` removes a skill. +- The checklist does not list a candidate that the tool would skip, for example a name that another package owns, or a name that an untracked folder uses. The final report names these candidates under the skipped warning. - When every discovered skill is already installed, the command prints the following and exits with code `0` without opening the checklist. When some candidates were skipped, only the first sentence is printed, followed by the skipped warning. ```text @@ -233,7 +234,7 @@ A skill is **stale** when the target no longer references its package, or refere ### Presentation -- The format is always `skill-name - description`, without a package/version suffix or a separate description column. Authored package prefixes in skill names remain intact. Long display names may be clipped with `...`; their canonical identities are unchanged. +- The format is always `skill-name - description`. There is no package or version suffix, and there is no separate description column. Authored package prefixes in a skill name remain intact. The tool may clip a long display name with `...`. A clipped name's canonical identity stays unchanged. - Descriptions wrap beneath the skill text with a small list indent, using the available width. Page sizes reflect rendered lines, not a fixed number of skills. A list that fits needs no paging. - Live resizing recalculates wrapping and pagination while preserving focus and selection. Oversized descriptions can be scrolled while their skill row remains visible. - The note under the title is omitted when the window is too small to fit it, rather than failing. @@ -244,12 +245,12 @@ The live checklist is displayed at the top of a temporary terminal screen, separ | --- | --- | | Blue row text | Keyboard focus, including the skill name and wrapped description lines. | | Blue `X` | Checked item: install in the install picker, or remove in the uninstall picker. Each picker does one thing, so a checked row looks the same in both, and the title and summary say what a check does. | -| Normal name/description text | Item is not focused; selection does not change the color of its name or brackets. | +| Normal name/description text | The item is not focused. Selection does not change the color of its name or its brackets. | | `>` and `[X]` when color is disabled | Focus and checked state, with no other marker and no color legend. `NO_COLOR` is honored. | | Key | Behavior | | --- | --- | -| Up / Down | Move between skills; wrap at the beginning/end. | +| Up / Down | Move between skills. Wrap at the beginning or the end. | | Left / Right or PageUp / PageDown | Move between pages. | | Home / End | Go to the first/last skill. | | Space | Toggle the focused skill. | @@ -299,7 +300,7 @@ dotnet-package-skills uninstall --interactive dotnet-package-skills uninstall --stale ``` -A bare package ID matches the package's tracked skills; `ID@VERSION` matches them only if that version is the one installed. The manifest records one version per package, so there is never more than one to choose from. Both modes use the same case-insensitive package matching and normalized version comparison: for example, `1.10` matches `1.10.0`. An explicitly blank, whitespace-only, or missing filter value is an error, never an instruction to remove everything. Uninstall accepts the package option only once; repeated occurrences and aliases are rejected even if the final value is absent. Only omitting `--package` means no filter. A dry run reports `Would remove` and preserves the files. +A bare package ID matches the package's tracked skills. `ID@VERSION` matches them only if that version is the one installed. The manifest records one version for each package, so there is never more than one version to choose from. Both modes use the same case-insensitive package matching and the same normalized version comparison. For example, `1.10` matches `1.10.0`. An explicitly blank, whitespace-only, or missing filter value is an error. It never means an instruction to remove everything. `uninstall` accepts the package option only once. It rejects a repeated occurrence or an alias, even when the final value is absent. Only a completely omitted `--package` means no filter. A dry run reports `Would remove`, and it leaves the files in place. The interactive picker lists only manifest-owned skills and reads descriptions from their installed copies. Nothing starts checked. A check means removal, not retention: @@ -358,8 +359,8 @@ Destination: C:\src\MyApp\.agents\skills Nothing to remove. No stale skills were found. ``` -- `--stale` reads the target's package references, so it requires a solution or project, found as in section 3 or named with `--target`. Without one, it fails with `No solution or project found under ...`. Like `install` and `list`, it runs `dotnet list package`, which the .NET SDK can restore the target for; when that fails, the command stops and shows what it reported. -- It needs only the target's package references, not the packages, so it doesn't look in the NuGet cache for skills. +- `--stale` reads the target's package references, so it requires a solution or project. The tool finds this target the same way as in section 3, or you name it with `--target`. Without a target, the command fails with `No solution or project found under ...`. Like `install` and `list`, `--stale` runs `dotnet list package`. The .NET SDK can restore the target during that step. When the step fails, the command stops, and it shows what it reported. +- `--stale` needs only the target's package references, not the packages themselves. Because of this, it does not look in the NuGet cache for skills. - It works when the target resolves more than one version of a package: a skill is stale only if no project references its installed version. - With `--interactive`, only stale skills are listed, under the note `Only skills that don't match the target are listed.` - `--stale` cannot be combined with `--package`. `--target` is accepted by `uninstall` only together with `--stale`. @@ -368,20 +369,20 @@ Nothing to remove. No stale skills were found. | Option | Applies to | Contract | | --- | --- | --- | -| `-t, --target ` | `list`, `install`; `uninstall` with `--stale` | Solution, project, or directory to search. Supported files: `.slnx`, `.sln`, `.csproj`, `.fsproj`, `.vbproj`. Defaults to discovery from the current directory. | -| `-p, --package ` | `list`, `install` | Exact package coordinates instead of a target. Repeatable, with one version per package; equivalent repeated coordinates are deduplicated. Floating versions and ranges are rejected. | +| `-t, --target ` | `list` and `install` always. `uninstall` only with `--stale`. | Solution, project, or directory to search. Supported files: `.slnx`, `.sln`, `.csproj`, `.fsproj`, `.vbproj`. Defaults to discovery from the current directory. | +| `-p, --package ` | `list`, `install` | Exact package coordinates instead of a target. Repeatable, with one version for each package. The tool removes a duplicate when you repeat an equivalent coordinate. The tool rejects a floating version and a version range. | | `-p, --package ` | `uninstall` | One occurrence of a nonempty package filter, optionally restricted to a normalized version. Same matching in both modes. | -| `-d, --destination ` | All three | Skills destination; default `.agents\skills`. Uninstall must use the same destination used for installation. | +| `-d, --destination ` | All three | The skills destination. Default `.agents\skills`. `uninstall` must use the same destination that installation used. | | `--global-packages ` | `list`, `install` | Existing extracted-package cache to use. Overrides `NUGET_PACKAGES` and the NuGet-configured cache. | | `--dry-run` | `install`, `uninstall` | Report planned destination changes without applying them. | -| `-i, --interactive` | `install`, `uninstall` | Open the paginated picker. On install, only skills that aren't installed are listed, and accepting only adds. On uninstall, checked skills are removed. Requires a terminal when there are rows to choose. | +| `-i, --interactive` | `install`, `uninstall` | Opens the paginated picker. On `install`, the picker lists only skills that are not installed, and accepting only adds them. On `uninstall`, accepting removes the checked skills. This option needs a terminal when there are rows to choose from. | | `--stale` | `uninstall` | Remove only stale skills: those whose package the target no longer references, or references at a different version. Requires a solution or project. | | `-?, -h, --help` | Root and all three | Display usage and supported options. | | `--version` | Root | Display version information. | -**Combination rules:** `--target` cannot be combined with `--package`. `--stale` cannot be combined with `--package`, and `uninstall` accepts `--target` only with `--stale`. Interactive selection can be combined with `--dry-run`, package filters, and `--stale`. `list` has neither `--interactive` nor `--dry-run`. No command has a JSON output option or a restore option; `--json` and `--no-restore` are rejected as unrecognized arguments. +**Combination rules:** You cannot combine `--target` with `--package`. You cannot combine `--stale` with `--package`, and `uninstall` accepts `--target` only together with `--stale`. You can combine interactive selection with `--dry-run`, with a package filter, and with `--stale`. `list` has neither `--interactive` nor `--dry-run`. No command has a JSON output option or a restore option. The tool rejects `--json` and `--no-restore` as unrecognized arguments. -**Path rule:** a relative destination is based on the invocation directory, not automatically on the directory containing `--target`. A relative `--global-packages` override is resolved from the target's directory in target mode and the invocation directory in named-package mode. That override selects the read cache; it does not reconfigure NuGet restore. +**Path rule:** The tool bases a relative destination on the invocation directory. It does not automatically base the destination on the directory that contains `--target`. The tool resolves a relative `--global-packages` override from the target's directory in target mode, and from the invocation directory in named-package mode. That override selects the cache that the tool reads from. It does not reconfigure NuGet restore. ## 8. Ownership manifest @@ -405,11 +406,11 @@ The destination's `.dotnet-package-skills.json` records what the tool installed. | Element | Required | Contract | | --- | --- | --- | | `version` | Yes | Format version, a whole number. This release reads and writes `1`. | -| `packages` | Yes | Object keyed by package ID. The tool writes lowercase IDs; IDs are matched case-insensitively, and two keys that differ only in case are invalid. | +| `packages` | Yes | An object keyed by package ID. The tool writes a lowercase ID. The tool matches an ID without regard to case, so two keys that differ only in case are invalid. | | `packages..version` | Yes | The one package version the skills were installed from, normalized as NuGet does (`1.10` is written `1.10.0`). | | `packages..skills` | Yes | Skill folder names directly under the destination that this package owns. Each name is claimed once across the whole manifest. | -Writing rules: UTF-8 without a byte order mark, two-space indentation, LF line endings with a final newline on every platform, packages and skills in a stable order. A repository can commit the file without line-ending churn between Windows and Unix checkouts. Properties the tool doesn't recognize are ignored when reading and are not preserved when the file is rewritten. +Writing rules: The tool uses UTF-8 encoding without a byte order mark. It uses two-space indentation. It uses LF line endings, with a final newline, on every platform. It lists packages and skills in a stable order. A repository can commit this file without line-ending differences between a Windows checkout and a Unix checkout. The tool ignores a property that it does not recognize when it reads the file. It does not preserve that property when it rewrites the file. Reading rules: @@ -417,7 +418,7 @@ Reading rules: - A manifest written by a pre-release build of the tool, which has an `installed` array and no `packages` object, is not converted. The tool asks the user to move the skills folder aside and install again. - Other damage, such as a merge conflict, a missing `version` or `packages`, an invalid package ID, a package without a version, a duplicate claim, or an unsafe skill name, fails as described in section 9. -Scripts should rely on exit codes and read this file for what's installed. The human-readable reports are for people and can change between releases. +A script must rely on exit codes, and it must read this file to find what is installed. The reports exist for people to read, and their wording can change between releases. Human-readable reports and diagnostics, including argument-validation errors and parser suggestions, remove terminal escape sequences and unsafe control characters from metadata, paths, and diagnostic text. This affects presentation only: arguments are validated as supplied, and stored identities remain unchanged. @@ -427,29 +428,29 @@ Human-readable reports and diagnostics, including argument-validation errors and | --- | --- | | No package ships a discoverable skill | Successful report: `No bundled skills found.` | | Skills exist but none are accepted | Report `Copied no skills.` or `Would copy no skills.`, with any skipped-item details. | -| Every discovered skill is already installed (`install -i`) | `Nothing new to install.`, with the explanation or the skipped warning; no checklist; exit code `0`. | +| Every discovered skill is already installed (`install -i`) | The command prints `Nothing new to install.`, with the explanation or the skipped warning. It opens no checklist. Exit code `0`. | | A resolved package is absent from the cache | A target-based `install` fails before changes, in every mode. `list` skips it silently. With `--package`, it contributes no skills. `uninstall --stale` is unaffected. | -| Packages resolve to more than one version | Every `install` mode fails before changes and names the versions to align. `list` shows each version; `uninstall --stale` still works. | -| A package left the target | `install` keeps its skills and lists them with a pointer to `uninstall --stale`. `install -i` fails until they're removed. | -| A package moved to a new version | `install` refreshes its skills and removes the ones the new version doesn't ship. `install -i` fails with a target (the skills are stale) and with `--package` (another version is installed). | +| Packages resolve to more than one version | Every `install` mode fails before changes, and it names the versions that you need to align. `list` shows each version. `uninstall --stale` still works. | +| A package left the target | `install` keeps its skills and lists them with a pointer to `uninstall --stale`. `install -i` fails until they are removed. | +| A package moved to a new version | `install` refreshes its skills, and it removes the ones that the new version does not ship. `install -i` fails with a target, because the skills are stale, and it fails with `--package`, because another version is installed. | | A destination name conflicts with another skill, an untracked folder, or a different installed owner | Warn and skip the conflicting copy. Preserve the current owner, including when the run leaves its package out. | | The owner's new version drops a skill that another package in the run ships | `install` fails before changes and suggests `uninstall --package ` for the owner (section 4). | -| A package filter is explicitly blank or missing its value | Fail; never broaden a selective uninstall to all tracked skills. | -| `uninstall --stale` finds no solution or project | Fail with `No solution or project found under ...`; nothing is removed. | +| A package filter is explicitly blank or missing its value | Fails. Never broadens a selective uninstall to all tracked skills. | +| `uninstall --stale` finds no solution or project | Fails with `No solution or project found under ...`. Removes nothing. | | The ownership manifest is absent | Existing folders are not assumed to belong to the tool. | -| The ownership manifest is unreadable, malformed, or unsafe | Fail before modifying destination skills; preserve the manifest and explain how to repair/restore it. Missing `version` or `packages`, duplicate JSON properties (including case variants), invalid package IDs, packages without a version, duplicate case-insensitive skill claims, and unsafe skill folder names are invalid. Names ending in a dot or space, including `...`, are rejected because Windows can resolve them to another folder or the destination itself. This also applies to interactive and dry-run modes. `list` remains available. | +| The ownership manifest is unreadable, malformed, or unsafe | Fails before it changes any destination skill. Preserves the manifest, and explains how to repair it or restore it. A missing `version` or `packages` property is invalid. A duplicate JSON property, including a case variant, is invalid. An invalid package ID is invalid. A package without a version is invalid. A duplicate case-insensitive skill claim is invalid. An unsafe skill folder name is invalid. The tool rejects a name that ends in a dot or a space, including the name `...`, because Windows can resolve such a name to a different folder or to the destination folder itself. This rule also applies to interactive mode and dry-run mode. `list` remains available. | | The manifest has a newer format version | Fail before changes and ask the user to update the tool. | | The manifest was written by a pre-release build | Fail before changes and ask the user to move the skills folder aside and install again. | | `dotnet list package` fails, for example because the restore it runs fails, or an earlier SDK says the target needs restoring | Stop before changes with exit code `1`, show the problems it reported, and ask the user to resolve them and run the command again. The tool never restores. | | Invalid option combination, missing target, failed restore, or filesystem error | Report an actionable error and return a non-zero exit code. | -The manifest is written after a successful installation or removal, not by `list`, a cancelled picker, or a dry run. Removing the last tracked entry deletes the manifest. The destination folder is deleted only if it is empty; hand-written skills keep that folder alive. Removing a tracking entry is reported even when its skill folder had already been deleted. +The tool writes the manifest after a successful installation or removal. It does not write the manifest after `list`, a cancelled picker, or a dry run. Removing the last tracked entry deletes the manifest. The tool deletes the destination folder only if that folder is empty. A hand-written skill keeps the folder alive. The tool reports the removal of a tracking entry even when its skill folder was already deleted. Cooperating tool processes serialize reads and changes for a destination. Ownership is loaded and checked inside that critical section, which remains held through copying, removal, and manifest persistence. A busy destination produces retry guidance rather than overlapping mutations. This is local-process coordination, not a distributed filesystem transaction. Equivalent Windows path spellings share that coordination. If a destination alias changes while an operation waits for access, the operation fails before modifying the newly resolved location. -Successful operations, empty results, and cancellation return exit code `0`. Command failures return `1`. Warnings/skipped skills can still accompany exit code `0`; automation should inspect the report when completeness matters. +A successful operation, an empty result, and a cancellation all return exit code `0`. A command failure returns exit code `1`. A warning or a skipped skill can still accompany exit code `0`. Automation must inspect the report when completeness matters. -Skill metadata is not a security review of the instructions. Developers remain responsible for deciding what their agent should trust. Unexpected filesystem failures are reported, but transactional rollback of a partially completed copy/removal is not provided in this version. Such failures can leave copied folders absent from the manifest. Restore a verified backup or move affected folders aside before retrying; do not blindly delete untracked guidance. +Skill metadata is not a security review of the skill's instructions. A developer remains responsible for deciding what their agent can trust. The tool reports an unexpected file system failure, but this version does not provide a transactional rollback of a partially completed copy or removal. Such a failure can leave a copied folder absent from the manifest. Restore a verified backup, or move the affected folders aside, before you retry. Do not delete untracked guidance without first checking what it is. ## 10. Product review checklist @@ -458,16 +459,16 @@ Skill metadata is not a security review of the instructions. Developers remain r | Discover before deciding | `list` shows available skills without installing them. | | Try the picker safely | `install -i --dry-run` previews a selection without writing skills or a manifest. | | Make an informed choice | Read descriptions, navigate pages, select skills, and accept. | -| Add a few more skills later | `install -i` lists only skills that aren't installed; accepting adds them and changes nothing else. With nothing new, it says so without a checklist. | -| Resize during selection | Text reflows and selections are retained while the window remains large enough; the note under the title gives way first; an unusable size fails without changing files. | +| Add a few more skills later | `install -i` lists only skills that are not installed. Accepting adds them, and changes nothing else. With nothing new to add, the command says so without opening a checklist. | +| Resize during selection | Text reflows, and the tool keeps the current selections, while the window stays large enough. The note under the title gives way first. An unusable size fails, and changes no file. | | Upgrade a package | `install` refreshes its skills and removes the ones the new version dropped. | -| Remove a package from the project | `install` keeps its skills and says which command removes them; `uninstall --stale` (with `--dry-run` or `-i`) removes them. | +| Remove a package from the project | `install` keeps its skills, and it states which command removes them. `uninstall --stale`, with `--dry-run` or `-i`, removes them. | | Mix package versions in one repository | `install` stops before changes and asks for the versions to be aligned, for example with Central Package Management. | -| Encounter incomplete discovery | Target-based install fails before copying or removing anything; restore and retry. | -| Encounter another package's owned name | Preserve the installed owner and warn; replacement requires explicit removal first. | +| Encounter incomplete discovery | A target-based install fails before it copies or removes anything. Restore the target, and retry. | +| Encounter another package's owned name | Preserves the installed owner, and shows a warning. Replacing that owner requires an explicit removal first. | | Remove selectively | `uninstall -i` offers tracked skills only and removes only checked items. | | Use package filters in scripts | Blank filters fail, and normalized version matching is identical with and without `-i`. | | Keep locally authored guidance | Keep guidance in separate, untracked skill folders. | | Automate reliably | Check exit codes, read the ownership manifest, and run `install` followed by `uninstall --stale`. | -| Commit the skills folder | The manifest has the same bytes on every platform, so commits don't churn line endings. | -| Recover from a damaged ownership record | Receive an explicit error; repair or restore the preserved manifest before retrying. | +| Commit the skills folder | The manifest has the same bytes on every platform, so a commit does not create line-ending differences. | +| Recover from a damaged ownership record | You receive an explicit error. Repair the preserved manifest, or restore it, before you retry. | diff --git a/dotnet-package-skills/docs/scenarios.md b/dotnet-package-skills/docs/scenarios.md index e4cf7d5..ff43efb 100644 --- a/dotnet-package-skills/docs/scenarios.md +++ b/dotnet-package-skills/docs/scenarios.md @@ -1,6 +1,6 @@ # dotnet-package-skills v1: scenarios and expected behavior -This document describes how v1 behaves, scenario by scenario. Rows marked **Changed in v1** or **New in v1** differ from the preview build (commit `7effb41`); everything else describes behavior that the preview build already had. +This document describes how v1 behaves, one scenario at a time. A row marked **Changed in v1** or **New in v1** differs from the preview build at commit `7effb41`. Every other row describes behavior that the preview build already had. ## 1. The idea @@ -9,10 +9,10 @@ NuGet packages can ship agent skills: folders that contain a `SKILL.md` file, un | Term | Meaning | | --- | --- | | Skill | A folder containing `SKILL.md`, shipped in a package's `skills/` folder. | -| Skills folder | Where skills are copied. The default is `.agents/skills` under the current folder; change it with `--destination`. | +| Skills folder | Where the tool copies skills. The default is `.agents/skills` under the current folder. Change it with `--destination`. | | Manifest | `.dotnet-package-skills.json` in the skills folder. It lists the folders the tool manages, by package and version. | | Tracked skill | A folder listed in the manifest. The tool may refresh or remove it. | -| Stale skill | A tracked skill that doesn't match the project: the project no longer references its package, or it uses a different version of that package. | +| Stale skill | A tracked skill that does not match the project. Either the project no longer references its package, or the project uses a different version of that package. | | Your own skill | Any other folder in the skills folder. The tool never changes or deletes it. | The examples use these packages: @@ -20,7 +20,7 @@ The examples use these packages: | Package | Ships | | --- | --- | | Mockly 1.10.0 | `mockly-usage`, `mockly-migration` | -| Mockly 1.11.0 | `mockly-usage` only; it dropped `mockly-migration` | +| Mockly 1.11.0 | `mockly-usage` only. This version dropped `mockly-migration`. | | Contoso.Widgets 2.3.0 | `contoso.widgets-usage` | | Newtonsoft.Json 13.0.3 | No skills | @@ -34,13 +34,13 @@ The examples use these packages: | Option | `list` | `install` | `uninstall` | Meaning | | --- | --- | --- | --- | --- | -| `-t, --target ` | Yes | Yes | With `--stale` | Solution or project to read. Without it, the tool looks for one in the current folder, and then in its subfolders, preferring a solution. Can't be combined with `--package`. | +| `-t, --target ` | Yes | Yes | With `--stale` | Solution or project to read. Without it, the tool looks for one in the current folder, and then in its subfolders, preferring a solution. Cannot be combined with `--package`. | | `-p, --package ` | Yes | Yes | | Name packages directly instead of reading a project. Repeatable, with one version per package. | | `-p, --package ` | | | Yes | Remove only this package's skills, or only if that version is installed. | -| `--stale` | | | Yes | Remove only stale skills. Requires a project or solution: the one passed with `--target`, or the one the tool finds. Can't be combined with `--package`. **New in v1.** | +| `--stale` | | | Yes | Remove only stale skills. Requires a project or solution: the one passed with `--target`, or the one the tool finds. Cannot be combined with `--package`. **New in v1.** | | `-d, --destination ` | Yes | Yes | Yes | The skills folder. | | `-i, --interactive` | | Yes | Yes | Choose skills from a paged checklist. | -| `--dry-run` | | Yes | Yes | Show what would happen; change nothing. | +| `--dry-run` | | Yes | Yes | Shows what would happen. Changes nothing. | | `--global-packages ` | Yes | Yes | | Read a different NuGet cache. | **Changed in v1:** the `--json` option is removed from every command. Each command reports its results as text only. The `--no-restore` option is removed too: the tool never restores (F3). @@ -53,9 +53,9 @@ Read these tables first. *Refresh* means that the tracked folder is deleted and | Mode | Copies | Refreshes | Removes | Stops without changing anything when | | --- | --- | --- | --- | --- | -| `install` for the project | Every new skill | Every installed skill whose package the project references, from the version the project uses | Skills that a package's new version no longer ships | Any package resolves to two versions, a package that the project references isn't in the NuGet cache, or a package's new version drops a skill whose name another package ships (E6) | +| `install` for the project | Every new skill | Every installed skill whose package the project references, from the version the project uses | Skills that a package's new version no longer ships | Any package resolves to two versions, a package that the project references is not in the NuGet cache, or a package's new version drops a skill whose name another package ships (E6) | | `install --package X@V` | X's new skills | X's installed skills, from version V | X's skills that version V no longer ships | The same package is named with two versions, or version V drops a skill whose name another named package ships (E6) | -| `install -i` for the project | The new skills you check | Nothing | Nothing | Any package resolves to two versions, a package that the project references isn't in the NuGet cache, or any installed skill is stale (run `uninstall --stale` first) | +| `install -i` for the project | The new skills you check | Nothing | Nothing | Any package resolves to two versions, a package that the project references is not in the NuGet cache, or any installed skill is stale (run `uninstall --stale` first) | | `install -i --package X@V` | The new skills from X that you check | Nothing | Nothing | The same package is named with two versions, or X is installed at another version (run `uninstall --package X` first) | No install mode removes skills whose package left the project. A plain `install` keeps them and suggests `uninstall --stale`. @@ -67,7 +67,7 @@ No install mode removes skills whose package left the project. A plain `install` | `uninstall` | Every tracked skill. | | `uninstall --package Mockly` | Mockly's tracked skills. | | `uninstall --package Mockly@1.10.0` | Mockly's tracked skills, only if 1.10.0 is the installed version. | -| `uninstall --stale` | Stale skills, including skills added with `install --package` for packages that the project doesn't reference. **New in v1.** | +| `uninstall --stale` | Stale skills, including skills added with `install --package` for packages that the project does not reference. **New in v1.** | | `uninstall -i` | The tracked skills you check. Nothing starts checked. | | `uninstall -i --stale` | The stale skills you check. Only stale skills are listed, and nothing starts checked. **New in v1.** | @@ -82,7 +82,7 @@ To move a package's skills to the version that the project now uses, instead of | A1 | The project references Mockly 1.10.0 and Newtonsoft.Json. | `install` | Copies `mockly-usage` and `mockly-migration` into `.agents/skills` and creates the manifest. Newtonsoft.Json is scanned and has nothing to copy. | | A2 | No referenced package ships skills. | `install` | Reports that no bundled skills were found. Creates no folder and no manifest. | | A3 | You want to look before copying anything. | `list`, or `install --dry-run` | Shows what would be copied. Nothing changes. | -| A4 | You want only some of the skills. | `install -i` | Opens a paged checklist of the skills that aren't installed yet, with their descriptions. The line under the title says that installed skills aren't listed. Everything starts unchecked, and the skills you check are copied. **Changed in v1:** the preview build listed installed skills too, checked, and unchecking one removed it. | +| A4 | You want only some of the skills. | `install -i` | Opens a paged checklist of the skills that are not installed yet, with their descriptions. The line under the title says that installed skills are not listed. Everything starts unchecked, and the skills you check are copied. **Changed in v1:** the preview build listed installed skills too, checked, and unchecking one removed it. | ### B. Keeping skills up to date @@ -90,7 +90,7 @@ To move a package's skills to the version that the project now uses, instead of | --- | --- | --- | --- | | B1 | Nothing has changed since the last install. | `install` | Tracked skills are copied again. The files and manifest come out identical, so git shows no changes. | | B2 | You edited `mockly-usage` by hand. | `install` | Your edits are replaced with the package's copy. Keep your own guidance in your own folders. | -| B3 | You deleted the `mockly-usage` folder by hand. | `install` | It's copied again. `install -i` doesn't list it, because the manifest still tracks it. | +| B3 | You deleted the `mockly-usage` folder by hand. | `install` | `install` copies it again. `install -i` does not list it, because the manifest still tracks it. | | B4 | Nothing is stale, and you want to add a skill. | `install -i` | Installed skills are left exactly as they are, including any edits. Only the skills you check are copied. **Changed in v1.** | ### C. A package changes version @@ -103,7 +103,7 @@ Mockly 1.10.0 is installed with `mockly-usage` and `mockly-migration`, and the p | C2 | 1.11.0 also adds `mockly-testing`. | `install` | `mockly-testing` is copied too. | | C3 | 1.11.0 dropped `mockly-migration`. | `install` | `mockly-usage` is refreshed, and `mockly-migration` is removed. | | C4 | Same as C3. | `install --package Mockly@1.11.0` | Same as C3. **Changed in v1:** the preview build kept `mockly-migration`, still recorded under 1.10.0. | -| C5 | Any version change. | `install -i` | Stops with exit code 1, because Mockly's installed skills are stale. After `uninstall --stale` removes them, `install -i` lists 1.11.0's skills as new. To move to 1.11.0 in one step instead, run a plain `install`; it also copies any new skills. **Changed in v1.** | +| C5 | Any version change. | `install -i` | Stops with exit code 1, because Mockly's installed skills are stale. After `uninstall --stale` removes them, `install -i` lists 1.11.0's skills as new. To move to 1.11.0 in one step instead, run a plain `install`. It also copies any new skills. **Changed in v1.** | | C6 | Any version change. | `install -i --package Mockly@1.11.0` | Stops with exit code 1, because Mockly 1.10.0 is installed, and asks you to run `uninstall --package Mockly` first. A plain `install --package Mockly@1.11.0` moves the skills to 1.11.0 instead. **Changed in v1.** | | C7 | Mockly moves to an older version. | Any command | The same rules as an upgrade apply. | @@ -117,7 +117,7 @@ Contoso.Widgets was installed, and the project no longer references it. | D2 | As described. | `install -i` | Stops with exit code 1 and asks you to run `uninstall --stale` first. **Changed in v1:** the preview build listed it, and removed it if you unchecked it. | | D3 | As described. | `install --package Mockly@1.11.0` | `contoso.widgets-usage` is left alone. | | D4 | As described. | `uninstall --stale` | Removes `contoso.widgets-usage`. Add `--dry-run` to preview, or `-i` to choose. **New in v1.** | -| D5 | Alpha's skills were added with `install --package Alpha@1.0.0`, and the project doesn't reference Alpha. | `install`, `install -i`, or `uninstall --stale` | `install` keeps them and prints the hint. `install -i` stops until they're removed. `uninstall --stale` removes them. Use one source per skills folder: the project or `--package`. | +| D5 | Alpha's skills were added with `install --package Alpha@1.0.0`, and the project does not reference Alpha. | `install`, `install -i`, or `uninstall --stale` | `install` keeps them and prints the hint. `install -i` stops until you remove them. `uninstall --stale` removes them. Use one source per skills folder: the project or `--package`. | ### E. Conflicts: the tool never overwrites @@ -125,40 +125,40 @@ Contoso.Widgets was installed, and the project no longer references it. | --- | --- | --- | --- | | E1 | You already have your own `mockly-usage` folder. | `install` | Mockly's copy is skipped with the reason "the destination folder already exists and is not managed by this tool". Your folder is untouched. | | E2 | Packages Alpha and Beta both ship a skill named `shared`. | `install` | The first package in a fixed order gets it: alphabetical by package ID for a project, or command-line order with `--package`. The other package's copy is skipped with a reason. | -| E3 | `shared` is installed from Alpha, and Beta also ships it. | `install` | Alpha keeps it, including when the project no longer references Alpha; Beta's copy is skipped. To switch owners, uninstall Alpha's skill first. | +| E3 | `shared` is installed from Alpha, and Beta also ships it. | `install` | Alpha keeps it, including when the project no longer references Alpha. Beta's copy is skipped. To switch owners, uninstall Alpha's skill first. | | E4 | A file named `shared` is in the skills folder. | `install` | The skill is skipped. | | E5 | Two projects reference different versions of the same package, for example through `VersionOverride`. This applies to every package, including ones that ship no skills, such as Newtonsoft.Json. | `install` in any mode, or `install --package` naming one package with two versions | Stops with exit code 1, changes nothing, and names the package and its versions. Align the versions with Central Package Management, and then try again. `list` still works. **Changed in v1:** the preview build installed from both versions. | -| E6 | `shared` is installed from Alpha 1.0.0. The project moves to Alpha 2.0.0, which doesn't ship `shared`, and Beta also ships it. | `install`, including `--dry-run` | Stops with exit code 1 and changes nothing. Removing Alpha's copy would hand the name to Beta, and keeping it would record Alpha 1.0.0's copy as 2.0.0's. The error suggests `uninstall --package Alpha`; after it, `install` copies Alpha 2.0.0's skills and Beta's `shared`. **New in v1.** | +| E6 | `shared` is installed from Alpha 1.0.0. The project moves to Alpha 2.0.0, which does not ship `shared`, and Beta also ships it. | `install`, including `--dry-run` | Stops with exit code 1 and changes nothing. Removing Alpha's copy would hand the name to Beta, and keeping it would record Alpha 1.0.0's copy as 2.0.0's. The error suggests `uninstall --package Alpha`. After that command, `install` copies Alpha 2.0.0's skills and Beta's `shared`. **New in v1.** | -Skipped skills don't fail the command; it still exits with code 0. With `install -i`, skills that can't be installed because of a conflict aren't listed, and the report shows them as skipped. +A skipped skill does not fail the command. The command still exits with code 0. With `install -i`, a skill that cannot install because of a conflict is not listed. The report shows that skill as skipped instead. ### F. The NuGet cache | # | Situation | You run | What happens | | --- | --- | --- | --- | -| F1 | A package that the project references isn't in the NuGet cache that the tool reads, for example because `--global-packages` names a different folder than the one restore uses. | `install` or `install -i`, including with `--dry-run` | Stops with exit code 1, changes nothing, and asks you to run `dotnet restore`. | -| F2 | A package isn't in the NuGet cache. | `list`, `install --package`, or `install -i --package` | The package is treated like one without skills, with no warning. A version that isn't in the cache never causes a removal: with `install --package Mockly@1.11.0` and 1.11.0 missing, Mockly's installed skills stay as they are. **Changed in v1:** the preview build warned that the package was "resolved but not extracted" and listed it in the JSON `notOnDisk` field. | -| F3 | `dotnet list package` fails: the restore that the .NET 10 SDK runs for it fails, or an earlier SDK says the project needs restoring. | `install`, `list`, or `uninstall --stale` | Stops with exit code 1, changes nothing, and shows what `dotnet list package` reported. Restore or fix the project, and then run the command again. The tool never restores. **Changed in v1:** the preview build ran `dotnet restore` itself when the project wasn't restored, and had a `--no-restore` option to prevent that. | +| F1 | A package that the project references is not in the NuGet cache that the tool reads, for example because `--global-packages` names a different folder than the one restore uses. | `install` or `install -i`, including with `--dry-run` | Stops with exit code 1, changes nothing, and asks you to run `dotnet restore`. | +| F2 | A package is not in the NuGet cache. | `list`, `install --package`, or `install -i --package` | The tool treats the package like one without skills, with no warning. A version that is not in the cache never causes a removal. With `install --package Mockly@1.11.0` and 1.11.0 missing, Mockly's installed skills stay as they are. **Changed in v1:** the preview build warned that the package was "resolved but not extracted" and listed it in the JSON `notOnDisk` field. | +| F3 | `dotnet list package` fails: the restore that the .NET 10 SDK runs for it fails, or an earlier SDK says the project needs restoring. | `install`, `list`, or `uninstall --stale` | Stops with exit code 1, changes nothing, and shows what `dotnet list package` reported. Restore or fix the project, and then run the command again. The tool never restores. **Changed in v1:** the preview build ran `dotnet restore` itself when the project was not restored, and had a `--no-restore` option to prevent that. | | F4 | Packages are missing from the NuGet cache. | `uninstall --stale` | Not affected, because it reads only the project's package references. | ### G. Removing skills | # | Situation | You run | What happens | | --- | --- | --- | --- | -| G1 | Mockly and Contoso.Widgets skills are installed, plus your own `team-notes` folder. | `uninstall` | Removes the tracked skills. `team-notes` stays. The manifest is deleted, and the skills folder too if it's empty. | +| G1 | Mockly and Contoso.Widgets skills are installed, plus your own `team-notes` folder. | `uninstall` | Removes the tracked skills. `team-notes` stays. The manifest is deleted, and so is the skills folder, if that folder is empty. | | G2 | Same as G1. | `uninstall --package Mockly` | Removes only Mockly's skills. | | G3 | Same as G1. | `uninstall -i` | Lists only the tracked skills, none checked. The ones you check are removed. | -| G4 | Nothing is tracked. | `uninstall` | Reports that there's nothing to remove, and exits with code 0. | +| G4 | Nothing is tracked. | `uninstall` | Reports that there is nothing to remove, and exits with code 0. | | G5 | Same as G1. | `uninstall --dry-run` | Shows what would be removed. Nothing changes. | -| G6 | Mockly 1.10.0 is installed, and the project now uses 1.11.0. | `uninstall --stale` | Removes Mockly's skills, because they don't match the project. To move them to 1.11.0 instead, run a plain `install`. **New in v1.** | +| G6 | Mockly 1.10.0 is installed, and the project now uses 1.11.0. | `uninstall --stale` | Removes Mockly's skills, because they do not match the project. To move them to 1.11.0 instead, run a plain `install`. **New in v1.** | | G7 | Nothing is stale. | `uninstall --stale` | Reports "Nothing to remove. No stale skills were found." and exits with code 0. **New in v1.** | -| G8 | There's no solution or project in the current folder or its subfolders, and `--target` isn't given. | `uninstall --stale` | Stops with exit code 1 and asks for a target. Nothing is removed. **New in v1.** | +| G8 | No solution or project exists in the current folder or its subfolders, and you do not give `--target`. | `uninstall --stale` | Stops with exit code 1 and asks for a target. Nothing is removed. **New in v1.** | ### H. Teams and source control | # | Situation | You run | What happens | | --- | --- | --- | --- | -| H1 | The skills folder and manifest are committed, and a teammate runs `install` with the same packages. | `install` | The same files are produced, so there's no diff. The manifest is sorted and uses LF line endings on every operating system. **Changed in v1:** LF line endings. | +| H1 | The skills folder and manifest are committed, and a teammate runs `install` with the same packages. | `install` | The same files are produced, so there is no difference. The manifest is sorted and uses LF line endings on every operating system. **Changed in v1:** LF line endings. | | H2 | A merge leaves conflict markers in the manifest. | `install` or `uninstall` | Stops with exit code 1 and changes nothing until you fix the file. `list` still works. | | H3 | The manifest says `"version": 2`, written by a newer tool. | `install` or `uninstall` | Stops and asks you to update the tool. **New in v1.** | | H4 | The manifest uses the pre-release format, with an `installed` list. | `install` or `uninstall` | Stops and asks you to move the skills folder aside and reinstall. **New in v1.** | @@ -169,20 +169,20 @@ Skipped skills don't fail the command; it still exits with code 0. With `install | # | Situation | What happens | | --- | --- | --- | | I1 | A script needs to know which skills are installed. | It reads the manifest, which is versioned JSON and the tool's only machine-readable output. | -| I2 | CI runs `install`. | The job checks the exit code. `install` exits with code 0 even when skills are skipped; skips appear as warnings in the text report. | -| I3 | CI should keep the skills folder exactly in sync with the project. | Run `install`, then `uninstall --stale`, and then `git status` to see what changed. **Changed in v1:** in the preview build, `install` alone did both. | +| I2 | CI runs `install`. | The job checks the exit code. `install` exits with code 0 even when skills are skipped. A skip appears as a warning in the text report. | +| I3 | CI must keep the skills folder exactly in sync with the project. | Run `install`, then `uninstall --stale`, and then `git status` to see what changed. **Changed in v1:** in the preview build, `install` alone did both. | | I4 | A command fails. | Exit code 1 and an error on standard error. | | I5 | A script passes `--json` to any command. | Rejected as an unrecognized argument, with exit code 1. **Changed in v1:** the preview build accepted it on every command. | -The text report is for people and isn't a stable format for scripts to parse. +The text report is for people, and it is not a stable format for scripts to parse. ### J. The interactive checklist | # | Situation | What happens | | --- | --- | --- | -| J1 | You open the checklist. | Skills are shown a page at a time, with descriptions. It uses a temporary screen, so it doesn't stay in your scrollback. | +| J1 | You open the checklist. | Skills are shown a page at a time, with descriptions. It uses a temporary screen, so it does not stay in your scrollback. | | J2 | Every skill that the packages ship is already installed. | `install -i` reports "Nothing new to install." and exits with code 0, without opening the checklist. **New in v1.** | -| J3 | The terminal is too small. | The line under the title is dropped first. If the checklist still doesn't fit, the command exits with code 1 and asks you to enlarge the window. Nothing changes. **New in v1:** the line under the title. | +| J3 | The terminal is too small. | The line under the title is dropped first. If the checklist still does not fit, the command exits with code 1 and asks you to enlarge the window. Nothing changes. **New in v1:** the line under the title. | | J4 | You cancel. | "Cancelled. Nothing was copied or removed." | | J5 | Another run changes the installed skills while the checklist is open. | Accepting fails, and nothing changes. | @@ -199,7 +199,7 @@ flowchart TD C -->|"yes"| S1["Skipped: name conflict"] C -->|"no"| D{"What is already at that path
in the skills folder?"} D -->|"a file"| S2["Skipped"] - D -->|"a folder the manifest doesn't track"| S3["Skipped: your own folder"] + D -->|"a folder the manifest does not track"| S3["Skipped: your own folder"] D -->|"a folder tracked for another package"| S4["Skipped: owned by another package"] D -->|"nothing, or a folder tracked for this package"| OK["Copied or refreshed"] ``` @@ -209,7 +209,7 @@ What a plain `install` for the project does with each installed skill: ```mermaid flowchart TD T["An installed skill"] --> R{"Does the project reference
its package?"} - R -->|"no"| K["Kept; the report suggests
uninstall --stale"] + R -->|"no"| K["Kept. The report suggests
uninstall --stale"] R -->|"yes, at the installed version"| F1["Refreshed"] R -->|"yes, at another version"| S{"Does that version
still ship the skill?"} S -->|"yes"| F2["Refreshed from that version"] @@ -217,8 +217,8 @@ flowchart TD ``` - `install --package X@V` follows this chart for X's skills only, and keeps every other skill. -- `install -i` doesn't follow this chart. If any installed skill would take the "no" or "another version" branch, it stops and asks you to run `uninstall --stale`. Otherwise, it leaves installed skills as they are. -- An installed skill involved in a conflict is never removed by the same run. If its package moved to a version that no longer ships it, `install` stops instead (E6). A version that isn't in the NuGet cache never causes a removal. +- `install -i` does not follow this chart. If any installed skill would take the "no" or "another version" branch, it stops and asks you to run `uninstall --stale`. Otherwise, it leaves installed skills as they are. +- An installed skill involved in a conflict is never removed by the same run. If its package moved to a version that no longer ships it, `install` stops instead (E6). A version that is not in the NuGet cache never causes a removal. ## 6. What the tool writes @@ -264,26 +264,26 @@ The manifest is the only file the tool writes besides the copied skills, and its | 6 | When a package changes version, `install` and `install --package` refresh its skills and remove the ones that the new version dropped. | | 7 | `install` never removes skills of packages that left the project. It keeps them and prints a hint. | | 8 | `uninstall --stale` removes stale skills: skills whose package the project no longer references, or uses at a different version. It requires a project or solution, from `--target` or found by the tool. | -| 9 | `install -i` only adds skills. It lists only skills that aren't installed, all unchecked, and leaves installed skills as they are. | +| 9 | `install -i` only adds skills. It lists only skills that are not installed, all unchecked, and leaves installed skills as they are. | | 10 | A project `install -i` stops with exit code 1 when any installed skill is stale, and asks you to run `uninstall --stale` first. | | 11 | Repositories are expected to use Central Package Management. `install`, in every mode, never proceeds when it finds two versions of the same package, whether or not that package ships skills. It stops with exit code 1, changes nothing, and names the package and its versions. Only direct references count, because those are all the tool reads. `list` still works. | -| 12 | The manifest is always written with LF line endings, so it's byte-for-byte identical on every operating system, whatever the repository's git line-ending settings. | -| 13 | Manifests in the pre-release format aren't converted. `install` and `uninstall` stop and ask you to move the skills folder aside. | +| 12 | The manifest is always written with LF line endings, so the file is byte-for-byte identical on every operating system, whatever the repository's git line-ending settings are. | +| 13 | Manifests in the pre-release format are not converted. `install` and `uninstall` stop and ask you to move the skills folder aside. | | 14 | Package IDs keep NuGet's casing when they come from packages, and are lowercase when they come from the manifest, as in the `uninstall` report and the stale hint. | -| 15 | A version that isn't in the NuGet cache never causes a removal. | +| 15 | A version that is not in the NuGet cache never causes a removal. | | 16 | `install -i --package X@V` stops when X is installed at another version, and asks you to run `uninstall --package X` first. | -| 17 | `uninstall --stale` can't be combined with `--package`, and `uninstall` accepts `--target` only together with `--stale`. | -| 18 | `uninstall --stale` still runs when a package resolves to two versions. A skill counts as stale only if the project doesn't reference its installed version at all. | -| 19 | `uninstall --stale --dry-run` is the way to see stale skills; there's no machine-readable list of them. | +| 17 | `uninstall --stale` cannot combine with `--package`, and `uninstall` accepts `--target` only together with `--stale`. | +| 18 | `uninstall --stale` still runs when a package resolves to two versions. A skill counts as stale only if the project does not reference its installed version at all. | +| 19 | `uninstall --stale --dry-run` is the way to see stale skills. There is no machine-readable list of them. | | 20 | When nothing new is available, `install -i` says so and exits with code 0. | | 21 | The line under the checklist title gives way when the terminal is too small to fit it, rather than the checklist refusing to open. | -| 22 | When a package moves to a version that no longer ships an installed skill, and another package in the same run ships a skill with that name, `install` stops and suggests `uninstall --package`. It doesn't hand the name over, and it doesn't keep the old copy under the new version (E6). | +| 22 | When a package moves to a version that no longer ships an installed skill, and another package in the same run ships a skill with that name, `install` stops and suggests `uninstall --package`. It does not hand the name over, and it does not keep the old copy under the new version (E6). | | 23 | The commands that reports and errors suggest repeat the `--target` and `--destination` of the command that was run, so they can be run as printed. | | 24 | Package IDs follow NuGet's own rule, which allows letters outside ASCII, on the command line and in the manifest. The tool never writes a manifest that it would refuse to read. | | 25 | Both checklists draw a checked skill the same way, with a blue X, because each does only one thing: the title and the summary say whether a check installs or removes. There's no separate removal cue, and without color, `[X]` alone marks a checked skill. | -| 26 | The tool never restores, and there's no `--no-restore` option. It runs `dotnet list package` as it is; the .NET 10 SDK restores during that when it needs to. When `dotnet list package` fails, the tool shows what it reported, and the customer restores or fixes the project and runs the command again. | +| 26 | The tool never restores, and there is no `--no-restore` option. It runs `dotnet list package` as it is. The .NET 10 SDK restores during that step when it needs to. When `dotnet list package` fails, the tool shows what it reported, and the customer restores or fixes the project and runs the command again. | -Known consequence: skills added with `install --package` for packages outside the project count as stale for project commands, so a project `install -i` stops until they're removed. +Known consequence: skills added with `install --package` for packages outside the project count as stale for project commands, so a project `install -i` stops until they are removed. ## 8. See also From 6565f4a2624aa99a35eca75db46ce1c472824521 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 11:54:58 -0700 Subject: [PATCH 04/12] Removed docs --- .../docs/dotnet-package-skills.md | 369 -------------- dotnet-package-skills/docs/functional-spec.md | 474 ------------------ dotnet-package-skills/docs/scenarios.md | 292 ----------- 3 files changed, 1135 deletions(-) delete mode 100644 dotnet-package-skills/docs/dotnet-package-skills.md delete mode 100644 dotnet-package-skills/docs/functional-spec.md delete mode 100644 dotnet-package-skills/docs/scenarios.md diff --git a/dotnet-package-skills/docs/dotnet-package-skills.md b/dotnet-package-skills/docs/dotnet-package-skills.md deleted file mode 100644 index 1feb0b8..0000000 --- a/dotnet-package-skills/docs/dotnet-package-skills.md +++ /dev/null @@ -1,369 +0,0 @@ ---- -title: dotnet-package-skills command -description: The 'dotnet-package-skills' command copies agent skills bundled in NuGet packages into a repository, where coding agents can find them. -ms.date: 09/24/2026 ---- -# dotnet-package-skills - -**This article applies to:** ✔️ `dotnet-package-skills` version 0.1.0 - -## Name - -`dotnet-package-skills` - Discovers, installs, and removes agent skills bundled in NuGet packages. - -> [!NOTE] -> `dotnet-package-skills` is a .NET tool, not part of the .NET SDK. Install it with [`dotnet tool install`](https://learn.microsoft.com/dotnet/core/tools/dotnet-tool-install), for example `dotnet tool install --global dotnet-package-skills`. To install it from a local package feed, add `--add-source `. The tool requires the .NET 8 runtime or later, and commands that read a solution or project also require the .NET SDK. - -## Synopsis - -```dotnetcli -dotnet-package-skills install [-d|--destination ] [--dry-run] - [--global-packages ] [-i|--interactive] - [-p|--package ...] [-t|--target ] - -dotnet-package-skills list [-d|--destination ] [--global-packages ] - [-p|--package ...] [-t|--target ] - -dotnet-package-skills uninstall [-d|--destination ] [--dry-run] - [-i|--interactive] [-p|--package ] - -dotnet-package-skills uninstall --stale [-d|--destination ] [--dry-run] - [-i|--interactive] [-t|--target ] - -dotnet-package-skills [install|list|uninstall] -h|--help - -dotnet-package-skills --version -``` - -## Description - -Some NuGet packages include *agent skills*. An agent skill is a set of instructions from the package author that teaches a coding agent how to use the package. Each skill is a folder that contains a `SKILL.md` file and any supporting files. Restore extracts these folders into the NuGet global packages folder. This folder sits outside your repository, where an agent does not look for skills. The `dotnet-package-skills` command copies these folders into your repository. - -To install the skills that your packages ship, run these commands from the root of your repository: - -```dotnetcli -dotnet restore -dotnet-package-skills install -``` - -The command copies skills to `.agents/skills` under the current directory. When your agent reads skills from another folder, add `--destination`. For example, add `--destination .claude/skills`. When your solution or project is not in the current directory, pass its path to both commands. For example, run `dotnet restore src/MyApp.slnx` and then `dotnet-package-skills install --target src/MyApp.slnx`. To choose which skills to install, add `--interactive`. Run `install` again after you add a package or upgrade a package. - -> [!IMPORTANT] -> Skills are instructions that your coding agent follows. Review them before you rely on them. - -The command reads only the direct package references of your solution or project. It does not read the packages that those packages depend on. The command does not download a package, and it does not change your project files. - -This article uses these terms: - -- **Target**: the solution or project whose package references the command reads. Without `--target`, the command looks for one in the current directory, and then in its subdirectories. Reports show the target after `Target:`. -- **Skills folder**: the folder that the command copies skills to. This is `.agents/skills` under the current directory, unless you specify `--destination`. `--target` does not change this folder. A report shows this folder after `Destination:`. -- **Tracked skill**: a skill that the command installed, as recorded in the [manifest](#manifest-file) in the skills folder. The command updates and removes only tracked skills, never folders that you created. -- **Stale skill**: a tracked skill whose package the target no longer references at the version that the skill came from. - -### List available skills - -`dotnet-package-skills list` shows the skills that the target's packages ship, without copying anything. It runs [`dotnet list package`](https://learn.microsoft.com/dotnet/core/tools/dotnet-package-list) on the target to find its top-level (direct) package references, and then looks for skills in those packages in the NuGet global packages folder: - -```output -Target: C:\src\MyApp\MyApp.slnx -NuGet cache: C:\packages -Destination: C:\src\MyApp\.agents\skills -Scanned 2 packages (direct). - -Found 4 skills: - contoso.widgets-widget-testing (Contoso.Widgets 2.3.0) - contoso.widgets-widget-usage (Contoso.Widgets 2.3.0) - fabrikam.testing-fakes (Fabrikam.Testing 1.4.0) - fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) -``` - -`NuGet cache` names the NuGet global packages folder that the command reads skills from. `Destination` names the skills folder that `install` would copy them to. `list` does not look in the skills folder, so it does not show which skills are installed. The [manifest](#manifest-file) records that information instead. - -Restore the target before you run the command. The command does not run `dotnet restore` on its own. With the .NET 10 SDK, `dotnet list package` restores the target when it needs to, even during a dry run. When `dotnet list package` fails, the command shows what it reported, and it changes nothing. Fix the problem, for example by restoring the target, and then run the command again. When a package that the target references is not in the NuGet global packages folder, `list` skips that package, and `install` stops. - -To read skills from specific packages instead of a target, specify `--package @`. The package must already be in the NuGet global packages folder, because the command does not download it. When the package is not there, the command finds no skills in it, and it shows no warning. - -### Install and update skills - -`dotnet-package-skills install` copies the target's skills into the skills folder, and it records them in the manifest. Each skill keeps its folder name from the package. The report starts with the same first lines as the `list` report. Then it lists the skills that the command copied: - -```output -Copied 4 skills: - contoso.widgets-widget-testing (Contoso.Widgets 2.3.0) - contoso.widgets-widget-usage (Contoso.Widgets 2.3.0) - fabrikam.testing-fakes (Fabrikam.Testing 1.4.0) - fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) - -These skills are instructions written by the package authors, and your coding agent will follow them. Review them before relying on them. -``` - -Run `install` again whenever your packages change. There is no separate update command. Each run compares the tracked skills with the target's packages: - -| If you've... | `install`... | -| --- | --- | -| Added a package that ships skills | Copies its skills. | -| Upgraded or downgraded a package | Replaces its skills with those of the new version, and removes the ones that the new version no longer ships. | -| Removed a package | Keeps its skills, and lists them as in the following example. To remove them, see [Remove stale skills](#remove-stale-skills). | -| Changed nothing | Copies each package's skills again, replacing the installed copies. | - -```output -2 installed skills belong to a package that the target no longer references: - fabrikam.testing-fakes (fabrikam.testing 1.4.0) - fabrikam.testing-fixtures (fabrikam.testing 1.4.0) -Run 'dotnet-package-skills uninstall --stale' to remove them. -``` - -> [!WARNING] -> `install` replaces the whole folder of each skill that it copies. This includes your edits and any files that you added. Keep your own instructions in separate skill folders. The command never changes a folder that it did not install. - -With `--package`, `install` does the same thing for the packages that you name, and it leaves every other skill alone. With `--interactive`, it adds only the skills that you choose. See [Choose skills interactively](#choose-skills-interactively). To preview an installation, add `--dry-run`. The report then lists the planned changes under `Would copy` and `Would remove`, and the command changes nothing. - -The skills folder holds the skills of only one version of each package. If the target references a package at more than one version, or `--package` names more than one, `install` stops and names the versions. [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management) keeps all the projects in a repository on one version of each package. - -Skill names are compared without regard to case. When two packages ship a skill with the same name, the command copies only one of them. `install` also never replaces a tracked skill with another package's skill, and it never overwrites a folder that it did not install. It skips such a skill, and it lists that skill in the report under `Warning:`. To give a skill name to another package, first remove the tracked skill, for example with `uninstall --package `. - -### Remove skills - -`dotnet-package-skills uninstall` removes every tracked skill from the skills folder. It does not remove a folder that you created, and it does not remove any NuGet package. It does not need a target. When you installed skills into another folder, specify the same `--destination`. - -```output -Destination: C:\src\MyApp\.agents\skills - -Removed 2 skills: - contoso.widgets-widget-testing (contoso.widgets 2.3.0) - contoso.widgets-widget-usage (contoso.widgets 2.3.0) -``` - -To remove only one package's skills, add `--package `, or add `--package @` to remove them only if that version is installed. Add `--dry-run` to preview the removal, or `--interactive` to choose the skills to remove. If no skills are tracked, the command says so and succeeds. - -### Remove stale skills - -`install` keeps the skills of packages that the target no longer references. To remove them, run: - -```dotnetcli -dotnet-package-skills uninstall --stale -``` - -This command removes every stale skill. Run `install` first, so that the skills of upgraded packages are updated instead of removed. The report looks like the `uninstall` report, with a `Target:` line first. Add `--dry-run` to preview the removal, or `--interactive` to choose among the stale skills. - -`--stale` needs a target, which the command finds the same way as for `install`, or which you specify with `--target`. - -Skills that you installed with `install --package` are stale unless the target references the same package version. For each skills folder, use either a target or `--package`, not both. - -### Choose skills interactively - -Add `--interactive` (`-i`) to choose skills in a paged checklist. `install -i` lists the skills that are not installed yet. `uninstall -i` lists the tracked skills, or with `--stale`, only the stale skills. Nothing starts checked. The following example shows an install checklist after you check one skill: - -```output -Which skills should be installed? (MyApp.slnx) -Installed skills aren't listed. - -> [X] contoso.widgets-widget-usage - Correct usage patterns for the Contoso.Widgets library, including lifetime rules and the batching API. Use whenever code creates, configures, or disposes a Widget. - [ ] fabrikam.testing-fakes - Create fakes and verify their calls in unit tests. - [ ] fabrikam.testing-fixtures - Share expensive setup across tests with fixtures. - -1 of 3 selected -(Press to select, to accept) -(Press / to move, / for first/last) -(Press
to select all, to clear all, // to cancel) -Blue X: selected -``` - -Each row shows a skill's name and the `description` from its `SKILL.md` file. `>` marks the focused row, and `X` marks a checked skill. When the terminal shows color, the focused row and each `X` are blue. - -| Key | Action | -| --- | --- | -| Up, Down | Move to the previous or next skill. | -| Left, Right, PageUp, PageDown | Move to the previous or next page. | -| Home, End | Go to the first or last skill. | -| Space | Check or uncheck the focused skill. | -| A, C | Check or clear all skills, on every page. | -| Ctrl+Up, Ctrl+Down | Scroll a description that is too long for the page. | -| Enter | Install or remove the checked skills. With `--dry-run`, only report what would change. | -| Esc, Q, Ctrl+C | Cancel without changing any skills. | - -`install -i` adds only the skills that you check. It never updates a skill, and it never removes a skill. Because of this, it first checks that the tracked skills match the packages. It stops before the checklist opens if they do not match: - -- With a target, it stops if any tracked skill is stale. Run `uninstall --stale`, and then try again. -- With `--package`, it stops if a named package is installed at another version. Run `uninstall --package ` first, or run `install --package` without `--interactive` to switch versions. - -The checklist does not list a skill that `install` would skip because its name is in use. The report after the checklist names that skill instead. When every skill is already installed, the checklist does not open, and the command reports `Nothing new to install.` - -### Manifest file - -The manifest, `.dotnet-package-skills.json` in the skills folder, records the skills that the command installed and the package version that each came from. Its shape follows the .NET local tool manifest, `dotnet-tools.json`: - -```json -{ - "version": 1, - "packages": { - "contoso.widgets": { - "version": "2.3.0", - "skills": [ - "contoso.widgets-widget-testing", - "contoso.widgets-widget-usage" - ] - } - } -} -``` - -| Property | Meaning | -| --- | --- | -| `version` | The manifest format version, `1`. | -| `packages` | One entry per package, keyed by the lowercase package ID. | -| `packages..version` | The package version that the skills were installed from. | -| `packages..skills` | The names of the skill folders installed from the package. | - -If you commit the skills folder to source control, commit the manifest with it. The command writes the file the same way on every platform. It uses UTF-8 encoding, LF line endings, and a stable order, so the file does not cause line-ending differences between checkouts. Do not edit the file by hand. The command relies on the file to decide which folders it can replace or remove. The command also drops a property that it does not recognize when it rewrites the file. When the last tracked skill is removed, the command deletes the manifest. It also deletes the skills folder, if that folder is empty. - -### Exit codes and common problems - -The command returns `0` on success. This includes when there is nothing to do, and when you cancel a checklist. The command returns `1` on failure. An error is written to standard error. A skipped skill is reported as a warning, and a warning does not change the exit code. Check the report when it matters that every skill was installed. Reports exist for people to read. A script must rely on the exit code instead, and it must read the manifest to find the installed skills. - -Each of these errors stops the command before it changes any skills or the manifest: - -| Problem | What to do | -| --- | --- | -| `dotnet list package` fails, for example because the target is not restored. | Fix what it reports, for example by running `dotnet restore`, and then run the command again. | -| `install` reports packages that are missing from the NuGet global packages folder. | Restore the target into that folder, and then run `install` again. If you use `--global-packages`, restore into the same folder, for example with `dotnet restore --packages `. | -| The target references a package at more than one version, or `--package` names more than one. | Align the versions, for example with [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management). With `--package`, name one version of each package. | -| `install --interactive` says that installed skills do not match the target, or that a named package is installed at another version. | Run the `uninstall` command that the error suggests, and then try again. To switch a package to another version instead, run `install` without `--interactive`. | -| The manifest cannot be read. | Resolve any merge conflict in `.dotnet-package-skills.json`, or restore the file from source control. If the error says that the manifest uses a newer format version, update the tool. If it says that a pre-release version of the tool wrote the manifest, move the skills folder aside, and then run `install` again. | -| Another run of the command is using the skills folder. | The command waits up to 30 seconds for the other run to finish, and then stops. Run the command again after the other run finishes. | -| The terminal is too small for the checklist. | Enlarge the window, or run the command without `--interactive`. | - -For other errors, run the command that the error suggests. Suggested commands repeat the `--target` and `--destination` that you specified, so you can run them as printed. - -When a file system error interrupts a copy or removal, the command reports that error, but it does not undo the changes that it already made. A copied folder might then be missing from the manifest. Move the affected skill folders aside, and then run the command again. - -## Commands - -- **`install`** - - Copies the skills of the target's direct package references, or of the packages named with `--package`, into the skills folder, and updates the skills that it installed before. - -- **`list`** - - Lists the skills available from the target's direct package references, or from the packages named with `--package`, without copying anything. - -- **`uninstall`** - - Removes tracked skills from the skills folder. With `--stale`, removes only stale skills. - -## Options - -- **`-d|--destination `** - - For `install`, `list`, and `uninstall`, specifies the skills folder. Defaults to `.agents/skills`. A relative path is resolved from the current directory, even when `--target` points somewhere else. `list` only shows the folder in its report. To remove skills, specify the folder that they were installed to. - -- **`--dry-run`** - - For `install` and `uninstall`, reports the planned changes without copying or removing skills or writing the manifest. With the .NET 10 SDK, `dotnet list package` can still restore the target. - -- **`--global-packages `** - - For `install` and `list`, specifies the NuGet global packages folder to read packages from. The folder must exist. This option overrides the `NUGET_PACKAGES` environment variable and the folder that NuGet is configured to use, but it does not change where restore extracts packages. Unlike `--destination`, the command resolves a relative path from the target's directory, or from the current directory when you use `--package`. - -- **`-?|-h|--help`** - - Prints out a description of how to use the command. - -- **`-i|--interactive`** - - For `install` and `uninstall`, opens a checklist for choosing the skills to install or remove. `install` lists only skills that are not installed, and it only adds skills. This option needs a terminal when there is something to choose. You can combine it with `--dry-run`, and with either `--package` or, for `uninstall`, `--stale`. For more information, see [Choose skills interactively](#choose-skills-interactively). - -- **`-p|--package `** - - For `install` and `list`, reads skills from the specified package instead of a target. Specify an exact version, such as `Contoso.Widgets@2.3.0`. The command does not accept a floating version or a version range. Repeat the option to name several packages. A duplicate, such as `Mockly@1.10` and `Mockly@1.10.0` named together, counts once. `install` accepts only one version of each package. `list` shows every version that you name. The package must already be in the NuGet global packages folder. If it is not there, the command finds no skills in it, and it shows no warning. You cannot combine this option with `--target`. - -- **`-p|--package `** - - For `uninstall`, removes only the skills of the specified package. With a version, removes them only if that version is installed. The command compares package IDs without regard to case, and `1.10` matches `1.10.0`. Specify this option at most once. A blank value is an error. You cannot combine this option with `--stale`. - -- **`--stale`** - - For `uninstall`, removes only stale skills. This option requires a target. You can combine it with `--target`, `--dry-run`, and `--interactive`, but not with `--package`. For more information, see [Remove stale skills](#remove-stale-skills). - -- **`-t|--target `** - - For `install`, `list`, and `uninstall --stale`, specifies the solution (`.slnx` or `.sln`), project (`.csproj`, `.fsproj`, or `.vbproj`), or directory to read package references from. For a directory, or when you omit the option, the command looks for a solution or project in that directory, and then in its subdirectories. It does not change the skills folder. You cannot combine it with `--package`. - -- **`--version`** - - Displays the tool version. Available without a command. - -## Examples - -- List the available skills: - - ```dotnetcli - dotnet-package-skills list - ``` - -- Install every available skill: - - ```dotnetcli - dotnet-package-skills install - ``` - -- Install the skills of a solution in a subfolder: - - ```dotnetcli - dotnet-package-skills install --target src/MyApp.slnx - ``` - -- Install skills into `.claude/skills` instead of `.agents/skills`: - - ```dotnetcli - dotnet-package-skills install --destination .claude/skills - ``` - -- Preview an installation without changing any skills: - - ```dotnetcli - dotnet-package-skills install --dry-run - ``` - -- Choose which skills to install: - - ```dotnetcli - dotnet-package-skills install --interactive - ``` - -- Install the skills of exact package versions, without a solution or project: - - ```dotnetcli - dotnet-package-skills install --package Contoso.Widgets@2.3.0 --package Mockly@1.10.0 - ``` - -- Remove every tracked skill from `.agents/skills`: - - ```dotnetcli - dotnet-package-skills uninstall - ``` - -- Remove the skills of one package: - - ```dotnetcli - dotnet-package-skills uninstall --package Contoso.Widgets - ``` - -- Choose which skills to remove: - - ```dotnetcli - dotnet-package-skills uninstall --interactive - ``` - -- Preview the removal of stale skills: - - ```dotnetcli - dotnet-package-skills uninstall --stale --dry-run - ``` - -- Keep a committed skills folder in step with the solution or project, for example in a CI job. `install` runs first, so that the skills of upgraded packages are updated instead of removed: - - ```dotnetcli - dotnet-package-skills install - dotnet-package-skills uninstall --stale - ``` diff --git a/dotnet-package-skills/docs/functional-spec.md b/dotnet-package-skills/docs/functional-spec.md deleted file mode 100644 index 894f9a4..0000000 --- a/dotnet-package-skills/docs/functional-spec.md +++ /dev/null @@ -1,474 +0,0 @@ -# .NET Package Skills: Functional Specification - -## 1. Purpose and scope - -The tool makes agent skills shipped in NuGet packages available inside a developer's repository, where their coding agent can find them. It supports three jobs: discover available skills, install the skills the developer wants, and remove previously installed skills. - -It copies whole skill folders, including supporting documents, from the NuGet cache. It does not move or modify the source package, change project package references, install an agent, or configure an MCP server. In this version, only direct package dependencies are scanned. - -The default destination is `.agents\skills` under the directory where the command runs. Developers can choose another destination, such as `.claude\skills`. Agent support for a destination remains the agent's responsibility. - -The examples below use made-up packages and paths. Interactive page sizes and line wrapping both -vary with the terminal's dimensions. The examples are not fixed screen layouts. - -## 2. Command structure - -Invoke the tool by its command name: - -```powershell -dotnet-package-skills [options] -``` - -There are three subcommands. Interactive selection, previews, and stale cleanup are options, not additional subcommands. - -| Command | Customer intent | Effect on destination skills | -| --- | --- | --- | -| `list` | See which package-provided skills are available. | No changes. | -| `install` | Copy or refresh available skills. | Refreshes the skills it finds, and removes the skills a package's new version no longer ships. Never removes skills because a package left the project. Interactive runs only add. | -| `uninstall` | Remove skills previously installed by this tool. | Removes all matching tracked skills, only the stale ones with `--stale`, or an interactive selection. | -| `--help` | Learn the commands and options. | No changes. | -| `--version` | Identify the tool build. | No changes. | - -The tool requires a compatible .NET runtime. Current builds target .NET 8 and .NET 10. Project discovery also uses the installed .NET SDK. - -Installing the .NET tool itself is separate from installing skills. For evaluation with a supplied local tool package: - -```powershell -dotnet tool install --global --add-source C:\tool-feed dotnet-package-skills --version 0.1.0 -``` - -### Help and version - -```powershell -dotnet-package-skills --help -dotnet-package-skills install --help -dotnet-package-skills list --help -dotnet-package-skills uninstall --help -dotnet-package-skills --version -``` - -Representative root help: - -```text -Usage: - dotnet-package-skills [command] [options] - -Options: - -?, -h, --help Show help and usage information - --version Show version information - -Commands: - install Copy skills bundled in NuGet packages into the repository. - list Show which packages ship skills, without copying anything. - uninstall Remove skills this tool previously copied in. -``` - -Version output starts with `0.1.0` and may include build metadata after `+`. - -## 3. Discover available skills: `list` - -```powershell -dotnet-package-skills list -``` - -The tool runs [`dotnet list package`](https://learn.microsoft.com/dotnet/core/tools/dotnet-package-list) on a solution or project to find its top-level package references. It then looks for an immediate subfolder of each package's `skills` directory that contains `SKILL.md`. Without `--target`, the tool uses a solution or project from the current directory, or from one of its subdirectories. The chosen path appears after `Target:`. Name a target when that choice matters. - -Sample output: - -```text -Target: C:\src\MyApp\MyApp.slnx -NuGet cache: C:\packages -Destination: C:\src\MyApp\.agents\skills -Scanned 2 packages (direct). - -Found 4 skills: - contoso.widgets-widget-testing (Contoso.Widgets 2.3.0) - contoso.widgets-widget-usage (Contoso.Widgets 2.3.0) - fabrikam.testing-fakes (Fabrikam.Testing 1.4.0) - fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) -``` - -`list` shows what is available from packages, not an inventory of installed skills. It never copies skills or writes the ownership manifest. The tool never restores: the .NET 10 SDK restores the target during `dotnet list package` when it needs to, and earlier SDKs report that it has to be restored first. When `dotnet list package` fails, every command that reads packages stops without changes and shows what it reported, so the user can restore or fix the target and run the command again. - -Other ways to scope discovery: - -```powershell -dotnet-package-skills list --target C:\src\MyApp\MyApp.slnx -dotnet-package-skills list --target C:\src\MyApp\App.Web\App.Web.csproj -dotnet-package-skills list --package Contoso.Widgets@2.3.0 -``` - -An explicit package must already be extracted in the selected NuGet cache. Naming it does not download it or add it to a project. Such reports use `Target: (packages named on the command line)` and `Scanned N packages (named explicitly)`. - -The cache directory itself must already exist. If it is absent, restore the project first. `list` skips a package that is not extracted in the selected cache, and it reports no warning for that package. When projects resolve different versions of one package, `list` shows each version. `install` refuses to proceed until you align those versions (section 4). `list` does not read destination ownership. Because of this, its first discovery candidate can differ from the owner-preferred candidate that `install` uses. - -## 4. Install or refresh skills: `install` - -```powershell -dotnet-package-skills install -``` - -Without `--interactive`, the tool attempts to install every discovered skill. It preserves the authored folder names and records ownership in `.dotnet-package-skills.json` inside the destination. Refreshing a tracked skill replaces its whole folder, including local edits or added files. Protection for hand-written skills applies to separate, untracked folders. - -The report uses the same context header as `list`. Its result section looks like: - -```text -Copied 4 skills: - contoso.widgets-widget-testing (Contoso.Widgets 2.3.0) - contoso.widgets-widget-usage (Contoso.Widgets 2.3.0) - fabrikam.testing-fakes (Fabrikam.Testing 1.4.0) - fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) - -These skills are instructions written by the package authors, and your coding agent will follow them. Review them before relying on them. -``` - -### Refresh and cleanup rules - -| Scope | Behavior | -| --- | --- | -| Any noninteractive run | Refresh the skills of the packages found in the cache. When a package's version differs from the version that the manifest records, refresh its skills, and remove its installed skills that the new version does not ship. A package at the same version never loses a skill. The tool retains a path that an ownership conflict protects. | -| Target | Keep the installed skills of packages the target no longer references, and list them with a pointer to `uninstall --stale` (section 6). | -| `--package` | Touch only the named packages. Other installed skills are left in place without comment. | -| Interactive install | Add only the skills that are not installed (section 5). Never refresh a skill, and never remove a skill. | -| A different package claims an installed name | Preserve the existing owner and skip the conflicting copy with a warning in every mode. If the run also supplies the current owner's candidate, refresh that candidate rather than letting the conflict block it. Replacement requires an explicit uninstall first. | -| The owner's package moves to a version that no longer ships that name | Stop before any change, including with `--dry-run`. Removing the old copy would hand the name to the other package, and keeping it would record the old version's copy under the new version. The error suggests `uninstall --package ` for the owner. | - -A target report ends with the skills it kept: - -```text -2 installed skills belong to a package that the target no longer references: - fabrikam.testing-fakes (fabrikam.testing 1.4.0) - fabrikam.testing-fixtures (fabrikam.testing 1.4.0) -Run 'dotnet-package-skills uninstall --stale' to remove them. -``` - -Installed skills are named by the lowercase package ID the manifest records. A reference can disappear temporarily, for example during a refactor, so removal after a package leaves the project is always an explicit command. The suggested command, like every command that an error suggests, repeats the `--target` and a non-default `--destination` of the run, so it can be run as printed. - -If any resolved package is missing from a target's cache, `install` stops before any skill or manifest change, including with `-i` or `--dry-run`. Restore into the selected cache before retrying. With `--package`, a package missing from the cache contributes no skills. - -There is no separate update command: running `install` again refreshes the applicable skill copies. - -```powershell -dotnet-package-skills install --package Contoso.Widgets@2.3.0 --package Fabrikam.Testing@1.4.0 -dotnet-package-skills install --destination .claude\skills -``` - -### One version per package - -Repositories are expected to keep one version of each package, for example with [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management). The manifest records one version per package. When the target resolves more than one version of a package, or `--package` names more than one, every `install` mode stops before any change and names the versions: - -```text -error: Cannot install skills because these packages resolve to more than one version: Contoso.Widgets (2.2.0, 2.3.0). Skills can come from only one version of each package. Align the versions, for example with Central Package Management, and then try again. No skills were changed. -``` - -Equivalent versions, such as `1.10` and `1.10.0`, count as one. `list` still shows every version, and `uninstall --stale` still works. - -### Preview without installing - -```powershell -dotnet-package-skills install --dry-run -``` - -Result excerpt: - -```text -Would copy 4 skills: - contoso.widgets-widget-testing (Contoso.Widgets 2.3.0) - contoso.widgets-widget-usage (Contoso.Widgets 2.3.0) - fabrikam.testing-fakes (Fabrikam.Testing 1.4.0) - fabrikam.testing-fixtures (Fabrikam.Testing 1.4.0) -``` - -Any planned removals appear under `Would remove`. A dry run does not copy or delete skills or create/update the ownership manifest. The .NET SDK can still restore the target while `dotnet list package` runs. - -## 5. Choose skills interactively - -```powershell -dotnet-package-skills install --interactive -dotnet-package-skills install --package Contoso.Widgets@2.3.0 -i -dotnet-package-skills install -i --dry-run -``` - -Representative page after making a selection, with `contoso.widgets-widget-testing` already installed: - -```text -Which skills should be installed? (MyApp.slnx) -Installed skills aren't listed. - -> [X] contoso.widgets-widget-usage - Correct usage patterns for the - Contoso.Widgets library, including lifetime rules and the batching API. - Use whenever code creates, configures, or disposes a Widget. - [ ] fabrikam.testing-fakes - Create fakes and verify their calls in unit - tests. - [ ] fabrikam.testing-fixtures - Share expensive setup across tests with - fixtures. - -1 of 3 selected -(Press to select, to accept) -(Press / to move, / for first/last) -(Press to select all, to clear all, // to cancel) -Blue X: selected -``` - -### Selection rules - -- The checklist lists only skills that would install cleanly and that are not installed. Nothing starts checked. The line under the title states that installed skills are not listed. -- Acceptance copies only the checked skills. An interactive install never refreshes a skill, and it never removes a skill. A noninteractive `install` refreshes a skill, and `uninstall` removes a skill. -- The checklist does not list a candidate that the tool would skip, for example a name that another package owns, or a name that an untracked folder uses. The final report names these candidates under the skipped warning. -- When every discovered skill is already installed, the command prints the following and exits with code `0` without opening the checklist. When some candidates were skipped, only the first sentence is printed, followed by the skipped warning. - - ```text - Nothing new to install. Every skill that these packages ship is already installed. - ``` - -Because the checklist only adds, it needs the installed skills to agree with the packages. Before the checklist opens, an interactive install fails with exit code `1`, changing nothing, in these cases: - -| Case | Message excerpt | Resolution | -| --- | --- | --- | -| More than one version of a package, in any mode | `...resolve to more than one version...` or `--package names more than one version...` | Align the versions, or name one version per package. | -| Target: a resolved package is missing from the cache | `...resolved packages are missing from...` | Restore the target. | -| Target: an installed skill is stale | `Cannot choose skills interactively because 2 installed skills don't match the target...` | Run `dotnet-package-skills uninstall --stale`. | -| `--package`: a named package is installed at another version | `Contoso.Widgets 2.2.0 is already installed, and an interactive install only adds skills...` | Run `install --package` without `-i` to change the version, or `uninstall --package ` first. | - -A skill is **stale** when the target no longer references its package, or references a different version than the manifest records. Skills installed with `install --package` from packages outside the target count as stale for target commands, so a target `install -i` stops until they are removed. - -### Presentation - -- The format is always `skill-name - description`. There is no package or version suffix, and there is no separate description column. Authored package prefixes in a skill name remain intact. The tool may clip a long display name with `...`. A clipped name's canonical identity stays unchanged. -- Descriptions wrap beneath the skill text with a small list indent, using the available width. Page sizes reflect rendered lines, not a fixed number of skills. A list that fits needs no paging. -- Live resizing recalculates wrapping and pagination while preserving focus and selection. Oversized descriptions can be scrolled while their skill row remains visible. -- The note under the title is omitted when the window is too small to fit it, rather than failing. - -The live checklist is displayed at the top of a temporary terminal screen, separate from the shell's scrollback. Its initial position does not depend on where the shell cursor was before invocation. On acceptance, cancellation, or a handled error, the original shell screen is restored and receives the final report. The checklist itself is not retained in normal history, preventing host-driven resize reflow from leaving duplicate headings or partial old frames. - -| Visual cue | Meaning | -| --- | --- | -| Blue row text | Keyboard focus, including the skill name and wrapped description lines. | -| Blue `X` | Checked item: install in the install picker, or remove in the uninstall picker. Each picker does one thing, so a checked row looks the same in both, and the title and summary say what a check does. | -| Normal name/description text | The item is not focused. Selection does not change the color of its name or its brackets. | -| `>` and `[X]` when color is disabled | Focus and checked state, with no other marker and no color legend. `NO_COLOR` is honored. | - -| Key | Behavior | -| --- | --- | -| Up / Down | Move between skills. Wrap at the beginning or the end. | -| Left / Right or PageUp / PageDown | Move between pages. | -| Home / End | Go to the first/last skill. | -| Space | Toggle the focused skill. | -| A / C | Select all / clear all, across all pages. | -| Ctrl+Up / Ctrl+Down | Scroll an oversized description. | -| Enter | Accept. With `--dry-run`, report only. | -| Esc / Q / Ctrl+C | Cancel without changing destination skills. | - -Every keyboard-help line begins with `Press`. Paging/scrolling hints appear only when applicable. An unusably small terminal, at startup or after a resize, fails with exit code `1` and guidance to enlarge it. Destination skills are unchanged, but the in-memory selection must be made again. If ownership changes while a picker is open, acceptance also fails without applying the stale choice. - -Descriptions are read from YAML frontmatter in `SKILL.md`. Missing descriptions show `No description provided.` Invalid or unreadable descriptive metadata produces a visible warning, but does not prevent selecting the skill. Descriptions are not rewritten, executed, or added to reports or the ownership manifest. - -Cancellation output: - -```text -Cancelled. Nothing was copied or removed. -``` - -## 6. Remove installed skills: `uninstall` - -```powershell -dotnet-package-skills uninstall -``` - -This removes all skills tracked by the destination's ownership manifest. It does not remove NuGet packages or hand-written skills, and does not require a solution or the NuGet cache. - -Sample output: - -```text -Destination: C:\src\MyApp\.agents\skills - -Removed 2 skills: - contoso.widgets-widget-testing (contoso.widgets 2.3.0) - contoso.widgets-widget-usage (contoso.widgets 2.3.0) -``` - -Removal reports name each skill's package by the lowercase ID the manifest records. - -Common variants: - -```powershell -dotnet-package-skills uninstall --package Contoso.Widgets -dotnet-package-skills uninstall --package Contoso.Widgets@2.3.0 -dotnet-package-skills uninstall --destination .claude\skills -dotnet-package-skills uninstall --dry-run -dotnet-package-skills uninstall --interactive -dotnet-package-skills uninstall --stale -``` - -A bare package ID matches the package's tracked skills. `ID@VERSION` matches them only if that version is the one installed. The manifest records one version for each package, so there is never more than one version to choose from. Both modes use the same case-insensitive package matching and the same normalized version comparison. For example, `1.10` matches `1.10.0`. An explicitly blank, whitespace-only, or missing filter value is an error. It never means an instruction to remove everything. `uninstall` accepts the package option only once. It rejects a repeated occurrence or an alias, even when the final value is absent. Only a completely omitted `--package` means no filter. A dry run reports `Would remove`, and it leaves the files in place. - -The interactive picker lists only manifest-owned skills and reads descriptions from their installed copies. Nothing starts checked. A check means removal, not retention: - -```text -Which skills should be uninstalled? - -> [X] contoso.widgets-widget-testing - Testing patterns for code that uses - Contoso.Widgets. Use when writing unit or integration tests involving - widgets. - [ ] contoso.widgets-widget-usage - Correct usage patterns for the - Contoso.Widgets library, including lifetime rules and the batching API. - Use whenever code creates, configures, or disposes a Widget. - -1 of 2 selected; 1 to remove -(Press to select, to accept) -(Press / to move, / for first/last) -(Press to select all, to clear all, // to cancel) -Blue X: selected -``` - -Accepting removes only the checked skill. If ownership changes while the picker is open or while waiting for another operation, acceptance fails without applying that stale selection. A missing installed `SKILL.md` does not prevent removing its tracked folder. With no tracked skills, the command succeeds without opening a picker: - -```text -Destination: C:\src\MyApp\.agents\skills - -Nothing to remove. No skills installed by this tool were found there. -``` - -### Remove stale skills: `uninstall --stale` - -`install` never removes the skills of a package that left the project. `uninstall --stale` removes every stale skill: a tracked skill whose package the target no longer references, or references at a different version than the manifest records. - -```powershell -dotnet-package-skills uninstall --stale --dry-run -dotnet-package-skills uninstall --stale -dotnet-package-skills uninstall --stale --target C:\src\MyApp\MyApp.slnx --interactive -``` - -Sample output: - -```text -Target: C:\src\MyApp\MyApp.slnx -Destination: C:\src\MyApp\.agents\skills - -Removed 2 skills: - fabrikam.testing-fakes (fabrikam.testing 1.4.0) - fabrikam.testing-fixtures (fabrikam.testing 1.4.0) -``` - -With nothing stale, the command succeeds and says so: - -```text -Target: C:\src\MyApp\MyApp.slnx -Destination: C:\src\MyApp\.agents\skills - -Nothing to remove. No stale skills were found. -``` - -- `--stale` reads the target's package references, so it requires a solution or project. The tool finds this target the same way as in section 3, or you name it with `--target`. Without a target, the command fails with `No solution or project found under ...`. Like `install` and `list`, `--stale` runs `dotnet list package`. The .NET SDK can restore the target during that step. When the step fails, the command stops, and it shows what it reported. -- `--stale` needs only the target's package references, not the packages themselves. Because of this, it does not look in the NuGet cache for skills. -- It works when the target resolves more than one version of a package: a skill is stale only if no project references its installed version. -- With `--interactive`, only stale skills are listed, under the note `Only skills that don't match the target are listed.` -- `--stale` cannot be combined with `--package`. `--target` is accepted by `uninstall` only together with `--stale`. - -## 7. Complete option reference - -| Option | Applies to | Contract | -| --- | --- | --- | -| `-t, --target ` | `list` and `install` always. `uninstall` only with `--stale`. | Solution, project, or directory to search. Supported files: `.slnx`, `.sln`, `.csproj`, `.fsproj`, `.vbproj`. Defaults to discovery from the current directory. | -| `-p, --package ` | `list`, `install` | Exact package coordinates instead of a target. Repeatable, with one version for each package. The tool removes a duplicate when you repeat an equivalent coordinate. The tool rejects a floating version and a version range. | -| `-p, --package ` | `uninstall` | One occurrence of a nonempty package filter, optionally restricted to a normalized version. Same matching in both modes. | -| `-d, --destination ` | All three | The skills destination. Default `.agents\skills`. `uninstall` must use the same destination that installation used. | -| `--global-packages ` | `list`, `install` | Existing extracted-package cache to use. Overrides `NUGET_PACKAGES` and the NuGet-configured cache. | -| `--dry-run` | `install`, `uninstall` | Report planned destination changes without applying them. | -| `-i, --interactive` | `install`, `uninstall` | Opens the paginated picker. On `install`, the picker lists only skills that are not installed, and accepting only adds them. On `uninstall`, accepting removes the checked skills. This option needs a terminal when there are rows to choose from. | -| `--stale` | `uninstall` | Remove only stale skills: those whose package the target no longer references, or references at a different version. Requires a solution or project. | -| `-?, -h, --help` | Root and all three | Display usage and supported options. | -| `--version` | Root | Display version information. | - -**Combination rules:** You cannot combine `--target` with `--package`. You cannot combine `--stale` with `--package`, and `uninstall` accepts `--target` only together with `--stale`. You can combine interactive selection with `--dry-run`, with a package filter, and with `--stale`. `list` has neither `--interactive` nor `--dry-run`. No command has a JSON output option or a restore option. The tool rejects `--json` and `--no-restore` as unrecognized arguments. - -**Path rule:** The tool bases a relative destination on the invocation directory. It does not automatically base the destination on the directory that contains `--target`. The tool resolves a relative `--global-packages` override from the target's directory in target mode, and from the invocation directory in named-package mode. That override selects the cache that the tool reads from. It does not reconfigure NuGet restore. - -## 8. Ownership manifest - -The destination's `.dotnet-package-skills.json` records what the tool installed. It is the tool's only machine-readable output, so its format is a public contract, guarded by its format `version`. Its shape follows the .NET local tool manifest, `dotnet-tools.json`: - -```json -{ - "version": 1, - "packages": { - "contoso.widgets": { - "version": "2.3.0", - "skills": [ - "contoso.widgets-widget-testing", - "contoso.widgets-widget-usage" - ] - } - } -} -``` - -| Element | Required | Contract | -| --- | --- | --- | -| `version` | Yes | Format version, a whole number. This release reads and writes `1`. | -| `packages` | Yes | An object keyed by package ID. The tool writes a lowercase ID. The tool matches an ID without regard to case, so two keys that differ only in case are invalid. | -| `packages..version` | Yes | The one package version the skills were installed from, normalized as NuGet does (`1.10` is written `1.10.0`). | -| `packages..skills` | Yes | Skill folder names directly under the destination that this package owns. Each name is claimed once across the whole manifest. | - -Writing rules: The tool uses UTF-8 encoding without a byte order mark. It uses two-space indentation. It uses LF line endings, with a final newline, on every platform. It lists packages and skills in a stable order. A repository can commit this file without line-ending differences between a Windows checkout and a Unix checkout. The tool ignores a property that it does not recognize when it reads the file. It does not preserve that property when it rewrites the file. - -Reading rules: - -- A newer format version fails with guidance to update the tool. -- A manifest written by a pre-release build of the tool, which has an `installed` array and no `packages` object, is not converted. The tool asks the user to move the skills folder aside and install again. -- Other damage, such as a merge conflict, a missing `version` or `packages`, an invalid package ID, a package without a version, a duplicate claim, or an unsafe skill name, fails as described in section 9. - -A script must rely on exit codes, and it must read this file to find what is installed. The reports exist for people to read, and their wording can change between releases. - -Human-readable reports and diagnostics, including argument-validation errors and parser suggestions, remove terminal escape sequences and unsafe control characters from metadata, paths, and diagnostic text. This affects presentation only: arguments are validated as supplied, and stored identities remain unchanged. - -## 9. Safety, empty results, and errors - -| Situation | User-visible behavior | -| --- | --- | -| No package ships a discoverable skill | Successful report: `No bundled skills found.` | -| Skills exist but none are accepted | Report `Copied no skills.` or `Would copy no skills.`, with any skipped-item details. | -| Every discovered skill is already installed (`install -i`) | The command prints `Nothing new to install.`, with the explanation or the skipped warning. It opens no checklist. Exit code `0`. | -| A resolved package is absent from the cache | A target-based `install` fails before changes, in every mode. `list` skips it silently. With `--package`, it contributes no skills. `uninstall --stale` is unaffected. | -| Packages resolve to more than one version | Every `install` mode fails before changes, and it names the versions that you need to align. `list` shows each version. `uninstall --stale` still works. | -| A package left the target | `install` keeps its skills and lists them with a pointer to `uninstall --stale`. `install -i` fails until they are removed. | -| A package moved to a new version | `install` refreshes its skills, and it removes the ones that the new version does not ship. `install -i` fails with a target, because the skills are stale, and it fails with `--package`, because another version is installed. | -| A destination name conflicts with another skill, an untracked folder, or a different installed owner | Warn and skip the conflicting copy. Preserve the current owner, including when the run leaves its package out. | -| The owner's new version drops a skill that another package in the run ships | `install` fails before changes and suggests `uninstall --package ` for the owner (section 4). | -| A package filter is explicitly blank or missing its value | Fails. Never broadens a selective uninstall to all tracked skills. | -| `uninstall --stale` finds no solution or project | Fails with `No solution or project found under ...`. Removes nothing. | -| The ownership manifest is absent | Existing folders are not assumed to belong to the tool. | -| The ownership manifest is unreadable, malformed, or unsafe | Fails before it changes any destination skill. Preserves the manifest, and explains how to repair it or restore it. A missing `version` or `packages` property is invalid. A duplicate JSON property, including a case variant, is invalid. An invalid package ID is invalid. A package without a version is invalid. A duplicate case-insensitive skill claim is invalid. An unsafe skill folder name is invalid. The tool rejects a name that ends in a dot or a space, including the name `...`, because Windows can resolve such a name to a different folder or to the destination folder itself. This rule also applies to interactive mode and dry-run mode. `list` remains available. | -| The manifest has a newer format version | Fail before changes and ask the user to update the tool. | -| The manifest was written by a pre-release build | Fail before changes and ask the user to move the skills folder aside and install again. | -| `dotnet list package` fails, for example because the restore it runs fails, or an earlier SDK says the target needs restoring | Stop before changes with exit code `1`, show the problems it reported, and ask the user to resolve them and run the command again. The tool never restores. | -| Invalid option combination, missing target, failed restore, or filesystem error | Report an actionable error and return a non-zero exit code. | - -The tool writes the manifest after a successful installation or removal. It does not write the manifest after `list`, a cancelled picker, or a dry run. Removing the last tracked entry deletes the manifest. The tool deletes the destination folder only if that folder is empty. A hand-written skill keeps the folder alive. The tool reports the removal of a tracking entry even when its skill folder was already deleted. - -Cooperating tool processes serialize reads and changes for a destination. Ownership is loaded and checked inside that critical section, which remains held through copying, removal, and manifest persistence. A busy destination produces retry guidance rather than overlapping mutations. This is local-process coordination, not a distributed filesystem transaction. Equivalent Windows path spellings share that coordination. If a destination alias changes while an operation waits for access, the operation fails before modifying the newly resolved location. - -A successful operation, an empty result, and a cancellation all return exit code `0`. A command failure returns exit code `1`. A warning or a skipped skill can still accompany exit code `0`. Automation must inspect the report when completeness matters. - -Skill metadata is not a security review of the skill's instructions. A developer remains responsible for deciding what their agent can trust. The tool reports an unexpected file system failure, but this version does not provide a transactional rollback of a partially completed copy or removal. Such a failure can leave a copied folder absent from the manifest. Restore a verified backup, or move the affected folders aside, before you retry. Do not delete untracked guidance without first checking what it is. - -## 10. Product review checklist - -| Scenario | Expected customer outcome | -| --- | --- | -| Discover before deciding | `list` shows available skills without installing them. | -| Try the picker safely | `install -i --dry-run` previews a selection without writing skills or a manifest. | -| Make an informed choice | Read descriptions, navigate pages, select skills, and accept. | -| Add a few more skills later | `install -i` lists only skills that are not installed. Accepting adds them, and changes nothing else. With nothing new to add, the command says so without opening a checklist. | -| Resize during selection | Text reflows, and the tool keeps the current selections, while the window stays large enough. The note under the title gives way first. An unusable size fails, and changes no file. | -| Upgrade a package | `install` refreshes its skills and removes the ones the new version dropped. | -| Remove a package from the project | `install` keeps its skills, and it states which command removes them. `uninstall --stale`, with `--dry-run` or `-i`, removes them. | -| Mix package versions in one repository | `install` stops before changes and asks for the versions to be aligned, for example with Central Package Management. | -| Encounter incomplete discovery | A target-based install fails before it copies or removes anything. Restore the target, and retry. | -| Encounter another package's owned name | Preserves the installed owner, and shows a warning. Replacing that owner requires an explicit removal first. | -| Remove selectively | `uninstall -i` offers tracked skills only and removes only checked items. | -| Use package filters in scripts | Blank filters fail, and normalized version matching is identical with and without `-i`. | -| Keep locally authored guidance | Keep guidance in separate, untracked skill folders. | -| Automate reliably | Check exit codes, read the ownership manifest, and run `install` followed by `uninstall --stale`. | -| Commit the skills folder | The manifest has the same bytes on every platform, so a commit does not create line-ending differences. | -| Recover from a damaged ownership record | You receive an explicit error. Repair the preserved manifest, or restore it, before you retry. | diff --git a/dotnet-package-skills/docs/scenarios.md b/dotnet-package-skills/docs/scenarios.md deleted file mode 100644 index ff43efb..0000000 --- a/dotnet-package-skills/docs/scenarios.md +++ /dev/null @@ -1,292 +0,0 @@ -# dotnet-package-skills v1: scenarios and expected behavior - -This document describes how v1 behaves, one scenario at a time. A row marked **Changed in v1** or **New in v1** differs from the preview build at commit `7effb41`. Every other row describes behavior that the preview build already had. - -## 1. The idea - -NuGet packages can ship agent skills: folders that contain a `SKILL.md` file, under the package's `skills/` folder. `dotnet-package-skills` copies those folders into your repository, where coding agents can read them. A manifest in the skills folder records which folders the tool copied, so it can refresh or remove them later without touching anything you wrote yourself. - -| Term | Meaning | -| --- | --- | -| Skill | A folder containing `SKILL.md`, shipped in a package's `skills/` folder. | -| Skills folder | Where the tool copies skills. The default is `.agents/skills` under the current folder. Change it with `--destination`. | -| Manifest | `.dotnet-package-skills.json` in the skills folder. It lists the folders the tool manages, by package and version. | -| Tracked skill | A folder listed in the manifest. The tool may refresh or remove it. | -| Stale skill | A tracked skill that does not match the project. Either the project no longer references its package, or the project uses a different version of that package. | -| Your own skill | Any other folder in the skills folder. The tool never changes or deletes it. | - -The examples use these packages: - -| Package | Ships | -| --- | --- | -| Mockly 1.10.0 | `mockly-usage`, `mockly-migration` | -| Mockly 1.11.0 | `mockly-usage` only. This version dropped `mockly-migration`. | -| Contoso.Widgets 2.3.0 | `contoso.widgets-usage` | -| Newtonsoft.Json 13.0.3 | No skills | - -## 2. Commands and options - -| Command | Use it to | Changes files | -| --- | --- | --- | -| `list` | See which skills your packages ship. | Never. | -| `install` | Copy new skills and bring installed ones up to date. | The skills folder and manifest, unless `--dry-run` is used. | -| `uninstall` | Remove skills that the tool installed. | The skills folder and manifest, unless `--dry-run` is used. | - -| Option | `list` | `install` | `uninstall` | Meaning | -| --- | --- | --- | --- | --- | -| `-t, --target ` | Yes | Yes | With `--stale` | Solution or project to read. Without it, the tool looks for one in the current folder, and then in its subfolders, preferring a solution. Cannot be combined with `--package`. | -| `-p, --package ` | Yes | Yes | | Name packages directly instead of reading a project. Repeatable, with one version per package. | -| `-p, --package ` | | | Yes | Remove only this package's skills, or only if that version is installed. | -| `--stale` | | | Yes | Remove only stale skills. Requires a project or solution: the one passed with `--target`, or the one the tool finds. Cannot be combined with `--package`. **New in v1.** | -| `-d, --destination ` | Yes | Yes | Yes | The skills folder. | -| `-i, --interactive` | | Yes | Yes | Choose skills from a paged checklist. | -| `--dry-run` | | Yes | Yes | Shows what would happen. Changes nothing. | -| `--global-packages ` | Yes | Yes | | Read a different NuGet cache. | - -**Changed in v1:** the `--json` option is removed from every command. Each command reports its results as text only. The `--no-restore` option is removed too: the tool never restores (F3). - -Only a project's direct package references are read. Repositories are expected to manage package versions with [Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management), so that each package resolves to one version. `uninstall` needs neither a project nor the NuGet cache, except that `uninstall --stale` reads the project's package references. It never reads the packages themselves. - -## 3. What each command can change - -Read these tables first. *Refresh* means that the tracked folder is deleted and copied again from the package. Adding `--dry-run` to any row shows the same result without changing anything. - -| Mode | Copies | Refreshes | Removes | Stops without changing anything when | -| --- | --- | --- | --- | --- | -| `install` for the project | Every new skill | Every installed skill whose package the project references, from the version the project uses | Skills that a package's new version no longer ships | Any package resolves to two versions, a package that the project references is not in the NuGet cache, or a package's new version drops a skill whose name another package ships (E6) | -| `install --package X@V` | X's new skills | X's installed skills, from version V | X's skills that version V no longer ships | The same package is named with two versions, or version V drops a skill whose name another named package ships (E6) | -| `install -i` for the project | The new skills you check | Nothing | Nothing | Any package resolves to two versions, a package that the project references is not in the NuGet cache, or any installed skill is stale (run `uninstall --stale` first) | -| `install -i --package X@V` | The new skills from X that you check | Nothing | Nothing | The same package is named with two versions, or X is installed at another version (run `uninstall --package X` first) | - -No install mode removes skills whose package left the project. A plain `install` keeps them and suggests `uninstall --stale`. - -**Changed in v1:** `install` no longer removes skills of packages that left the project. `install --package` now removes the skills that its version no longer ships. `install -i` only adds skills, and stops when installed skills are stale. - -| Mode | Removes | -| --- | --- | -| `uninstall` | Every tracked skill. | -| `uninstall --package Mockly` | Mockly's tracked skills. | -| `uninstall --package Mockly@1.10.0` | Mockly's tracked skills, only if 1.10.0 is the installed version. | -| `uninstall --stale` | Stale skills, including skills added with `install --package` for packages that the project does not reference. **New in v1.** | -| `uninstall -i` | The tracked skills you check. Nothing starts checked. | -| `uninstall -i --stale` | The stale skills you check. Only stale skills are listed, and nothing starts checked. **New in v1.** | - -To move a package's skills to the version that the project now uses, instead of removing them, run a plain `install`. - -## 4. Scenarios - -### A. Getting started - -| # | Situation | You run | What happens | -| --- | --- | --- | --- | -| A1 | The project references Mockly 1.10.0 and Newtonsoft.Json. | `install` | Copies `mockly-usage` and `mockly-migration` into `.agents/skills` and creates the manifest. Newtonsoft.Json is scanned and has nothing to copy. | -| A2 | No referenced package ships skills. | `install` | Reports that no bundled skills were found. Creates no folder and no manifest. | -| A3 | You want to look before copying anything. | `list`, or `install --dry-run` | Shows what would be copied. Nothing changes. | -| A4 | You want only some of the skills. | `install -i` | Opens a paged checklist of the skills that are not installed yet, with their descriptions. The line under the title says that installed skills are not listed. Everything starts unchecked, and the skills you check are copied. **Changed in v1:** the preview build listed installed skills too, checked, and unchecking one removed it. | - -### B. Keeping skills up to date - -| # | Situation | You run | What happens | -| --- | --- | --- | --- | -| B1 | Nothing has changed since the last install. | `install` | Tracked skills are copied again. The files and manifest come out identical, so git shows no changes. | -| B2 | You edited `mockly-usage` by hand. | `install` | Your edits are replaced with the package's copy. Keep your own guidance in your own folders. | -| B3 | You deleted the `mockly-usage` folder by hand. | `install` | `install` copies it again. `install -i` does not list it, because the manifest still tracks it. | -| B4 | Nothing is stale, and you want to add a skill. | `install -i` | Installed skills are left exactly as they are, including any edits. Only the skills you check are copied. **Changed in v1.** | - -### C. A package changes version - -Mockly 1.10.0 is installed with `mockly-usage` and `mockly-migration`, and the project now uses Mockly 1.11.0. - -| # | Situation | You run | What happens | -| --- | --- | --- | --- | -| C1 | 1.11.0 ships the same skills. | `install` | Both skills are refreshed from 1.11.0, and the manifest records 1.11.0. | -| C2 | 1.11.0 also adds `mockly-testing`. | `install` | `mockly-testing` is copied too. | -| C3 | 1.11.0 dropped `mockly-migration`. | `install` | `mockly-usage` is refreshed, and `mockly-migration` is removed. | -| C4 | Same as C3. | `install --package Mockly@1.11.0` | Same as C3. **Changed in v1:** the preview build kept `mockly-migration`, still recorded under 1.10.0. | -| C5 | Any version change. | `install -i` | Stops with exit code 1, because Mockly's installed skills are stale. After `uninstall --stale` removes them, `install -i` lists 1.11.0's skills as new. To move to 1.11.0 in one step instead, run a plain `install`. It also copies any new skills. **Changed in v1.** | -| C6 | Any version change. | `install -i --package Mockly@1.11.0` | Stops with exit code 1, because Mockly 1.10.0 is installed, and asks you to run `uninstall --package Mockly` first. A plain `install --package Mockly@1.11.0` moves the skills to 1.11.0 instead. **Changed in v1.** | -| C7 | Mockly moves to an older version. | Any command | The same rules as an upgrade apply. | - -### D. A package is removed from the project - -Contoso.Widgets was installed, and the project no longer references it. - -| # | Situation | You run | What happens | -| --- | --- | --- | --- | -| D1 | As described. | `install` | `contoso.widgets-usage` is kept. The report says that its package is no longer referenced and suggests `uninstall --stale`. **Changed in v1:** the preview build removed it without asking. | -| D2 | As described. | `install -i` | Stops with exit code 1 and asks you to run `uninstall --stale` first. **Changed in v1:** the preview build listed it, and removed it if you unchecked it. | -| D3 | As described. | `install --package Mockly@1.11.0` | `contoso.widgets-usage` is left alone. | -| D4 | As described. | `uninstall --stale` | Removes `contoso.widgets-usage`. Add `--dry-run` to preview, or `-i` to choose. **New in v1.** | -| D5 | Alpha's skills were added with `install --package Alpha@1.0.0`, and the project does not reference Alpha. | `install`, `install -i`, or `uninstall --stale` | `install` keeps them and prints the hint. `install -i` stops until you remove them. `uninstall --stale` removes them. Use one source per skills folder: the project or `--package`. | - -### E. Conflicts: the tool never overwrites - -| # | Situation | You run | What happens | -| --- | --- | --- | --- | -| E1 | You already have your own `mockly-usage` folder. | `install` | Mockly's copy is skipped with the reason "the destination folder already exists and is not managed by this tool". Your folder is untouched. | -| E2 | Packages Alpha and Beta both ship a skill named `shared`. | `install` | The first package in a fixed order gets it: alphabetical by package ID for a project, or command-line order with `--package`. The other package's copy is skipped with a reason. | -| E3 | `shared` is installed from Alpha, and Beta also ships it. | `install` | Alpha keeps it, including when the project no longer references Alpha. Beta's copy is skipped. To switch owners, uninstall Alpha's skill first. | -| E4 | A file named `shared` is in the skills folder. | `install` | The skill is skipped. | -| E5 | Two projects reference different versions of the same package, for example through `VersionOverride`. This applies to every package, including ones that ship no skills, such as Newtonsoft.Json. | `install` in any mode, or `install --package` naming one package with two versions | Stops with exit code 1, changes nothing, and names the package and its versions. Align the versions with Central Package Management, and then try again. `list` still works. **Changed in v1:** the preview build installed from both versions. | -| E6 | `shared` is installed from Alpha 1.0.0. The project moves to Alpha 2.0.0, which does not ship `shared`, and Beta also ships it. | `install`, including `--dry-run` | Stops with exit code 1 and changes nothing. Removing Alpha's copy would hand the name to Beta, and keeping it would record Alpha 1.0.0's copy as 2.0.0's. The error suggests `uninstall --package Alpha`. After that command, `install` copies Alpha 2.0.0's skills and Beta's `shared`. **New in v1.** | - -A skipped skill does not fail the command. The command still exits with code 0. With `install -i`, a skill that cannot install because of a conflict is not listed. The report shows that skill as skipped instead. - -### F. The NuGet cache - -| # | Situation | You run | What happens | -| --- | --- | --- | --- | -| F1 | A package that the project references is not in the NuGet cache that the tool reads, for example because `--global-packages` names a different folder than the one restore uses. | `install` or `install -i`, including with `--dry-run` | Stops with exit code 1, changes nothing, and asks you to run `dotnet restore`. | -| F2 | A package is not in the NuGet cache. | `list`, `install --package`, or `install -i --package` | The tool treats the package like one without skills, with no warning. A version that is not in the cache never causes a removal. With `install --package Mockly@1.11.0` and 1.11.0 missing, Mockly's installed skills stay as they are. **Changed in v1:** the preview build warned that the package was "resolved but not extracted" and listed it in the JSON `notOnDisk` field. | -| F3 | `dotnet list package` fails: the restore that the .NET 10 SDK runs for it fails, or an earlier SDK says the project needs restoring. | `install`, `list`, or `uninstall --stale` | Stops with exit code 1, changes nothing, and shows what `dotnet list package` reported. Restore or fix the project, and then run the command again. The tool never restores. **Changed in v1:** the preview build ran `dotnet restore` itself when the project was not restored, and had a `--no-restore` option to prevent that. | -| F4 | Packages are missing from the NuGet cache. | `uninstall --stale` | Not affected, because it reads only the project's package references. | - -### G. Removing skills - -| # | Situation | You run | What happens | -| --- | --- | --- | --- | -| G1 | Mockly and Contoso.Widgets skills are installed, plus your own `team-notes` folder. | `uninstall` | Removes the tracked skills. `team-notes` stays. The manifest is deleted, and so is the skills folder, if that folder is empty. | -| G2 | Same as G1. | `uninstall --package Mockly` | Removes only Mockly's skills. | -| G3 | Same as G1. | `uninstall -i` | Lists only the tracked skills, none checked. The ones you check are removed. | -| G4 | Nothing is tracked. | `uninstall` | Reports that there is nothing to remove, and exits with code 0. | -| G5 | Same as G1. | `uninstall --dry-run` | Shows what would be removed. Nothing changes. | -| G6 | Mockly 1.10.0 is installed, and the project now uses 1.11.0. | `uninstall --stale` | Removes Mockly's skills, because they do not match the project. To move them to 1.11.0 instead, run a plain `install`. **New in v1.** | -| G7 | Nothing is stale. | `uninstall --stale` | Reports "Nothing to remove. No stale skills were found." and exits with code 0. **New in v1.** | -| G8 | No solution or project exists in the current folder or its subfolders, and you do not give `--target`. | `uninstall --stale` | Stops with exit code 1 and asks for a target. Nothing is removed. **New in v1.** | - -### H. Teams and source control - -| # | Situation | You run | What happens | -| --- | --- | --- | --- | -| H1 | The skills folder and manifest are committed, and a teammate runs `install` with the same packages. | `install` | The same files are produced, so there is no difference. The manifest is sorted and uses LF line endings on every operating system. **Changed in v1:** LF line endings. | -| H2 | A merge leaves conflict markers in the manifest. | `install` or `uninstall` | Stops with exit code 1 and changes nothing until you fix the file. `list` still works. | -| H3 | The manifest says `"version": 2`, written by a newer tool. | `install` or `uninstall` | Stops and asks you to update the tool. **New in v1.** | -| H4 | The manifest uses the pre-release format, with an `installed` list. | `install` or `uninstall` | Stops and asks you to move the skills folder aside and reinstall. **New in v1.** | -| H5 | Two runs use the same skills folder at the same time. | Any command that changes files | The second run waits up to 30 seconds, then stops with "Another operation is using the skills destination … Wait for it to finish and try again." | - -### I. Scripts and CI - -| # | Situation | What happens | -| --- | --- | --- | -| I1 | A script needs to know which skills are installed. | It reads the manifest, which is versioned JSON and the tool's only machine-readable output. | -| I2 | CI runs `install`. | The job checks the exit code. `install` exits with code 0 even when skills are skipped. A skip appears as a warning in the text report. | -| I3 | CI must keep the skills folder exactly in sync with the project. | Run `install`, then `uninstall --stale`, and then `git status` to see what changed. **Changed in v1:** in the preview build, `install` alone did both. | -| I4 | A command fails. | Exit code 1 and an error on standard error. | -| I5 | A script passes `--json` to any command. | Rejected as an unrecognized argument, with exit code 1. **Changed in v1:** the preview build accepted it on every command. | - -The text report is for people, and it is not a stable format for scripts to parse. - -### J. The interactive checklist - -| # | Situation | What happens | -| --- | --- | --- | -| J1 | You open the checklist. | Skills are shown a page at a time, with descriptions. It uses a temporary screen, so it does not stay in your scrollback. | -| J2 | Every skill that the packages ship is already installed. | `install -i` reports "Nothing new to install." and exits with code 0, without opening the checklist. **New in v1.** | -| J3 | The terminal is too small. | The line under the title is dropped first. If the checklist still does not fit, the command exits with code 1 and asks you to enlarge the window. Nothing changes. **New in v1:** the line under the title. | -| J4 | You cancel. | "Cancelled. Nothing was copied or removed." | -| J5 | Another run changes the installed skills while the checklist is open. | Accepting fails, and nothing changes. | - -## 5. How `install` decides - -What `install` checks before it changes anything, and then what happens to each skill that a package offers. `install -i` also stops when installed skills are stale, and applies the per-skill checks only to the skills you check. - -```mermaid -flowchart TD - S["install starts"] --> P{"Does any package resolve to two versions,
or is a package the project references
missing from the NuGet cache?"} - P -->|"yes"| X["The command stops with exit code 1
and changes nothing"] - P -->|"no"| A["For each skill that a package offers"] - A --> C{"Does another package in this run
get the same folder name?"} - C -->|"yes"| S1["Skipped: name conflict"] - C -->|"no"| D{"What is already at that path
in the skills folder?"} - D -->|"a file"| S2["Skipped"] - D -->|"a folder the manifest does not track"| S3["Skipped: your own folder"] - D -->|"a folder tracked for another package"| S4["Skipped: owned by another package"] - D -->|"nothing, or a folder tracked for this package"| OK["Copied or refreshed"] -``` - -What a plain `install` for the project does with each installed skill: - -```mermaid -flowchart TD - T["An installed skill"] --> R{"Does the project reference
its package?"} - R -->|"no"| K["Kept. The report suggests
uninstall --stale"] - R -->|"yes, at the installed version"| F1["Refreshed"] - R -->|"yes, at another version"| S{"Does that version
still ship the skill?"} - S -->|"yes"| F2["Refreshed from that version"] - S -->|"no"| X["Removed"] -``` - -- `install --package X@V` follows this chart for X's skills only, and keeps every other skill. -- `install -i` does not follow this chart. If any installed skill would take the "no" or "another version" branch, it stops and asks you to run `uninstall --stale`. Otherwise, it leaves installed skills as they are. -- An installed skill involved in a conflict is never removed by the same run. If its package moved to a version that no longer ships it, `install` stops instead (E6). A version that is not in the NuGet cache never causes a removal. - -## 6. What the tool writes - -### Manifest - -```json -{ - "version": 1, - "packages": { - "contoso.widgets": { - "version": "2.3.0", - "skills": [ - "contoso.widgets-usage" - ] - }, - "mockly": { - "version": "1.10.0", - "skills": [ - "mockly-migration", - "mockly-usage" - ] - } - } -} -``` - -- The top-level `version` is the file format version, as in `dotnet-tools.json`. The `version` inside each package is the package version. -- Package IDs are written in lowercase, as the .NET SDK does for tool manifests. Each package has one version. -- There's no `isRoot`, because the tool never searches parent folders, and no `note`. -- The file is UTF-8 without a byte order mark, with LF line endings and packages and skills in a stable order. - -The manifest is the only file the tool writes besides the copied skills, and its only machine-readable output. - -## 7. Decisions - -| # | Decision | -| --- | --- | -| 1 | No command has a `--json` option. Passing it fails as an unrecognized argument. | -| 2 | Commands report their results as text only. The manifest, which records what's installed, is the tool's only machine-readable output. | -| 3 | `list` and `install --package` never report missing packages. A project `install`, including `-i`, still stops and asks you to run `dotnet restore`. | -| 4 | The manifest follows the `dotnet-tools.json` layout: a format `version` and a `packages` map keyed by lowercase package ID. | -| 5 | Each package has exactly one version in the manifest. | -| 6 | When a package changes version, `install` and `install --package` refresh its skills and remove the ones that the new version dropped. | -| 7 | `install` never removes skills of packages that left the project. It keeps them and prints a hint. | -| 8 | `uninstall --stale` removes stale skills: skills whose package the project no longer references, or uses at a different version. It requires a project or solution, from `--target` or found by the tool. | -| 9 | `install -i` only adds skills. It lists only skills that are not installed, all unchecked, and leaves installed skills as they are. | -| 10 | A project `install -i` stops with exit code 1 when any installed skill is stale, and asks you to run `uninstall --stale` first. | -| 11 | Repositories are expected to use Central Package Management. `install`, in every mode, never proceeds when it finds two versions of the same package, whether or not that package ships skills. It stops with exit code 1, changes nothing, and names the package and its versions. Only direct references count, because those are all the tool reads. `list` still works. | -| 12 | The manifest is always written with LF line endings, so the file is byte-for-byte identical on every operating system, whatever the repository's git line-ending settings are. | -| 13 | Manifests in the pre-release format are not converted. `install` and `uninstall` stop and ask you to move the skills folder aside. | -| 14 | Package IDs keep NuGet's casing when they come from packages, and are lowercase when they come from the manifest, as in the `uninstall` report and the stale hint. | -| 15 | A version that is not in the NuGet cache never causes a removal. | -| 16 | `install -i --package X@V` stops when X is installed at another version, and asks you to run `uninstall --package X` first. | -| 17 | `uninstall --stale` cannot combine with `--package`, and `uninstall` accepts `--target` only together with `--stale`. | -| 18 | `uninstall --stale` still runs when a package resolves to two versions. A skill counts as stale only if the project does not reference its installed version at all. | -| 19 | `uninstall --stale --dry-run` is the way to see stale skills. There is no machine-readable list of them. | -| 20 | When nothing new is available, `install -i` says so and exits with code 0. | -| 21 | The line under the checklist title gives way when the terminal is too small to fit it, rather than the checklist refusing to open. | -| 22 | When a package moves to a version that no longer ships an installed skill, and another package in the same run ships a skill with that name, `install` stops and suggests `uninstall --package`. It does not hand the name over, and it does not keep the old copy under the new version (E6). | -| 23 | The commands that reports and errors suggest repeat the `--target` and `--destination` of the command that was run, so they can be run as printed. | -| 24 | Package IDs follow NuGet's own rule, which allows letters outside ASCII, on the command line and in the manifest. The tool never writes a manifest that it would refuse to read. | -| 25 | Both checklists draw a checked skill the same way, with a blue X, because each does only one thing: the title and the summary say whether a check installs or removes. There's no separate removal cue, and without color, `[X]` alone marks a checked skill. | -| 26 | The tool never restores, and there is no `--no-restore` option. It runs `dotnet list package` as it is. The .NET 10 SDK restores during that step when it needs to. When `dotnet list package` fails, the tool shows what it reported, and the customer restores or fixes the project and runs the command again. | - -Known consequence: skills added with `install --package` for packages outside the project count as stale for project commands, so a project `install -i` stops until they are removed. - -## 8. See also - -- [README](../README.md) -- [Functional specification](functional-spec.md) -- [`dotnet-package-skills` command reference](dotnet-package-skills.md) From f16ba42efc535b90c504ec00fa4e10420c9a4413 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 12:03:55 -0700 Subject: [PATCH 05/12] refactor(dotnet-package-skills): flatten src/ and tests/ project folders Remove the redundant nested DotnetPackageSkills folder under src/ and DotnetPackageSkills.Tests folder under tests/, since the whole tool is already scoped under the dotnet-package-skills/ folder. - src/DotnetPackageSkills/* -> src/* - tests/DotnetPackageSkills.Tests/* -> tests/* Updated every reference to the old nested paths: - DotnetPackageSkills.slnx project paths - tests/DotnetPackageSkills.Tests.csproj (ProjectReference to the main project, and the linked Get-PackageVersion.ps1 path) - src/DotnetPackageSkills.csproj (packed README.md path) - eng/pipelines/dotnet-package-skills/stage.yml and steps-sign.yml (build/test/pack/sign working paths) - CONTRIBUTING.md, README.md, and the pipeline README (example commands and the folder-layout diagram) Verified: dotnet restore/build/test (807/807 tests x net8.0/net10.0), dotnet pack, and Verify-Package.ps1 all succeed from the new layout. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet-package-skills/CONTRIBUTING.md | 12 ++++++------ dotnet-package-skills/DotnetPackageSkills.slnx | 4 ++-- dotnet-package-skills/README.md | 6 +++--- .../Cli/CommandLineDiagnostics.cs | 0 .../{DotnetPackageSkills => }/Cli/ConsoleViewport.cs | 0 .../src/{DotnetPackageSkills => }/Cli/ITerminal.cs | 0 .../Cli/InteractiveScreen.cs | 0 .../Cli/InteractiveSkills.cs | 0 .../{DotnetPackageSkills => }/Cli/OutputWriter.cs | 0 .../{DotnetPackageSkills => }/Cli/PickerLayout.cs | 0 .../src/{DotnetPackageSkills => }/Cli/SkillPicker.cs | 0 .../{DotnetPackageSkills => }/Cli/TerminalText.cs | 0 .../DotnetPackageSkills.csproj | 2 +- .../Infrastructure/DotnetCli.cs | 0 .../Infrastructure/ProcessRunner.cs | 0 .../NuGet/GlobalPackagesLocator.cs | 0 .../NuGet/PackageCoordinate.cs | 0 .../{DotnetPackageSkills => }/NuGet/PackageLister.cs | 0 .../NuGet/PackagePathResolver.cs | 0 .../{DotnetPackageSkills => }/NuGet/TargetLocator.cs | 0 .../PackageSkillsException.cs | 0 .../src/{DotnetPackageSkills => }/Program.cs | 0 .../{DotnetPackageSkills => }/SkillInstallService.cs | 0 .../{DotnetPackageSkills => }/Skills/BundledSkill.cs | 0 .../Skills/DestinationLock.cs | 0 .../Skills/InstallManifest.cs | 0 .../Skills/SkillDescriptionReader.cs | 0 .../Skills/SkillDiscovery.cs | 0 .../Skills/SkillInstaller.cs | 0 .../CommandLineDiagnosticsTests.cs | 0 .../CommandLineTests.cs | 0 .../DestinationLockTests.cs | 0 .../DotnetPackageSkills.Tests.csproj | 4 ++-- .../{DotnetPackageSkills.Tests => }/FakeTerminal.cs | 0 .../GlobalPackagesLocatorTests.cs | 0 .../InstallManifestTests.cs | 0 .../InteractiveSkillsTests.cs | 0 .../OutputLayoutTests.cs | 0 .../OutputWriterTests.cs | 0 .../PackageCoordinateTests.cs | 0 .../PackageListerTests.cs | 0 .../PackagePathResolverTests.cs | 0 .../PickerLayoutTests.cs | 0 .../PipelineVersionTests.cs | 0 .../SkillDescriptionReaderTests.cs | 0 .../SkillDiscoveryTests.cs | 0 .../SkillInstallServiceTests.cs | 0 .../SkillInstallerTests.cs | 0 .../SkillPickerTests.cs | 0 .../TargetLocatorTests.cs | 0 .../{DotnetPackageSkills.Tests => }/TempDirectory.cs | 0 .../TerminalTextTests.cs | 0 eng/pipelines/dotnet-package-skills/README.md | 2 +- eng/pipelines/dotnet-package-skills/stage.yml | 8 ++++---- eng/pipelines/dotnet-package-skills/steps-sign.yml | 10 +++++----- 55 files changed, 24 insertions(+), 24 deletions(-) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Cli/CommandLineDiagnostics.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Cli/ConsoleViewport.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Cli/ITerminal.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Cli/InteractiveScreen.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Cli/InteractiveSkills.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Cli/OutputWriter.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Cli/PickerLayout.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Cli/SkillPicker.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Cli/TerminalText.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/DotnetPackageSkills.csproj (96%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Infrastructure/DotnetCli.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Infrastructure/ProcessRunner.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/NuGet/GlobalPackagesLocator.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/NuGet/PackageCoordinate.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/NuGet/PackageLister.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/NuGet/PackagePathResolver.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/NuGet/TargetLocator.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/PackageSkillsException.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Program.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/SkillInstallService.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Skills/BundledSkill.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Skills/DestinationLock.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Skills/InstallManifest.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Skills/SkillDescriptionReader.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Skills/SkillDiscovery.cs (100%) rename dotnet-package-skills/src/{DotnetPackageSkills => }/Skills/SkillInstaller.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/CommandLineDiagnosticsTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/CommandLineTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/DestinationLockTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/DotnetPackageSkills.Tests.csproj (80%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/FakeTerminal.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/GlobalPackagesLocatorTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/InstallManifestTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/InteractiveSkillsTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/OutputLayoutTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/OutputWriterTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/PackageCoordinateTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/PackageListerTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/PackagePathResolverTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/PickerLayoutTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/PipelineVersionTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/SkillDescriptionReaderTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/SkillDiscoveryTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/SkillInstallServiceTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/SkillInstallerTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/SkillPickerTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/TargetLocatorTests.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/TempDirectory.cs (100%) rename dotnet-package-skills/tests/{DotnetPackageSkills.Tests => }/TerminalTextTests.cs (100%) diff --git a/dotnet-package-skills/CONTRIBUTING.md b/dotnet-package-skills/CONTRIBUTING.md index 4cc1262..71297f3 100644 --- a/dotnet-package-skills/CONTRIBUTING.md +++ b/dotnet-package-skills/CONTRIBUTING.md @@ -14,23 +14,23 @@ git clone https://github.com/NuGet/Client.Tools.git Set-Location .\Client.Tools\dotnet-package-skills dotnet restore .\DotnetPackageSkills.slnx --configfile .\NuGet.config dotnet build .\DotnetPackageSkills.slnx -c Release --no-restore -dotnet test .\tests\DotnetPackageSkills.Tests\DotnetPackageSkills.Tests.csproj -c Release --no-build --no-restore +dotnet test .\tests\DotnetPackageSkills.Tests.csproj -c Release --no-build --no-restore ``` Try your build against a real repository without installing it: ```bash -dotnet run --project src/DotnetPackageSkills -f net10.0 -- list --target /path/to/YourApp.sln +dotnet run --project src -f net10.0 -- list --target /path/to/YourApp.sln ``` Pack and verify your build without replacing a globally installed tool: ```powershell -dotnet pack .\src\DotnetPackageSkills\DotnetPackageSkills.csproj -c Release --no-build --no-restore -o .\artifacts\packages +dotnet pack .\src\DotnetPackageSkills.csproj -c Release --no-build --no-restore -o .\artifacts\packages pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 ` -PackagePath .\artifacts\packages\dotnet-package-skills.0.1.0-dev.nupkg ` -ExpectedVersion 0.1.0-dev ` - -BuildOutputPath .\src\DotnetPackageSkills\bin\Release + -BuildOutputPath .\src\bin\Release ``` ## Origin @@ -49,7 +49,7 @@ example when its function moves into the .NET SDK. ## Layout ``` -src/DotnetPackageSkills/ +src/ ├── Program.cs CLI surface: commands, options, exit codes ├── SkillInstallService.cs Orchestration. This is the only file that puts the steps in order. ├── Cli/OutputWriter.cs Writes reports for people to read @@ -60,7 +60,7 @@ src/DotnetPackageSkills/ ├── NuGet/ Target detection, package listing, cache path resolution └── Skills/ Discovery, copying, version-change removal, the install manifest -tests/DotnetPackageSkills.Tests/ xunit tests. Application tests use in-process fakes. +tests/ xunit tests. Application tests use in-process fakes. samples/Contoso.Widgets/ An example of a package that ships a skill ``` diff --git a/dotnet-package-skills/DotnetPackageSkills.slnx b/dotnet-package-skills/DotnetPackageSkills.slnx index c4b0f84..5ef6c89 100644 --- a/dotnet-package-skills/DotnetPackageSkills.slnx +++ b/dotnet-package-skills/DotnetPackageSkills.slnx @@ -3,9 +3,9 @@ - + - + diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md index 430b6bd..62bed16 100644 --- a/dotnet-package-skills/README.md +++ b/dotnet-package-skills/README.md @@ -598,12 +598,12 @@ commands on Windows only. Set-Location .\dotnet-package-skills dotnet restore .\DotnetPackageSkills.slnx --configfile .\NuGet.config dotnet build .\DotnetPackageSkills.slnx -c Release --no-restore -dotnet test .\tests\DotnetPackageSkills.Tests\DotnetPackageSkills.Tests.csproj -c Release --no-build --no-restore -dotnet pack .\src\DotnetPackageSkills\DotnetPackageSkills.csproj -c Release --no-build --no-restore -o .\artifacts\packages +dotnet test .\tests\DotnetPackageSkills.Tests.csproj -c Release --no-build --no-restore +dotnet pack .\src\DotnetPackageSkills.csproj -c Release --no-build --no-restore -o .\artifacts\packages pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 ` -PackagePath .\artifacts\packages\dotnet-package-skills.0.1.0-dev.nupkg ` -ExpectedVersion 0.1.0-dev ` - -BuildOutputPath .\src\DotnetPackageSkills\bin\Release + -BuildOutputPath .\src\bin\Release ``` This verification command installs the exact local package into a temporary tool path, once for diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/CommandLineDiagnostics.cs b/dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Cli/CommandLineDiagnostics.cs rename to dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/ConsoleViewport.cs b/dotnet-package-skills/src/Cli/ConsoleViewport.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Cli/ConsoleViewport.cs rename to dotnet-package-skills/src/Cli/ConsoleViewport.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/ITerminal.cs b/dotnet-package-skills/src/Cli/ITerminal.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Cli/ITerminal.cs rename to dotnet-package-skills/src/Cli/ITerminal.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveScreen.cs b/dotnet-package-skills/src/Cli/InteractiveScreen.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveScreen.cs rename to dotnet-package-skills/src/Cli/InteractiveScreen.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveSkills.cs b/dotnet-package-skills/src/Cli/InteractiveSkills.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Cli/InteractiveSkills.cs rename to dotnet-package-skills/src/Cli/InteractiveSkills.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/OutputWriter.cs b/dotnet-package-skills/src/Cli/OutputWriter.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Cli/OutputWriter.cs rename to dotnet-package-skills/src/Cli/OutputWriter.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/PickerLayout.cs b/dotnet-package-skills/src/Cli/PickerLayout.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Cli/PickerLayout.cs rename to dotnet-package-skills/src/Cli/PickerLayout.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/SkillPicker.cs b/dotnet-package-skills/src/Cli/SkillPicker.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Cli/SkillPicker.cs rename to dotnet-package-skills/src/Cli/SkillPicker.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Cli/TerminalText.cs b/dotnet-package-skills/src/Cli/TerminalText.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Cli/TerminalText.cs rename to dotnet-package-skills/src/Cli/TerminalText.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/DotnetPackageSkills.csproj b/dotnet-package-skills/src/DotnetPackageSkills.csproj similarity index 96% rename from dotnet-package-skills/src/DotnetPackageSkills/DotnetPackageSkills.csproj rename to dotnet-package-skills/src/DotnetPackageSkills.csproj index bdcb1b4..9ca917e 100644 --- a/dotnet-package-skills/src/DotnetPackageSkills/DotnetPackageSkills.csproj +++ b/dotnet-package-skills/src/DotnetPackageSkills.csproj @@ -37,7 +37,7 @@ - + diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/DotnetCli.cs b/dotnet-package-skills/src/Infrastructure/DotnetCli.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/DotnetCli.cs rename to dotnet-package-skills/src/Infrastructure/DotnetCli.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/ProcessRunner.cs b/dotnet-package-skills/src/Infrastructure/ProcessRunner.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Infrastructure/ProcessRunner.cs rename to dotnet-package-skills/src/Infrastructure/ProcessRunner.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/NuGet/GlobalPackagesLocator.cs b/dotnet-package-skills/src/NuGet/GlobalPackagesLocator.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/NuGet/GlobalPackagesLocator.cs rename to dotnet-package-skills/src/NuGet/GlobalPackagesLocator.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageCoordinate.cs b/dotnet-package-skills/src/NuGet/PackageCoordinate.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageCoordinate.cs rename to dotnet-package-skills/src/NuGet/PackageCoordinate.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageLister.cs b/dotnet-package-skills/src/NuGet/PackageLister.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackageLister.cs rename to dotnet-package-skills/src/NuGet/PackageLister.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackagePathResolver.cs b/dotnet-package-skills/src/NuGet/PackagePathResolver.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/NuGet/PackagePathResolver.cs rename to dotnet-package-skills/src/NuGet/PackagePathResolver.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/NuGet/TargetLocator.cs b/dotnet-package-skills/src/NuGet/TargetLocator.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/NuGet/TargetLocator.cs rename to dotnet-package-skills/src/NuGet/TargetLocator.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/PackageSkillsException.cs b/dotnet-package-skills/src/PackageSkillsException.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/PackageSkillsException.cs rename to dotnet-package-skills/src/PackageSkillsException.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Program.cs b/dotnet-package-skills/src/Program.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Program.cs rename to dotnet-package-skills/src/Program.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/SkillInstallService.cs b/dotnet-package-skills/src/SkillInstallService.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/SkillInstallService.cs rename to dotnet-package-skills/src/SkillInstallService.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/BundledSkill.cs b/dotnet-package-skills/src/Skills/BundledSkill.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Skills/BundledSkill.cs rename to dotnet-package-skills/src/Skills/BundledSkill.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/DestinationLock.cs b/dotnet-package-skills/src/Skills/DestinationLock.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Skills/DestinationLock.cs rename to dotnet-package-skills/src/Skills/DestinationLock.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/InstallManifest.cs b/dotnet-package-skills/src/Skills/InstallManifest.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Skills/InstallManifest.cs rename to dotnet-package-skills/src/Skills/InstallManifest.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDescriptionReader.cs b/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDescriptionReader.cs rename to dotnet-package-skills/src/Skills/SkillDescriptionReader.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDiscovery.cs b/dotnet-package-skills/src/Skills/SkillDiscovery.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillDiscovery.cs rename to dotnet-package-skills/src/Skills/SkillDiscovery.cs diff --git a/dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillInstaller.cs b/dotnet-package-skills/src/Skills/SkillInstaller.cs similarity index 100% rename from dotnet-package-skills/src/DotnetPackageSkills/Skills/SkillInstaller.cs rename to dotnet-package-skills/src/Skills/SkillInstaller.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineDiagnosticsTests.cs b/dotnet-package-skills/tests/CommandLineDiagnosticsTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineDiagnosticsTests.cs rename to dotnet-package-skills/tests/CommandLineDiagnosticsTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineTests.cs b/dotnet-package-skills/tests/CommandLineTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/CommandLineTests.cs rename to dotnet-package-skills/tests/CommandLineTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/DestinationLockTests.cs b/dotnet-package-skills/tests/DestinationLockTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/DestinationLockTests.cs rename to dotnet-package-skills/tests/DestinationLockTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/DotnetPackageSkills.Tests.csproj b/dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj similarity index 80% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/DotnetPackageSkills.Tests.csproj rename to dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj index f67a172..fb15040 100644 --- a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/DotnetPackageSkills.Tests.csproj +++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj @@ -19,8 +19,8 @@ - - + diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/FakeTerminal.cs b/dotnet-package-skills/tests/FakeTerminal.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/FakeTerminal.cs rename to dotnet-package-skills/tests/FakeTerminal.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/GlobalPackagesLocatorTests.cs b/dotnet-package-skills/tests/GlobalPackagesLocatorTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/GlobalPackagesLocatorTests.cs rename to dotnet-package-skills/tests/GlobalPackagesLocatorTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/InstallManifestTests.cs b/dotnet-package-skills/tests/InstallManifestTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/InstallManifestTests.cs rename to dotnet-package-skills/tests/InstallManifestTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/InteractiveSkillsTests.cs b/dotnet-package-skills/tests/InteractiveSkillsTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/InteractiveSkillsTests.cs rename to dotnet-package-skills/tests/InteractiveSkillsTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputLayoutTests.cs b/dotnet-package-skills/tests/OutputLayoutTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputLayoutTests.cs rename to dotnet-package-skills/tests/OutputLayoutTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputWriterTests.cs b/dotnet-package-skills/tests/OutputWriterTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/OutputWriterTests.cs rename to dotnet-package-skills/tests/OutputWriterTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageCoordinateTests.cs b/dotnet-package-skills/tests/PackageCoordinateTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageCoordinateTests.cs rename to dotnet-package-skills/tests/PackageCoordinateTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageListerTests.cs b/dotnet-package-skills/tests/PackageListerTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackageListerTests.cs rename to dotnet-package-skills/tests/PackageListerTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackagePathResolverTests.cs b/dotnet-package-skills/tests/PackagePathResolverTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/PackagePathResolverTests.cs rename to dotnet-package-skills/tests/PackagePathResolverTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PickerLayoutTests.cs b/dotnet-package-skills/tests/PickerLayoutTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/PickerLayoutTests.cs rename to dotnet-package-skills/tests/PickerLayoutTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/PipelineVersionTests.cs b/dotnet-package-skills/tests/PipelineVersionTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/PipelineVersionTests.cs rename to dotnet-package-skills/tests/PipelineVersionTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDescriptionReaderTests.cs b/dotnet-package-skills/tests/SkillDescriptionReaderTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDescriptionReaderTests.cs rename to dotnet-package-skills/tests/SkillDescriptionReaderTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDiscoveryTests.cs b/dotnet-package-skills/tests/SkillDiscoveryTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillDiscoveryTests.cs rename to dotnet-package-skills/tests/SkillDiscoveryTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallServiceTests.cs b/dotnet-package-skills/tests/SkillInstallServiceTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallServiceTests.cs rename to dotnet-package-skills/tests/SkillInstallServiceTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallerTests.cs b/dotnet-package-skills/tests/SkillInstallerTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillInstallerTests.cs rename to dotnet-package-skills/tests/SkillInstallerTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillPickerTests.cs b/dotnet-package-skills/tests/SkillPickerTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/SkillPickerTests.cs rename to dotnet-package-skills/tests/SkillPickerTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TargetLocatorTests.cs b/dotnet-package-skills/tests/TargetLocatorTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/TargetLocatorTests.cs rename to dotnet-package-skills/tests/TargetLocatorTests.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TempDirectory.cs b/dotnet-package-skills/tests/TempDirectory.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/TempDirectory.cs rename to dotnet-package-skills/tests/TempDirectory.cs diff --git a/dotnet-package-skills/tests/DotnetPackageSkills.Tests/TerminalTextTests.cs b/dotnet-package-skills/tests/TerminalTextTests.cs similarity index 100% rename from dotnet-package-skills/tests/DotnetPackageSkills.Tests/TerminalTextTests.cs rename to dotnet-package-skills/tests/TerminalTextTests.cs diff --git a/eng/pipelines/dotnet-package-skills/README.md b/eng/pipelines/dotnet-package-skills/README.md index 82f41ec..fae4a0c 100644 --- a/eng/pipelines/dotnet-package-skills/README.md +++ b/eng/pipelines/dotnet-package-skills/README.md @@ -121,7 +121,7 @@ From the tool source folder, after building and packing: pwsh -NoProfile -File ..\eng\pipelines\dotnet-package-skills\Verify-Package.ps1 ` -PackagePath .\artifacts\packages\dotnet-package-skills.0.1.0-dev.nupkg ` -ExpectedVersion 0.1.0-dev ` - -BuildOutputPath .\src\DotnetPackageSkills\bin\Release + -BuildOutputPath .\src\bin\Release ``` Use the actual package version when verifying CI artifacts. The helper uses a local-only diff --git a/eng/pipelines/dotnet-package-skills/stage.yml b/eng/pipelines/dotnet-package-skills/stage.yml index bd615ab..ccbc6d9 100644 --- a/eng/pipelines/dotnet-package-skills/stage.yml +++ b/eng/pipelines/dotnet-package-skills/stage.yml @@ -112,7 +112,7 @@ stages: version: 8.0.x - pwsh: | - $baseVersion = dotnet msbuild .\src\DotnetPackageSkills\DotnetPackageSkills.csproj -nologo -getProperty:VersionPrefix + $baseVersion = dotnet msbuild .\src\DotnetPackageSkills.csproj -nologo -getProperty:VersionPrefix if ($LASTEXITCODE -ne 0) { throw 'Could not evaluate the tool base version.' } $official = [bool]::Parse('${{ parameters.isOfficialBuild }}') $release = [bool]::Parse('${{ parameters.releaseBuild }}') @@ -144,7 +144,7 @@ stages: workingDirectory: $(DotnetPackageSkillsDirectory) - pwsh: | - dotnet test .\tests\DotnetPackageSkills.Tests\DotnetPackageSkills.Tests.csproj ` + dotnet test .\tests\DotnetPackageSkills.Tests.csproj ` --configuration Release --no-build --no-restore ` "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` --logger "trx;LogFilePrefix=dotnet-package-skills" ` @@ -170,7 +170,7 @@ stages: target: assemblies - pwsh: | - dotnet pack .\src\DotnetPackageSkills\DotnetPackageSkills.csproj ` + dotnet pack .\src\DotnetPackageSkills.csproj ` --configuration Release --no-build --no-restore ` "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` "-p:SourceRevisionId=$(Build.SourceVersion)" ` @@ -190,7 +190,7 @@ stages: & "$(DotnetPackageSkillsPipelineDirectory)\Verify-Package.ps1" ` -PackagePath "$(DotnetPackageSkillsArtifacts)\packages\dotnet-package-skills.$(DotnetPackageSkillsVersion).nupkg" ` -ExpectedVersion "$(DotnetPackageSkillsVersion)" ` - -BuildOutputPath "$(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\bin\Release" ` + -BuildOutputPath "$(DotnetPackageSkillsDirectory)\src\bin\Release" ` -RequireSigned:$signed displayName: Verify and install the exact package on both runtimes workingDirectory: $(DotnetPackageSkillsDirectory) diff --git a/eng/pipelines/dotnet-package-skills/steps-sign.yml b/eng/pipelines/dotnet-package-skills/steps-sign.yml index 77debc1..6b92d01 100644 --- a/eng/pipelines/dotnet-package-skills/steps-sign.yml +++ b/eng/pipelines/dotnet-package-skills/steps-sign.yml @@ -9,9 +9,9 @@ steps: - ${{ if eq(parameters.target, 'assemblies') }}: - pwsh: | foreach ($framework in @('net8.0', 'net10.0')) { - $assembly = Get-Item -LiteralPath "$(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\obj\Release\$framework\dotnet-package-skills.dll" + $assembly = Get-Item -LiteralPath "$(DotnetPackageSkillsDirectory)\src\obj\Release\$framework\dotnet-package-skills.dll" if ($assembly.Length -eq 0) { throw "Empty signing input for $framework." } - $built = "$(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\bin\Release\$framework\dotnet-package-skills.dll" + $built = "$(DotnetPackageSkillsDirectory)\src\bin\Release\$framework\dotnet-package-skills.dll" if ((Get-FileHash -LiteralPath $assembly.FullName).Hash -cne (Get-FileHash -LiteralPath $built).Hash) { throw "The $framework pack input does not match the tested build output." } @@ -28,7 +28,7 @@ steps: AuthSignCertName: $(DotnetPackageSkillsEsrpRequestSigningCertificate) UseMSIAuthentication: true # PackAsTool publishes the intermediate assembly, not the copy in bin. - FolderPath: $(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\obj\Release + FolderPath: $(DotnetPackageSkillsDirectory)\src\obj\Release Pattern: | net8.0/dotnet-package-skills.dll net10.0/dotnet-package-skills.dll @@ -59,12 +59,12 @@ steps: ] - pwsh: | foreach ($framework in @('net8.0', 'net10.0')) { - $assembly = "$(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\obj\Release\$framework\dotnet-package-skills.dll" + $assembly = "$(DotnetPackageSkillsDirectory)\src\obj\Release\$framework\dotnet-package-skills.dll" $signature = Get-AuthenticodeSignature -LiteralPath $assembly if ($signature.Status -ne 'Valid') { throw "The $framework assembly signature is not valid: $($signature.StatusMessage)" } - Copy-Item -LiteralPath $assembly -Destination "$(DotnetPackageSkillsDirectory)\src\DotnetPackageSkills\bin\Release\$framework\dotnet-package-skills.dll" -Force + Copy-Item -LiteralPath $assembly -Destination "$(DotnetPackageSkillsDirectory)\src\bin\Release\$framework\dotnet-package-skills.dll" -Force } displayName: Verify assembly signatures before packing From 0e1f40069e952b060e0a5b417c98bebe5b5e4d85 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 12:17:13 -0700 Subject: [PATCH 06/12] update nuget.config file --- dotnet-package-skills/NuGet.config | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/dotnet-package-skills/NuGet.config b/dotnet-package-skills/NuGet.config index c94f073..bff9791 100644 --- a/dotnet-package-skills/NuGet.config +++ b/dotnet-package-skills/NuGet.config @@ -2,12 +2,6 @@ - + - - - - - - From 957c8fe6cb50b2f6611f1b7ab6192c74b15cbdb1 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 12:17:15 -0700 Subject: [PATCH 07/12] docs(dotnet-package-skills): record dotnet/sign as an alternative considered Researched via eng.ms (ESRP onboarding guidance) and public sources how other teams configure code signing. dotnet/sign (a .NET Foundation CLI tool) offers a much lighter onboarding path by signing against a self-owned Azure Key Vault certificate through workload identity federation, with no ESRP client, OneCert registration, or SAW access needed. Documented why it is not adopted here: Microsoft's internal SFI compliance for this production/official pipeline requires the EsrpCodeSigning task itself to execute, and the resulting artifact needs Microsoft's own code-signing identity, not a self-owned certificate. Kept ESRP (steps-sign.yml) as the only official signing path; no behavior changed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/dotnet-package-skills/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/eng/pipelines/dotnet-package-skills/README.md b/eng/pipelines/dotnet-package-skills/README.md index fae4a0c..fd4a5d3 100644 --- a/eng/pipelines/dotnet-package-skills/README.md +++ b/eng/pipelines/dotnet-package-skills/README.md @@ -107,6 +107,21 @@ Packing must not rebuild or replace those assemblies. The first successful signed run remains an onboarding acceptance gate. Local and public PR validation alone do not establish that ESRP permissions are configured. +### Alternative considered: `dotnet/sign` + +[`dotnet/sign`](https://github.com/dotnet/sign) is a .NET Foundation CLI tool that signs +`.nupkg`/`.dll`/`.vsix`/ClickOnce files by delegating to an Azure Key Vault certificate, with no +ESRP client, OneCert registration, or SAW access required. A team only needs a managed identity +granted `sign`/`get` on its own Key Vault certificate, behind an ordinary workload-identity-federated +service connection. This is a materially lighter onboarding path than ESRP. + +It is not used here because Microsoft's internal SFI compliance for production/official Azure +Pipelines requires the `EsrpCodeSigning` task itself to execute; a cryptographically valid +signature produced by `dotnet sign` against a self-owned certificate does not satisfy that +requirement, and the resulting artifact would not carry Microsoft's own code-signing identity. +`dotnet/sign` remains worth a second look if this tool's signing requirements ever change, for +example a community-owned fork that signs with its own certificate instead of Microsoft's. + ## Artifacts and local verification Artifacts are named `dotnet-package-skills-packages`, `dotnet-package-skills-testresults`, and From ba8a7eeefbca3fe358ac907fd7b2f3bba359e96f Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 12:17:42 -0700 Subject: [PATCH 08/12] Update README to remove command reference and scenarios Removed references to command reference and scenarios in the README. --- dotnet-package-skills/README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md index 62bed16..77f8e42 100644 --- a/dotnet-package-skills/README.md +++ b/dotnet-package-skills/README.md @@ -3,11 +3,6 @@ This tool copies agent skills from inside NuGet packages into a folder that your coding agent reads. -For a command reference and sample output, see the -[functional specification](docs/functional-spec.md). For the expected result in each situation, -such as a package upgrade or a package that leaves the project, see -[scenarios](docs/scenarios.md). - ## The problem Package authors know their own libraries best. Some package authors now ship an **agent skill** From b8f17e81289b4482f8fd53425cdac86038fee37e Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 12:48:07 -0700 Subject: [PATCH 09/12] delete samples folder --- .../Contoso.Widgets/Contoso.Widgets.csproj | 35 ------------------- .../samples/Contoso.Widgets/Widget.cs | 7 ---- .../contoso.widgets-widget-testing/SKILL.md | 8 ----- .../contoso.widgets-widget-usage/SKILL.md | 11 ------ .../references/batching.md | 3 -- 5 files changed, 64 deletions(-) delete mode 100644 dotnet-package-skills/samples/Contoso.Widgets/Contoso.Widgets.csproj delete mode 100644 dotnet-package-skills/samples/Contoso.Widgets/Widget.cs delete mode 100644 dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-testing/SKILL.md delete mode 100644 dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/SKILL.md delete mode 100644 dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/references/batching.md diff --git a/dotnet-package-skills/samples/Contoso.Widgets/Contoso.Widgets.csproj b/dotnet-package-skills/samples/Contoso.Widgets/Contoso.Widgets.csproj deleted file mode 100644 index a8e8737..0000000 --- a/dotnet-package-skills/samples/Contoso.Widgets/Contoso.Widgets.csproj +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - netstandard2.0 - Contoso.Widgets - 2.3.0 - Sample package that bundles an agent skill. - true - - false - false - - - - - - - - - - - - diff --git a/dotnet-package-skills/samples/Contoso.Widgets/Widget.cs b/dotnet-package-skills/samples/Contoso.Widgets/Widget.cs deleted file mode 100644 index 4e204cf..0000000 --- a/dotnet-package-skills/samples/Contoso.Widgets/Widget.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Contoso.Widgets; - -/// Sample type so the package has some code in it. -public sealed class Widget -{ - public string Name { get; set; } = string.Empty; -} diff --git a/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-testing/SKILL.md b/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-testing/SKILL.md deleted file mode 100644 index 3a9f969..0000000 --- a/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-testing/SKILL.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: contoso.widgets-widget-testing -description: Testing patterns for code that uses Contoso.Widgets. Use when writing unit or integration tests involving widgets. ---- - -# Testing Contoso.Widgets - -Use `WidgetFactory.CreateForTest()` to isolate tests from the production batching pipeline. diff --git a/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/SKILL.md b/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/SKILL.md deleted file mode 100644 index 1ed5674..0000000 --- a/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/SKILL.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: contoso.widgets-widget-usage -description: Correct usage patterns for the Contoso.Widgets library, including lifetime rules and the batching API. Use whenever code creates, configures, or disposes a Widget. ---- - -# Using Contoso.Widgets - -Create widgets through `WidgetFactory`, never with `new Widget()` directly — the -factory is what registers the instance with the batching pipeline. - -See `references/batching.md` for the batching rules. diff --git a/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/references/batching.md b/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/references/batching.md deleted file mode 100644 index d472198..0000000 --- a/dotnet-package-skills/samples/Contoso.Widgets/skills/contoso.widgets-widget-usage/references/batching.md +++ /dev/null @@ -1,3 +0,0 @@ -# Batching rules - -Batches flush at 500 items or 200ms, whichever comes first. From f61470bdfc970a8f69219ccd68f8623a4079e203 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 12:55:59 -0700 Subject: [PATCH 10/12] docs(dotnet-package-skills): document 1ES PT SDL/compliance defaults Answered whether the official pipeline uses 1ES template compliance options, researched via /enghub (SDL Analysis in 1ES Pipeline Templates, TSA support docs). Confirmed from the pipeline files directly: - official.yml extends v1/1ES.Official.PipelineTemplate.yml, passing only pool/stages: no sdl:/settings: block tunes or suppresses any 1ES PT compliance tool. Everything documented is 1ES PT's default behavior for the Official template, not something this repo configured. - No .config/tsaoptions.json exists anywhere in the repo, so TSA (Trust Services Automation) is off. With TSA off, several 1ES PT tools fail the build outright on any finding instead of filing a bug, with no override available. - For this C#/PowerShell/.NET repo, that concretely means BinSkim, Component Governance, and PSScriptAnalyzer (we ship several .ps1 files and inline pwsh steps) can hard-fail the official run on any finding. CodeQL 3000 and 1ES Secret Scanning (SPMI) also run by default but only file findings, they do not break the build. - This SDL gate has never been exercised: the official pipeline has never had a successful run, so it is an unvalidated risk alongside the already-documented ESRP gap. - pr.yml extends no 1ES template at all, so none of this applies to public PR validation. No pipeline behavior changed. Did not fabricate TSA codebase/area-path values, since those belong to whichever team registers this pipeline in Service Tree. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/dotnet-package-skills/README.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/eng/pipelines/dotnet-package-skills/README.md b/eng/pipelines/dotnet-package-skills/README.md index fd4a5d3..b1eb9a2 100644 --- a/eng/pipelines/dotnet-package-skills/README.md +++ b/eng/pipelines/dotnet-package-skills/README.md @@ -107,6 +107,62 @@ Packing must not rebuild or replace those assemblies. The first successful signed run remains an onboarding acceptance gate. Local and public PR validation alone do not establish that ESRP permissions are configured. +## SDL and compliance (1ES Pipeline Templates) + +`official.yml` extends `v1/1ES.Official.PipelineTemplate.yml@1esPipelines`, so Azure DevOps +injects 1ES Pipeline Templates (1ES PT) SDL analysis automatically. This stage passes only +`pool` and `stages` to that template: no `sdl:` or `settings:` block tunes, suppresses, or +disables any 1ES PT compliance tool. Every behavior below is the 1ES PT **default** for a +pipeline extending the Official template, not something this repository configured. + +The repository has no `.config\tsaoptions.json` anywhere, and `official.yml` sets no +`sdl.tsa.enabled`. [Trust Services Automation (TSA)](https://aka.ms/tsa) is therefore off, which +matters because several 1ES PT tools behave differently with TSA on or off: with TSA on, a +finding files an ADO bug and the pipeline keeps going; with TSA off, the same finding fails the +job outright, with no override available. For a C#/PowerShell/.NET tool repository with no +JavaScript, Java, Rust, C/C++, or ARM template content, the tools that actually run are: + +| Tool | Runs by default | Breaks the run today (TSA is off) | +| --- | --- | --- | +| AntiMalware | Yes | Yes, always (no override exists) | +| BinSkim (binary analysis) | Yes, against `dotnet-package-skills.dll` | Yes, on any finding | +| Component Governance | Yes, against every restored NuGet package | Yes, on an unresolved alert | +| PSScriptAnalyzer | Yes, against every `.ps1` file and inline `pwsh` step | Yes, on any finding | +| CodeQL 3000 | Yes, source analysis | No, it only files findings | +| 1ES Secret Scanning (SPMI) | Yes | No, it only files findings | +| CredScan, PoliCheck, Bandit, Roslyn Analyzers, ESLint, SpotBugs, Armory, AccessibilityInsights, ApiScan | No (off by default, or not applicable to this stack) | N/A | + +This stage ships `Get-PackageVersion.ps1`, `Verify-Package.ps1`, and several inline `pwsh` build +steps, so PSScriptAnalyzer is the most likely of these to surface a real finding. BinSkim and +Component Governance are untested here too: the official pipeline has never had a successful +run (see above), so this SDL gate is unvalidated in addition to the ESRP gap. Budget for the +first real run to fail on a 1ES PT finding independently of ESRP configuration. + +A pipeline owner can change this balance by enabling TSA so these tools file bugs instead of +failing the build: + +```yaml +extends: + template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines + parameters: + sdl: + tsa: + enabled: true + config: + # Real codebase name, area path, and notification aliases from the owning + # Service Tree entry. Do not invent placeholder values here. +``` + +This repository does not set these values because they belong to whichever team registers this +pipeline in Service Tree, not to the tool itself. See [SDL Analysis in 1ES Pipeline +Templates](https://eng.ms/docs/coreai/devdiv/one-engineering-system-1es/1es-docs/1es-pipeline-templates/features/sdlanalysis/overview) +and [TSA support in 1ES PT](https://eng.ms/docs/coreai/devdiv/one-engineering-system-1es/1es-docs/1es-pipeline-templates/features/sdlanalysis/tsasupport) +for the full tool matrix and TSA onboarding steps. + +`pr.yml` extends no 1ES template at all (`dnceng-public/public` has no access to +`1ESPipelineTemplates`), so none of this SDL analysis runs against public pull requests. It +applies only to the trusted internal/official pipeline. + ### Alternative considered: `dotnet/sign` [`dotnet/sign`](https://github.com/dotnet/sign) is a .NET Foundation CLI tool that signs From 7b6ef1e4c91ab32babaa94f872558194a352c131 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 12:58:36 -0700 Subject: [PATCH 11/12] fix(dotnet-package-skills): clean up dangling samples/ references You deleted samples/Contoso.Widgets directly on the branch. Fixed the resulting dangling references so restore/build/test work again: - DotnetPackageSkills.slnx: removed the /samples/ folder and its Contoso.Widgets.csproj project entry (dotnet restore was pointing at a project that no longer exists). - README.md: removed the dangling link to samples/Contoso.Widgets in the 'ship a skill' section; the inline example already stands on its own. - CONTRIBUTING.md: removed the samples/Contoso.Widgets/ row from the layout diagram. - eng/pipelines/dotnet-package-skills/README.md: removed the two stale 'the sample ...' sentences (package-version override note, signing-scope note) and changed 'No test/sample assemblies' to 'No test assemblies'. - eng/pipelines/dotnet-package-skills/stage.yml: renamed the restore step's displayName from 'Restore tool, samples, and tests' to 'Restore tool and tests'. No test ever depended on the sample project: tests and Verify-Package.ps1 build their own synthetic fixtures and only reuse the string 'Contoso.Widgets' as a realistic package ID, as already established in this session. The remaining 'Contoso.Widgets' mentions in README.md are illustrative sample-output text, not file paths, and are unaffected. Verified: dotnet restore/build/test (807/807 tests x net8.0/net10.0) all succeed from the trimmed two-project solution. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- dotnet-package-skills/CONTRIBUTING.md | 1 - dotnet-package-skills/DotnetPackageSkills.slnx | 3 --- dotnet-package-skills/README.md | 2 -- eng/pipelines/dotnet-package-skills/README.md | 5 ++--- eng/pipelines/dotnet-package-skills/stage.yml | 2 +- 5 files changed, 3 insertions(+), 10 deletions(-) diff --git a/dotnet-package-skills/CONTRIBUTING.md b/dotnet-package-skills/CONTRIBUTING.md index 71297f3..85ba9af 100644 --- a/dotnet-package-skills/CONTRIBUTING.md +++ b/dotnet-package-skills/CONTRIBUTING.md @@ -61,7 +61,6 @@ src/ └── Skills/ Discovery, copying, version-change removal, the install manifest tests/ xunit tests. Application tests use in-process fakes. -samples/Contoso.Widgets/ An example of a package that ships a skill ``` ## Rules that must stay true diff --git a/dotnet-package-skills/DotnetPackageSkills.slnx b/dotnet-package-skills/DotnetPackageSkills.slnx index 5ef6c89..1bdf0b9 100644 --- a/dotnet-package-skills/DotnetPackageSkills.slnx +++ b/dotnet-package-skills/DotnetPackageSkills.slnx @@ -1,7 +1,4 @@ - - - diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md index 77f8e42..af74ac4 100644 --- a/dotnet-package-skills/README.md +++ b/dotnet-package-skills/README.md @@ -442,8 +442,6 @@ skills from colliding with another package's skills on the consumer's machine. ``` -[`samples/Contoso.Widgets`](samples/Contoso.Widgets) contains a complete, working example. - Every skill must have its own immediate subfolder under `skills/`. The tool does not discover a lone `skills/SKILL.md` file. diff --git a/eng/pipelines/dotnet-package-skills/README.md b/eng/pipelines/dotnet-package-skills/README.md index b1eb9a2..96057d3 100644 --- a/eng/pipelines/dotnet-package-skills/README.md +++ b/eng/pipelines/dotnet-package-skills/README.md @@ -37,7 +37,6 @@ Within the tool stage: 8. Publish the successful package. Retain test results and diagnostic logs when a step fails. All dotnet commands run from the tool folder so SDK selection honors its scoped `global.json`. -The sample builds with the solution but is not included in the shipping package output. PRs do not require the 1ES template repository or any production credential. ## Package versions @@ -45,7 +44,7 @@ PRs do not require the 1ES template repository or any production credential. The tool project's `VersionPrefix` is the single base-version setting, initially `0.1.0`. Local builds use the `dev` suffix. CI computes a version with `Get-PackageVersion.ps1` and passes `DotnetPackageSkillsVersion` to both build and pack. This override is consumed only by -the tool project; it does not change the sample's `2.3.0` version. +the tool project. | Run | Example | | --- | --- | @@ -97,7 +96,7 @@ single generated tool `.nupkg`. Tool packing publishes the intermediate assembli `obj\Release`, not the copies under `bin\Release`: the signing step first checks that these match the tested build outputs, signs the intermediate assemblies, then copies the verified signed bytes back to `bin` for final payload comparison. Signing only `bin` would allow pack -to replace the signed payload. No test/sample assemblies or third-party dependencies are +to replace the signed payload. No test assemblies or third-party dependencies are signed, and strong-name identities are unchanged. `Verify-Package.ps1 -RequireSigned` requires a NuGet signature, checks `dotnet nuget verify --all`, diff --git a/eng/pipelines/dotnet-package-skills/stage.yml b/eng/pipelines/dotnet-package-skills/stage.yml index ccbc6d9..e4e7159 100644 --- a/eng/pipelines/dotnet-package-skills/stage.yml +++ b/eng/pipelines/dotnet-package-skills/stage.yml @@ -131,7 +131,7 @@ stages: "-p:DotnetPackageSkillsVersion=$(DotnetPackageSkillsVersion)" ` "-bl:$(DotnetPackageSkillsArtifacts)\logs\restore.binlog" if ($LASTEXITCODE -ne 0) { throw 'Restore failed.' } - displayName: Restore tool, samples, and tests + displayName: Restore tool and tests workingDirectory: $(DotnetPackageSkillsDirectory) - pwsh: | From 5d000397970357e2a7d3dfa06472a176e628a016 Mon Sep 17 00:00:00 2001 From: Kartheek Penagamuri Date: Mon, 5 Oct 2026 13:02:49 -0700 Subject: [PATCH 12/12] Revert "docs(dotnet-package-skills): document 1ES PT SDL/compliance defaults" This reverts commit f61470bdfc970a8f69219ccd68f8623a4079e203 per user request. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/dotnet-package-skills/README.md | 56 ------------------- 1 file changed, 56 deletions(-) diff --git a/eng/pipelines/dotnet-package-skills/README.md b/eng/pipelines/dotnet-package-skills/README.md index 96057d3..0aedd4e 100644 --- a/eng/pipelines/dotnet-package-skills/README.md +++ b/eng/pipelines/dotnet-package-skills/README.md @@ -106,62 +106,6 @@ Packing must not rebuild or replace those assemblies. The first successful signed run remains an onboarding acceptance gate. Local and public PR validation alone do not establish that ESRP permissions are configured. -## SDL and compliance (1ES Pipeline Templates) - -`official.yml` extends `v1/1ES.Official.PipelineTemplate.yml@1esPipelines`, so Azure DevOps -injects 1ES Pipeline Templates (1ES PT) SDL analysis automatically. This stage passes only -`pool` and `stages` to that template: no `sdl:` or `settings:` block tunes, suppresses, or -disables any 1ES PT compliance tool. Every behavior below is the 1ES PT **default** for a -pipeline extending the Official template, not something this repository configured. - -The repository has no `.config\tsaoptions.json` anywhere, and `official.yml` sets no -`sdl.tsa.enabled`. [Trust Services Automation (TSA)](https://aka.ms/tsa) is therefore off, which -matters because several 1ES PT tools behave differently with TSA on or off: with TSA on, a -finding files an ADO bug and the pipeline keeps going; with TSA off, the same finding fails the -job outright, with no override available. For a C#/PowerShell/.NET tool repository with no -JavaScript, Java, Rust, C/C++, or ARM template content, the tools that actually run are: - -| Tool | Runs by default | Breaks the run today (TSA is off) | -| --- | --- | --- | -| AntiMalware | Yes | Yes, always (no override exists) | -| BinSkim (binary analysis) | Yes, against `dotnet-package-skills.dll` | Yes, on any finding | -| Component Governance | Yes, against every restored NuGet package | Yes, on an unresolved alert | -| PSScriptAnalyzer | Yes, against every `.ps1` file and inline `pwsh` step | Yes, on any finding | -| CodeQL 3000 | Yes, source analysis | No, it only files findings | -| 1ES Secret Scanning (SPMI) | Yes | No, it only files findings | -| CredScan, PoliCheck, Bandit, Roslyn Analyzers, ESLint, SpotBugs, Armory, AccessibilityInsights, ApiScan | No (off by default, or not applicable to this stack) | N/A | - -This stage ships `Get-PackageVersion.ps1`, `Verify-Package.ps1`, and several inline `pwsh` build -steps, so PSScriptAnalyzer is the most likely of these to surface a real finding. BinSkim and -Component Governance are untested here too: the official pipeline has never had a successful -run (see above), so this SDL gate is unvalidated in addition to the ESRP gap. Budget for the -first real run to fail on a 1ES PT finding independently of ESRP configuration. - -A pipeline owner can change this balance by enabling TSA so these tools file bugs instead of -failing the build: - -```yaml -extends: - template: v1/1ES.Official.PipelineTemplate.yml@1esPipelines - parameters: - sdl: - tsa: - enabled: true - config: - # Real codebase name, area path, and notification aliases from the owning - # Service Tree entry. Do not invent placeholder values here. -``` - -This repository does not set these values because they belong to whichever team registers this -pipeline in Service Tree, not to the tool itself. See [SDL Analysis in 1ES Pipeline -Templates](https://eng.ms/docs/coreai/devdiv/one-engineering-system-1es/1es-docs/1es-pipeline-templates/features/sdlanalysis/overview) -and [TSA support in 1ES PT](https://eng.ms/docs/coreai/devdiv/one-engineering-system-1es/1es-docs/1es-pipeline-templates/features/sdlanalysis/tsasupport) -for the full tool matrix and TSA onboarding steps. - -`pr.yml` extends no 1ES template at all (`dnceng-public/public` has no access to -`1ESPipelineTemplates`), so none of this SDL analysis runs against public pull requests. It -applies only to the trusted internal/official pipeline. - ### Alternative considered: `dotnet/sign` [`dotnet/sign`](https://github.com/dotnet/sign) is a .NET Foundation CLI tool that signs