skills.ps1
|
function _ParseSimpleYaml { param( [string]$YamlText ) $Data = @{} foreach($Line in @($YamlText -split "`r?`n")){ $Trimmed = [string]$Line if([string]::IsNullOrWhiteSpace($Trimmed)){ continue } $Trimmed = $Trimmed.Trim() if($Trimmed.StartsWith("#")){ continue } if($Trimmed -match '^([A-Za-z0-9_\-]+)\s*:\s*(.*)$'){ $Key = $Matches[1] $Value = $Matches[2].Trim() if(($Value.StartsWith('"') -and $Value.EndsWith('"')) -or ($Value.StartsWith("'") -and $Value.EndsWith("'"))){ $Value = $Value.Substring(1,$Value.Length-2) } $Data[$Key] = $Value } } return $Data } function _ParseAiSkillFile { param( [Parameter(Mandatory)] [string]$SkillFilePath ) $Resolved = Resolve-Path -LiteralPath $SkillFilePath -EA SilentlyContinue if(!$Resolved){ throw "POWERSHAI_AISKILL_FILE_NOTFOUND: $SkillFilePath" } $SkillFilePath = [string]$Resolved $SkillDir = Split-Path -Parent $SkillFilePath $Raw = Get-Content -LiteralPath $SkillFilePath -Raw -Encoding UTF8 $FrontmatterText = $null $Body = $Raw $Metadata = @{} $Diagnostics = @() if($Raw -match '(?s)^\s*---\s*\r?\n(.*?)\r?\n---\s*\r?\n?(.*)$'){ $FrontmatterText = $Matches[1] $Body = $Matches[2] $YamlCommand = Get-Command -Name ConvertFrom-Yaml -EA SilentlyContinue if($YamlCommand){ try { $MetadataObj = ConvertFrom-Yaml -Yaml $FrontmatterText -EA Stop $Metadata = HashTableMerge @{} $MetadataObj } catch { $Diagnostics += "YAML parser failed, fallback parser used: $($_.Exception.Message)" $Metadata = _ParseSimpleYaml -YamlText $FrontmatterText } } else { $Metadata = _ParseSimpleYaml -YamlText $FrontmatterText } } else { $Diagnostics += "No YAML frontmatter found in SKILL.md" } $SkillName = [string]$Metadata.name $SkillDescription = [string]$Metadata.description if([string]::IsNullOrWhiteSpace($SkillDescription)){ throw "POWERSHAI_AISKILL_INVALID_DESCRIPTION: Description is required in $SkillFilePath" } if([string]::IsNullOrWhiteSpace($SkillName)){ $SkillName = Split-Path -Leaf $SkillDir $Diagnostics += "Missing 'name' in frontmatter. Falling back to directory name: $SkillName" } $DirName = Split-Path -Leaf $SkillDir if($DirName -ne $SkillName){ $Diagnostics += "Skill name does not match directory name. name=$SkillName dir=$DirName" } $Resources = @() $AllFiles = Get-ChildItem -LiteralPath $SkillDir -File -Recurse -EA SilentlyContinue foreach($File in @($AllFiles)){ if($File.Name -ieq "SKILL.md"){ continue } $Relative = $File.FullName.Substring($SkillDir.Length).TrimStart([char]92,[char]47) $Resources += $Relative } $Skill = [ordered]@{ name = $SkillName description = $SkillDescription location = $SkillFilePath skillDir = $SkillDir body = [string]$Body.Trim() resources = @($Resources | Sort-Object -Unique) diagnostics = @($Diagnostics) metadata = $Metadata } SetType $Skill "AiSkill" return $Skill } function _ResolveAiSkillSelection { param( $Skill, [string]$Name, $Skills ) if($Skill){ return $Skill } $ByName = @($Skills) | Where-Object { $_ -and $_.name -eq $Name } | Select-Object -First 1 if(!$ByName){ throw "POWERSHAI_AISKILL_NOTFOUND: $Name" } return $ByName } function _FormatAiSkillActivationContent { param( [Parameter(Mandatory)] $Skill ) $ResLines = @() foreach($Res in @($Skill.resources)){ $ResLines += " <file>$Res</file>" } $ResourcesBlock = "" if($ResLines.Count){ $ResourcesBlock = @( "<skill_resources>" $ResLines "</skill_resources>" ) -join "`n" } $Content = @( "<skill_content name=`"$($Skill.name)`">" $Skill.body "" "Skill directory: $($Skill.skillDir)" "Relative paths in this skill are relative to the skill directory." $ResourcesBlock "</skill_content>" ) -join "`n" return $Content.Trim() } function Test-AiSkillScriptAuthorization { param( [Parameter(Mandatory)] [string]$SkillName, [Parameter(Mandatory)] [string]$Script, [string[]]$AuthorizedSkillsScripts = @() ) if(!$AuthorizedSkillsScripts){ return $false } $ScriptNorm = ([string]$Script).Replace('\\','/').TrimStart('/') $ScriptLeaf = Split-Path -Leaf $ScriptNorm $Candidates = @( "$SkillName/$ScriptNorm", "$SkillName/$ScriptLeaf" ) foreach($Pattern in @($AuthorizedSkillsScripts)){ if([string]::IsNullOrWhiteSpace([string]$Pattern)){ continue } $PatNorm = ([string]$Pattern).Replace('\\','/') foreach($Candidate in $Candidates){ if($Candidate -like $PatNorm){ return $true } } } return $false } function Test-AiPowershellCommandAuthorization { param( [Parameter(Mandatory)] [string]$Command, [string[]]$AuthorizedPowershellCommands = @() ) if(!$AuthorizedPowershellCommands){ return $false } foreach($Pattern in @($AuthorizedPowershellCommands)){ if([string]::IsNullOrWhiteSpace([string]$Pattern)){ continue } if($Command -like [string]$Pattern){ return $true } } return $false } function Get-AiSkillFileContent { [CmdletBinding()] param( # Objeto de skill retornado por Get-AiSkills. $Skill, # Nome da skill para resolver com -Skills. [string]$Name, # Lista de skills retornada por Get-AiSkills. $Skills = @(), # Caminho relativo de arquivo dentro da skill. [Parameter(Mandatory)] [string]$Path, # Limite maximo de caracteres retornados. [int]$MaxChars = 200000 ) $ResolvedSkill = _ResolveAiSkillSelection -Skill $Skill -Name $Name -Skills $Skills $SkillRoot = [IO.Path]::GetFullPath($ResolvedSkill.skillDir) $TargetPath = $Path if(-not [IO.Path]::IsPathRooted($TargetPath)){ $TargetPath = Join-Path $ResolvedSkill.skillDir $TargetPath } $ResolvedTarget = Resolve-Path -LiteralPath $TargetPath -EA SilentlyContinue if(!$ResolvedTarget){ throw "POWERSHAI_AISKILL_FILE_NOTFOUND: $Path" } $TargetFullPath = [IO.Path]::GetFullPath([string]$ResolvedTarget) if($TargetFullPath -notlike "$SkillRoot*"){ throw "POWERSHAI_AISKILL_FILE_OUTSIDE_SKILLDIR: $Path" } $Content = Get-Content -LiteralPath $TargetFullPath -Raw -Encoding UTF8 $Truncated = $false if($MaxChars -gt 0 -and $Content.Length -gt $MaxChars){ $Content = $Content.Substring(0,$MaxChars) $Truncated = $true } $RelativePath = $TargetFullPath.Substring($SkillRoot.Length).TrimStart([char]92,[char]47).Replace('\\','/') $Output = @( "<skill_file_content skill=`"$($ResolvedSkill.name)`" path=`"$RelativePath`" truncated=`"$Truncated`">" $Content "</skill_file_content>" ) -join "`n" return $Output } function Invoke-AiPowershellCommand { [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")] param( # Comando PowerShell a executar. [Parameter(Mandatory)] [string]$Command, # Diretório de execução do comando. [string]$WorkingDirectory = $null, # Objeto de skill retornado por Get-AiSkills. $Skill, # Nome da skill para resolver com -Skills. [string]$Name, # Lista de skills retornada por Get-AiSkills. $Skills = @(), # Padrões de comandos autorizados automaticamente (wildcards suportados). [string[]]$AuthorizedPowershellCommands = @(), # Pula confirmação e executa o comando diretamente nesta chamada. [switch]$AllowCommandExecution ) $ResolvedSkill = $null if($Skill -or $Name){ $ResolvedSkill = _ResolveAiSkillSelection -Skill $Skill -Name $Name -Skills $Skills } if(!$WorkingDirectory -and $ResolvedSkill){ $WorkingDirectory = $ResolvedSkill.skillDir } $IsAuthorized = Test-AiPowershellCommandAuthorization -Command $Command -AuthorizedPowershellCommands $AuthorizedPowershellCommands if(!$AllowCommandExecution -and !$IsAuthorized){ $Prompt = "Authorize PowerShell command execution '$Command'? [y/N]" $Resp = Read-Host $Prompt if($Resp -notmatch '^(?i:y|yes)$'){ throw "POWERSHAI_AISKILL_COMMAND_NOTAUTHORIZED: $Command" } } $OldLocation = Get-Location try { if($WorkingDirectory){ $null = Set-Location -LiteralPath $WorkingDirectory } $Output = & ([scriptblock]::Create($Command)) 2>&1 | Out-String } finally { $null = Set-Location -LiteralPath $OldLocation.Path } $SkillName = "" if($ResolvedSkill){ $SkillName = $ResolvedSkill.name } $Result = @( "<powershell_command_result skill=`"$SkillName`">" "<command>$Command</command>" "<working_directory>$WorkingDirectory</working_directory>" "<output>" $Output "</output>" "</powershell_command_result>" ) -join "`n" return $Result } function Get-AiSkills { <# .SYNOPSIS Descobre e carrega skills no formato Agent Skills (SKILL.md). .DESCRIPTION Varre os caminhos informados em -Path e retorna objetos de skill no formato interno do PowershAI. Cada skill encontrada precisa conter description valida no frontmatter YAML do arquivo SKILL.md. Este cmdlet nao persiste estado global: ele apenas devolve a lista para ser usada diretamente, por exemplo no parametro -Skills de Invoke-AiChatTools. .EXAMPLE $s = Get-AiSkills -Path ./.agents/skills -Recurse Carrega skills de uma raiz de projeto. .EXAMPLE $s = Get-AiSkills -Path ./my-skill Carrega uma skill de um diretorio especifico. #> [CmdletBinding()] param( # Caminho(s) para descoberta de skills. # Pode ser raiz, diretorio de skill ou arquivo SKILL.md. [parameter(Position=0, ValueFromPipeline, ValueFromPipelineByPropertyName)] [Alias("FullName")] [string[]]$Path = @("."), # Varre subdiretorios recursivamente. [switch]$Recurse, # Limite maximo de skills retornadas. [int]$MaxSkills = 2000 ) begin { $AllSkills = @() $KnownFiles = @{} } process { foreach($CurrentPath in @($Path)){ $ResolvedList = Resolve-Path -Path $CurrentPath -EA SilentlyContinue if(!$ResolvedList){ Write-Warning "POWERSHAI_AISKILL_PATH_NOTFOUND: $CurrentPath" continue } foreach($Resolved in @($ResolvedList)){ $ResolvedPath = [string]$Resolved $Item = Get-Item -LiteralPath $ResolvedPath -EA SilentlyContinue if(!$Item){ continue } $Candidates = @() if(-not $Item.PSIsContainer){ if($Item.Name -ieq "SKILL.md"){ $Candidates += $Item.FullName } } else { $DirectSkill = Join-Path $Item.FullName "SKILL.md" if(Test-Path -LiteralPath $DirectSkill){ $Candidates += $DirectSkill } $SearchDirs = @() if($Recurse){ $SearchDirs = Get-ChildItem -LiteralPath $Item.FullName -Directory -Recurse -EA SilentlyContinue } else { $SearchDirs = Get-ChildItem -LiteralPath $Item.FullName -Directory -EA SilentlyContinue } foreach($Dir in @($SearchDirs)){ if($Dir.Name -in @('.git','node_modules','.venv','dist','bin','obj')){ continue } $SkillFile = Join-Path $Dir.FullName "SKILL.md" if(Test-Path -LiteralPath $SkillFile){ $Candidates += $SkillFile } } } foreach($SkillFilePath in @($Candidates | Select-Object -Unique)){ if($AllSkills.Count -ge $MaxSkills){ Write-Warning "POWERSHAI_AISKILL_MAX_REACHED: MaxSkills=$MaxSkills" break } $CanonPath = [string](Resolve-Path -LiteralPath $SkillFilePath -EA SilentlyContinue) if(!$CanonPath -or $KnownFiles[$CanonPath]){ continue } $KnownFiles[$CanonPath] = $true try { $SkillObj = _ParseAiSkillFile -SkillFilePath $CanonPath $AllSkills += $SkillObj } catch { Write-Warning $_ } } } } } end { return @($AllSkills) } } function Invoke-AiSkill { <# .SYNOPSIS Ativa uma skill carregada e retorna seu conteudo formatado para contexto. .DESCRIPTION Resolve uma skill por objeto (-Skill) ou por nome (-Name + -Skills) e retorna o conteudo em formato estruturado (<skill_content ...>) para uso no fluxo de chat. Tambem pode executar script de recurso da skill via -Script, com confirmacao por padrao. .EXAMPLE $s = Get-AiSkills -Path ./.agents/skills -Recurse Invoke-AiSkill -Name "pdf-processing" -Skills $s Ativa skill por nome e retorna conteudo estruturado. .EXAMPLE $s = Get-AiSkills -Path ./my-skill Invoke-AiSkill -Skill $s[0] -Raw Retorna somente o body da skill. .EXAMPLE $s = Get-AiSkills -Path ./my-skill Invoke-AiSkill -Skill $s[0] -Script "scripts/run.ps1" -ScriptArgs @{ Value = "abc" } Executa script de recurso da skill com confirmacao por padrao. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "High")] param( # Objeto de skill retornado por Get-AiSkills. [parameter(ValueFromPipeline)] $Skill, # Nome da skill para resolver com -Skills. [string]$Name, # Lista de skills retornada por Get-AiSkills. $Skills = @(), # Retorna apenas o body da skill, sem wrapper estruturado. [switch]$Raw, # Script de recurso da skill a executar. [string]$Script, # Argumentos para o script de recurso. [hashtable]$ScriptArgs = @{}, # Lista de autorizacoes no formato skill/script com suporte a wildcard. # Exemplos: myskill/run.ps1, myskill/*, */* [string[]]$AuthorizedSkillsScripts = @(), # Pula confirmacao e executa o script diretamente nesta chamada. [switch]$AllowScriptExecution ) process { $ResolvedSkill = _ResolveAiSkillSelection -Skill $Skill -Name $Name -Skills $Skills if($Script){ $ScriptPath = $Script $ScriptRelative = $Script if(-not [IO.Path]::IsPathRooted($ScriptPath)){ $ScriptPath = Join-Path $ResolvedSkill.skillDir $ScriptPath } $ScriptResolved = Resolve-Path -LiteralPath $ScriptPath -EA SilentlyContinue if(!$ScriptResolved){ throw "POWERSHAI_AISKILL_SCRIPT_NOTFOUND: $Script" } $ScriptResolved = [string]$ScriptResolved $SkillRoot = [IO.Path]::GetFullPath($ResolvedSkill.skillDir) $ScriptFullPath = [IO.Path]::GetFullPath($ScriptResolved) if($ScriptFullPath -notlike "$SkillRoot*"){ throw "POWERSHAI_AISKILL_SCRIPT_OUTSIDE_SKILLDIR: $Script" } if([IO.Path]::IsPathRooted($ScriptRelative)){ $ScriptRelative = $ScriptFullPath.Substring($SkillRoot.Length).TrimStart('\\','/') } $ScriptRelative = [string]$ScriptRelative $ScriptRelative = $ScriptRelative.Replace('\\','/').TrimStart('/') $AuthKey = "$($ResolvedSkill.name)/$ScriptRelative" $IsAuthorized = Test-AiSkillScriptAuthorization -SkillName $ResolvedSkill.name -Script $ScriptRelative -AuthorizedSkillsScripts $AuthorizedSkillsScripts if(!$AllowScriptExecution -and !$IsAuthorized){ $Prompt = "Authorize skill script execution '$AuthKey'? [y/N]" $Resp = Read-Host $Prompt if($Resp -notmatch '^(?i:y|yes)$'){ throw "POWERSHAI_AISKILL_SCRIPT_NOTAUTHORIZED: $AuthKey" } } $ScriptResult = & $ScriptResolved @ScriptArgs return @{ skill = $ResolvedSkill.name script = $ScriptResolved result = $ScriptResult } } if($Raw){ return $ResolvedSkill.body } return _FormatAiSkillActivationContent -Skill $ResolvedSkill } } |