diff --git a/LICENSE b/LICENSE
index b26b9d4..2bdf256 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,7 +1,9 @@
-MIT License
+The MIT License (MIT)
Copyright (c) 2026 NuGet
+All rights reserved.
+
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
diff --git a/README.md b/README.md
index 541af27..2e99f9c 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,24 @@
# Client.Tools
-This repository contains tools shipped by the NuGet Client team to help developers effectively use the latest NuGet features.
\ No newline at end of file
+This repository contains tools shipped by the NuGet Client team to help developers effectively use the latest NuGet features.
+
+## Tools
+
+- [dotnet-package-skills](dotnet-package-skills/README.md) copies agent skills bundled in NuGet packages into a repository's skills folder.
+
+## Build
+
+Run from the repository root on Windows, with PowerShell 7 and the root-pinned .NET SDK:
+
+```powershell
+eng\common\build.cmd -restore -build -test -pack -configuration Release
+```
+
+Install the .NET 8 runtime into the same .NET installation that Arcade selects. The SDK supplies
+the .NET 10 runtime. The build graph retains `eng\Infrastructure.proj` and includes the tool
+solution. Artifacts use Arcade's `artifacts\bin`, `obj`, `log`, `TestResults`, and
+`packages\Release\Shipping` layout.
+
+The public pipeline produces unsigned packages. The official pipeline uses Arcade's recursive
+signing with owner-approved MicroBuild/ESRP resources. Both publish NuGet packages as build
+artifacts only. See the [tool pipeline guide](eng/pipelines/dotnet-package-skills/README.md).
\ No newline at end of file
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..8ebd8ed
--- /dev/null
+++ b/dotnet-package-skills/CONTRIBUTING.md
@@ -0,0 +1,44 @@
+# How to contribute
+
+Thank you for your help. This tool is small and simple on purpose. Keep changes small and simple
+too.
+
+## Set up your computer
+
+Install the .NET SDK that the repository root's `global.json` selects. Install PowerShell 7.
+Install the .NET 8 runtime into the same .NET installation that Arcade uses. The SDK supplies the .NET 10 runtime.
+The tool and its C# test suite target both net8.0 and net10.0. CI runs on Windows only.
+Use the root SDK, Arcade imports, and public feeds. Do not add a tool-local `global.json` or `NuGet.config`.
+
+```powershell
+git clone https://github.com/NuGet/Client.Tools.git
+Set-Location .\Client.Tools
+eng\common\build.cmd -restore -build -test -pack -configuration Release
+```
+
+Try your build against a real repository without installing it:
+
+```powershell
+eng\common\dotnet.cmd artifacts\bin\DotnetPackageSkills\Release\net10.0\dotnet-package-skills.dll `
+ list --target C:\path\to\YourApp.sln
+```
+
+The build creates an unsigned tool package under `artifacts\packages\Release\Shipping`.
+It does not replace a globally installed tool. Only the official pipeline signs the package.
+
+## Layout
+
+```
+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
+├── 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/ xunit tests. Application tests use in-process fakes.
+```
diff --git a/dotnet-package-skills/Directory.Build.props b/dotnet-package-skills/Directory.Build.props
new file mode 100644
index 0000000..17cfbd5
--- /dev/null
+++ b/dotnet-package-skills/Directory.Build.props
@@ -0,0 +1,24 @@
+
+
+
+ 17.14.1
+ 2.9.3
+ 1.18.0
+ 3.1.4
+
+
+
+
+
+ false
+ false
+ false
+ latest
+ enable
+ enable
+ true
+ true
+ true
+
+
+
diff --git a/dotnet-package-skills/DotnetPackageSkills.slnx b/dotnet-package-skills/DotnetPackageSkills.slnx
new file mode 100644
index 0000000..1bdf0b9
--- /dev/null
+++ b/dotnet-package-skills/DotnetPackageSkills.slnx
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/dotnet-package-skills/README.md b/dotnet-package-skills/README.md
new file mode 100644
index 0000000..c781c63
--- /dev/null
+++ b/dotnet-package-skills/README.md
@@ -0,0 +1,598 @@
+# dotnet-package-skills
+
+This tool copies agent skills from inside NuGet packages into a folder that your coding agent
+reads.
+
+## The problem
+
+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**. 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 closes 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
+
+Run this command from your repository root:
+
+```bash
+dotnet-package-skills install
+```
+
+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 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` | 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. |
+
+### Choose what to read 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
+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. 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 combine, because both options answer the same question.
+
+NuGet's version parser checks exact versions, including the older one-, two-, and four-part
+forms. Invalid prerelease or metadata labels are rejected. Package comparisons normalize
+padding and letter case and ignore build metadata, so `1.10` and `1.10.0+build.1` identify the
+same release. Reports retain the version text that you supplied.
+
+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.
+[Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management)
+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.
+
+### Keep skills in step with the project
+
+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:
+
+```
+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)
+Use uninstall with --stale to remove skills that no longer match the project.
+```
+
+The tool gives removal-option guidance, not a paste-ready command with repository paths.
+Use the reported Target and Destination context to choose the project and skills folder for
+your uninstall.
+
+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 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.
+
+### Choose which skills to install
+
+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
+```
+
+`--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)
+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 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. Use uninstall with `--stale` first.
+- With `--package`, a named package is installed at a different version. Use uninstall with
+ `--package` to remove that package's installed skills 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` | 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
+```
+
+```
+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 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?
+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 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. 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 | Reports what would change, and writes nothing. |
+
+### Target another agent's folder
+
+`.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 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
+```
+
+### Scripts and CI
+
+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 these two commands:
+
+```bash
+dotnet-package-skills install
+dotnet-package-skills uninstall --stale
+```
+
+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.
+
+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
+
+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 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`. 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
+{
+ "version": 1,
+ "packages": {
+ "contoso.widgets": {
+ "version": "2.3.0",
+ "skills": [
+ "contoso.widgets-widget-testing",
+ "contoso.widgets-widget-usage"
+ ]
+ }
+ }
+}
+```
+
+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.
+
+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
+
+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 directs you to uninstall with `--package` for the
+owner. After you remove the owner's installed skills, `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 one
+version.
+[NuGet Central Package Management](https://learn.microsoft.com/nuget/consume-packages/central-package-management)
+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.
+
+### Commit or ignore this folder
+
+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: ship a skill
+
+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
+
+
+
+
+```
+
+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 a customer 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.
+---
+```
+
+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
+
+Auto-detection checks the top level before nested targets, and prefers solutions over projects
+within each stage. It never enters `bin`, `obj`, `.git`, `node_modules`, or `artifacts`, regardless
+of letter case. It skips inaccessible children and directory links. An explicitly named target
+directory can still be a link, but an unreadable requested directory produces an error.
+
+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 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 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 has an invalid or missing exact 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. It rejects a manifest file that is a symbolic link or reparse point, including a dangling
+link, before install or uninstall changes skills. Use a regular manifest file instead.
+A destination directory reached through a link or junction remains supported. This check
+is limited to the manifest file entry; it does not guarantee safety against a link replaced
+concurrently between checking and writing.
+
+Install and uninstall are not transactional in this prerelease. A failed skill copy or manifest
+write (for example a read-only or locked manifest, or exhausted disk space), or an interruption,
+can leave changed skills and a stale or partial manifest. Detected failures return a nonzero
+exit; changes are not rolled back or recovered automatically.
+
+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 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.
+
+## Common problems
+
+**"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 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 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
+one package. Align those versions, for example with Central Package Management, 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 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 someone edited it
+into a shape that the tool cannot trust. See
+[Removal is manifest-driven](#removal-is-manifest-driven).
+
+**"Unrecognized option '--format'"** Your SDK is older than 7.0.200. Upgrade it.
+
+**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.
+
+**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`.
diff --git a/dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs b/dotnet-package-skills/src/Cli/CommandLineDiagnostics.cs
new file mode 100644
index 0000000..5267761
--- /dev/null
+++ b/dotnet-package-skills/src/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/Cli/ConsoleViewport.cs b/dotnet-package-skills/src/Cli/ConsoleViewport.cs
new file mode 100644
index 0000000..7f8980c
--- /dev/null
+++ b/dotnet-package-skills/src/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/Cli/ITerminal.cs b/dotnet-package-skills/src/Cli/ITerminal.cs
new file mode 100644
index 0000000..f5a4461
--- /dev/null
+++ b/dotnet-package-skills/src/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/Cli/InteractiveScreen.cs b/dotnet-package-skills/src/Cli/InteractiveScreen.cs
new file mode 100644
index 0000000..afa9cbf
--- /dev/null
+++ b/dotnet-package-skills/src/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/Cli/InteractiveSkills.cs b/dotnet-package-skills/src/Cli/InteractiveSkills.cs
new file mode 100644
index 0000000..d5cd537
--- /dev/null
+++ b/dotnet-package-skills/src/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/Cli/OutputWriter.cs b/dotnet-package-skills/src/Cli/OutputWriter.cs
new file mode 100644
index 0000000..45108ba
--- /dev/null
+++ b/dotnet-package-skills/src/Cli/OutputWriter.cs
@@ -0,0 +1,202 @@
+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 removal option applies.
+ ///
+ 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(SkillInstallService.StaleSkillAdvice);
+ }
+
+ 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/Cli/PickerLayout.cs b/dotnet-package-skills/src/Cli/PickerLayout.cs
new file mode 100644
index 0000000..e752e5d
--- /dev/null
+++ b/dotnet-package-skills/src/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/Cli/SkillPicker.cs b/dotnet-package-skills/src/Cli/SkillPicker.cs
new file mode 100644
index 0000000..4dffe99
--- /dev/null
+++ b/dotnet-package-skills/src/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/Cli/TerminalText.cs b/dotnet-package-skills/src/Cli/TerminalText.cs
new file mode 100644
index 0000000..47007c9
--- /dev/null
+++ b/dotnet-package-skills/src/Cli/TerminalText.cs
@@ -0,0 +1,261 @@
+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;
+ }
+
+ if (text[index] == '\x1b')
+ {
+ 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.csproj b/dotnet-package-skills/src/DotnetPackageSkills.csproj
new file mode 100644
index 0000000..20d2299
--- /dev/null
+++ b/dotnet-package-skills/src/DotnetPackageSkills.csproj
@@ -0,0 +1,44 @@
+
+
+
+ Exe
+ net8.0;net10.0
+ dotnet-package-skills
+ DotnetPackageSkills
+
+
+ Major
+
+
+
+ true
+ true
+ true
+
+ dotnet-package-skills
+ dotnet-package-skills
+ 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/Infrastructure/DotnetCli.cs b/dotnet-package-skills/src/Infrastructure/DotnetCli.cs
new file mode 100644
index 0000000..bc0167c
--- /dev/null
+++ b/dotnet-package-skills/src/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/Infrastructure/ProcessRunner.cs b/dotnet-package-skills/src/Infrastructure/ProcessRunner.cs
new file mode 100644
index 0000000..0bb5ca1
--- /dev/null
+++ b/dotnet-package-skills/src/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/NuGet/GlobalPackagesLocator.cs b/dotnet-package-skills/src/NuGet/GlobalPackagesLocator.cs
new file mode 100644
index 0000000..e991b24
--- /dev/null
+++ b/dotnet-package-skills/src/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/NuGet/PackageCoordinate.cs b/dotnet-package-skills/src/NuGet/PackageCoordinate.cs
new file mode 100644
index 0000000..3e1c113
--- /dev/null
+++ b/dotnet-package-skills/src/NuGet/PackageCoordinate.cs
@@ -0,0 +1,110 @@
+using System.Text.RegularExpressions;
+using NuGet.Versioning;
+
+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.
+ """);
+ }
+
+ ParseVersion(version);
+
+ return new PackageCoordinate(id, version);
+ }
+
+ internal static NuGetVersion ParseVersion(string version)
+ {
+ if (!NuGetVersion.TryParse(version, out var parsed))
+ {
+ throw new PackageSkillsException(
+ $"'{version}' is not a version this tool recognises. Expected something like 1.10.0 or 2.0.0-beta.1.");
+ }
+
+ return parsed;
+ }
+
+ 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();
+
+}
diff --git a/dotnet-package-skills/src/NuGet/PackageLister.cs b/dotnet-package-skills/src/NuGet/PackageLister.cs
new file mode 100644
index 0000000..e7e3a67
--- /dev/null
+++ b/dotnet-package-skills/src/NuGet/PackageLister.cs
@@ -0,0 +1,223 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using DotnetPackageSkills.Infrastructure;
+using NuGet.Versioning;
+
+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))
+ {
+ continue;
+ }
+
+ PackageCoordinate.ValidateId(id);
+ if (version is null || !NuGetVersion.TryParse(version, out _))
+ {
+ throw new PackageSkillsException(
+ $"'dotnet list package' reported an invalid exact version '{version}' for package '{id}'. " +
+ "Restore or fix the target's package references, then try again.");
+ }
+
+ var key = (id.ToLowerInvariant(), PackagePathResolver.NormalizeVersion(version));
+ 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/NuGet/PackagePathResolver.cs b/dotnet-package-skills/src/NuGet/PackagePathResolver.cs
new file mode 100644
index 0000000..7390b65
--- /dev/null
+++ b/dotnet-package-skills/src/NuGet/PackagePathResolver.cs
@@ -0,0 +1,52 @@
+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. A case-insensitive directory scan also supports older cache
+/// layouts that retained an original valid version spelling.
+///
+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 normalized = NormalizeVersion(version);
+ var packageDirectory = Path.Combine(globalPackagesFolder, packageId.ToLowerInvariant());
+
+ if (!Directory.Exists(packageDirectory))
+ {
+ return null;
+ }
+
+ var candidate = Path.Combine(packageDirectory, normalized);
+ if (Directory.Exists(candidate))
+ {
+ return candidate;
+ }
+
+ // Older cache layouts may retain the original spelling of a valid version.
+ 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) =>
+ PackageCoordinate.ParseVersion(version).ToNormalizedString().ToLowerInvariant();
+}
diff --git a/dotnet-package-skills/src/NuGet/TargetLocator.cs b/dotnet-package-skills/src/NuGet/TargetLocator.cs
new file mode 100644
index 0000000..3eeb00d
--- /dev/null
+++ b/dotnet-package-skills/src/NuGet/TargetLocator.cs
@@ -0,0 +1,128 @@
+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}");
+ }
+
+ var (topLevel, children) = ReadDirectory(directory, explicitRoot: true);
+ if (BestTarget(topLevel) is { } rootTarget) { return rootTarget; }
+
+ var nested = new List();
+ var pending = new Stack(children);
+ while (pending.TryPop(out var child))
+ {
+ var (files, directories) = ReadDirectory(child, explicitRoot: false);
+ nested.AddRange(files);
+ foreach (var descendant in directories) { pending.Push(descendant); }
+ }
+
+ if (BestTarget(nested) is { } nestedTarget) { return nestedTarget; }
+
+ throw new PackageSkillsException(
+ $"No solution or project found under {directory}. " +
+ "Pass one explicitly, for example: --target src/MyApp.sln");
+ }
+
+ private static (List Files, List Children) ReadDirectory(string directory, bool explicitRoot)
+ {
+ var files = new List();
+ var children = new List();
+ try
+ {
+ foreach (var entry in new DirectoryInfo(directory).EnumerateFileSystemInfos())
+ {
+ if (entry is DirectoryInfo child)
+ {
+ if (!IgnoredDirectories.Contains(child.Name, StringComparer.OrdinalIgnoreCase) &&
+ (child.Attributes & FileAttributes.ReparsePoint) == 0)
+ {
+ children.Add(child.FullName);
+ }
+ }
+ else if (Rank(entry.FullName) >= 0)
+ {
+ files.Add(entry.FullName);
+ }
+ }
+ }
+ catch (UnauthorizedAccessException error)
+ {
+ if (explicitRoot)
+ {
+ throw new PackageSkillsException(
+ $"Could not read target directory '{directory}'. Check its permissions or pass a readable --target.",
+ error);
+ }
+
+ return ([], []);
+ }
+ catch (DirectoryNotFoundException) when (!explicitRoot) { return ([], []); }
+
+ return (files, children);
+ }
+
+ private static string? BestTarget(IEnumerable files) =>
+ files.OrderBy(Rank).ThenBy(file => file, StringComparer.Ordinal).FirstOrDefault();
+
+ private static int Rank(string file)
+ {
+ var extension = Path.GetExtension(file);
+ var solution = Array.FindIndex(SolutionExtensions,
+ candidate => candidate.Equals(extension, StringComparison.OrdinalIgnoreCase));
+ if (solution >= 0) { return solution; }
+
+ var project = Array.FindIndex(ProjectExtensions,
+ candidate => candidate.Equals(extension, StringComparison.OrdinalIgnoreCase));
+ return project < 0 ? -1 : SolutionExtensions.Length + project;
+ }
+}
diff --git a/dotnet-package-skills/src/PackageSkillsException.cs b/dotnet-package-skills/src/PackageSkillsException.cs
new file mode 100644
index 0000000..6106713
--- /dev/null
+++ b/dotnet-package-skills/src/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/Program.cs b/dotnet-package-skills/src/Program.cs
new file mode 100644
index 0000000..21bb1fa
--- /dev/null
+++ b/dotnet-package-skills/src/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/SkillInstallService.cs b/dotnet-package-skills/src/SkillInstallService.cs
new file mode 100644
index 0000000..1ff0288
--- /dev/null
+++ b/dotnet-package-skills/src/SkillInstallService.cs
@@ -0,0 +1,457 @@
+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's --stale option for removing them.
+ ///
+ public IReadOnlyList Unreferenced { get; init; } = [];
+
+ ///
+ /// 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)
+{
+ internal const string StaleSkillAdvice =
+ "Use uninstall with --stale to remove skills that no longer match the project.";
+
+ 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);
+
+ 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 : [],
+ 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(discovered, installed);
+ }
+ else
+ {
+ RequireNoOtherInstalledVersion(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(
+ 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})"))}. " +
+ StaleSkillAdvice + " Then try again. " +
+ "No skills were changed.");
+ }
+
+ private static void RequireNoOtherInstalledVersion(
+ 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;
+ }
+
+ 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. " + SkillInstaller.PackageConflictAdvice + " " +
+ "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);
+}
+
+/// 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/Skills/BundledSkill.cs b/dotnet-package-skills/src/Skills/BundledSkill.cs
new file mode 100644
index 0000000..0cdff99
--- /dev/null
+++ b/dotnet-package-skills/src/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/Skills/DestinationLock.cs b/dotnet-package-skills/src/Skills/DestinationLock.cs
new file mode 100644
index 0000000..9f74773
--- /dev/null
+++ b/dotnet-package-skills/src/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/Skills/InstallManifest.cs b/dotnet-package-skills/src/Skills/InstallManifest.cs
new file mode 100644
index 0000000..23ed2c7
--- /dev/null
+++ b/dotnet-package-skills/src/Skills/InstallManifest.cs
@@ -0,0 +1,378 @@
+using System.Text;
+using System.Text.Json;
+using DotnetPackageSkills.NuGet;
+using NuGet.Versioning;
+
+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);
+ RequireRegularManifest(path);
+
+ 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 (!NuGetVersion.TryParse(version.GetString(), out _))
+ {
+ throw CannotRead(path, $"'packages.{id}.version' must be an exact NuGet version");
+ }
+
+ 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)
+ {
+ var path = Path.Combine(destinationRoot, FileName);
+ RequireRegularManifest(path);
+ Directory.CreateDirectory(destinationRoot);
+
+ // Rewrite the existing file rather than replacing it, which keeps its permissions.
+ File.WriteAllText(
+ path,
+ 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);
+ RequireRegularManifest(path);
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ }
+ }
+
+ private static void RequireRegularManifest(string path)
+ {
+ try
+ {
+ if (new FileInfo(path).LinkTarget is not null ||
+ (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
+ {
+ throw new PackageSkillsException(
+ $"The install manifest '{path}' must be a regular file, not a symbolic link or reparse point. " +
+ "Replace the manifest link with a regular file before trying again.");
+ }
+ }
+ catch (FileNotFoundException) { }
+ catch (DirectoryNotFoundException) { }
+ catch (Exception error) when (error is IOException or UnauthorizedAccessException)
+ {
+ throw CannotRead(path, "its file entry could not be inspected", error);
+ }
+ }
+}
diff --git a/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs b/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs
new file mode 100644
index 0000000..203feef
--- /dev/null
+++ b/dotnet-package-skills/src/Skills/SkillDescriptionReader.cs
@@ -0,0 +1,256 @@
+using System.Text;
+using YamlDotNet.Core;
+using YamlDotNet.Core.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)
+ {
+ try
+ {
+ return ParseDocument(frontmatter);
+ }
+ catch (SemanticErrorException exception) when (
+ exception.Start.Index == frontmatter.Length && exception.End.Index == frontmatter.Length)
+ {
+ // YamlDotNet rejects indentation in an all-blank trailing block. Reparse the
+ // whole document without trailing whitespace, keeping all metadata checks.
+ return ParseDocument(frontmatter.TrimEnd(' ', '\t', '\n') + '\n');
+ }
+ }
+
+ private static SkillDescriptionResult ParseDocument(string frontmatter)
+ {
+ var parser = new Parser(new StringReader(frontmatter));
+ parser.Consume();
+ if (parser.TryConsume(out _))
+ {
+ return new(null, null);
+ }
+
+ parser.Consume();
+ 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.TryConsume(out _))
+ {
+ 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.Consume();
+ parser.Consume();
+ return new(description, null);
+ }
+
+ private static Scalar? ReadNode(IParser 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.TryConsume(out _))
+ {
+ ReadNode(parser, depth + 1);
+ ReadNode(parser, depth + 1);
+ }
+ }
+ else
+ {
+ while (!parser.TryConsume(out _))
+ {
+ ReadNode(parser, depth + 1);
+ }
+ }
+
+ return null;
+ }
+
+ private static NodeEvent ReadNodeStart(IParser parser)
+ {
+ if (parser.Accept(out _))
+ {
+ throw new FrontmatterException("uses YAML anchors or aliases; replace them with literal values.");
+ }
+
+ var node = parser.Consume();
+ if (!node.Anchor.IsEmpty)
+ {
+ throw new FrontmatterException("uses YAML anchors or aliases; replace them with literal values.");
+ }
+
+ if (!node.Tag.IsEmpty)
+ {
+ 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/Skills/SkillDiscovery.cs b/dotnet-package-skills/src/Skills/SkillDiscovery.cs
new file mode 100644
index 0000000..28b8edc
--- /dev/null
+++ b/dotnet-package-skills/src/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/Skills/SkillInstaller.cs b/dotnet-package-skills/src/Skills/SkillInstaller.cs
new file mode 100644
index 0000000..9035ffa
--- /dev/null
+++ b/dotnet-package-skills/src/Skills/SkillInstaller.cs
@@ -0,0 +1,453 @@
+using DotnetPackageSkills.NuGet;
+using NuGet.Versioning;
+
+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
+{
+ internal const string PackageConflictAdvice =
+ "Use uninstall with --package to remove skills for the conflicting packages, then try again.";
+
+ ///
+ /// 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.
+ ///
+ public InstallOutcome Install(
+ string destinationRoot,
+ IReadOnlyList skills,
+ bool dryRun,
+ IReadOnlyDictionary? offered = null,
+ IReadOnlyCollection? expectedInstalled = 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);
+
+ foreach (var version in versions.Values) { PackageCoordinate.ParseVersion(version); }
+
+ 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}'; use uninstall with --package to remove the current owner's skills " +
+ "before replacing it"));
+ 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);
+ }
+
+ 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) =>
+ VersionComparer.VersionRelease.Equals(PackageCoordinate.ParseVersion(left), PackageCoordinate.ParseVersion(right));
+
+ private static PackageSkillsException NameWouldChangeOwner(
+ IReadOnlyList stranded,
+ IReadOnlyDictionary versions,
+ IReadOnlyList selected)
+ {
+ // 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";
+ });
+ 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. " + PackageConflictAdvice + " 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)
+ {
+ if (packageVersion is not null) { PackageCoordinate.ParseVersion(packageVersion); }
+
+ 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 ||
+ SameVersion(entry.Version, packageVersion);
+ }
+
+ 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/CommandLineDiagnosticsTests.cs b/dotnet-package-skills/tests/CommandLineDiagnosticsTests.cs
new file mode 100644
index 0000000..31e4af7
--- /dev/null
+++ b/dotnet-package-skills/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/CommandLineTests.cs b/dotnet-package-skills/tests/CommandLineTests.cs
new file mode 100644
index 0000000..5ecadae
--- /dev/null
+++ b/dotnet-package-skills/tests/CommandLineTests.cs
@@ -0,0 +1,376 @@
+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")]
+ [InlineData("01.02.0-BETA.1+Build.A", "1.2-beta.1+Build.B")]
+ 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));
+ }
+
+ [Theory]
+ [InlineData("1.0.0-alpha.")]
+ [InlineData("1.0.0+a..b")]
+ [InlineData("2147483648.0.0")]
+ [InlineData("1.2.3.4.5")]
+ public void Invalid_exact_versions_cannot_be_uninstall_filters_in_either_mode(string version)
+ {
+ Assert.Throws(() => CommandLineBuilder.ParseUninstallFilter($"Mockly@{version}"));
+ Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", "--package", $"Mockly@{version}"]).Errors);
+ Assert.NotEmpty(CommandLineBuilder.Build().Parse(["uninstall", "-i", "--package", $"Mockly@{version}"]).Errors);
+ }
+
+ [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/DestinationLockTests.cs b/dotnet-package-skills/tests/DestinationLockTests.cs
new file mode 100644
index 0000000..cfbdc9b
--- /dev/null
+++ b/dotnet-package-skills/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.csproj b/dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj
new file mode 100644
index 0000000..255daf6
--- /dev/null
+++ b/dotnet-package-skills/tests/DotnetPackageSkills.Tests.csproj
@@ -0,0 +1,24 @@
+
+
+
+ net8.0;net10.0
+ enable
+ enable
+ false
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/dotnet-package-skills/tests/FakeTerminal.cs b/dotnet-package-skills/tests/FakeTerminal.cs
new file mode 100644
index 0000000..a14f3ff
--- /dev/null
+++ b/dotnet-package-skills/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/GlobalPackagesLocatorTests.cs b/dotnet-package-skills/tests/GlobalPackagesLocatorTests.cs
new file mode 100644
index 0000000..e75aaa8
--- /dev/null
+++ b/dotnet-package-skills/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/InstallManifestTests.cs b/dotnet-package-skills/tests/InstallManifestTests.cs
new file mode 100644
index 0000000..f3271fc
--- /dev/null
+++ b/dotnet-package-skills/tests/InstallManifestTests.cs
@@ -0,0 +1,427 @@
+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());
+ }
+
+ [Theory]
+ [InlineData("1.0.0-alpha.")]
+ [InlineData("1.0.0+a..b")]
+ [InlineData("1.2.3.4.5")]
+ [InlineData("2147483648.0.0")]
+ [InlineData("1.*")]
+ [InlineData("[1.0,2.0)")]
+ public void Invalid_stored_versions_fail_before_reading_or_writing_ownership(string version)
+ {
+ using var temp = new TempDirectory();
+ var destination = temp.CreateDirectory("dest");
+ var contents = $$$"""
+ {"version":1,"packages":{
+ "mockly":{"version":"{{{version}}}","skills":["usage"]}
+ }}
+ """;
+ 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("mockly", error.Message);
+ Assert.Contains("version", error.Message);
+ Assert.Equal(contents, File.ReadAllText(path));
+
+ var manifest = new InstallManifest();
+ Assert.Throws(() =>
+ manifest.SetSkills([new TrackedSkill("Mockly", version, "usage")]));
+ Assert.Empty(manifest.EnumerateSkills());
+ }
+
+ [Fact]
+ public void NuGet_metadata_and_padding_do_not_create_two_manifest_versions()
+ {
+ var manifest = new InstallManifest();
+ manifest.SetSkills(
+ [
+ new TrackedSkill("Mockly", "01.10.00.0-BETA.1+Build.A", "first"),
+ new TrackedSkill("mockly", "1.10-beta.1+Build.B", "second"),
+ ]);
+
+ Assert.Equal("1.10.0-beta.1", manifest.Packages["mockly"].Version);
+ Assert.Equal(["first", "second"], manifest.Packages["mockly"].Skills);
+ }
+
+ [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);
+ }
+
+ [SymbolicLinkTheory]
+ [InlineData("load", false)]
+ [InlineData("save", false)]
+ [InlineData("delete", false)]
+ [InlineData("load", true)]
+ [InlineData("save", true)]
+ [InlineData("delete", true)]
+ public void Linked_manifest_entries_are_rejected_including_dangling_links(string operation, bool dangling)
+ {
+ using var temp = new TempDirectory();
+ var destination = temp.CreateDirectory("dest");
+ var target = temp.Combine("outside.json");
+ if (!dangling) { File.WriteAllText(target, Contents); }
+ var path = Path.Combine(destination, InstallManifest.FileName);
+ File.CreateSymbolicLink(path, target);
+ try
+ {
+ var error = Assert.Throws(() =>
+ {
+ switch (operation)
+ {
+ case "load": InstallManifest.Load(destination); break;
+ case "save": new InstallManifest().Save(destination); break;
+ case "delete": InstallManifest.Delete(destination); break;
+ }
+ });
+
+ Assert.Contains("regular", error.Message);
+ Assert.Contains(path, error.Message);
+ Assert.Equal(target, new FileInfo(path).LinkTarget);
+ if (dangling) { Assert.False(File.Exists(target)); }
+ else { Assert.Equal(Contents, File.ReadAllText(target)); }
+ }
+ finally { File.Delete(path); }
+ }
+
+ [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/InteractiveSkillsTests.cs b/dotnet-package-skills/tests/InteractiveSkillsTests.cs
new file mode 100644
index 0000000..4ebc683
--- /dev/null
+++ b/dotnet-package-skills/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/OutputLayoutTests.cs b/dotnet-package-skills/tests/OutputLayoutTests.cs
new file mode 100644
index 0000000..7ff58a6
--- /dev/null
+++ b/dotnet-package-skills/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/OutputWriterTests.cs b/dotnet-package-skills/tests/OutputWriterTests.cs
new file mode 100644
index 0000000..5a50c4a
--- /dev/null
+++ b/dotnet-package-skills/tests/OutputWriterTests.cs
@@ -0,0 +1,460 @@
+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_stale_option_guidance()
+ {
+ 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 +
+ "Use uninstall with --stale to remove skills that no longer match the project.",
+ 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("Use uninstall with --stale to remove skills that no longer match the project.", output.ToString());
+ }
+
+ [Theory]
+ [InlineData("my skills")]
+ [InlineData("src/$(command).sln")]
+ [InlineData("$(Write-Host injected)")]
+ [InlineData("my`skills")]
+ [InlineData("my\"skills")]
+ [InlineData("path;command")]
+ [InlineData("path\ncommand")]
+ [InlineData("path\tcommand")]
+ [InlineData("my\u001b[2Jskills")]
+ public void Stale_advice_never_formats_report_paths_as_an_executable_command(string path)
+ {
+ using var output = new StringWriter();
+ var result = ResultWithCollision() with
+ {
+ Unreferenced = [new TrackedSkill("contoso.widgets", "2.3.0", "contoso.widgets-usage")],
+ Target = path,
+ Destination = path,
+ };
+
+ new OutputWriter(output).WriteInstallReport(result, copied: true);
+
+ var report = output.ToString();
+ Assert.Contains($"Target: {TerminalText.Sanitize(path)}", report);
+ Assert.Contains($"Destination: {TerminalText.Sanitize(path)}", report);
+ Assert.Contains("Use uninstall with --stale to remove skills that no longer match the project.", report);
+ Assert.DoesNotContain("dotnet-package-skills uninstall", report);
+ Assert.DoesNotContain("--target", report);
+ Assert.DoesNotContain("--destination", report);
+ AssertPlainText(report);
+ }
+
+ [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_rejected_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");
+ var version = "1.0.0" + ClipboardControl;
+ var manifest = temp.CreateFile("dest/.dotnet-package-skills.json", JsonSerializer.Serialize(new
+ {
+ version = 1,
+ packages = new Dictionary