-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Expand file tree
/
Copy pathuninstall.ps1
More file actions
263 lines (241 loc) · 13.1 KB
/
Copy pathuninstall.ps1
File metadata and controls
263 lines (241 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
#Requires -Version 5.1
<#
.SYNOPSIS
Claude Ads Uninstaller for Windows (multi-host).
.DESCRIPTION
Removes only paths listed in install.ps1's ownership manifest. Unrelated
ads-* skills and agents are never discovered or deleted by namespace.
.PARAMETER Target
Which host CLI to uninstall from. Default: claude.
#>
param(
[ValidateSet('claude','codex','cursor','windsurf','gemini','goose')]
[string]$Target = 'claude',
[string]$SkillDir = '',
[string]$AgentDir = ''
)
$ErrorActionPreference = "Stop"
function Get-ClaudeAdsUserHome {
if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) { return $env:USERPROFILE }
if (-not [string]::IsNullOrWhiteSpace($HOME)) { return $HOME }
$ProfileHome = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)
if (-not [string]::IsNullOrWhiteSpace($ProfileHome)) { return $ProfileHome }
throw "Cannot determine the current user's home directory."
}
function Resolve-TargetPaths {
param([string]$T)
$UserHome = Get-ClaudeAdsUserHome
switch ($T) {
'claude' { return @{ SkillBase = Join-Path $UserHome ".claude\skills"; AgentDir = Join-Path $UserHome ".claude\agents" } }
'codex' { return @{ SkillBase = Join-Path $UserHome ".codex\skills"; AgentDir = Join-Path $UserHome ".codex\agents" } }
'cursor' { return @{ SkillBase = Join-Path $UserHome ".cursor\extensions\claude-ads\skills"; AgentDir = Join-Path $UserHome ".cursor\extensions\claude-ads\agents" } }
'windsurf' { return @{ SkillBase = Join-Path $UserHome ".windsurf\skills"; AgentDir = Join-Path $UserHome ".windsurf\agents" } }
'gemini' { return @{ SkillBase = Join-Path $UserHome ".gemini\extensions\claude-ads\skills"; AgentDir = Join-Path $UserHome ".gemini\extensions\claude-ads\agents" } }
'goose' { return @{ SkillBase = Join-Path $UserHome ".config\goose\skills"; AgentDir = Join-Path $UserHome ".config\goose\agents" } }
default { throw "Unknown target: $T" }
}
}
function Get-NormalizedInstallRoot([string]$Path) {
$FullPath = [IO.Path]::GetFullPath($Path)
$PathRoot = [IO.Path]::GetPathRoot($FullPath)
$Comparison = if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) {
[StringComparison]::OrdinalIgnoreCase
} else {
[StringComparison]::Ordinal
}
if ($FullPath.Equals($PathRoot, $Comparison)) { return $FullPath }
$Separators = [char[]]@([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
return $FullPath.TrimEnd($Separators)
}
function Main {
$paths = Resolve-TargetPaths -T $Target
$Separators = [char[]]@([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
$Boundary = [string][IO.Path]::DirectorySeparatorChar
$PathComparison = if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) {
[StringComparison]::OrdinalIgnoreCase
} else {
[StringComparison]::Ordinal
}
$StringComparer = if ($PathComparison -eq [StringComparison]::OrdinalIgnoreCase) {
[StringComparer]::OrdinalIgnoreCase
} else {
[StringComparer]::Ordinal
}
$SkillBase = if ($SkillDir) { $SkillDir } else { $paths.SkillBase }
$AgentDirResolved = if ($AgentDir) { $AgentDir } else { $paths.AgentDir }
$SkillBase = Get-NormalizedInstallRoot $SkillBase
$AgentDirResolved = Get-NormalizedInstallRoot $AgentDirResolved
function Get-RootPrefix([string]$Root) {
if ($Root.EndsWith($Boundary, $PathComparison)) { return $Root }
return $Root + $Boundary
}
$SkillRootPrefix = Get-RootPrefix $SkillBase
$AgentRootPrefix = Get-RootPrefix $AgentDirResolved
if ($SkillBase.Equals($AgentDirResolved, $PathComparison) -or
$SkillBase.StartsWith($AgentRootPrefix, $PathComparison) -or
$AgentDirResolved.StartsWith($SkillRootPrefix, $PathComparison)) {
throw "Skill and agent install roots must not overlap: $SkillBase ; $AgentDirResolved"
}
$ManifestPath = [IO.Path]::GetFullPath((Join-Path $SkillBase ".claude-ads-$Target.manifest.json"))
function Get-ContainingRoot([string]$Path) {
$FullPath = [IO.Path]::GetFullPath($Path)
if ($FullPath.StartsWith($SkillRootPrefix, $PathComparison)) { return $SkillBase }
if ($FullPath.StartsWith($AgentRootPrefix, $PathComparison)) { return $AgentDirResolved }
throw "Unsafe ownership-manifest path: $Path"
}
function Assert-CanonicalOwnedPath([string]$Path) {
if ([string]::IsNullOrWhiteSpace($Path)) { throw "Invalid empty ownership-manifest path" }
$FullPath = [IO.Path]::GetFullPath($Path)
if (-not $Path.Equals($FullPath, $PathComparison)) {
throw "Non-canonical ownership-manifest path: $Path"
}
try { $null = Get-ContainingRoot $FullPath } catch {
throw "Unsafe ownership-manifest path: $Path"
}
return $FullPath
}
function Assert-NoReparseChain([string]$Path, [string]$StopAt = '') {
$FullPath = [IO.Path]::GetFullPath($Path)
$StopPath = if ($StopAt) { [IO.Path]::GetFullPath($StopAt) } else { '' }
$Current = $FullPath
while ($true) {
$Item = Get-Item -LiteralPath $Current -Force -ErrorAction SilentlyContinue
if ($null -ne $Item) {
if (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Refusing reparse-point uninstall path: $Current"
}
if (-not $Current.Equals($FullPath, $PathComparison) -and -not $Item.PSIsContainer) {
throw "Uninstall path parent is not a directory: $Current"
}
}
if ($StopPath -and $Current.Equals($StopPath, $PathComparison)) { break }
$Parent = [IO.Path]::GetDirectoryName($Current)
if ([string]::IsNullOrEmpty($Parent) -or $Parent.Equals($Current, $PathComparison)) { break }
$Current = $Parent
}
return $FullPath
}
function Assert-SafeRecursiveTree([string]$Path) {
$Root = Get-ContainingRoot $Path
[void](Assert-NoReparseChain $Path $Root)
$Item = Get-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
if ($null -eq $Item) { return }
if (-not $Item.PSIsContainer) { throw "Recursive ownership entry is not a directory: $Path" }
$Pending = [System.Collections.Generic.Stack[string]]::new()
$Pending.Push([IO.Path]::GetFullPath($Path))
while ($Pending.Count -gt 0) {
$Directory = $Pending.Pop()
[void](Assert-NoReparseChain $Directory $Root)
foreach ($Child in @(Get-ChildItem -LiteralPath $Directory -Force)) {
if (($Child.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Refusing reparse point inside recursive ownership directory: $($Child.FullName)"
}
if ($Child.PSIsContainer) { $Pending.Push($Child.FullName) }
}
}
}
function Assert-CurrentUserOwnedFile([string]$Path) {
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { return }
$Identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$Sections = [System.Security.AccessControl.AccessControlSections]::Owner
if ($PSVersionTable.PSEdition -eq 'Core') {
$FileInfo = [System.IO.FileInfo]::new($Path)
$FileSecurity = [System.IO.FileSystemAclExtensions]::GetAccessControl($FileInfo, $Sections)
} else {
$FileSecurity = [System.IO.File]::GetAccessControl($Path, $Sections)
}
$OwnerSid = $FileSecurity.GetOwner([System.Security.Principal.SecurityIdentifier])
$CallerSids = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
if ($null -ne $Identity.User) { [void]$CallerSids.Add($Identity.User.Value) }
foreach ($Group in @($Identity.Groups)) { [void]$CallerSids.Add($Group.Value) }
if ($null -eq $OwnerSid -or -not $CallerSids.Contains($OwnerSid.Value)) {
throw "Ownership manifest is not owned by the current Windows principal: $Path"
}
}
[void](Assert-NoReparseChain $SkillBase)
[void](Assert-NoReparseChain $AgentDirResolved)
[void](Assert-NoReparseChain $ManifestPath $SkillBase)
$ManifestItem = Get-Item -LiteralPath $ManifestPath -Force -ErrorAction SilentlyContinue
if ($null -eq $ManifestItem -or $ManifestItem.PSIsContainer -or
(($ManifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
throw "Ownership manifest not found or unsafe: $ManifestPath. Refusing namespace-based deletion."
}
Assert-CurrentUserOwnedFile $ManifestPath
try { $Manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json } catch {
throw "Invalid ownership manifest: $ManifestPath"
}
$ExpectedProperties = @('version','target','files','directories','recursive_directories')
$ActualProperties = @($Manifest.PSObject.Properties.Name)
$VersionType = if ($null -eq $Manifest.version) { $null } else { $Manifest.version.GetType() }
if (@(Compare-Object $ExpectedProperties $ActualProperties).Count -ne 0 -or
$VersionType -notin @([int], [long]) -or $Manifest.version -ne 1 -or
-not ($Manifest.target -is [string]) -or $Manifest.target -cne $Target) {
throw "Invalid or mismatched ownership manifest: $ManifestPath"
}
$AllPaths = [System.Collections.Generic.HashSet[string]]::new($StringComparer)
$Validated = @{}
foreach ($PropertyName in @('files','directories','recursive_directories')) {
if (-not ($Manifest.$PropertyName -is [System.Array])) {
throw "Ownership manifest property must be an array: $PropertyName"
}
$Paths = [System.Collections.Generic.List[string]]::new()
foreach ($OwnedPath in @($Manifest.$PropertyName)) {
if (-not ($OwnedPath -is [string])) {
throw "Invalid ownership-manifest entry in ${PropertyName}: $ManifestPath"
}
$FullPath = Assert-CanonicalOwnedPath $OwnedPath
if (-not $AllPaths.Add($FullPath)) { throw "Duplicate ownership-manifest path: $FullPath" }
[void](Assert-NoReparseChain $FullPath (Get-ContainingRoot $FullPath))
$Item = Get-Item -LiteralPath $FullPath -Force -ErrorAction SilentlyContinue
if ($null -ne $Item) {
if ($PropertyName -eq 'files' -and $Item.PSIsContainer) {
throw "Owned file entry is a directory: $FullPath"
}
if ($PropertyName -ne 'files' -and -not $Item.PSIsContainer) {
throw "Owned directory entry is not a directory: $FullPath"
}
}
[void]$Paths.Add($FullPath)
}
$Validated[$PropertyName] = $Paths
}
foreach ($RecursivePath in $Validated.recursive_directories) { Assert-SafeRecursiveTree $RecursivePath }
Write-Host "Uninstalling Claude Ads from $SkillBase and $AgentDirResolved..."
foreach ($FullPath in $Validated.files) {
[void](Assert-NoReparseChain $FullPath (Get-ContainingRoot $FullPath))
$Item = Get-Item -LiteralPath $FullPath -Force -ErrorAction SilentlyContinue
if ($null -ne $Item) {
if ($Item.PSIsContainer -or (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
throw "Owned file changed type before deletion: $FullPath"
}
Remove-Item -LiteralPath $FullPath -Force
}
}
foreach ($FullPath in $Validated.recursive_directories) {
Assert-SafeRecursiveTree $FullPath
$Item = Get-Item -LiteralPath $FullPath -Force -ErrorAction SilentlyContinue
if ($null -ne $Item) { Remove-Item -LiteralPath $FullPath -Recurse -Force }
}
foreach ($FullPath in @($Validated.directories | Sort-Object Length -Descending)) {
[void](Assert-NoReparseChain $FullPath (Get-ContainingRoot $FullPath))
$Item = Get-Item -LiteralPath $FullPath -Force -ErrorAction SilentlyContinue
if ($null -ne $Item) {
if (-not $Item.PSIsContainer -or (($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
throw "Owned directory changed type before deletion: $FullPath"
}
$Children = @(Get-ChildItem -LiteralPath $FullPath -Force -ErrorAction SilentlyContinue)
if ($Children.Count -eq 0) {
Remove-Item -LiteralPath $FullPath -Force -Confirm:$false
}
}
}
[void](Assert-NoReparseChain $ManifestPath $SkillBase)
$ManifestItem = Get-Item -LiteralPath $ManifestPath -Force -ErrorAction SilentlyContinue
if ($null -eq $ManifestItem -or $ManifestItem.PSIsContainer -or
(($ManifestItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) {
throw "Ownership manifest changed before deletion: $ManifestPath"
}
Remove-Item -LiteralPath $ManifestPath -Force
Write-Host "[OK] Claude Ads uninstalled." -ForegroundColor Green
}
Main