diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 734c39bb20..1050921a03 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -21,6 +21,9 @@ /plugins/dotnet/skills/nuget-trusted-publishing/ @lewing @kartheekp-ms @dotnet/skills-csharp-language-reviewers /tests/dotnet/nuget-trusted-publishing/ @lewing @kartheekp-ms @dotnet/skills-csharp-language-reviewers +/plugins/dotnet/skills/setup-local-sdk/ @jfversluis @Redth @dotnet/skills-csharp-language-reviewers +/tests/dotnet/setup-local-sdk/ @jfversluis @Redth @dotnet/skills-csharp-language-reviewers + /plugins/dotnet/agents/optimizing-dotnet-performance.agent.md @dotnet/appmodel /plugins/dotnet-ai/skills/mcp-csharp-create/ @leslierichardson95 @cathysull diff --git a/plugins/dotnet/skills/setup-local-sdk/SKILL.md b/plugins/dotnet/skills/setup-local-sdk/SKILL.md new file mode 100644 index 0000000000..60f585d777 --- /dev/null +++ b/plugins/dotnet/skills/setup-local-sdk/SKILL.md @@ -0,0 +1,386 @@ +--- +name: setup-local-sdk +license: MIT +description: > + Install a .NET SDK locally for safe preview testing, specific-version pinning, or + reproducible team setups — without modifying the system-wide installation. + USE FOR: trying .NET previews safely, testing specific SDK versions, installing MAUI + or other workloads on a preview, updating or replacing an existing local SDK, + creating reproducible team/CI install scripts, configuring global.json paths. + DO NOT USE FOR: system-wide SDK installs, .NET hosts older than 10, runtime-only + installs, or projects not using SDK-style commands. +--- + +# setup-local-sdk + +## Purpose + +Guide the user through installing a .NET SDK into a project-local `.dotnet/` +directory and wiring it up via the `global.json` `paths` feature (.NET 10+). +The examples use .NET 11, but this works with any version — prerelease or stable. + +The result is a fully isolated SDK that: +- Does **not** modify the system-wide .NET installation. +- Is picked up automatically by `dotnet` commands from the project root. +- Can be deleted to revert (`rm -rf .dotnet/` or `Remove-Item -Recurse -Force .\.dotnet`). + +## When NOT to use + +- User wants a **system-wide** install — direct to the official installer. +- Host `dotnet` is **older than v10** — `paths` doesn't exist; explain and stop. +- User needs a **runtime-only** install — `paths` applies to SDK resolution only. + +## Inputs / Prerequisites + +| Input | Required | Default | Notes | +|---|---|---|---| +| Channel or version | No | `11.0` | e.g. `11.0`, `STS`, `LTS`, or an exact version like `11.0.100-preview.2.26159.112` | +| Quality | No | `preview` | One of: `daily`, `preview`, `ga` | +| jq | No | — | Optional for bash team scripts when patching an existing `global.json`; without it, do not overwrite the file | + +### Prerequisites + +1. **A .NET 10+ SDK is installed globally** — run `dotnet --version`; major ≥ 10. +2. **curl** (macOS/Linux) or **PowerShell** (Windows) is available. + +## Workflow + +### Step 1 — Clarify what to install + +If the user didn't specify, ask what .NET SDK version they want (e.g., "latest +.NET 11 preview" or an exact version like `11.0.100-preview.2.26159.112`). +Map the answer to `--channel`/`--quality` or `--version` flags. + +### Step 2 — Verify .NET 10+ host + +If the user already provided `dotnet --version` output, treat that as the +authoritative version for their machine. Do not override it with the agent +workspace's version; if the two differ, explain that the workspace differs and +continue advising for the user's machine. + +```bash +dotnet --version +``` + +If major version < 10, stop before downloading anything: the `paths` feature +requires a .NET 10+ host SDK. Tell the user to install .NET 10 or later +system-wide first, then return to the local SDK setup. + +### Step 3 — Detect operating system + +Run `uname -s 2>/dev/null`. If it succeeds (including `MINGW*`, `MSYS*`, `CYGWIN*` — +these are bash-capable environments like Git Bash) → use bash/`dotnet-install.sh`. +If it fails (native Windows without Git Bash) → use PowerShell/`dotnet-install.ps1`. + +### Step 4 — Check for existing local SDK + +**macOS / Linux:** + +```bash +test -d .dotnet && echo "exists" || echo "not found" +``` + +**Windows (PowerShell):** + +```powershell +if (Test-Path -LiteralPath .\.dotnet) { "exists" } else { "not found" } +``` + +If `.dotnet/` exists, ask: update with the new version, or skip and keep it? + +### Step 5 — Download and run the install script + +**macOS / Linux:** + +```bash +INSTALL_SCRIPT="$(mktemp "${TMPDIR:-/tmp}/dotnet-install.XXXXXX")" +trap 'rm -f "$INSTALL_SCRIPT"' EXIT +curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$INSTALL_SCRIPT" +bash "$INSTALL_SCRIPT" --channel --quality --install-dir .dotnet +``` + +**Windows (PowerShell):** + +```powershell +$installScript = Join-Path $env:TEMP "dotnet-install-$([guid]::NewGuid()).ps1" +try { + Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile $installScript + & $installScript -Channel -Quality -InstallDir .dotnet +} +finally { + if (Test-Path -LiteralPath $installScript) { + Remove-Item -LiteralPath $installScript -Force + } +} +``` + +For exact versions: use `--version ` (bash) or `-Version ` (PowerShell) +instead of channel/quality flags. The install scripts are from Microsoft's official +URLs: `https://dot.net/v1/dotnet-install.sh` and `https://dot.net/v1/dotnet-install.ps1`. + +### Step 6 — Identify the installed version + +```bash +./.dotnet/dotnet --version # macOS/Linux +.\.dotnet\dotnet.exe --version # Windows +``` + +Record the exact version string (e.g., `11.0.100-preview.2.26159.112`) for `global.json`. + +### Step 7 — Create or update global.json + +```json +{ + "sdk": { + "version": "", + "allowPrerelease": true, + "rollForward": "latestFeature", + "paths": [".dotnet", "$host$"], + "errorMessage": "Required .NET SDK not found. Run ./install-dotnet.sh (or .ps1) to install it locally." + } +} +``` + +- `paths`: `.dotnet` first (local priority), `$host$` = system-wide fallback. +- `rollForward: "latestFeature"`: use for latest-preview or floating feature-band installs. +- Exact version requests: use `rollForward: "disable"` so SDK resolution doesn't move to a different feature band. +- `allowPrerelease`: set to `true` only when installing a prerelease SDK. Omit for stable versions. +- `errorMessage`: include only when team install scripts are created (Step 10). Otherwise omit. + +If `global.json` already exists, **merge** carefully: preserve existing properties (`msbuild-sdks`, +`tools`, etc.) and only add/update the `sdk` section. Read the existing file first, update/add +the `sdk` object, then write it back. This ensures cross-project config (e.g., MSBuild settings) +isn't lost. Always back up the original file (e.g., `global.json.bak`) before modifying. + +**Minimal config** (when version pinning isn't needed): +`{"sdk":{"paths":[".dotnet","$host$"]}}` + +### Step 8 — Update .gitignore + +**macOS / Linux (or Git Bash):** + +```bash +grep -qxF '.dotnet/' .gitignore 2>/dev/null || printf '\n.dotnet/\n' >> .gitignore +``` + +**Windows (PowerShell):** + +```powershell +if (-not (Test-Path .gitignore) -or -not (Select-String -Path .gitignore -Pattern '^\.dotnet/$' -Quiet)) { + Add-Content -Path .gitignore -Value '.dotnet/' +} +``` + +### Step 9 — Install workloads (if requested) + +Only do this after `global.json` and `.gitignore` are complete, so a slow or +platform-limited workload install does not prevent the base local SDK setup from +being usable. + +If the user mentioned MAUI, mobile, workload, Blazor WASM, or cross-platform, +install using the **local** binary (no sudo needed): + +```bash +./.dotnet/dotnet workload install # macOS/Linux +.\.dotnet\dotnet.exe workload install # Windows +``` + +Verify: `./.dotnet/dotnet workload list` (or `.\.dotnet\dotnet.exe workload list`). + +For MAUI, pick a workload supported by the current OS and target platform. On +Linux, the full `maui` meta-workload is not available; use a supported workload +such as `maui-android` when Android is the target, or explain the platform +limitation and ask which target to configure. + +> **Always use the local dotnet binary for workload commands.** Workload metadata +> is stored relative to the host process's dotnet root. The system `dotnet` puts +> metadata in the wrong location. (See [dotnet/sdk#49825](https://github.com/dotnet/sdk/issues/49825).) + +### Step 10 — Create team install scripts + +Create if user mentioned "team", "share", "CI", "scripts", etc. Otherwise offer. +These examples back up `global.json` and preserve existing settings. The bash script +uses `jq` when an existing `global.json` must be patched; if `jq` is unavailable, +it refuses to overwrite the file and prints the settings to merge manually. +Adapt script variables to the install choice from Step 1: exact versions should +use `--version` / `-Version` and `rollForward: "disable"`; channel installs should +use channel/quality and only set `allowPrerelease: true` for prerelease SDKs. +If `global.json` already pins `sdk.version` and the user mainly needs team +scripts, reuse that version in the scripts and update `global.json` first; do +not start a long SDK download just to discover the version. When the user asks +for both setup and scripts, create the scripts/config before any long install so +the reproducible setup exists even if download or workload installation is slow. + +**install-dotnet.sh:** + +```bash +#!/usr/bin/env bash +set -euo pipefail +INSTALL_DIR=".dotnet" +CHANNEL="11.0" +QUALITY="preview" +VERSION="" +ROLL_FORWARD="latestFeature" +ALLOW_PRERELEASE="true" +WORKLOADS=("${@}") +ERROR_MESSAGE="Required .NET SDK not found. Run ./install-dotnet.sh (or .ps1) to install it locally." +INSTALL_SCRIPT="$(mktemp "${TMPDIR:-/tmp}/dotnet-install.XXXXXX")" +GLOBAL_JSON_TMP="" +cleanup() { + rm -f "$INSTALL_SCRIPT" + [ -n "$GLOBAL_JSON_TMP" ] && rm -f "$GLOBAL_JSON_TMP" +} +trap cleanup EXIT +curl -fsSL https://dot.net/v1/dotnet-install.sh -o "$INSTALL_SCRIPT" +INSTALL_ARGS=(--install-dir "$INSTALL_DIR") +if [ -n "$VERSION" ]; then + INSTALL_ARGS+=(--version "$VERSION") + ROLL_FORWARD="disable" +else + INSTALL_ARGS+=(--channel "$CHANNEL" --quality "$QUALITY") +fi +bash "$INSTALL_SCRIPT" "${INSTALL_ARGS[@]}" +SDK_VERSION=$("$INSTALL_DIR/dotnet" --version) +write_global_json() { + if [ -f global.json ]; then + cp global.json global.json.bak + if ! command -v jq >/dev/null 2>&1; then + echo "global.json exists; install succeeded, but this script will not overwrite it without jq." >&2 + echo "Merge these sdk settings manually so existing global.json properties are preserved:" >&2 + cat >&2 < "$GLOBAL_JSON_TMP" + mv "$GLOBAL_JSON_TMP" global.json + GLOBAL_JSON_TMP="" + else + cat > global.json </dev/null || printf '\n.dotnet/\n' >> .gitignore +[ ${#WORKLOADS[@]} -gt 0 ] && "$INSTALL_DIR/dotnet" workload install "${WORKLOADS[@]}" +echo "Done. SDK: $SDK_VERSION" +``` + +```bash +chmod +x install-dotnet.sh +``` + +**install-dotnet.ps1:** + +```powershell +param([string[]]$Workloads = @()) +$ErrorActionPreference = 'Stop' +$installDir = '.dotnet'; $channel = '11.0'; $quality = 'preview' +$version = ''; $rollForward = 'latestFeature'; $allowPrerelease = $true +$errorMessage = 'Required .NET SDK not found. Run ./install-dotnet.sh (or .ps1) to install it locally.' +$installScript = Join-Path $env:TEMP "dotnet-install-$([guid]::NewGuid()).ps1" +try { + Invoke-WebRequest -Uri 'https://dot.net/v1/dotnet-install.ps1' -OutFile $installScript + $installArgs = @('-InstallDir', $installDir) + if ($version) { + $installArgs += @('-Version', $version) + $rollForward = 'disable' + } else { + $installArgs += @('-Channel', $channel, '-Quality', $quality) + } + & $installScript @installArgs +} +finally { + if (Test-Path -LiteralPath $installScript) { + Remove-Item -LiteralPath $installScript -Force + } +} +$sdkVersion = & "$installDir\dotnet.exe" --version +$globalJson = if (Test-Path 'global.json') { + Copy-Item 'global.json' 'global.json.bak' + Get-Content -Path 'global.json' -Raw | ConvertFrom-Json +} else { + [pscustomobject]@{} +} +if (-not $globalJson.PSObject.Properties['sdk']) { + $globalJson | Add-Member -MemberType NoteProperty -Name 'sdk' -Value ([pscustomobject]@{}) +} +$updates = [ordered]@{ + version = $sdkVersion + allowPrerelease = $allowPrerelease + rollForward = $rollForward + paths = @('.dotnet', '$host$') + errorMessage = $errorMessage +} +foreach ($entry in $updates.GetEnumerator()) { + $property = $globalJson.sdk.PSObject.Properties[$entry.Key] + if ($property) { + $property.Value = $entry.Value + } else { + $globalJson.sdk | Add-Member -MemberType NoteProperty -Name $entry.Key -Value $entry.Value + } +} +$globalJson | ConvertTo-Json -Depth 10 | Set-Content -Path 'global.json' -Encoding UTF8 +if (-not (Test-Path .gitignore) -or -not (Select-String -Path .gitignore -Pattern '^\.dotnet/$' -Quiet)) { + Add-Content -Path .gitignore -Value '.dotnet/' +} +if ($Workloads.Count -gt 0) { & "$installDir\dotnet.exe" workload install @Workloads } +Write-Host "Done. SDK: $sdkVersion" +``` + +Commit these scripts to the repo so teammates can run them. + +### Step 11 — Verify SDK resolution + +```bash +dotnet --version +``` + +Output should match the locally installed version. If not, check: global.json +location, `paths` array contents, host dotnet version ≥ 10. + +### Step 12 — Summarize and explain cleanup + +Tell the user: SDK installed, global.json configured, .dotnet/ gitignored, system +install untouched. Cleanup: delete `.dotnet/`, remove `paths`/`errorMessage` from +global.json, optionally delete install scripts. Include the final `global.json` +`sdk` values (or a short snippet) so the user can see the configured version, +`paths`, and any `errorMessage`. If workloads were requested, include the local +`dotnet workload install ...` command used and the workload verification result +or the exact blocker if the workload could not be installed. + +## Common pitfalls + +| Pitfall | Cause | Fix | +|---|---|---| +| `paths` ignored | Host `dotnet` < v10 | Install .NET 10+ system-wide | +| Wrong SDK resolves | `global.json` in parent directory | Check for global.json up the tree | +| Teammates get "SDK not found" | `.dotnet/` gitignored, no install script run | Use `errorMessage` in global.json | +| Workloads missing | Used system `dotnet` instead of local | Use `./.dotnet/dotnet workload install` | +| `dotnet app.dll` wrong runtime | `paths` is SDK-only, not apphost | Use `dotnet run` or set `DOTNET_ROOT` | diff --git a/tests/dotnet/setup-local-sdk/eval.yaml b/tests/dotnet/setup-local-sdk/eval.yaml new file mode 100644 index 0000000000..691b97ba42 --- /dev/null +++ b/tests/dotnet/setup-local-sdk/eval.yaml @@ -0,0 +1,154 @@ +scenarios: + - name: "Basic local SDK setup with .NET 11 preview" + prompt: "I want to try the latest .NET 11 preview in my project without affecting my global install. Set it up locally for me." + assertions: + - type: "output_contains" + value: "dotnet-install" + - type: "output_contains" + value: "global.json" + - type: "output_contains" + value: ".dotnet" + - type: "output_matches" + pattern: '("paths"|global\.json.{0,80}paths|paths.{0,80}global\.json)' + - type: "output_matches" + pattern: "(.gitignore|gitignore|source control|committed|commit)" + rubric: + - "The agent set up a project-local SDK without changing the system-wide installation" + - "The agent configured SDK resolution so the local SDK is preferred and the host install remains available as fallback" + - "The agent excluded the local SDK directory from source control" + - "The agent explained how to clean up" + timeout: 300 + + - name: "Install a specific SDK version locally" + prompt: "Install .NET SDK version 10.0.100 just for this repo so it is used here automatically instead of relying on a machine-wide SDK." + assertions: + - type: "output_contains" + value: "10.0.100" + - type: "output_contains" + value: "global.json" + - type: "output_matches" + pattern: "(--version|-Version).{0,40}10\\.0\\.100|10\\.0\\.100.{0,40}(--version|-Version)" + - type: "output_matches" + pattern: '("paths"|global\.json.{0,80}paths|paths.{0,80}global\.json)' + - type: "output_matches" + pattern: '("rollForward".{0,40}"disable"|disable.{0,80}rollForward)' + - type: "output_matches" + pattern: '(dotnet\s+--version|normal dotnet commands|SDK resolution|resolves? to).{0,120}10\.0\.100|10\.0\.100.{0,120}(dotnet\s+--version|normal dotnet commands|SDK resolution|resolves? to)' + - type: "output_not_matches" + pattern: "(export\\s+PATH|setx\\s+PATH|\\$env:PATH\\s*=|set\\s+PATH=)" + - type: "file_contains" + path: "global.json" + value: "10.0.100" + - type: "file_contains" + path: "global.json" + value: "paths" + - type: "file_contains" + path: "global.json" + value: "rollForward" + - type: "file_contains" + path: "global.json" + value: "disable" + rubric: + - "The agent selected the exact requested SDK version rather than a moving latest preview" + - "The agent made regular dotnet commands in the project resolve to the local SDK without requiring PATH changes" + - "The agent prevented roll-forward to any SDK version other than the one requested" + - "The agent confirmed project-level SDK resolution returns the requested version" + timeout: 600 + + - name: "Set up local SDK with MAUI workload" + prompt: "I'm on Linux and need to test my .NET MAUI Android app with the latest .NET 11 preview. Set up a local SDK with the MAUI Android workload installed." + assertions: + - type: "output_contains" + value: "workload" + - type: "output_matches" + pattern: '(maui-android|MAUI Android|Android.{0,40}workload)' + - type: "output_matches" + pattern: '(\./[^\s]*/dotnet|\.\\[^\s]+\\dotnet\.exe).{0,120}workload|workload.{0,120}(\./[^\s]*/dotnet|\.\\[^\s]+\\dotnet\.exe)' + - type: "output_contains" + value: "global.json" + rubric: + - "The agent installed a project-local SDK and either completed the MAUI Android workload install or reported a concrete platform/feed blocker after the base setup was usable" + - "The agent ensured workload installation targeted the local SDK rather than the global installation" + - "The agent configured SDK resolution to use the local SDK" + timeout: 600 + + - name: "Create team install scripts" + prompt: "This repo already has a global.json for a .NET 11 preview. Create install scripts and repo configuration, including a clear missing-SDK message, so teammates can reproduce the project-local SDK setup on any OS after cloning. Preserve the existing global.json sections. Don't download the SDK now." + setup: + files: + - path: "global.json" + content: | + { + "sdk": { + "version": "11.0.100-preview.3.26207.106", + "allowPrerelease": true, + "rollForward": "latestFeature" + }, + "msbuild-sdks": { + "Example.Sdk": "1.2.3" + }, + "tools": { + "dotnet-example": "4.5.6" + } + } + assertions: + - type: "file_exists" + path: "install*.sh" + - type: "file_exists" + path: "install*.ps1" + - type: "output_matches" + pattern: "install[^\\s]*\\.sh" + - type: "output_matches" + pattern: "install[^\\s]*\\.ps1" + - type: "file_contains" + path: "global.json" + value: "paths" + - type: "file_contains" + path: "global.json" + value: "version" + - type: "file_contains" + path: "global.json" + value: "msbuild-sdks" + - type: "file_contains" + path: "global.json" + value: "tools" + - type: "file_contains" + path: "global.json" + value: "errorMessage" + - type: "output_matches" + pattern: "(merge|preserve|backup|back up|existing)" + - type: "output_matches" + pattern: "(teammates|team|clone|after cloning)" + - type: "file_not_contains" + path: "install*.sh" + value: "export PATH" + - type: "file_not_contains" + path: "install*.ps1" + value: "$env:PATH" + rubric: + - "The agent created cross-platform scripts that teammates can run after cloning" + - "The agent configured missing-SDK guidance that points teammates to the install scripts" + - "The agent avoided dropping existing global.json settings" + - "The agent made the setup reproducible without requiring teammates to change PATH" + timeout: 300 + + - name: "Detect incompatible .NET host version" + prompt: "I already ran dotnet --version and it printed 9.0.306. I want to set up a local .NET 11 preview SDK for my project using the global.json SDK paths feature. Is that possible?" + reject_tools: ["bash", "powershell", "web_fetch"] + assertions: + - type: "output_contains" + value: "9.0.306" + - type: "output_matches" + pattern: "(\\.NET 10\\+|\\.NET 10 or later|10 or higher|10 or later|requires.{0,40}10|version 10)" + - type: "output_matches" + pattern: '("paths"|global\.json.{0,80}paths|paths.{0,80}global\.json)' + - type: "output_matches" + pattern: "(install|upgrade).{0,80}(\\.NET 10|10)" + - type: "output_not_matches" + pattern: "(dotnet-install|--install-dir|Let me download|Downloading and installing)" + rubric: + - "The agent detected that version 9.0.306 is below the .NET 10 requirement" + - "The agent explained that .NET 10+ is required for the paths feature" + - "The agent told the user to install .NET 10+ system-wide first" + - "The agent did not proceed with local SDK installation commands before the host SDK prerequisite was met" + timeout: 60