[CmdletBinding()] param() Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $repositoryRoot = Split-Path -Parent $PSScriptRoot $requiredFiles = @('skills.cmd', 'scripts\Invoke-XasCli.ps1', 'scripts\XasSkills.psm1', 'install.ps1') foreach ($relativePath in $requiredFiles) { if (-not (Test-Path -LiteralPath (Join-Path $repositoryRoot $relativePath) -PathType Leaf)) { throw "Required CLI file does not exist: $relativePath" } } $testRoot = Join-Path ([IO.Path]::GetTempPath()) ('xas-cli-tests-' + [guid]::NewGuid().ToString('N')) $null = New-Item -ItemType Directory -Path $testRoot $utf8WithoutBom = New-Object Text.UTF8Encoding($false) $script:CaseCount = 0 $script:TestCount = 0 $script:FailureCount = 0 function Write-TestFile { param([string]$Path, [string]$Content) $parent = Split-Path -Parent $Path if (-not (Test-Path -LiteralPath $parent)) { $null = New-Item -ItemType Directory -Path $parent -Force } [IO.File]::WriteAllText($Path, $Content, $utf8WithoutBom) } function New-CliContext { $script:CaseCount++ $unicodeName = ([string][char]0x4E2D) + ([string][char]0x6587) + ' cli space' $root = Join-Path $testRoot (('{0:D2}-' -f $script:CaseCount) + $unicodeName) $checkout = Join-Path $root 'source checkout' $profilePath = Join-Path $root 'isolated profile' $outsideDirectory = Join-Path $root 'unrelated working directory' foreach ($directory in @($checkout, $profilePath, $outsideDirectory)) { $null = New-Item -ItemType Directory -Path $directory -Force } foreach ($relativePath in $requiredFiles) { $destination = Join-Path $checkout $relativePath $null = New-Item -ItemType Directory -Path (Split-Path -Parent $destination) -Force Copy-Item -LiteralPath (Join-Path $repositoryRoot $relativePath) -Destination $destination } foreach ($name in @('xas-one', 'xas-two', 'xas-three')) { $skillRoot = Join-Path (Join-Path $checkout 'skills') $name Write-TestFile (Join-Path $skillRoot 'SKILL.md') "---`nname: $name`ndescription: A CLI fixture skill.`n---`n`nRead the bundled reference.`n" Write-TestFile (Join-Path $skillRoot 'references\guide.md') ('Reference for ' + $name) } Write-TestFile (Join-Path $checkout 'templates\skill-template\SKILL.md') "---`nname: hidden-template`ndescription: Template only.`n---`n" # This stub is the only update entry point in every fixture. It never invokes Git. $updateStub = @' [CmdletBinding()] param([switch]$WhatIf) $ErrorActionPreference = 'Stop' $record = [bool]$WhatIf [IO.File]::AppendAllText($env:XAS_CLI_TEST_UPDATE_LOG, ($record.ToString() + [Environment]::NewLine)) Write-Output 'fixture-update-called' exit [int]$env:XAS_CLI_TEST_UPDATE_EXIT '@ Write-TestFile (Join-Path $checkout 'update.ps1') $updateStub [pscustomobject]@{ Root = $root RepositoryRoot = $checkout ProfilePath = $profilePath SkillsRoot = Join-Path $profilePath '.agents\skills' OutsideDirectory = $outsideDirectory EntryPath = Join-Path $checkout 'skills.cmd' UpdateLog = Join-Path $root 'update-calls.log' } } function Assert-True { param([bool]$Condition, [string]$Message) if (-not $Condition) { throw $Message } } function Assert-Equal { param($Actual, $Expected, [string]$Message) if ($Actual -cne $Expected) { throw ($Message + ". Expected: [$Expected]; actual: [$Actual].") } } function Get-TestSnapshot { param([string]$Path) if (-not (Test-Path -LiteralPath $Path)) { return '' } $base = [IO.Path]::GetFullPath($Path).TrimEnd('\', '/') $lines = foreach ($item in (Get-ChildItem -LiteralPath $Path -Recurse -Force | Sort-Object FullName)) { $relative = $item.FullName.Substring($base.Length + 1).Replace('\', '/') if ($item.PSIsContainer) { 'D:' + $relative } else { 'F:' + $relative + ':' + (Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256).Hash } } @($lines) -join "`n" } function Invoke-CliChild { param($Context, [string]$Executable, [string]$Arguments, [string]$WorkingDirectory, [int]$UpdateExitCode = 0) $startInfo = New-Object Diagnostics.ProcessStartInfo $startInfo.FileName = $Executable $startInfo.Arguments = $Arguments $startInfo.WorkingDirectory = $WorkingDirectory $startInfo.UseShellExecute = $false $startInfo.CreateNoWindow = $true $startInfo.RedirectStandardOutput = $true $startInfo.RedirectStandardError = $true $startInfo.EnvironmentVariables['USERPROFILE'] = $Context.ProfilePath $startInfo.EnvironmentVariables['XAS_CLI_TEST_UPDATE_LOG'] = $Context.UpdateLog $startInfo.EnvironmentVariables['XAS_CLI_TEST_UPDATE_EXIT'] = [string]$UpdateExitCode $process = New-Object Diagnostics.Process $process.StartInfo = $startInfo $null = $process.Start() $outputTask = $process.StandardOutput.ReadToEndAsync() $errorTask = $process.StandardError.ReadToEndAsync() if (-not $process.WaitForExit(30000)) { $process.Kill() $process.Dispose() throw 'CLI fixture process did not finish within 30 seconds.' } $result = [pscustomobject]@{ ExitCode = $process.ExitCode Output = $outputTask.GetAwaiter().GetResult() Error = $errorTask.GetAwaiter().GetResult() } $process.Dispose() $result } function Invoke-Cli { param($Context, [string[]]$Tokens = @(), [int]$UpdateExitCode = 0) # All tokens below are fixed fixture values, with no shell metacharacters. foreach ($token in $Tokens) { if ($token -notmatch '^[a-zA-Z0-9_./-]+$') { throw "Unsafe CLI test token: $token" } } $arguments = '/d /s /c ""' + $Context.EntryPath + '" ' + ($Tokens -join ' ') + '"' Invoke-CliChild -Context $Context -Executable $env:ComSpec -Arguments $arguments -WorkingDirectory $Context.OutsideDirectory -UpdateExitCode $UpdateExitCode } function Invoke-Test { param([string]$Name, [scriptblock]$Action) $script:TestCount++ try { & $Action Write-Host ('PASS ' + $Name) } catch { $script:FailureCount++ Write-Host ('FAIL ' + $Name + ': ' + $_.Exception.Message) -ForegroundColor Red Write-Host $_.ScriptStackTrace } } function Assert-CliSuccess { param($Result, [string]$Message) Assert-Equal $Result.ExitCode 0 ($Message + ' Output: ' + $Result.Output + ' Error: ' + $Result.Error) } try { Invoke-Test 'actual cmd entry shows help and lists only real skills from an unrelated cwd' { $context = New-CliContext $before = Get-TestSnapshot $context.RepositoryRoot foreach ($tokens in @(@(), @('help'), @('list'))) { $result = Invoke-Cli -Context $context -Tokens $tokens Assert-CliSuccess $result 'Help/list should succeed' Assert-True (-not [string]::IsNullOrWhiteSpace($result.Output)) 'Help/list should print useful output' } $listed = Invoke-Cli -Context $context -Tokens @('list') foreach ($name in @('xas-one', 'xas-two', 'xas-three')) { Assert-True ($listed.Output -match [regex]::Escape($name)) "List should include $name" } Assert-True ($listed.Output -notmatch 'hidden-template') 'Template must not appear in CLI skill list' Assert-Equal (Get-TestSnapshot $context.RepositoryRoot) $before 'Help/list should preserve checkout files' Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.ProfilePath '.agents'))) 'Help/list should not create skills directories' Assert-True (-not (Test-Path -LiteralPath $context.UpdateLog)) 'Help/list should not invoke update' } Invoke-Test 'single install and repeat install use the isolated user destination' { $context = New-CliContext $installed = Invoke-Cli -Context $context -Tokens @('install', 'xas-one') Assert-CliSuccess $installed 'Single skill install should succeed' Assert-True (Test-Path -LiteralPath (Join-Path $context.SkillsRoot 'xas-one\SKILL.md')) 'Selected skill should be installed in isolated profile' Assert-Equal ([IO.File]::ReadAllText((Join-Path $context.SkillsRoot 'xas-one\references\guide.md'))) 'Reference for xas-one' 'Bundled resources should reach the installed skill' Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.SkillsRoot 'xas-two'))) 'Other skills should remain uninstalled' $before = Get-TestSnapshot $context.SkillsRoot $repeated = Invoke-Cli -Context $context -Tokens @('install', 'xas-one') Assert-CliSuccess $repeated 'Repeated skill install should succeed' Assert-True ($repeated.Output -match 'Skipped') 'Repeated install should report Skipped' Assert-Equal (Get-TestSnapshot $context.SkillsRoot) $before 'Repeated install should preserve installed files' } Invoke-Test 'multiple skill names are forwarded as separate array entries' { $context = New-CliContext $result = Invoke-Cli -Context $context -Tokens @('install', 'xas-one', 'xas-two') Assert-CliSuccess $result 'Multiple skill install should succeed' foreach ($name in @('xas-one', 'xas-two')) { Assert-True (Test-Path -LiteralPath (Join-Path $context.SkillsRoot "$name\SKILL.md")) "Selected skill should be installed: $name" } Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.SkillsRoot 'xas-three'))) 'Third skill must remain uninstalled' } Invoke-Test 'install all installs every formal skill without the template' { $context = New-CliContext $result = Invoke-Cli -Context $context -Tokens @('install', '--all') Assert-CliSuccess $result 'Install all should succeed' $names = @((Get-ChildItem -LiteralPath $context.SkillsRoot -Directory).Name | Sort-Object) Assert-Equal ($names -join ',') 'xas-one,xas-three,xas-two' 'Install all should contain exactly the three formal skills' } Invoke-Test 'preview forwards WhatIf and creates no installed files' { $context = New-CliContext $before = Get-TestSnapshot $context.RepositoryRoot foreach ($tokens in @(@('install', 'xas-one', '--preview'), @('install', '--all', '--preview'))) { $result = Invoke-Cli -Context $context -Tokens $tokens Assert-CliSuccess $result 'Install preview should succeed' Assert-True ($result.Output -match 'xas-one') 'Preview should identify the planned skill' Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.ProfilePath '.agents'))) 'Preview must not create the skills destination' } Assert-Equal (Get-TestSnapshot $context.RepositoryRoot) $before 'Preview must preserve source files' } Invoke-Test 'invalid commands and arguments fail before install or update' { $context = New-CliContext $invalid = @( @('unknown-command'), @('list', 'unexpected'), @('help', 'unexpected'), @('install'), @('install', '--preview'), @('install', 'missing-skill'), @('install', 'xas-one', 'missing-skill'), @('install', '--all', 'xas-one'), @('install', 'xas-one', '--all'), @('install', 'xas-one', '--unknown'), @('install', '../xas-one'), @('update', '--unknown'), @('update', 'xas-one') ) $before = Get-TestSnapshot $context.RepositoryRoot foreach ($tokens in $invalid) { $result = Invoke-Cli -Context $context -Tokens $tokens Assert-True ($result.ExitCode -ne 0) ('Invalid command must fail: ' + ($tokens -join ' ')) Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.ProfilePath '.agents'))) ('Invalid command must not install: ' + ($tokens -join ' ')) Assert-True (-not (Test-Path -LiteralPath $context.UpdateLog)) ('Invalid command must not invoke update: ' + ($tokens -join ' ')) } Assert-Equal (Get-TestSnapshot $context.RepositoryRoot) $before 'Invalid commands should preserve fixture checkout' } Invoke-Test 'update routes exactly once, forwards preview and preserves nonzero exit codes' { $context = New-CliContext $updated = Invoke-Cli -Context $context -Tokens @('update') -UpdateExitCode 37 Assert-Equal $updated.ExitCode 37 ('Update exit code should be preserved. Output: ' + $updated.Output + ' Error: ' + $updated.Error) $calls = @(Get-Content -LiteralPath $context.UpdateLog) Assert-Equal $calls.Count 1 'Normal update should invoke the stub exactly once' Assert-Equal $calls[0] 'False' 'Normal update should not enable WhatIf' $previewed = Invoke-Cli -Context $context -Tokens @('update', '--preview') -UpdateExitCode 29 Assert-Equal $previewed.ExitCode 29 'Preview update exit code should be preserved' $calls = @(Get-Content -LiteralPath $context.UpdateLog) Assert-Equal $calls.Count 2 'Preview update should add exactly one stub invocation' Assert-Equal $calls[1] 'True' 'Preview update should pass WhatIf' Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.ProfilePath '.agents'))) 'Update routing stub should not install skills' } Invoke-Test 'update can replace its own batch entrypoint and preserve the exit code' { $context = New-CliContext Write-TestFile (Join-Path $context.RepositoryRoot 'update.ps1') @' param([switch]$WhatIf) [IO.File]::WriteAllText((Join-Path $PSScriptRoot 'skills.cmd'), "@echo off`r`nexit /b 99`r`n", [Text.Encoding]::ASCII) exit 37 '@ $result = Invoke-Cli -Context $context -Tokens @('update') Assert-Equal $result.ExitCode 37 'The active wrapper must finish with the update result even if replaced' Assert-True ([IO.File]::ReadAllText((Join-Path $context.RepositoryRoot 'skills.cmd')) -match 'exit /b 99') 'The fixture update must actually replace the wrapper' } Invoke-Test 'PowerShell 5.1 and 7 resolve dot-backslash-skills without an extension' { $context = New-CliContext $runnerPath = Join-Path $context.Root 'invoke-short-name.ps1' Write-TestFile $runnerPath @' $ErrorActionPreference = 'Stop' & .\skills list exit $LASTEXITCODE '@ $shells = @( (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'), (Get-Command pwsh.exe -ErrorAction Stop).Source ) foreach ($shellPath in $shells) { $arguments = '-NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "' + $runnerPath + '"' $result = Invoke-CliChild -Context $context -Executable $shellPath -Arguments $arguments -WorkingDirectory $context.RepositoryRoot Assert-CliSuccess $result ('Short command should work from ' + $shellPath) Assert-True ($result.Output -match 'xas-one') 'Short command should list fixture skills' } Assert-True (-not (Test-Path -LiteralPath (Join-Path $context.ProfilePath '.agents'))) 'Short command list should not install skills' } } finally { $resolvedRoot = [IO.Path]::GetFullPath($testRoot) $resolvedTemp = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()).TrimEnd('\', '/') + [IO.Path]::DirectorySeparatorChar if (-not $resolvedRoot.StartsWith($resolvedTemp, [StringComparison]::OrdinalIgnoreCase) -or -not ([IO.Path]::GetFileName($resolvedRoot)).StartsWith('xas-cli-tests-', [StringComparison]::Ordinal)) { throw "Refusing to clean unexpected CLI test path: $resolvedRoot" } if (Test-Path -LiteralPath $resolvedRoot) { Remove-Item -LiteralPath $resolvedRoot -Recurse -Force } } Write-Host ("CLI tests: $script:TestCount; failures: $script:FailureCount") if ($script:FailureCount -gt 0) { exit 1 } exit 0