Files
xas-skills/scripts/XasSkills.psm1
T

429 lines
22 KiB
PowerShell
Raw Normal View History

2026-09-20 15:59:30 +08:00
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, "^'(?<value>(?:[^']|'')*)'(?<tail>.*)$")
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, '^"(?<value>(?:[^"\\]|\\.)*)"(?<tail>.*)$')
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], '^(?<key>name|description):(?<value>.*)$')
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(?<body>[\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