Skip to content

Commit 5e3c958

Browse files
Add mur clean-local and uninstall-skill-kit (#272)
- Add `mur clean-local` CLI command that removes local .nupkg/.snupkg files, cleans the NuGet global-packages cache for local versions, clears the HTTP cache, and uninstalls dotnet new project templates. - Add `tools/uninstall-skill-kit.ps1` that removes the installed skill-kit directory and cleans both x64 and arm64 PATH entries. - Extract shared `RepoRootFinder` from PackLocalCommand for reuse across pack/clean commands. - Add unit tests for CleanLocalCommand and RepoRootFinder. Closes #238 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 87d09cc commit 5e3c958

7 files changed

Lines changed: 358 additions & 12 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// `mur clean-local` — removes local NuGet packages produced by `mur pack-local`,
2+
// clears the matching entries from the NuGet global packages cache, and uninstalls
3+
// any `dotnet new` project templates that were installed from the local feed.
4+
//
5+
// This is the inverse of `mur pack-local` and addresses the "need uninstall"
6+
// half of https://github.com/microsoft/microsoft-ui-reactor/issues/238.
7+
8+
using System.Diagnostics;
9+
10+
namespace Microsoft.UI.Reactor.Cli.Pack;
11+
12+
public static class CleanLocalCommand
13+
{
14+
// Package IDs that `pack-local` produces — lowercase to match the NuGet
15+
// global-packages folder convention.
16+
internal static readonly string[] PackageIds =
17+
[
18+
"microsoft.ui.reactor",
19+
"microsoft.ui.reactor.projecttemplates",
20+
];
21+
22+
// Template package ID used by `dotnet new install`.
23+
internal const string TemplatePackageId = "Microsoft.UI.Reactor.ProjectTemplates";
24+
25+
public static int Run(string[] args)
26+
{
27+
var repoRoot = RepoRootFinder.FindRepoRoot(Directory.GetCurrentDirectory())
28+
?? RepoRootFinder.FindRepoRoot();
29+
30+
if (repoRoot is null)
31+
{
32+
Console.Error.WriteLine("mur clean-local: must be run from a Reactor source checkout (could not locate src/Reactor).");
33+
return 1;
34+
}
35+
36+
var feed = Path.Combine(repoRoot, "local-nupkgs");
37+
var removed = 0;
38+
39+
// 1. Delete .nupkg / .snupkg files from the local feed.
40+
if (Directory.Exists(feed))
41+
{
42+
foreach (var file in Directory.EnumerateFiles(feed, "*.nupkg")
43+
.Concat(Directory.EnumerateFiles(feed, "*.snupkg")))
44+
{
45+
try
46+
{
47+
File.Delete(file);
48+
Console.WriteLine($" Deleted {Path.GetFileName(file)}");
49+
removed++;
50+
}
51+
catch (Exception ex)
52+
{
53+
Console.Error.WriteLine($" Could not delete {Path.GetFileName(file)}: {ex.Message}");
54+
}
55+
}
56+
}
57+
58+
if (removed == 0)
59+
Console.WriteLine(" No local packages found — nothing to remove.");
60+
61+
// 2. Remove cached copies from the NuGet global-packages folder so that
62+
// subsequent restores don't silently resolve a stale local version.
63+
var globalPackages = GetGlobalPackagesPath();
64+
if (globalPackages is not null)
65+
{
66+
foreach (var id in PackageIds)
67+
{
68+
var pkgDir = Path.Combine(globalPackages, id);
69+
if (!Directory.Exists(pkgDir)) continue;
70+
71+
// Only delete versions that look like local builds.
72+
foreach (var versionDir in Directory.EnumerateDirectories(pkgDir))
73+
{
74+
var versionName = Path.GetFileName(versionDir);
75+
if (IsLocalVersion(versionName))
76+
{
77+
try
78+
{
79+
Directory.Delete(versionDir, recursive: true);
80+
Console.WriteLine($" Removed cached {id}/{versionName}");
81+
}
82+
catch (Exception ex)
83+
{
84+
Console.Error.WriteLine($" Could not remove cached {id}/{versionName}: {ex.Message}");
85+
}
86+
}
87+
}
88+
}
89+
}
90+
91+
// 3. Clear the NuGet HTTP cache so stale metadata doesn't linger.
92+
RunDotnet(repoRoot, "nuget", "locals", "http-cache", "--clear");
93+
94+
// 4. Uninstall project templates (non-fatal).
95+
UninstallTemplates(repoRoot);
96+
97+
Console.WriteLine();
98+
Console.WriteLine("Done.");
99+
return 0;
100+
}
101+
102+
internal static string? GetGlobalPackagesPath()
103+
{
104+
try
105+
{
106+
var psi = new ProcessStartInfo("dotnet")
107+
{
108+
UseShellExecute = false,
109+
RedirectStandardOutput = true,
110+
RedirectStandardError = true,
111+
ArgumentList = { "nuget", "locals", "global-packages", "--list" },
112+
};
113+
using var proc = Process.Start(psi);
114+
if (proc is null) return null;
115+
var output = proc.StandardOutput.ReadToEnd();
116+
proc.WaitForExit();
117+
118+
// Output is: "global-packages: C:\Users\...\.nuget\packages\"
119+
var idx = output.IndexOf(':');
120+
return idx >= 0 ? output[(idx + 1)..].Trim().TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) : null;
121+
}
122+
catch
123+
{
124+
return null;
125+
}
126+
}
127+
128+
internal static bool IsLocalVersion(string version)
129+
{
130+
// Match PackLocalCommand.DefaultLocalVersion ("0.0.0-local") and any
131+
// user-supplied version containing "local" (e.g. "1.0.0-local.42").
132+
return version.Contains("local", StringComparison.OrdinalIgnoreCase);
133+
}
134+
135+
static void UninstallTemplates(string repoRoot)
136+
{
137+
try
138+
{
139+
var psi = new ProcessStartInfo("dotnet")
140+
{
141+
UseShellExecute = false,
142+
WorkingDirectory = repoRoot,
143+
RedirectStandardOutput = true,
144+
RedirectStandardError = true,
145+
ArgumentList = { "new", "uninstall", TemplatePackageId },
146+
};
147+
using var proc = Process.Start(psi);
148+
if (proc is null) return;
149+
proc.WaitForExit();
150+
if (proc.ExitCode == 0)
151+
Console.WriteLine($" Uninstalled dotnet new template: {TemplatePackageId}");
152+
// Exit code != 0 means the template wasn't installed — that's fine.
153+
}
154+
catch { /* non-fatal */ }
155+
}
156+
157+
static void RunDotnet(string workingDirectory, params string[] arguments)
158+
{
159+
try
160+
{
161+
var psi = new ProcessStartInfo("dotnet")
162+
{
163+
UseShellExecute = false,
164+
WorkingDirectory = workingDirectory,
165+
RedirectStandardOutput = true,
166+
RedirectStandardError = true,
167+
};
168+
foreach (var a in arguments) psi.ArgumentList.Add(a);
169+
using var proc = Process.Start(psi);
170+
proc?.WaitForExit();
171+
}
172+
catch { /* non-fatal */ }
173+
}
174+
}

src/Reactor.Cli/Pack/PackLocalCommand.cs

Lines changed: 1 addition & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -135,16 +135,5 @@ static int RunPack(string repoRoot, string projectRelative, string configuration
135135
return null;
136136
}
137137

138-
static string? FindRepoRoot()
139-
{
140-
for (var d = new DirectoryInfo(AppContext.BaseDirectory); d is not null; d = d.Parent)
141-
{
142-
if (Directory.Exists(Path.Combine(d.FullName, "src", "Reactor"))
143-
&& File.Exists(Path.Combine(d.FullName, "src", "Reactor", "Reactor.csproj")))
144-
{
145-
return d.FullName;
146-
}
147-
}
148-
return null;
149-
}
138+
static string? FindRepoRoot() => RepoRootFinder.FindRepoRoot();
150139
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
namespace Microsoft.UI.Reactor.Cli.Pack;
2+
3+
internal static class RepoRootFinder
4+
{
5+
/// <summary>
6+
/// Walks up from <paramref name="startDirectory"/> looking for a directory
7+
/// that contains <c>src/Reactor/Reactor.csproj</c>. Falls back to
8+
/// <see cref="AppContext.BaseDirectory"/> when <paramref name="startDirectory"/>
9+
/// is <c>null</c>.
10+
/// </summary>
11+
public static string? FindRepoRoot(string? startDirectory = null)
12+
{
13+
var start = startDirectory ?? AppContext.BaseDirectory;
14+
for (var d = new DirectoryInfo(start); d is not null; d = d.Parent)
15+
{
16+
if (Directory.Exists(Path.Combine(d.FullName, "src", "Reactor"))
17+
&& File.Exists(Path.Combine(d.FullName, "src", "Reactor", "Reactor.csproj")))
18+
{
19+
return d.FullName;
20+
}
21+
}
22+
return null;
23+
}
24+
}

src/Reactor.Cli/Program.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@
7979
return Microsoft.UI.Reactor.Cli.Pack.PackLocalCommand.Run(args.Skip(1).ToArray());
8080
}
8181

82+
if (arg == "clean-local")
83+
{
84+
return Microsoft.UI.Reactor.Cli.Pack.CleanLocalCommand.Run(args.Skip(1).ToArray());
85+
}
86+
8287
Console.Error.WriteLine($"Unknown option: {args[0]}");
8388
Console.Error.WriteLine();
8489
ShowHelp();
@@ -112,6 +117,7 @@ void ShowHelp()
112117
Console.WriteLine(" devtools Launch project with --devtools run and supervise reloads");
113118
Console.WriteLine(" check [path] Build and emit one-line diagnostics with skill-file pointers");
114119
Console.WriteLine(" pack-local Pack the in-source framework to <repo>/local-nupkgs/ as 0.0.0-local");
120+
Console.WriteLine(" clean-local Remove local packages, NuGet cache entries, and templates");
115121
}
116122

117123
int ShowSkill()
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
using Microsoft.UI.Reactor.Cli.Pack;
2+
using Xunit;
3+
4+
namespace Microsoft.UI.Reactor.Tests;
5+
6+
public class CleanLocalCommandTests
7+
{
8+
[Theory]
9+
[InlineData("0.0.0-local", true)]
10+
[InlineData("1.0.0-local.42", true)]
11+
[InlineData("2.0.0-LOCAL", true)]
12+
[InlineData("3.0.0-preview.1", false)]
13+
[InlineData("1.0.0", false)]
14+
public void IsLocalVersion_ClassifiesCorrectly(string version, bool expected)
15+
{
16+
Assert.Equal(expected, CleanLocalCommand.IsLocalVersion(version));
17+
}
18+
19+
[Fact]
20+
public void PackageIds_ContainsExpectedEntries()
21+
{
22+
Assert.Contains("microsoft.ui.reactor", CleanLocalCommand.PackageIds);
23+
Assert.Contains("microsoft.ui.reactor.projecttemplates", CleanLocalCommand.PackageIds);
24+
}
25+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using Microsoft.UI.Reactor.Cli.Pack;
2+
using Xunit;
3+
4+
namespace Microsoft.UI.Reactor.Tests;
5+
6+
public class RepoRootFinderTests
7+
{
8+
[Fact]
9+
public void FindRepoRoot_FromRepoDirectory_ReturnsRoot()
10+
{
11+
// We're running inside the repo, so starting from the test assembly
12+
// base directory should find the root.
13+
var root = RepoRootFinder.FindRepoRoot();
14+
Assert.NotNull(root);
15+
Assert.True(File.Exists(Path.Combine(root, "src", "Reactor", "Reactor.csproj")));
16+
}
17+
18+
[Fact]
19+
public void FindRepoRoot_FromExplicitRepoPath_ReturnsRoot()
20+
{
21+
// Start from a known subdirectory within the repo.
22+
var root = RepoRootFinder.FindRepoRoot();
23+
Assert.NotNull(root);
24+
25+
var subDir = Path.Combine(root, "src", "Reactor");
26+
var found = RepoRootFinder.FindRepoRoot(subDir);
27+
Assert.Equal(root, found);
28+
}
29+
30+
[Fact]
31+
public void FindRepoRoot_FromUnrelatedPath_ReturnsNull()
32+
{
33+
var temp = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
34+
Directory.CreateDirectory(temp);
35+
try
36+
{
37+
Assert.Null(RepoRootFinder.FindRepoRoot(temp));
38+
}
39+
finally
40+
{
41+
Directory.Delete(temp);
42+
}
43+
}
44+
}

tools/uninstall-skill-kit.ps1

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Uninstall the Reactor skill kit.
2+
#
3+
# Removes the installed kit directory and cleans the user PATH entries that
4+
# install-skill-kit.ps1 added. This is the inverse of install-skill-kit.ps1
5+
# and addresses https://github.com/microsoft/microsoft-ui-reactor/issues/238.
6+
#
7+
# Usage:
8+
# .\uninstall-skill-kit.ps1 # default: ~/.claude/skills/reactor
9+
# .\uninstall-skill-kit.ps1 -Path C:\foo # custom location
10+
11+
[CmdletBinding()]
12+
param(
13+
[string] $Path = (Join-Path $env:USERPROFILE '.claude\skills\reactor')
14+
)
15+
16+
$ErrorActionPreference = 'Stop'
17+
18+
$absPath = [System.IO.Path]::GetFullPath($Path)
19+
20+
# Safety: refuse to delete drive roots, profile root, system dirs, etc.
21+
$forbidden = @(
22+
[System.IO.Path]::GetPathRoot($absPath).TrimEnd('\'),
23+
$env:USERPROFILE,
24+
$env:SystemRoot,
25+
"$env:SystemRoot\System32",
26+
$env:ProgramFiles,
27+
"${env:ProgramFiles(x86)}",
28+
"$env:USERPROFILE\Desktop",
29+
"$env:USERPROFILE\Documents",
30+
"$env:USERPROFILE\Downloads"
31+
) | Where-Object { $_ }
32+
foreach ($f in $forbidden) {
33+
if ($absPath -ieq $f.TrimEnd('\')) {
34+
throw "Refusing to delete '$absPath' — that's a system or user-data root."
35+
}
36+
}
37+
if ($absPath.Length -lt 12) {
38+
throw "Refusing to delete '$absPath' — path is suspiciously short."
39+
}
40+
41+
# 1. Remove PATH entries for both architectures so we don't leave stale
42+
# entries behind (e.g. installed on x64, uninstalling from ARM64).
43+
$userPathRaw = [Environment]::GetEnvironmentVariable('Path', 'User')
44+
$entries = ($userPathRaw -split ';') | Where-Object { $_ -ne '' }
45+
$archDirs = @(
46+
[System.IO.Path]::GetFullPath((Join-Path $absPath 'bin\x64')),
47+
[System.IO.Path]::GetFullPath((Join-Path $absPath 'bin\arm64'))
48+
)
49+
50+
$cleaned = @()
51+
$removedEntries = @()
52+
foreach ($e in $entries) {
53+
$eNorm = [System.IO.Path]::GetFullPath($e).TrimEnd('\')
54+
$match = $false
55+
foreach ($a in $archDirs) {
56+
if ($eNorm -ieq $a.TrimEnd('\')) {
57+
$match = $true
58+
$removedEntries += $e
59+
break
60+
}
61+
}
62+
if (-not $match) { $cleaned += $e }
63+
}
64+
65+
if ($removedEntries.Count -gt 0) {
66+
$newPath = $cleaned -join ';'
67+
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
68+
foreach ($r in $removedEntries) {
69+
Write-Host " Removed from user PATH: $r"
70+
}
71+
} else {
72+
Write-Host " No skill-kit PATH entries found."
73+
}
74+
75+
# 2. Delete the installed directory.
76+
if (Test-Path $absPath) {
77+
Remove-Item -Recurse -Force $absPath
78+
Write-Host " Removed install directory: $absPath"
79+
} else {
80+
Write-Host " Install directory not found: $absPath (already removed?)"
81+
}
82+
83+
Write-Host ""
84+
Write-Host "Done. Reactor skill kit has been uninstalled."

0 commit comments

Comments
 (0)