mirror of
https://github.com/AgriciDaniel/claude-ads.git
synced 2026-09-14 20:36:30 +08:00
264 lines
13 KiB
PowerShell
Vendored
264 lines
13 KiB
PowerShell
Vendored
#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
|