Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $script:RepositoryUrl = 'https://code.it.aisuan.cn/wenhuadian/xas-skills.git' $script:StateFile = '.xas-install.json' function Get-XasDefaultSkillsRoot { $profileDirectory = $env:USERPROFILE if ([string]::IsNullOrWhiteSpace($profileDirectory)) { $profileDirectory = [Environment]::GetFolderPath('UserProfile') } if ([string]::IsNullOrWhiteSpace($profileDirectory)) { throw 'Cannot determine the user profile directory.' } return Join-Path $profileDirectory '.agents/skills' } function Get-XasFullPath { param([Parameter(Mandatory)][string]$Path) $absolute = [IO.Path]::GetFullPath($Path) if ($absolute -ieq [IO.Path]::GetPathRoot($absolute)) { return $absolute } return $absolute.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) } function Test-XasWithin { param([string]$Path, [string]$Root) $candidate = Get-XasFullPath $Path $boundary = (Get-XasFullPath $Root).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar return $candidate.StartsWith($boundary, [StringComparison]::OrdinalIgnoreCase) } function Assert-XasNoLinkPath { param([string]$Path) $cursor = [IO.Path]::GetFullPath($Path) while ($cursor) { if (Test-Path -LiteralPath $cursor) { $item = Get-Item -LiteralPath $cursor -Force if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Links/reparse points are not supported: $cursor" } } $parent = [IO.Path]::GetDirectoryName($cursor) if ($parent -eq $cursor) { break } $cursor = $parent } } function Assert-XasSkillName { param([string]$Name) if ($Name.Length -gt 63 -or $Name -cnotmatch '^[a-z0-9]+(-[a-z0-9]+)*$' -or $Name -match '^(con|prn|aux|nul|com[0-9]|lpt[0-9])$') { throw "Invalid skill name: $Name" } } function ConvertFrom-XasYamlString { param([string]$Value, [string]$Field, [string]$Entrypoint) $text = $Value.Trim() $message = "Unsupported YAML string for '$Field' in $Entrypoint" if ($text.StartsWith("'")) { $quoted = [regex]::Match($text, "^'(?(?:[^']|'')*)'(?.*)$") if (-not $quoted.Success -or $quoted.Groups['tail'].Value -notmatch '^(?:[ \t]*|[ \t]+#.*)$') { throw "$message. Use a single-line quoted string, optionally followed by a comment." } $result = $quoted.Groups['value'].Value.Replace("''", "'") } elseif ($text.StartsWith('"')) { $quoted = [regex]::Match($text, '^"(?(?:[^"\\]|\\.)*)"(?.*)$') if (-not $quoted.Success -or $quoted.Groups['tail'].Value -notmatch '^(?:[ \t]*|[ \t]+#.*)$') { throw "$message. Use a single-line quoted string, optionally followed by a comment." } # JSON escapes are the supported subset of YAML double-quoted escapes. $encoded = $quoted.Groups['value'].Value if ($encoded -notmatch '^(?:[^"\\]|\\(?:["\\/bfnrt]|u[0-9a-fA-F]{4}))*$') { throw "$message. Double-quoted strings support JSON-style escapes only." } try { $result = ConvertFrom-Json -InputObject ('"' + $encoded + '"') -ErrorAction Stop } catch { throw "$message. Invalid double-quoted string or escape sequence." } } else { $comment = [regex]::Match($text, '(^|[ \t]+)#') if ($comment.Success) { $text = $text.Substring(0, $comment.Index).TrimEnd() } if ($text -match '^[\[\]{}&*!|>%@`]' -or $text -match '^(?:-|\?|:)(?:[ \t]|$)' -or $text -match ':(?:[ \t]|$)' -or $text -match '^(?:---|\.\.\.)$') { throw "$message. Quote plain text containing YAML syntax; collections, aliases and tags are not supported." } if ($text -match '^(?:null|~|true|false|yes|no|on|off)$' -or $text -match '^[+-]?(?:[0-9][0-9_]*(?:\.[0-9_]*)?(?:[eE][+-]?[0-9]+)?|\.[0-9_]+(?:[eE][+-]?[0-9]+)?|\.inf|\.nan|0x[0-9a-f_]+|0o[0-7_]+|0b[01_]+)$') { throw "'$Field' must be a non-empty string in $Entrypoint. Quote values that resemble null, booleans or numbers." } $result = $text } if ([string]::IsNullOrWhiteSpace($result)) { throw "'$Field' must be a non-empty string in $Entrypoint." } return $result } function ConvertFrom-XasRequiredFrontmatter { param([string]$Body, [string]$Entrypoint) $values = @{} $lines = @($Body -split '\r?\n') for ($index = 0; $index -lt $lines.Count; $index++) { $match = [regex]::Match($lines[$index], '^(?name|description):(?.*)$') if (-not $match.Success) { continue } $field = $match.Groups['key'].Value if ($values.ContainsKey($field)) { throw "Duplicate required key '$field' in $Entrypoint." } $raw = $match.Groups['value'].Value if ($raw.Length -gt 0 -and $raw -notmatch '^[ \t]') { throw "Expected whitespace after '$field`:' in $Entrypoint." } $value = $raw.Trim() if ($value -match '^[|>]') { $blockHeader = [regex]::Replace($value, '[ \t]+#.*$', '').TrimEnd() if ($field -ne 'description' -or $blockHeader -notmatch '^[|>][+-]?$') { throw "Unsupported YAML block for '$field' in $Entrypoint. Only description supports |, > and optional + or - chomping." } $block = New-Object 'System.Collections.Generic.List[string]' $end = $index + 1 while ($end -lt $lines.Count) { $line = $lines[$end] if (-not [string]::IsNullOrWhiteSpace($line) -and $line -notmatch '^[ \t]') { break } if ($line -match '^ *\t') { throw "YAML block indentation must use spaces in $Entrypoint." } $block.Add($line) $end++ } $nonempty = @($block | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($nonempty.Count -eq 0) { throw "'description' must be a non-empty string in $Entrypoint." } $indent = [regex]::Match($nonempty[0], '^ +').Length $normalized = New-Object 'System.Collections.Generic.List[string]' foreach ($line in $block) { if ([string]::IsNullOrWhiteSpace($line)) { $normalized.Add(''); continue } if ([regex]::Match($line, '^ +').Length -lt $indent) { throw "Inconsistent YAML block indentation in $Entrypoint." } $normalized.Add($line.Substring($indent)) } if ($blockHeader.StartsWith('|')) { $result = $normalized.ToArray() -join "`n" } else { $builder = New-Object Text.StringBuilder for ($part = 0; $part -lt $normalized.Count; $part++) { $current = $normalized[$part] $null = $builder.Append($current) if ($part + 1 -ge $normalized.Count) { continue } $next = $normalized[$part + 1] if ($current.Length -gt 0 -and $next.Length -gt 0 -and -not $current.StartsWith(' ') -and -not $next.StartsWith(' ')) { $null = $builder.Append(' ') } elseif ($current.Length -eq 0 -and $next.Length -gt 0 -and $part -gt 0 -and -not $normalized[$part - 1].StartsWith(' ') -and -not $next.StartsWith(' ')) { # One blank line separates folded paragraphs with one newline. } else { $null = $builder.Append("`n") } } $result = $builder.ToString() } if ($blockHeader.EndsWith('-')) { $result = $result.TrimEnd([char]10) } elseif ($blockHeader.EndsWith('+')) { $result += "`n" } else { $result = $result.TrimEnd([char]10) + "`n" } $values[$field] = $result $index = $end - 1 } else { $values[$field] = ConvertFrom-XasYamlString -Value $raw -Field $field -Entrypoint $Entrypoint # Multiline plain/quoted strings need a YAML parser; do not silently truncate them. for ($nextIndex = $index + 1; $nextIndex -lt $lines.Count; $nextIndex++) { $continuation = $lines[$nextIndex] if ([string]::IsNullOrWhiteSpace($continuation) -or $continuation -match '^[ \t]*#') { continue } if ($continuation -match '^[ \t]') { throw "Unsupported multiline scalar for '$field' in $Entrypoint. Use a description block with | or >." } break } } } foreach ($field in @('name', 'description')) { if (-not $values.ContainsKey($field)) { throw "Missing required '$field' in $Entrypoint. Use unquoted top-level name: and description: keys." } } return $values } function Get-XasSkillCatalog { [CmdletBinding()] param([Parameter(Mandatory)][string]$RepositoryRoot) $skillsDirectory = Join-Path (Get-XasFullPath $RepositoryRoot) 'skills' Assert-XasNoLinkPath $skillsDirectory if (-not (Test-Path -LiteralPath $skillsDirectory -PathType Container)) { return } foreach ($directory in @(Get-ChildItem -LiteralPath $skillsDirectory -Directory -Force | Sort-Object Name)) { Assert-XasNoLinkPath $directory.FullName $entrypoint = Join-Path $directory.FullName 'SKILL.md' if (-not (Test-Path -LiteralPath $entrypoint -PathType Leaf)) { continue } Assert-XasSkillName $directory.Name Assert-XasNoLinkPath $entrypoint $content = [IO.File]::ReadAllText($entrypoint) $header = [regex]::Match($content, '\A---\r?\n(?[\s\S]*?)\r?\n---(?:\r?\n|\z)') if (-not $header.Success) { throw "Missing YAML frontmatter: $entrypoint" } $metadata = ConvertFrom-XasRequiredFrontmatter -Body $header.Groups['body'].Value -Entrypoint $entrypoint if ($metadata.name -cne $directory.Name) { throw "The frontmatter name must match the directory: $entrypoint" } [pscustomobject]@{ Name = $metadata.name; Description = $metadata.description; Path = $directory.FullName } } } function Get-XasFileDigest { param([string]$Path) # Use a read-only .NET stream: Windows PowerShell's Get-FileHash can be # affected by an inherited WhatIfPreference even though hashing is read-only. $algorithm = [Security.Cryptography.SHA256]::Create() $stream = $null try { $stream = [IO.File]::OpenRead($Path) return [BitConverter]::ToString($algorithm.ComputeHash($stream)).Replace('-', '').ToLowerInvariant() } finally { if ($null -ne $stream) { $stream.Dispose() } $algorithm.Dispose() } } function Get-XasTreeSnapshot { param([string]$Directory, [switch]$ExcludeState) Assert-XasNoLinkPath $Directory if (-not (Test-Path -LiteralPath $Directory -PathType Container)) { throw "Directory not found: $Directory" } $root = Get-XasFullPath $Directory $files = New-Object 'System.Collections.Generic.List[object]' $directories = New-Object 'System.Collections.Generic.List[string]' $pending = New-Object 'System.Collections.Generic.Queue[string]' $pending.Enqueue($root) while ($pending.Count -gt 0) { foreach ($item in @(Get-ChildItem -LiteralPath $pending.Dequeue() -Force)) { if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Links/reparse points are not supported: $($item.FullName)" } $relative = $item.FullName.Substring($root.Length + 1).Replace('\', '/') if ($ExcludeState -and $relative -ieq $script:StateFile) { continue } if ($item.PSIsContainer) { $directories.Add($relative) $pending.Enqueue($item.FullName) } else { $files.Add([pscustomobject]@{ path = $relative; sha256 = Get-XasFileDigest $item.FullName }) } } } return [pscustomobject]@{ files = @($files.ToArray() | Sort-Object path) directories = @($directories.ToArray() | Sort-Object) } } function Get-XasSnapshotKey { param($Snapshot) $entries = @(@($Snapshot.directories) | Sort-Object | ForEach-Object { 'D:' + $_ }) $entries += @(@($Snapshot.files) | Sort-Object path | ForEach-Object { 'F:' + $_.path + ':' + $_.sha256.ToLowerInvariant() }) return $entries -join "`n" } function Read-XasState { param([string]$Directory, [string]$Name, [switch]$AllowForeign) Assert-XasNoLinkPath $Directory $statePath = Join-Path $Directory $script:StateFile if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) { throw "Existing skill is not managed by xas-skills; nothing was overwritten: $Directory" } Assert-XasNoLinkPath $statePath try { $state = [IO.File]::ReadAllText($statePath) | ConvertFrom-Json } catch { throw "Invalid installation record: $statePath" } if ($null -eq $state -or @($state.PSObject.Properties.Name) -notcontains 'repository') { throw "Invalid installation record: $statePath" } if ($state.repository -cne $script:RepositoryUrl) { if ($AllowForeign) { return $null } throw "Skill belongs to another source; nothing was overwritten: $Directory" } foreach ($field in @('schemaVersion', 'skill', 'files', 'directories')) { if (@($state.PSObject.Properties.Name) -notcontains $field) { throw "Incomplete installation record: $statePath" } } if ($state.schemaVersion -ne 1 -or $state.skill -cne $Name -or $null -eq $state.files -or $null -eq $state.directories) { throw "Invalid installation record: $statePath" } $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) foreach ($file in @($state.files)) { if ($null -eq $file -or @($file.PSObject.Properties.Name) -notcontains 'path' -or @($file.PSObject.Properties.Name) -notcontains 'sha256' -or $file.sha256 -notmatch '^[a-fA-F0-9]{64}$') { throw "Invalid file digest in installation record: $statePath" } } foreach ($relative in @(@($state.files | ForEach-Object { $_.path }) + @($state.directories))) { if ([string]::IsNullOrWhiteSpace($relative) -or $relative -match '(^[\\/]|:|\\|(^|/)\.\.?(/|$))' -or $relative -ieq $script:StateFile -or -not $seen.Add($relative)) { throw "Invalid file list in installation record: $statePath" } } return $state } function Get-XasInstalledSkillNames { [CmdletBinding()] param([Parameter(Mandatory)][string]$SkillsRoot) Assert-XasNoLinkPath $SkillsRoot if (-not (Test-Path -LiteralPath $SkillsRoot -PathType Container)) { return } foreach ($directory in @(Get-ChildItem -LiteralPath $SkillsRoot -Directory -Force | Sort-Object Name)) { $statePath = Join-Path $directory.FullName $script:StateFile if (-not (Test-Path -LiteralPath $statePath)) { continue } Assert-XasSkillName $directory.Name $state = Read-XasState -Directory $directory.FullName -Name $directory.Name -AllowForeign if ($null -ne $state) { $directory.Name } } } function Move-XasDirectory { param([string]$Source, [string]$Destination) [IO.Directory]::Move($Source, $Destination) } function Remove-XasWorkDirectory { param([string]$Path, [string]$WorkRoot) if (-not (Test-XasWithin -Path $Path -Root $WorkRoot)) { throw "Refusing cleanup outside workspace: $Path" } if (Test-Path -LiteralPath $Path) { $null = Get-XasTreeSnapshot -Directory $Path Remove-Item -LiteralPath $Path -Recurse -Force } } function Copy-XasTree { param([string]$Source, [string]$Destination, $Snapshot) $null = [IO.Directory]::CreateDirectory($Destination) foreach ($relative in @($Snapshot.directories)) { $null = [IO.Directory]::CreateDirectory((Join-Path $Destination $relative)) } foreach ($file in @($Snapshot.files)) { $sourcePath = Join-Path $Source $file.path Assert-XasNoLinkPath $sourcePath [IO.File]::Copy($sourcePath, (Join-Path $Destination $file.path), $false) } } function Invoke-XasInstall { [CmdletBinding()] param( [Parameter(Mandatory)][string]$RepositoryRoot, [Parameter(Mandatory)][string]$SkillsRoot, [Parameter(Mandatory)][string[]]$Skill, [switch]$Preview ) $sourceRoot = Get-XasFullPath $RepositoryRoot $targetRoot = Get-XasFullPath $SkillsRoot if ($sourceRoot -ieq $targetRoot -or (Test-XasWithin $targetRoot $sourceRoot) -or (Test-XasWithin $sourceRoot $targetRoot)) { throw 'The repository and installation directories must not overlap.' } Assert-XasNoLinkPath $sourceRoot Assert-XasNoLinkPath $targetRoot if ((Test-Path -LiteralPath $targetRoot) -and -not (Test-Path -LiteralPath $targetRoot -PathType Container)) { throw "Installation root is not a directory: $targetRoot" } $catalog = @(Get-XasSkillCatalog -RepositoryRoot $sourceRoot) $plan = New-Object 'System.Collections.Generic.List[object]' # Validate the entire batch before creating a directory or replacing any skill. foreach ($name in @($Skill | Select-Object -Unique)) { Assert-XasSkillName $name $entry = @($catalog | Where-Object { $_.Name -ceq $name }) if ($entry.Count -ne 1) { throw "Skill not found in this repository: $name" } $source = $entry[0].Path if (Test-Path -LiteralPath (Join-Path $source $script:StateFile)) { throw "Source contains a reserved installation record: $source" } $snapshot = Get-XasTreeSnapshot -Directory $source $destination = Join-Path $targetRoot $name Assert-XasNoLinkPath $destination $exists = Test-Path -LiteralPath $destination $status = 'Installed' $previous = $null if ($exists) { if (-not (Test-Path -LiteralPath $destination -PathType Container)) { throw "Destination is not a directory: $destination" } $previous = Read-XasState -Directory $destination -Name $name $actual = Get-XasTreeSnapshot -Directory $destination -ExcludeState if ((Get-XasSnapshotKey $actual) -cne (Get-XasSnapshotKey $previous)) { throw "Installed skill has local additions, changes or deletions; preserve them before updating: $destination" } $status = 'Updated' if ((Get-XasSnapshotKey $snapshot) -ceq (Get-XasSnapshotKey $actual)) { $status = 'Skipped' } } $plan.Add([pscustomobject]@{ Name = $name; Source = $source; Destination = $destination; Snapshot = $snapshot; Previous = $previous; Status = $status; Exists = $exists }) } foreach ($operation in $plan) { $status = $operation.Status if ($status -eq 'Skipped') { [pscustomobject]@{ Name = $operation.Name; Status = $status }; continue } if ($Preview) { if ($operation.Exists) { $status = 'WouldUpdate' } else { $status = 'WouldInstall' } [pscustomobject]@{ Name = $operation.Name; Status = $status } continue } $workRoot = Join-Path ([IO.Path]::GetDirectoryName($targetRoot)) '.xas-skills-work' Assert-XasNoLinkPath $workRoot $work = Join-Path $workRoot ([Guid]::NewGuid().ToString('N')) $staging = Join-Path $work 'new' $backup = Join-Path $work 'old' $preserveRecovery = $false try { Copy-XasTree -Source $operation.Source -Destination $staging -Snapshot $operation.Snapshot if ((Get-XasSnapshotKey (Get-XasTreeSnapshot $staging)) -cne (Get-XasSnapshotKey $operation.Snapshot)) { throw "Source changed during copying: $($operation.Source)" } $record = [ordered]@{ schemaVersion = 1 repository = $script:RepositoryUrl skill = $operation.Name files = @($operation.Snapshot.files) directories = @($operation.Snapshot.directories) } [IO.File]::WriteAllText((Join-Path $staging $script:StateFile), ($record | ConvertTo-Json -Depth 8), (New-Object Text.UTF8Encoding $false)) Assert-XasNoLinkPath $targetRoot $null = [IO.Directory]::CreateDirectory($targetRoot) if ($operation.Exists) { $current = Get-XasTreeSnapshot -Directory $operation.Destination -ExcludeState $currentRecord = Read-XasState -Directory $operation.Destination -Name $operation.Name if ((Get-XasSnapshotKey $current) -cne (Get-XasSnapshotKey $operation.Previous) -or (Get-XasSnapshotKey $currentRecord) -cne (Get-XasSnapshotKey $operation.Previous)) { throw "Installed skill changed during update: $($operation.Destination)" } Move-XasDirectory -Source $operation.Destination -Destination $backup } elseif (Test-Path -LiteralPath $operation.Destination) { throw "Destination appeared during installation: $($operation.Destination)" } try { Move-XasDirectory -Source $staging -Destination $operation.Destination } catch { $failure = $_ if (Test-Path -LiteralPath $backup) { try { Move-XasDirectory -Source $backup -Destination $operation.Destination } catch { $preserveRecovery = $true throw "Replacement and rollback failed. Recover the old skill from: $backup" } } throw $failure } [pscustomobject]@{ Name = $operation.Name; Status = $status } } finally { if (-not $preserveRecovery) { Remove-XasWorkDirectory -Path $work -WorkRoot $workRoot } } } } Export-ModuleMember -Function Get-XasDefaultSkillsRoot, Get-XasSkillCatalog, Get-XasInstalledSkillNames, Invoke-XasInstall