# FLABS Skill installer. Downloads a five-file Skill package; never runs their contents. [CmdletBinding()] param( [ValidateSet('agents','claude','codex','gemini','copilot','opencode')][string]$Target = 'agents', [switch]$Update, [switch]$MigrateLegacy, [string]$Actor, [switch]$NoActor, [string]$ProfileDir, [string]$SkillsDir, [string]$LinkDir, [switch]$Help ) Set-StrictMode -Version 2.0 $ErrorActionPreference = 'Stop' if ($Help) { Write-Output 'Usage: install.ps1 [-Target agents|claude|codex|gemini|copilot|opencode] [-Update] [-MigrateLegacy] [-ProfileDir DIR] [-SkillsDir DIR] [-LinkDir DIR] [-Actor UUID | -NoActor]' Write-Output 'DIR names the parent of flabs. Defaults: ~/.agents/skills and ~/.claude/skills.' Write-Output 'Existing content is refused by default. -Update keeps a backup and rolls back on failure.' Write-Output 'Claude may reuse a valid existing flabs package without download or update.' Write-Output 'Windows creates a native directory junction without requesting administrator rights.' Write-Output 'Wrong links and ordinary directories at the Claude link are never replaced.' Write-Output 'All six targets use the shared agents directory; only Claude adds a link.' Write-Output 'Five files and four strict SHA256 entries are checked before migration or update.' Write-Output 'Legacy FLABS copies are refused (exit 3) unless -MigrateLegacy is explicit; backups remain.' Write-Output '-ProfileDir confines profile scanning and ignores inherited CODEX_HOME.' Write-Output 'Actor ID is local UUIDv4 state, not authentication; -Actor UUID or -NoActor overrides inherited state.' Write-Output 'Default inherits or creates an ID unless disabled; server statistics are disabled by default.' Write-Output 'Inspect this script before use. Start a new Agent session after installation.' return } $flabsActorExplicit = $PSBoundParameters.ContainsKey('Actor') if ($flabsActorExplicit -and $NoActor) { throw 'Actor options are mutually exclusive.' } if ($flabsActorExplicit -and ($Actor.Length -ne 36 -or $Actor -cnotmatch '\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\z')) { throw 'Actor requires a strict UUIDv4 value.' } $flabsBase = 'https://fai.fashion' $flabsExplicitProfile = -not [string]::IsNullOrWhiteSpace($ProfileDir) if ($flabsExplicitProfile) { $flabsProfileRoot = [IO.Path]::GetFullPath($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($ProfileDir)) } else { $flabsProfileRoot = [Environment]::GetFolderPath('UserProfile') } if ([string]::IsNullOrWhiteSpace($flabsProfileRoot)) { throw 'A profile root is required.' } $profileItem = Get-Item -LiteralPath $flabsProfileRoot -Force -ErrorAction SilentlyContinue if ($null -eq $profileItem -or -not $profileItem.PSIsContainer -or ($profileItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'ProfileDir must name a real existing directory.' } if ($flabsProfileRoot.TrimEnd('\','/') -eq [IO.Path]::GetPathRoot($flabsProfileRoot).TrimEnd('\','/')) { throw 'A filesystem root cannot be ProfileDir.' } $flabsCodexRoot = Join-Path $flabsProfileRoot '.codex' if (-not $flabsExplicitProfile -and -not [string]::IsNullOrWhiteSpace($env:CODEX_HOME)) { $flabsCodexRoot = $env:CODEX_HOME } if ([string]::IsNullOrWhiteSpace($SkillsDir)) { $SkillsDir = Join-Path $flabsProfileRoot '.agents/skills' } if ($Target -eq 'claude' -and [string]::IsNullOrWhiteSpace($LinkDir)) { $LinkDir = Join-Path $flabsProfileRoot '.claude/skills' } function Get-AbsoluteLiteralPath([string]$Value) { return [IO.Path]::GetFullPath($ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Value)) } function Assert-ProfileAncestors([string]$Value) { if (-not $flabsExplicitProfile) { return } $ancestor = Get-AbsoluteLiteralPath $Value $root = (Get-AbsoluteLiteralPath $flabsProfileRoot).TrimEnd('\','/') if (-not $ancestor.Equals($root,[StringComparison]::OrdinalIgnoreCase) -and -not $ancestor.StartsWith($root + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) { return } while (-not $ancestor.Equals($root,[StringComparison]::OrdinalIgnoreCase)) { $item = Get-Item -LiteralPath $ancestor -Force -ErrorAction SilentlyContinue if ($null -ne $item -and ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Profile ancestor is a link: $ancestor. Nothing moved." } $ancestor = [IO.Path]::GetDirectoryName($ancestor) } } function Initialize-Root([string]$Value) { $absolute = Get-AbsoluteLiteralPath $Value Assert-ProfileAncestors $absolute if ($absolute.TrimEnd('\','/') -eq [IO.Path]::GetPathRoot($absolute).TrimEnd('\','/')) { throw 'A filesystem root cannot be the skills or link directory.' } $item = Get-Item -LiteralPath $absolute -Force -ErrorAction SilentlyContinue if ($null -ne $item -and (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint))) { throw "Root must be a real directory, not a file or link: $absolute" } [IO.Directory]::CreateDirectory($absolute) | Out-Null return (Get-AbsoluteLiteralPath $absolute).TrimEnd('\','/') } function Assert-ChildPath([string]$Candidate, [string]$Root) { $absolute = Get-AbsoluteLiteralPath $Candidate $rootAbsolute = (Get-AbsoluteLiteralPath $Root).TrimEnd('\','/') $prefix = $rootAbsolute + [IO.Path]::DirectorySeparatorChar if (-not $absolute.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { throw "Refused operation outside named root: $absolute" } return $absolute } $skillsRoot = Initialize-Root $SkillsDir $skillPath = Join-Path $skillsRoot 'flabs' $lockPath = Join-Path $skillsRoot '.flabs-install.lock' $stage = $null $backup = $null $linkRoot = $null $linkPath = $null $promoted = $false $backedUp = $false $linkCreated = $false $committed = $false $lockOwned = $false function Move-OwnedDirectory([string]$From, [string]$To) { $fromAbsolute = Assert-ChildPath $From $skillsRoot $toAbsolute = Assert-ChildPath $To $skillsRoot $item = Get-Item -LiteralPath $fromAbsolute -Force if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Move source must be a real directory: $fromAbsolute" } if ($null -ne (Get-Item -LiteralPath $toAbsolute -Force -ErrorAction SilentlyContinue)) { throw "Move destination already exists: $toAbsolute" } Move-Item -LiteralPath $fromAbsolute -Destination $toAbsolute -ErrorAction Stop } function Test-FlabsSkill([string]$Directory) { $file = Join-Path $Directory 'SKILL.md' $item = Get-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue if ($null -eq $item -or $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { return $false } $lines = [IO.File]::ReadAllLines($file) if ($lines.Length -lt 3 -or $lines[0].TrimStart([char]0xFEFF).Trim() -ne '---') { return $false } $named = $false for ($index = 1; $index -lt [Math]::Min($lines.Length,81); $index++) { if ($lines[$index].Trim() -eq '---') { return $named } if ($lines[$index] -cmatch '^name:\s*(?:flabs|"flabs"|''flabs'')\s*$') { $named = $true } } return $false } function Test-CompletePackage([string]$Directory) { $agents = Get-Item -LiteralPath (Join-Path $Directory 'agents') -Force -ErrorAction SilentlyContinue if ($null -eq $agents -or -not $agents.PSIsContainer -or ($agents.Attributes -band [IO.FileAttributes]::ReparsePoint)) { return $false } $names = @('SKILL.md','README.md','LICENSE','agents/openai.yaml','manifest.sha256') foreach ($name in $names) { $item = Get-Item -LiteralPath (Join-Path $Directory $name) -Force -ErrorAction SilentlyContinue if ($null -eq $item -or $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or $item.Length -eq 0) { return $false } } if (-not (Test-FlabsSkill $Directory)) { return $false } $lines = [IO.File]::ReadAllLines((Join-Path $Directory 'manifest.sha256')) if ($lines.Length -ne 4) { return $false } $seen = @{} foreach ($line in $lines) { if ($line -cnotmatch '^([0-9a-fA-F]{64}) (SKILL\.md|README\.md|LICENSE|agents/openai\.yaml)$') { return $false } $wanted = $Matches[1].ToLowerInvariant(); $name = $Matches[2] if ($seen.ContainsKey($name)) { return $false } $seen[$name] = $true $stream = [IO.File]::OpenRead((Join-Path $Directory $name)) $hasher = [Security.Cryptography.SHA256]::Create() try { $actual = [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-','').ToLowerInvariant() } finally { $stream.Dispose(); $hasher.Dispose() } if ($actual -ne $wanted) { return $false } } return $true } $script:flabsLegacyPaths = @() $script:flabsLegacyRoots = @() $script:flabsLegacyBackups = @() $script:flabsLegacyMoved = 0 $script:flabsLegacyPending = $true function Test-ExpectedSharedLink($Item) { if ($null -eq $Item -or ($Item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -or $Item.LinkType -notin @('SymbolicLink','Junction')) { return $false } $destination = [string](@($Item.Target)[0]) if (-not [IO.Path]::IsPathRooted($destination)) { $destination = Join-Path $Item.Parent.FullName $destination } return (Get-AbsoluteLiteralPath $destination).Equals((Get-AbsoluteLiteralPath $skillPath),[StringComparison]::OrdinalIgnoreCase) } function Find-LegacySkills { $script:flabsLegacyPaths = @(); $script:flabsLegacyRoots = @() $parents = @((Join-Path $flabsProfileRoot '.claude/skills'),(Join-Path $flabsCodexRoot 'skills'), (Join-Path $flabsProfileRoot '.gemini/skills'),(Join-Path $flabsProfileRoot '.copilot/skills'), (Join-Path $flabsProfileRoot '.config/opencode/skills')) foreach ($parent in $parents) { Assert-ProfileAncestors $parent $candidate = Get-AbsoluteLiteralPath (Join-Path $parent 'flabs') $item = Get-Item -LiteralPath $candidate -Force -ErrorAction SilentlyContinue if ($null -eq $item) { continue } if ($candidate.Equals((Get-AbsoluteLiteralPath $skillPath),[StringComparison]::OrdinalIgnoreCase)) { continue } if (Test-ExpectedSharedLink $item) { continue } if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Unknown or wrong legacy link/file: $candidate. Nothing moved." } if (-not (Test-FlabsSkill $candidate)) { throw "Legacy directory is not name: flabs: $candidate. Nothing moved." } if ($script:flabsLegacyPaths -contains $candidate) { continue } $script:flabsLegacyPaths += $candidate $script:flabsLegacyRoots += (Get-AbsoluteLiteralPath $parent) } if ($script:flabsLegacyPaths.Count -gt 0 -and -not $MigrateLegacy) { [Console]::Error.WriteLine('FLABS: legacy copies detected; inspect and explicitly use -MigrateLegacy. Nothing moved.') foreach ($candidate in $script:flabsLegacyPaths) { [Console]::Error.WriteLine($candidate) } exit 3 } } function Move-LegacyDirectory([string]$From,[string]$To,[string]$Root) { $fromAbsolute = Assert-ChildPath $From $Root $toAbsolute = Assert-ChildPath $To $Root $item = Get-Item -LiteralPath $fromAbsolute -Force if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or -not (Test-FlabsSkill $fromAbsolute)) { throw 'Legacy move source is not an identified real FLABS directory.' } if ($null -ne (Get-Item -LiteralPath $toAbsolute -Force -ErrorAction SilentlyContinue)) { throw 'Legacy move destination already exists.' } Move-Item -LiteralPath $fromAbsolute -Destination $toAbsolute -ErrorAction Stop } function Save-LegacyBackups { for ($index=0; $index -lt $script:flabsLegacyPaths.Count; $index++) { $legacyBackup = Join-Path $script:flabsLegacyRoots[$index] ('.flabs-legacy-backup.' + [Guid]::NewGuid().ToString('N')) Move-LegacyDirectory $script:flabsLegacyPaths[$index] $legacyBackup $script:flabsLegacyRoots[$index] $script:flabsLegacyBackups += $legacyBackup $script:flabsLegacyMoved++ } $script:flabsLegacyPending = $false } function Assert-ExistingSkill { $item = Get-Item -LiteralPath $skillPath -Force -ErrorAction SilentlyContinue if ($null -ne $item) { if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Existing flabs must be a real directory, not a file or link.' } if (-not (Test-FlabsSkill $skillPath)) { throw 'Existing SKILL.md does not identify name: flabs. No overwrite.' } if (-not $Update -and -not ($MigrateLegacy -and $script:flabsLegacyPaths.Count -gt 0)) { $readme = Get-Item -LiteralPath (Join-Path $skillPath 'README.md') -Force -ErrorAction SilentlyContinue if ($Target -eq 'claude' -and $null -ne $readme -and -not $readme.PSIsContainer -and ($readme.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -and $readme.Length -gt 0) { if (-not (Test-CompletePackage $skillPath)) { throw 'Existing old or invalid package cannot be reused; inspect and explicitly use -Update.' } return $true } throw "Already exists: $skillPath. Use -Update only after reviewing it." } } return $false } function Assert-ClaudeLink { $item = Get-Item -LiteralPath $linkPath -Force -ErrorAction SilentlyContinue if ($null -eq $item) { return } if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -or $item.LinkType -notin @('SymbolicLink','Junction')) { if ($script:flabsLegacyPending -and $MigrateLegacy -and ($script:flabsLegacyPaths -contains (Get-AbsoluteLiteralPath $linkPath))) { return } throw "Claude link path is an ordinary file, directory, or unsupported link: $linkPath. Inspect it manually." } $destination = [string](@($item.Target)[0]) if (-not [IO.Path]::IsPathRooted($destination)) { $destination = Join-Path $linkRoot $destination } $resolved = Get-AbsoluteLiteralPath $destination if (-not $resolved.Equals((Get-AbsoluteLiteralPath $skillPath), [StringComparison]::OrdinalIgnoreCase)) { throw "Existing Claude link points elsewhere: $linkPath. Inspect it manually." } } $script:flabsActorKind = 'none' $script:flabsActorId = $null $script:flabsActorStateStage = $null $script:flabsActorStateActive = $false $script:flabsActorRestoreFailed = $false $script:flabsActorOld = @() $script:flabsActorNew = @() function Test-ActorUuid([string]$Value) { return ($Value.Length -eq 36 -and $Value -cmatch '\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}\z') } function Assert-ActorStateFiles([string]$Directory) { $rootItem = Get-Item -LiteralPath $Directory -Force -ErrorAction Stop if (-not $rootItem.PSIsContainer -or ($rootItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Actor source must be a real Skill directory.' } foreach ($name in @('.flabs-actor-id','.flabs-actor-disabled','.gitignore')) { $item = Get-Item -LiteralPath (Join-Path $Directory $name) -Force -ErrorAction SilentlyContinue if ($null -ne $item -and ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint))) { throw 'Actor state and ignore files must be regular files, not links or directories.' } } } function Read-ActorId([string]$Directory) { $file = Join-Path $Directory '.flabs-actor-id' $item = Get-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue if ($null -eq $item) { return $null } $value = [IO.File]::ReadAllText($file).TrimEnd([char]13,[char]10) if (Test-ActorUuid $value) { return $value.ToLowerInvariant() } return $null } function Select-ActorState { $script:flabsActorKind = 'none'; $script:flabsActorId = $null $sources = @() $canonical = Get-Item -LiteralPath $skillPath -Force -ErrorAction SilentlyContinue if ($null -ne $canonical) { $sources += $skillPath } $sources += $script:flabsLegacyPaths foreach ($directory in $sources) { Assert-ActorStateFiles $directory } if ($flabsActorExplicit) { $script:flabsActorKind = 'id'; $script:flabsActorId = $Actor.ToLowerInvariant(); return } if ($NoActor) { $script:flabsActorKind = 'disabled'; return } foreach ($directory in $sources) { if ($null -ne (Get-Item -LiteralPath (Join-Path $directory '.flabs-actor-disabled') -Force -ErrorAction SilentlyContinue)) { $script:flabsActorKind = 'disabled'; return } } if ($null -ne $canonical) { $value = Read-ActorId $skillPath if ($null -ne $value) { $script:flabsActorKind = 'id'; $script:flabsActorId = $value; return } } $first = $null foreach ($directory in $script:flabsLegacyPaths) { $value = Read-ActorId $directory if ($null -eq $value) { continue } if ($null -eq $first) { $first = $value } elseif ($first -cne $value) { $script:flabsActorKind = 'conflict' Write-Warning 'Legacy actor identifiers conflict; no identifier or disabled marker will be created.' return } } $script:flabsActorKind = 'id' if ($null -ne $first) { $script:flabsActorId = $first } else { $script:flabsActorId = [Guid]::NewGuid().ToString().ToLowerInvariant() } if (-not (Test-ActorUuid $script:flabsActorId)) { throw 'UUIDv4 generation failed; no identifier was written.' } } function Get-ActorFileHash([string]$File) { $stream = [IO.File]::OpenRead($File); $hasher = [Security.Cryptography.SHA256]::Create() try { return [BitConverter]::ToString($hasher.ComputeHash($stream)).Replace('-','').ToLowerInvariant() } finally { $stream.Dispose(); $hasher.Dispose() } } function Write-PrivateActorFile([string]$Directory,[string]$Name,[string]$Value) { if ($Name -notin @('.flabs-actor-id','.flabs-actor-disabled','.gitignore')) { throw 'Unknown actor output file.' } $absolute = Assert-ChildPath (Join-Path $Directory $Name) $Directory Assert-ChildPath $Directory $skillsRoot | Out-Null $directoryItem = Get-Item -LiteralPath $Directory -Force -ErrorAction Stop if (-not $directoryItem.PSIsContainer -or ($directoryItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Actor output must be a real directory.' } if ($null -ne (Get-Item -LiteralPath $absolute -Force -ErrorAction SilentlyContinue)) { throw 'Actor output file already exists.' } $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User $acl = [Security.AccessControl.FileSecurity]::new() $acl.SetAccessRuleProtection($true,$false); $acl.SetOwner($sid) $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid,[Security.AccessControl.FileSystemRights]::FullControl,[Security.AccessControl.AccessControlType]::Allow)) $stream = [IO.FileStream]::new($absolute,[IO.FileMode]::CreateNew,[Security.AccessControl.FileSystemRights]::Write,[IO.FileShare]::None,4096,[IO.FileOptions]::None,$acl) try { $bytes = [Text.UTF8Encoding]::new($false).GetBytes($Value); $stream.Write($bytes,0,$bytes.Length) } finally { $stream.Dispose() } $actual = [IO.File]::GetAccessControl($absolute) if (-not $actual.AreAccessRulesProtected -or -not $actual.GetOwner([Security.Principal.SecurityIdentifier]).Equals($sid)) { throw 'Actor file ACL verification failed.' } foreach ($rule in $actual.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and -not $rule.IdentityReference.Equals($sid)) { throw 'Actor file ACL allows another identity.' } } } function Write-ActorState([string]$Directory) { Assert-ActorStateFiles $Directory if ($script:flabsActorKind -eq 'id') { Write-PrivateActorFile $Directory '.flabs-actor-id' ($script:flabsActorId + [char]10) } if ($script:flabsActorKind -eq 'disabled') { Write-PrivateActorFile $Directory '.flabs-actor-disabled' '' } Write-PrivateActorFile $Directory '.gitignore' ('/.flabs-actor-id' + [char]10 + '/.flabs-actor-disabled' + [char]10) } function Apply-ActorReuse { $script:flabsActorStateStage = Join-Path $skillsRoot ('.flabs-actor-state.' + [Guid]::NewGuid().ToString('N')) Assert-ChildPath $script:flabsActorStateStage $skillsRoot | Out-Null [IO.Directory]::CreateDirectory($script:flabsActorStateStage) | Out-Null $before = Join-Path $script:flabsActorStateStage 'before'; $after = Join-Path $script:flabsActorStateStage 'new' [IO.Directory]::CreateDirectory($before) | Out-Null; [IO.Directory]::CreateDirectory($after) | Out-Null Write-ActorState $after Assert-ActorStateFiles $skillPath $originalIgnore = Join-Path $skillPath '.gitignore' if ($null -ne (Get-Item -LiteralPath $originalIgnore -Force -ErrorAction SilentlyContinue)) { $originalBytes = [IO.File]::ReadAllBytes($originalIgnore) $existingLines = @([IO.File]::ReadAllText($originalIgnore).Split([char]10) | ForEach-Object { $_.TrimEnd([char]13) }) $suffix = '' foreach ($line in @('/.flabs-actor-id','/.flabs-actor-disabled')) { if ($existingLines -cnotcontains $line) { $suffix += [char]10 + $line + [char]10 } } $extraBytes = [Text.UTF8Encoding]::new($false).GetBytes($suffix) [IO.File]::WriteAllBytes((Join-Path $after '.gitignore'),[byte[]]($originalBytes + $extraBytes)) } $script:flabsActorStateActive = $true foreach ($name in @('.flabs-actor-id','.flabs-actor-disabled','.gitignore')) { $file = Assert-ChildPath (Join-Path $skillPath $name) $skillPath $oldItem = Get-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue if ($null -ne $oldItem) { $digest = Get-ActorFileHash $file Move-Item -LiteralPath $file -Destination (Join-Path $before $name) -ErrorAction Stop $script:flabsActorOld += @{ name=$name; hash=$digest } } $newFile = Join-Path $after $name if ($null -ne (Get-Item -LiteralPath $newFile -Force -ErrorAction SilentlyContinue)) { $digest = Get-ActorFileHash $newFile if ($null -ne (Get-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue)) { throw 'Actor destination changed before replacement.' } Move-Item -LiteralPath $newFile -Destination $file -ErrorAction Stop $script:flabsActorNew += @{ name=$name; hash=$digest } } } } function Restore-ActorReuse { if (-not $script:flabsActorStateActive) { return } $item = Get-Item -LiteralPath $skillPath -Force -ErrorAction Stop if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Actor restore target changed; original state backup preserved.' } for ($index=$script:flabsActorNew.Count-1; $index -ge 0; $index--) { $entry = $script:flabsActorNew[$index] $file = Assert-ChildPath (Join-Path $skillPath $entry.name) $skillPath $item = Get-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue if ($null -eq $item) { continue } if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or (Get-ActorFileHash $file) -cne $entry.hash) { $script:flabsActorRestoreFailed=$true; continue } try { [IO.File]::Delete($file) } catch { $script:flabsActorRestoreFailed=$true } } for ($index=$script:flabsActorOld.Count-1; $index -ge 0; $index--) { $entry = $script:flabsActorOld[$index] $file = Assert-ChildPath (Join-Path $skillPath $entry.name) $skillPath $original = Assert-ChildPath (Join-Path (Join-Path $script:flabsActorStateStage 'before') $entry.name) $script:flabsActorStateStage $item = Get-Item -LiteralPath $original -Force -ErrorAction SilentlyContinue if ($null -ne (Get-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue) -or $null -eq $item -or $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -or (Get-ActorFileHash $original) -cne $entry.hash) { $script:flabsActorRestoreFailed=$true; continue } try { Move-Item -LiteralPath $original -Destination $file -ErrorAction Stop } catch { $script:flabsActorRestoreFailed=$true } } if ($script:flabsActorRestoreFailed) { throw 'Actor state restore failed; original state backup preserved.' } $script:flabsActorStateActive=$false } function Remove-ActorTransaction { if ($null -eq $script:flabsActorStateStage -or $script:flabsActorRestoreFailed) { return } $absolute = Assert-ChildPath $script:flabsActorStateStage $skillsRoot if ([IO.Path]::GetFileName($absolute) -notlike '.flabs-actor-state.*') { throw 'Refused actor transaction cleanup outside its named root.' } $item = Get-Item -LiteralPath $absolute -Force -ErrorAction SilentlyContinue if ($null -eq $item) { return } if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Actor transaction changed; preserved.' } foreach ($child in @('before','new')) { $childItem = Get-Item -LiteralPath (Join-Path $absolute $child) -Force -ErrorAction Stop if (-not $childItem.PSIsContainer -or ($childItem.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Actor transaction child changed; preserved.' } } foreach ($childItem in Get-ChildItem -LiteralPath $absolute -Force) { if ($childItem.Name -notin @('before','new')) { throw 'Unknown actor transaction entry; preserved.' } } foreach ($child in @('before','new')) { foreach ($entry in Get-ChildItem -LiteralPath (Join-Path $absolute $child) -Force) { if ($entry.Name -notin @('.flabs-actor-id','.flabs-actor-disabled','.gitignore') -or $entry.PSIsContainer -or ($entry.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Unknown actor transaction child; preserved.' } } } # Verified absolute path belongs to this run under skillsRoot, never the installed Skill itself. Remove-Item -LiteralPath $absolute -Recurse -Force -ErrorAction Stop } function Remove-OwnedStage([string]$Directory) { $absolute = Assert-ChildPath $Directory $skillsRoot if ([IO.Path]::GetFileName($absolute) -notlike '.flabs-stage.*') { throw 'Refused cleanup outside installer staging.' } $item = Get-Item -LiteralPath $absolute -Force -ErrorAction SilentlyContinue if ($null -eq $item) { return } if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Staging path changed; preserved: $absolute" } # The resolved absolute staging path is inside skillsRoot and names this run's staging directory. Remove-Item -LiteralPath $absolute -Recurse -Force -ErrorAction Stop } try { Assert-ChildPath $lockPath $skillsRoot | Out-Null New-Item -ItemType Directory -Path $lockPath -ErrorAction Stop | Out-Null $lockOwned = $true Find-LegacySkills $reuseExisting = Assert-ExistingSkill Select-ActorState if ($Target -eq 'claude') { $linkRoot = Initialize-Root $LinkDir $linkPath = Join-Path $linkRoot 'flabs' if ($linkPath.Equals($skillPath,[StringComparison]::OrdinalIgnoreCase)) { throw '-LinkDir must differ from -SkillsDir.' } Assert-ClaudeLink } if ($reuseExisting) { Assert-ClaudeLink Apply-ActorReuse if ($null -eq (Get-Item -LiteralPath $linkPath -Force -ErrorAction SilentlyContinue)) { Assert-ChildPath $linkPath $linkRoot | Out-Null New-Item -ItemType Junction -Path $linkPath -Target $skillPath -ErrorAction Stop | Out-Null $linkCreated = $true } Assert-ClaudeLink $committed = $true Write-Output "Existing FLABS Skill reused: $skillPath" Write-Output "Claude link: $linkPath" Write-Output 'No package was downloaded, backed up or updated; its version was not checked. Local actor state was processed.' Write-Output 'Open a new Agent session to discover the Skill.' return } $stage = Join-Path $skillsRoot ('.flabs-stage.' + [Guid]::NewGuid().ToString('N')) Assert-ChildPath $stage $skillsRoot | Out-Null [IO.Directory]::CreateDirectory($stage) | Out-Null [IO.Directory]::CreateDirectory((Join-Path $stage 'agents')) | Out-Null foreach ($filename in @('SKILL.md','README.md','LICENSE','agents/openai.yaml','manifest.sha256')) { $file = Assert-ChildPath (Join-Path $stage $filename) $stage Invoke-WebRequest -Uri ($flabsBase + '/flabs-skill/' + $filename) -OutFile $file -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop if ((Get-Item -LiteralPath $file).Length -eq 0) { throw "Empty downloaded file: $filename" } } if (-not (Test-CompletePackage $stage)) { throw 'Downloaded complete package or manifest failed validation; nothing migrated or upgraded.' } Find-LegacySkills Assert-ExistingSkill | Out-Null if ($Target -eq 'claude') { Assert-ClaudeLink } Select-ActorState Write-ActorState $stage Save-LegacyBackups if ($Target -eq 'claude') { Assert-ClaudeLink } if ($null -ne (Get-Item -LiteralPath $skillPath -Force -ErrorAction SilentlyContinue)) { $backup = Join-Path $skillsRoot ('.flabs-backup.' + [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssfffZ') + '.' + [Guid]::NewGuid().ToString('N')) Move-OwnedDirectory $skillPath $backup $backedUp = $true } Move-OwnedDirectory $stage $skillPath $promoted = $true if ($Target -eq 'claude' -and $null -eq (Get-Item -LiteralPath $linkPath -Force -ErrorAction SilentlyContinue)) { Assert-ChildPath $linkPath $linkRoot | Out-Null New-Item -ItemType Junction -Path $linkPath -Target $skillPath -ErrorAction Stop | Out-Null $linkCreated = $true } if ($Target -eq 'claude') { Assert-ClaudeLink } if (-not (Test-CompletePackage $skillPath)) { throw 'Installed complete package validation failed.' } $committed = $true foreach ($legacyBackup in $script:flabsLegacyBackups) { Write-Output "Legacy directory backup retained: $legacyBackup" } Write-Output "FLABS Skill installed: $skillPath" if ($backedUp) { Write-Output "Original directory backup retained: $backup" } if ($Target -eq 'claude') { Write-Output "Claude link: $linkPath" } Write-Output 'Open a new Agent session, then ask for the five most important AI design and production updates from the past 24 hours.' } catch { $installationError = $_ if (-not $committed) { if ($linkCreated) { try { $linkAbsolute = Assert-ChildPath $linkPath $linkRoot $createdLink = Get-Item -LiteralPath $linkAbsolute -Force -ErrorAction Stop if (($createdLink.Attributes -band [IO.FileAttributes]::ReparsePoint) -eq 0 -or $createdLink.LinkType -ne 'Junction') { throw 'Created link is no longer the expected junction.' } Assert-ClaudeLink # Remove only this verified junction, without traversing its target or prompting. [IO.Directory]::Delete($linkAbsolute, $false) if ($null -ne (Get-Item -LiteralPath $linkAbsolute -Force -ErrorAction SilentlyContinue)) { throw 'Created junction remains after cleanup.' } $linkCreated = $false } catch { Write-Warning "Created link changed or cleanup failed; preserved for inspection: $linkPath; error: $($_.Exception.GetType().FullName); reason: $($_.Exception.Message)" } } if ($promoted) { try { Move-OwnedDirectory $skillPath $stage; $promoted = $false } catch { Write-Warning "Rollback could not move new installation; preserved: $skillPath" } } if ($backedUp) { try { Move-OwnedDirectory $backup $skillPath; $backedUp = $false } catch { Write-Warning "Restore failed. Original backup preserved: $backup" } } } if (-not $committed) { try { Restore-ActorReuse } catch { $script:flabsActorRestoreFailed=$true; Write-Warning "Actor state restore failed; original state backup preserved: $script:flabsActorStateStage" } for ($index=$script:flabsLegacyMoved-1; $index -ge 0; $index--) { try { Move-LegacyDirectory $script:flabsLegacyBackups[$index] $script:flabsLegacyPaths[$index] $script:flabsLegacyRoots[$index] } catch { Write-Warning "Legacy restore failed. Original backup preserved: $($script:flabsLegacyBackups[$index])" } } } throw $installationError } finally { if ($null -ne $stage) { try { Remove-OwnedStage $stage } catch { Write-Warning "Staging cleanup refused or failed; inspect: $stage" } } try { Remove-ActorTransaction } catch { Write-Warning "Actor transaction cleanup refused or failed; inspect: $script:flabsActorStateStage" } if ($lockOwned) { try { $absolute = Assert-ChildPath $lockPath $skillsRoot $item = Get-Item -LiteralPath $absolute -Force if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Install lock changed.' } # Only this run's empty lock directory is removed, without recursion. if (@(Get-ChildItem -LiteralPath $absolute -Force).Count -ne 0) { throw 'Install lock is no longer empty.' } Remove-Item -LiteralPath $absolute -Force -ErrorAction Stop } catch { Write-Warning "Install lock cleanup refused or failed; inspect: $lockPath" } } }