diff --git a/cli/azd/extensions/azure.ai.skills/.gitignore b/cli/azd/extensions/azure.ai.skills/.gitignore new file mode 100644 index 00000000000..6171e0ea543 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/.gitignore @@ -0,0 +1,9 @@ +# Local test artifacts +SKILL.md +test-skill/ +test-min/ +test-*/ +*.zip +*.tar.gz +azd-ai-skills-*.log +bin/ diff --git a/cli/azd/extensions/azure.ai.skills/.golangci.yaml b/cli/azd/extensions/azure.ai.skills/.golangci.yaml new file mode 100644 index 00000000000..b88a74c6a0b --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/.golangci.yaml @@ -0,0 +1,17 @@ +version: "2" + +linters: + default: none + enable: + - gosec + - lll + - unused + - errorlint + settings: + lll: + line-length: 220 + tab-width: 4 + +formatters: + enable: + - gofmt diff --git a/cli/azd/extensions/azure.ai.skills/AGENTS.md b/cli/azd/extensions/azure.ai.skills/AGENTS.md new file mode 100644 index 00000000000..bb01bd4e24f --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/AGENTS.md @@ -0,0 +1,73 @@ +# Azure AI Skills Extension - Agent Instructions + +Use this file together with `cli/azd/AGENTS.md`. This guide supplements the root azd instructions with the conventions that are specific to this extension. + +## Overview + +`azure.ai.skills` is a first-party azd extension under `cli/azd/extensions/azure.ai.skills/`. It runs as a separate Go binary and talks to the azd host over gRPC. It exposes the `azd ai skill ` command group for managing Foundry Skills. + +Useful places to start: + +- `internal/cmd/`: Cobra commands and top-level orchestration +- `internal/pkg/skill_api/`: typed Foundry Skills REST client, models, SKILL.md parser, and safe ZIP extractor +- `internal/exterrors/`: structured error factories and extension-specific codes + +## Relationship to `azure.ai.agents` + +This extension is intentionally separate from `azure.ai.agents`. It shares no code symbols but cooperates with it via the global-config endpoint key: + +- This extension writes to `extensions.ai-skills.project.context.endpoint` (none yet — read-only today). +- This extension reads `extensions.ai-skills.project.context.endpoint` first, then falls back to `extensions.ai-agents.project.context.endpoint` so users who already configured the endpoint via the agents extension are not forced to re-run `set`. + +`AgentCardSkill` (in `azure.ai.agents`) is unrelated to the `Skill` resource managed here and lives in a different Go module. + +## Build and test + +From `cli/azd/extensions/azure.ai.skills`: + +```bash +# Build using developer extension (for local development) +azd x build + +# Or build using Go directly +go build +``` + +If extension work depends on a new azd core change, plan for two PRs: + +1. Land the core change in `cli/azd` first. +2. Land the extension change after that, updating this module to the newer azd dependency with `go get github.com/azure/azure-dev/cli/azd && go mod tidy`. + +For local development, draft work, or validating both sides together before the core PR is merged, you may temporarily add: + +```go +replace github.com/azure/azure-dev/cli/azd => ../../ +``` + +That `replace` points this extension at your local `cli/azd` checkout instead of the version in `go.mod`. Do not merge the extension with that `replace` still present. + +## Error handling + +This extension uses `internal/exterrors` so the azd host can show a useful message, attach an optional suggestion, and emit stable telemetry. See `cli/azd/extensions/azure.ai.agents/AGENTS.md` "Error handling" section for the full conventions — they apply here unchanged. + +Skill-specific error codes live in `internal/exterrors/codes.go`: + +- `CodeInvalidSkillName` — name fails the alphanumeric-with-hyphens regex +- `CodeInvalidSkillFile` — SKILL.md front matter unparsable, or `--file` extension unsupported +- `CodeSkillArchiveUnsafe` — `download` rejected an archive entry (zip-slip, symlink, oversized, etc.) +- `CodeSkillOutputCollision` — `download` would overwrite an existing file without `--force` + +## Debug logging + +Each `--debug` run writes to `azd-ai-skills-.log` in the current working directory. The `skill_api` client deliberately opts out of `IncludeBody` request/response logging until a sanitizer is in place that redacts user-authored `description` and `instructions` fields. Do not enable body logging without that sanitizer. + +## File handling + +- `--file` is **not** a manifest. It is read at invocation time only; the CLI does not track or re-read it after the command returns. +- `create`: accepts `.md` or `.zip`. Mode is inferred from extension; conflicting modes (inline + `--file`) are rejected. +- `update`: accepts `.md` only. `.zip` is rejected with a structured suggestion to use `create --force`. +- `download`: writes either an extracted directory (default) or the unmodified ZIP archive (`--raw`). + +## Release preparation + +Follows the same two-PR convention as `azure.ai.agents`: a version-bump PR that touches only `version.txt`, `extension.yaml`, and `CHANGELOG.md`, followed by a registry-update PR generated by `azd x publish` against the released artifacts. diff --git a/cli/azd/extensions/azure.ai.skills/CHANGELOG.md b/cli/azd/extensions/azure.ai.skills/CHANGELOG.md index 3a52e0f1122..3fb7a84548a 100644 --- a/cli/azd/extensions/azure.ai.skills/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.skills/CHANGELOG.md @@ -1,3 +1,19 @@ # Release History -## 0.0.1-preview - Initial Version \ No newline at end of file +## 0.0.1-preview (Unreleased) + +- Initial preview release of the `azure.ai.skills` extension. +- Adds the `azd ai skill` command group with full CRUD over Foundry Skills: + - `azd ai skill create ` — inline (`--description` + `--instructions`), + SKILL.md file (`--file ./SKILL.md`), or ZIP package (`--file ./skill.zip`). + - `azd ai skill update ` — inline or `--file *.md`. + - `azd ai skill show ` — metadata only. + - `azd ai skill list` — paginated, supports `--top` and `--orderby`. + - `azd ai skill download ` — extracts to `./.agents/skills//` by + default; `--raw` keeps the archive as-is. The downloader auto-detects ZIP + vs gzip-tar via magic bytes because the Foundry surface is asymmetric: + uploads require `application/zip`, downloads return `application/gzip`. + - `azd ai skill delete ` — confirmation by default, `--force` to skip. +- Shares the Foundry project-endpoint resolution cascade with `azure.ai.agents`, + reading `extensions.ai-skills.project.context.endpoint` first and falling + back to `extensions.ai-agents.project.context.endpoint`. diff --git a/cli/azd/extensions/azure.ai.skills/README.md b/cli/azd/extensions/azure.ai.skills/README.md index 4893a2f7010..1aa1e50e3d9 100644 --- a/cli/azd/extensions/azure.ai.skills/README.md +++ b/cli/azd/extensions/azure.ai.skills/README.md @@ -1,3 +1,75 @@ -# Foundry Skills +# Azure Developer CLI (azd) Skills Extension -Manage Microsoft Foundry Skills from your terminal. (Preview) +Manage [Microsoft Foundry](https://learn.microsoft.com/azure/ai-services/) **skills** +(reusable behavioral guidelines an agent can attach at runtime) directly from your +terminal. + +## Commands + +```bash +azd ai skill create [--description "..." --instructions "..."] +azd ai skill create --file ./SKILL.md +azd ai skill create --file ./skill.zip + +azd ai skill update [--description "..."] [--instructions "..."] [--file ./SKILL.md] +azd ai skill show +azd ai skill list [--top N] [--orderby ] +azd ai skill download [--output-dir ] [--raw] [--force] +azd ai skill delete [--force] +``` + +All commands accept the standard cross-cutting flags: `-p` / `--project-endpoint`, +`--output table|json`, `--no-prompt`, and `--debug`. + +## Project endpoint resolution + +The Foundry project endpoint is resolved in this order: + +1. `-p` / `--project-endpoint` flag on the command. +2. Active azd env value `AZURE_AI_PROJECT_ENDPOINT`. +3. Global config `extensions.ai-skills.project.context.endpoint` + (falls back to `extensions.ai-agents.project.context.endpoint` so users who + configured the endpoint via the agents extension are not forced to re-run `set`). +4. Host environment variable `FOUNDRY_PROJECT_ENDPOINT`. +5. Structured error with an actionable suggestion. + +## Local Development + +### Prerequisites + +1. **Install developer kit extension** (if not already installed): + + ```bash + azd ext install microsoft.azd.extensions + ``` + +### Building and installing locally + +1. **Navigate to the extension directory**: + + ```bash + cd cli/azd/extensions/azure.ai.skills + ``` + +2. **Initial setup** (first time only): + + ```bash + azd x build + azd x pack + azd x publish + ``` + +3. **Install the extension**: + + ```bash + azd ext install azure.ai.skills + ``` + +4. **For subsequent development** (after initial setup): + + ```bash + azd x watch + ``` + + This automatically watches for file changes, rebuilds, and installs updates + locally. diff --git a/cli/azd/extensions/azure.ai.skills/build.ps1 b/cli/azd/extensions/azure.ai.skills/build.ps1 index 5ceb60a8bbc..6ddfeb6a65e 100644 --- a/cli/azd/extensions/azure.ai.skills/build.ps1 +++ b/cli/azd/extensions/azure.ai.skills/build.ps1 @@ -41,7 +41,7 @@ else { ) } -$APP_PATH = "$env:EXTENSION_ID/internal/cmd" +$VERSION_PATH = "azureaiskills/internal/version" # Loop through platforms and build foreach ($PLATFORM in $PLATFORMS) { @@ -65,7 +65,7 @@ foreach ($PLATFORM in $PLATFORMS) { $env:GOARCH = $ARCH go build ` - -ldflags="-X '$APP_PATH.Version=$env:EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" ` + -ldflags="-X '$VERSION_PATH.Version=$env:EXTENSION_VERSION' -X '$VERSION_PATH.Commit=$COMMIT' -X '$VERSION_PATH.BuildDate=$BUILD_DATE'" ` -o $OUTPUT_NAME if ($LASTEXITCODE -ne 0) { diff --git a/cli/azd/extensions/azure.ai.skills/build.sh b/cli/azd/extensions/azure.ai.skills/build.sh index f1a995ec5e9..0f508b130a2 100644 --- a/cli/azd/extensions/azure.ai.skills/build.sh +++ b/cli/azd/extensions/azure.ai.skills/build.sh @@ -33,7 +33,7 @@ else ) fi -APP_PATH="$EXTENSION_ID/internal/cmd" +VERSION_PATH="azureaiskills/internal/version" # Loop through platforms and build for PLATFORM in "${PLATFORMS[@]}"; do @@ -53,7 +53,7 @@ for PLATFORM in "${PLATFORMS[@]}"; do # Set environment variables for Go build GOOS=$OS GOARCH=$ARCH go build \ - -ldflags="-X '$APP_PATH.Version=$EXTENSION_VERSION' -X '$APP_PATH.Commit=$COMMIT' -X '$APP_PATH.BuildDate=$BUILD_DATE'" \ + -ldflags="-X '$VERSION_PATH.Version=$EXTENSION_VERSION' -X '$VERSION_PATH.Commit=$COMMIT' -X '$VERSION_PATH.BuildDate=$BUILD_DATE'" \ -o "$OUTPUT_NAME" if [ $? -ne 0 ]; then diff --git a/cli/azd/extensions/azure.ai.skills/ci-build.ps1 b/cli/azd/extensions/azure.ai.skills/ci-build.ps1 index ae71d3468ac..56992c10be5 100644 --- a/cli/azd/extensions/azure.ai.skills/ci-build.ps1 +++ b/cli/azd/extensions/azure.ai.skills/ci-build.ps1 @@ -1,12 +1,11 @@ param( - [string] $Version = (Get-Content "$PSScriptRoot/version.txt"), + [string] $Version = (Get-Content "$PSScriptRoot/../version.txt"), [string] $SourceVersion = (git rev-parse HEAD), [switch] $CodeCoverageEnabled, [switch] $BuildRecordMode, [string] $MSYS2Shell, # path to msys2_shell.cmd [string] $OutputFileName ) - $PSNativeCommandArgumentPassing = 'Legacy' # Remove any previously built binaries @@ -19,7 +18,17 @@ if ($LASTEXITCODE) { # Run `go help build` to obtain detailed information about `go build` flags. $buildFlags = @( + # remove all file system paths from the resulting executable. + # Instead of absolute file system paths, the recorded file names + # will begin either a module path@version (when using modules), + # or a plain import path (when using the standard library, or GOPATH). "-trimpath", + + # Use buildmode=pie (Position Independent Executable) for enhanced security across platforms + # against memory corruption exploits across all major platforms. + # + # On Windows, the -buildmode=pie flag enables Address Space Layout + # Randomization (ASLR) and automatically sets DYNAMICBASE and HIGH-ENTROPY-VA flags in the PE header. "-buildmode=pie" ) @@ -27,9 +36,18 @@ if ($CodeCoverageEnabled) { $buildFlags += "-cover" } +# Build constraint tags +# cfi: Enable Control Flow Integrity (CFI), +# cfg: Enable Control Flow Guard (CFG), +# osusergo: Optimize for OS user accounts $tagsFlag = "-tags=cfi,cfg,osusergo" -$ldFlag = "-ldflags=-s -w -X azure.ai.skills/internal/cmd.Version=$Version -X azure.ai.skills/internal/cmd.Commit=$SourceVersion -X azure.ai.skills/internal/cmd.BuildDate=$(Get-Date -Format o) " +# ld linker flags +# -s: Omit symbol table and debug information +# -w: Omit DWARF symbol table +# -X: Set variable at link time. Used to set the version in source. + +$ldFlag = "-ldflags=-s -w -X 'azureaiskills/internal/version.Version=$Version' -X 'azureaiskills/internal/version.Commit=$SourceVersion' -X 'azureaiskills/internal/version.BuildDate=$(Get-Date -Format o)' " if ($IsWindows) { $msg = "Building for Windows" @@ -37,6 +55,13 @@ if ($IsWindows) { } elseif ($IsLinux) { Write-Host "Building for linux" + + # Disable cgo in the x64 Linux build. This will also statically + # link the resulting binary which increases backwards + # compatibility with older versions of Linux. + if ($env:GOARCH -ne "arm64") { + $env:CGO_ENABLED = "0" + } } elseif ($IsMacOS) { Write-Host "Building for macOS" @@ -57,13 +82,17 @@ function PrintFlags() { [string] $flags ) + # Attempt to format flags so that they are easily copy-pastable to be ran inside pwsh $i = 0 foreach ($buildFlag in $buildFlags) { + # If the flag has a value, wrap it in quotes. This is not required when invoking directly below, + # but when repasted into a shell for execution, the quotes can help escape special characters such as ','. $argWithValue = $buildFlag.Split('=', 2) if ($argWithValue.Length -eq 2 -and !$argWithValue[1].StartsWith("`"")) { $buildFlag = "$($argWithValue[0])=`"$($argWithValue[1])`"" } + # Write each flag on a newline with '`' acting as the multiline separator if ($i -eq $buildFlags.Length - 1) { Write-Host " $buildFlag" } @@ -75,6 +104,8 @@ function PrintFlags() { } $oldGOEXPERIMENT = $env:GOEXPERIMENT +# Enable the loopvar experiment, which makes the loop variaible for go loops like `range` behave as most folks would expect. +# the go team is exploring making this default in the future, and we'd like to opt into the behavior now. $env:GOEXPERIMENT = "loopvar" try { @@ -87,6 +118,7 @@ try { } if ($BuildRecordMode) { + # Modify build tags to include record $recordTagPatched = $false for ($i = 0; $i -lt $buildFlags.Length; $i++) { if ($buildFlags[$i].StartsWith("-tags=")) { @@ -97,6 +129,7 @@ try { if (-not $recordTagPatched) { $buildFlags += "-tags=record" } + # Add output file flag for record mode $recordOutput = "-o=$OutputFileName-record" if ($IsWindows) { $recordOutput += ".exe" } $buildFlags += $recordOutput diff --git a/cli/azd/extensions/azure.ai.skills/cspell.yaml b/cli/azd/extensions/azure.ai.skills/cspell.yaml index 258d305a22d..a10ee426b12 100644 --- a/cli/azd/extensions/azure.ai.skills/cspell.yaml +++ b/cli/azd/extensions/azure.ai.skills/cspell.yaml @@ -1,2 +1,13 @@ import: ../../.vscode/cspell.yaml -words: [] +words: + # Skill commands + - azureaiskills + - exterrors + - foundry + - foundrysdk + - orderby + - tarball + - zipslip + - gzip + - skill + - skills diff --git a/cli/azd/extensions/azure.ai.skills/extension.yaml b/cli/azd/extensions/azure.ai.skills/extension.yaml index 90ef16ee1a9..cdd77b52ca3 100644 --- a/cli/azd/extensions/azure.ai.skills/extension.yaml +++ b/cli/azd/extensions/azure.ai.skills/extension.yaml @@ -1,14 +1,26 @@ -# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json -capabilities: - - custom-commands - - metadata -description: Manage Microsoft Foundry Skills from your terminal. (Preview) -displayName: Foundry Skills (Preview) +# yaml-language-server: $schema=../extension.schema.json id: azure.ai.skills -language: go namespace: ai.skill -tags: - - ai - - skill +displayName: Foundry skills (Preview) +description: Manage Microsoft Foundry skills (reusable agent behavioral guidelines) from your terminal. (Preview) usage: azd ai skill [options] +# NOTE: Make sure version.txt is in sync with this version. version: 0.0.1-preview +requiredAzdVersion: ">1.23.13" +language: go +capabilities: + - custom-commands + - metadata +tags: + - ai + - skill +examples: + - name: list + description: List skills in the current Foundry project. + usage: azd ai skill list + - name: create + description: Create a skill from a SKILL.md file. + usage: azd ai skill create my-skill --file ./SKILL.md + - name: download + description: Download and extract a skill into ./.agents/skills/. + usage: azd ai skill download my-skill diff --git a/cli/azd/extensions/azure.ai.skills/go.mod b/cli/azd/extensions/azure.ai.skills/go.mod index d6d53f3f877..50fa701b89e 100644 --- a/cli/azd/extensions/azure.ai.skills/go.mod +++ b/cli/azd/extensions/azure.ai.skills/go.mod @@ -1,20 +1,22 @@ -module azure.ai.skills - +module azureaiskills go 1.26.1 require ( - github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3 github.com/azure/azure-dev/cli/azd v1.25.0 github.com/fatih/color v1.18.0 - github.com/spf13/cobra v1.10.1 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 + go.yaml.in/yaml/v3 v3.0.4 ) require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 // indirect @@ -75,8 +77,6 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/theckman/yacspin v0.13.12 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect diff --git a/cli/azd/extensions/azure.ai.skills/go.sum b/cli/azd/extensions/azure.ai.skills/go.sum index 09262a16074..fe043d0e5f7 100644 --- a/cli/azd/extensions/azure.ai.skills/go.sum +++ b/cli/azd/extensions/azure.ai.skills/go.sum @@ -1,22 +1,22 @@ code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc= -github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= -github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3 h1:0g4UTtvRA9goC37cmD9ZHdW6CCNJR4cOXBnHz0r4ubM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0-beta.3/go.mod h1:fEiHi0sbYqbo3shUkIF1SNxm8GyeEJl+Poc/djOvbdE= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0 h1:JI8PcWOImyvIUEZ0Bbmfe05FOlWkMi2KhjG+cAKaUms= +github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appservice/armappservice/v2 v2.3.0/go.mod h1:nJLFPGJkyKfDDyJiPuHIXsCi/gpJkm07EvRgiX7SGlI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= @@ -200,8 +200,8 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -247,6 +247,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go new file mode 100644 index 00000000000..46045b9d233 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "io" + "log" + "os" + "strconv" + "time" + + azcorelog "github.com/Azure/azure-sdk-for-go/sdk/azcore/log" + "github.com/spf13/pflag" +) + +// setupDebugLogging routes the standard logger and the Azure SDK logger to a +// per-day file when --debug is set, and to io.Discard otherwise. The SDK +// pipeline runs with IncludeBody=false so request/response bodies (which can +// carry user-authored description / instructions) never reach the log. +func setupDebugLogging(flags *pflag.FlagSet) func() { + if !isDebug(flags) { + log.SetOutput(io.Discard) + azcorelog.SetListener(nil) + return func() {} + } + + logFileName := fmt.Sprintf("azd-ai-skills-%s.log", time.Now().Format("2006-01-02")) + + //nolint:gosec // log file name is generated locally from date and not user-controlled + logFile, err := os.OpenFile(logFileName, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) + + var w io.Writer + var closeFile func() + if err != nil { + w = os.Stderr + closeFile = func() {} + } else { + w = logFile + closeFile = func() { _ = logFile.Close() } + } + + log.SetOutput(w) + azcorelog.SetListener(func(event azcorelog.Event, msg string) { + fmt.Fprintf(w, "[%s] %s: %s\n", time.Now().Format(time.RFC3339), event, msg) + }) + + return func() { + log.SetOutput(io.Discard) + azcorelog.SetListener(nil) + closeFile() + } +} + +func isDebug(flags *pflag.FlagSet) bool { + if flags != nil { + if v, err := flags.GetBool("debug"); err == nil && v { + return true + } + } + v, _ := strconv.ParseBool(os.Getenv("AZD_EXT_DEBUG")) + return v +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go new file mode 100644 index 00000000000..c113cdc1167 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "log" + "net/url" + "os" + "strings" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +type endpointSource string + +const ( + sourceFlag endpointSource = "flag" + sourceAzdEnv endpointSource = "azdEnv" + sourceGlobalConfig endpointSource = "globalConfig" + sourceFoundryEnv endpointSource = "foundryEnv" +) + +const ( + skillsContextEndpointKey = "extensions.ai-skills.project.context.endpoint" + // Read-only fallback to the ai-agents key so users who configured the + // endpoint via that extension don't have to re-run `set`. + agentsContextEndpointKey = "extensions.ai-agents.project.context.endpoint" + azdEnvKey = "AZURE_AI_PROJECT_ENDPOINT" + foundryEnvKey = "FOUNDRY_PROJECT_ENDPOINT" +) + +// resolveProjectEndpoint implements the 5-level cascade from the design spec: +// flag, azd env, global config (skills then agents), host env, error. +func resolveProjectEndpoint(ctx context.Context, flagEndpoint string) (string, endpointSource, error) { + if flagEndpoint != "" { + if err := validateEndpoint(flagEndpoint); err != nil { + return "", "", err + } + return flagEndpoint, sourceFlag, nil + } + + if azdClient, err := azdext.NewAzdClient(); err == nil { + defer azdClient.Close() + + if envResp, envErr := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}); envErr == nil { + if valResp, valErr := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envResp.Environment.Name, + Key: azdEnvKey, + }); valErr == nil && valResp.Value != "" { + if err := validateEndpoint(valResp.Value); err != nil { + return "", "", err + } + return valResp.Value, sourceAzdEnv, nil + } + } + + if ch, chErr := azdext.NewConfigHelper(azdClient); chErr == nil { + for _, key := range []string{skillsContextEndpointKey, agentsContextEndpointKey} { + var endpoint string + if found, getErr := ch.GetUserJSON(ctx, key, &endpoint); getErr == nil && found && endpoint != "" { + if key == agentsContextEndpointKey { + log.Printf("resolveProjectEndpoint: using fallback global config key %q", agentsContextEndpointKey) + } + if err := validateEndpoint(endpoint); err != nil { + return "", "", err + } + return endpoint, sourceGlobalConfig, nil + } + } + } + } + + if ep := os.Getenv(foundryEnvKey); ep != "" { + if err := validateEndpoint(ep); err != nil { + return "", "", err + } + return ep, sourceFoundryEnv, nil + } + + return "", "", exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + "no Foundry project endpoint resolved", + "pass --project-endpoint, set "+azdEnvKey+" in the active azd environment, "+ + "persist a workspace default with `azd ai agent project set `, "+ + "or export "+foundryEnvKey+" in your shell", + ) +} + +func validateEndpoint(endpoint string) error { + u, err := url.Parse(endpoint) + if err != nil { + return fmt.Errorf("invalid project endpoint %q: %w", endpoint, err) + } + if !strings.EqualFold(u.Scheme, "https") { + return fmt.Errorf("invalid project endpoint %q: must use https scheme", endpoint) + } + if u.Host == "" { + return fmt.Errorf("invalid project endpoint %q: missing host", endpoint) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go new file mode 100644 index 00000000000..54917ecdf80 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestResolveProjectEndpoint_FlagWins(t *testing.T) { + ep, src, err := resolveProjectEndpoint(context.Background(), "https://flag.example.com") + require.NoError(t, err) + require.Equal(t, "https://flag.example.com", ep) + require.Equal(t, sourceFlag, src) +} + +func TestResolveProjectEndpoint_HostEnvVar(t *testing.T) { + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://host.example.com") + + ep, src, err := resolveProjectEndpoint(context.Background(), "") + require.NoError(t, err) + require.Equal(t, "https://host.example.com", ep) + require.Equal(t, sourceFoundryEnv, src) +} + +func TestResolveProjectEndpoint_InvalidScheme(t *testing.T) { + cases := []struct { + name string + endpoint string + wantMsg string + }{ + {"http scheme", "http://example.com", "must use https scheme"}, + {"no scheme", "example.com/foo", "must use https scheme"}, + {"empty host", "https:///path", "missing host"}, + {"ftp scheme", "ftp://example.com", "must use https scheme"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, _, err := resolveProjectEndpoint(context.Background(), tc.endpoint) + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantMsg) + }) + } +} + +func TestResolveProjectEndpoint_MissingAll(t *testing.T) { + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + + _, _, err := resolveProjectEndpoint(context.Background(), "") + require.Error(t, err) + require.Contains(t, err.Error(), "no Foundry project endpoint resolved") +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go new file mode 100644 index 00000000000..c62b7cf71d7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "azureaiskills/internal/pkg/skill_api" +) + +const ( + outputJSON = "json" + outputTable = "table" +) + +func printJSON(v any) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.SetEscapeHTML(false) + return enc.Encode(v) +} + +func printSkillDetail(s *skill_api.Skill, format string) error { + if format == outputJSON { + return printJSON(s) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + defer tw.Flush() + fmt.Fprintf(tw, "Name\t%s\n", s.Name) + if s.SkillID != "" { + fmt.Fprintf(tw, "Skill ID\t%s\n", s.SkillID) + } + if s.Description != "" { + fmt.Fprintf(tw, "Description\t%s\n", s.Description) + } + fmt.Fprintf(tw, "Has Blob\t%t\n", s.HasBlob) + if len(s.Metadata) > 0 { + fmt.Fprintln(tw, "Metadata\t") + for k, v := range s.Metadata { + fmt.Fprintf(tw, " %s\t%s\n", k, v) + } + } + return nil +} + +func printSkillList(items []skill_api.Skill, format string) error { + if format == outputJSON { + if items == nil { + items = []skill_api.Skill{} + } + return printJSON(items) + } + return writeSkillTable(os.Stdout, items) +} + +func writeSkillTable(w io.Writer, items []skill_api.Skill) error { + tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0) + defer tw.Flush() + fmt.Fprintln(tw, "NAME\tDESCRIPTION\tHAS BLOB") + fmt.Fprintln(tw, "----\t-----------\t--------") + for _, s := range items { + fmt.Fprintf(tw, "%s\t%s\t%t\n", s.Name, truncate(s.Description, 60), s.HasBlob) + } + return nil +} + +func truncate(s string, max int) string { + s = strings.ReplaceAll(s, "\n", " ") + if len(s) <= max { + return s + } + if max < 3 { + return s[:max] + } + return s[:max-3] + "..." +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/output_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/output_test.go new file mode 100644 index 00000000000..32d7f6460d8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/output_test.go @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "strings" + "testing" + + "azureaiskills/internal/pkg/skill_api" + + "github.com/stretchr/testify/require" +) + +func TestWriteSkillTable(t *testing.T) { + var buf bytes.Buffer + skills := []skill_api.Skill{ + {Name: "alpha", Description: "first skill", HasBlob: false}, + {Name: "bravo", Description: "second skill with quite a long description that should be truncated", HasBlob: true}, + } + require.NoError(t, writeSkillTable(&buf, skills)) + + out := buf.String() + require.True(t, strings.Contains(out, "NAME"), out) + require.True(t, strings.Contains(out, "alpha"), out) + require.True(t, strings.Contains(out, "bravo"), out) + // Long description is truncated. + require.True(t, strings.Contains(out, "..."), out) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go index 3dcf86953ad..fdd93cf3b74 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/root.go @@ -4,7 +4,10 @@ package cmd import ( + "fmt" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" "github.com/spf13/cobra" ) @@ -12,20 +15,50 @@ func NewRootCommand() *cobra.Command { rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ Name: "skill", Use: "skill [options]", - Short: "Manage Microsoft Foundry Skills from your terminal. (Preview)", - }) + Short: fmt.Sprintf("Manage Foundry skills from your terminal. %s", color.YellowString("(Preview)")), + Long: `Manage Foundry skills — reusable behavioral guidelines an agent can attach +at runtime — from your terminal. +Skills carry either inline JSON (description + Markdown instructions) or a +packaged ZIP archive bundling SKILL.md plus any sibling assets. Use this +command group to create, update, show, list, download, and delete skills in +a Foundry project.`, + }) rootCmd.SilenceUsage = true rootCmd.SilenceErrors = true - rootCmd.CompletionOptions = cobra.CompletionOptions{ - DisableDefaultCmd: true, + rootCmd.CompletionOptions.DisableDefaultCmd = true + + sdkPreRun := rootCmd.PersistentPreRunE + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + if sdkPreRun != nil { + if err := sdkPreRun(cmd, args); err != nil { + return err + } + } + setupDebugLogging(cmd.Flags()) + return nil } rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) - rootCmd.AddCommand(newContextCommand()) - rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) + rootCmd.PersistentFlags().StringP("project-endpoint", "p", "", + "Foundry project endpoint URL (overrides env vars and global config)") + + rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) + rootCmd.AddCommand(newVersionCommand()) rootCmd.AddCommand(newMetadataCommand(rootCmd)) + rootCmd.AddCommand(newContextCommand()) + + rootCmd.AddCommand(newCreateCommand(extCtx)) + rootCmd.AddCommand(newUpdateCommand(extCtx)) + rootCmd.AddCommand(newShowCommand(extCtx)) + rootCmd.AddCommand(newListCommand(extCtx)) + rootCmd.AddCommand(newDownloadCommand(extCtx)) + rootCmd.AddCommand(newDeleteCommand(extCtx)) return rootCmd } + +// configureExtensionHost is the listen callback. Skills register no +// lifecycle hooks, so it's a no-op. +func configureExtensionHost(host *azdext.ExtensionHost) { _ = host } diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go new file mode 100644 index 00000000000..5b9fbde3a43 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + "azureaiskills/internal/version" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" +) + +type skillContext struct { + client *skill_api.Client + endpoint string + source endpointSource +} + +func resolveSkillContext(ctx context.Context, flagEndpoint string) (*skillContext, error) { + endpoint, src, err := resolveProjectEndpoint(ctx, flagEndpoint) + if err != nil { + return nil, err + } + cred, err := newCredential() + if err != nil { + return nil, err + } + return &skillContext{ + client: skill_api.NewClient(endpoint, cred, version.Version), + endpoint: endpoint, + source: src, + }, nil +} + +func newCredential() (azcore.TokenCredential, error) { + cred, err := azidentity.NewAzureDeveloperCLICredential( + &azidentity.AzureDeveloperCLICredentialOptions{}, + ) + if err != nil { + return nil, exterrors.Auth( + exterrors.CodeCredentialCreationFailed, + fmt.Sprintf("failed to create Azure credential: %s", err), + "run `azd auth login` to authenticate", + ) + } + return cred, nil +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go new file mode 100644 index 00000000000..5430ead395a --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go @@ -0,0 +1,409 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type createFlags struct { + name string + description string + instructions string + file string + force bool + noPrompt bool + output string + projectEndpoint string + + descriptionSet bool + instructionsSet bool +} + +type createAction struct{ flags *createFlags } + +func (a *createAction) Run(ctx context.Context) error { + if err := validateSkillName(a.flags.name); err != nil { + return err + } + + mode, err := selectCreateMode(a.flags) + if err != nil { + return err + } + + if mode == modeNone { + if a.flags.noPrompt { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "no input supplied to skill create", + "pass --description and --instructions, or --file ", + ) + } + if err := promptForInline(ctx, a.flags); err != nil { + return err + } + mode = modeInline + } + + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + + // --force in package mode is destructive: we delete the existing skill by + // positional name before any guarantee the archive targets the same one. + // Peek the embedded SKILL.md `name` and bail if it disagrees. + if mode == modeFilePackage && a.flags.force { + if err := verifyPackageNameMatches(a.flags.file, a.flags.name); err != nil { + return err + } + } + + if a.flags.force { + if _, delErr := skillCtx.client.Delete(ctx, a.flags.name); delErr != nil && !isNotFound(delErr) { + return exterrors.ServiceFromAzure(delErr, exterrors.OpDeleteSkill) + } + } + + switch mode { + case modeInline: + return a.runInline(ctx, skillCtx.client) + case modeFileMd: + return a.runFileMd(ctx, skillCtx.client) + case modeFilePackage: + return a.runFilePackage(ctx, skillCtx.client) + } + return exterrors.Validation( + exterrors.CodeInvalidParameter, + "unsupported create mode", + "this is a bug; please file an issue", + ) +} + +func (a *createAction) runInline(ctx context.Context, client *skill_api.Client) error { + if strings.TrimSpace(a.flags.description) == "" { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "inline mode requires --description", + "pass --description and --instructions, or use --file ", + ) + } + if strings.TrimSpace(a.flags.instructions) == "" { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "inline mode requires --instructions", + "pass --description and --instructions, or use --file ", + ) + } + + created, err := client.CreateInline(ctx, skill_api.CreateRequest{ + Name: a.flags.name, + Description: a.flags.description, + Instructions: a.flags.instructions, + }) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateSkill) + } + return printCreateResult(created, a.flags.output) +} + +func (a *createAction) runFileMd(ctx context.Context, client *skill_api.Client) error { + data, err := readFileWithLimit(a.flags.file) + if err != nil { + return err + } + parsed, parseErr := skill_api.ParseSkillMd(data) + if parseErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("failed to parse %s: %s", a.flags.file, parseErr), + "ensure the file begins with a YAML front matter block delimited by '---'", + ) + } + + if parsed.Name != "" && parsed.Name != a.flags.name && !shouldSuppressWarning(a.flags.noPrompt, a.flags.output) { + fmt.Fprintf(os.Stderr, + "Warning: SKILL.md front matter `name: %q` does not match positional argument %q; using %q\n", + parsed.Name, a.flags.name, a.flags.name, + ) + } + + created, err := client.CreateInline(ctx, skill_api.CreateRequest{ + Name: a.flags.name, + Description: parsed.Description, + Instructions: parsed.Instructions, + Metadata: parsed.Metadata, + }) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateSkill) + } + return printCreateResult(created, a.flags.output) +} + +func (a *createAction) runFilePackage(ctx context.Context, client *skill_api.Client) error { + info, statErr := os.Stat(a.flags.file) + if statErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot stat %s: %s", a.flags.file, statErr), + "verify the path exists and is readable", + ) + } + if info.IsDir() { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("--file %s is a directory; expected a .zip archive", a.flags.file), + "pass a single .zip archive path", + ) + } + + f, openErr := os.Open(a.flags.file) //nolint:gosec // user-supplied path opened on user's behalf + if openErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot open %s: %s", a.flags.file, openErr), + "verify the file is readable", + ) + } + defer f.Close() + + created, err := client.CreatePackage(ctx, f, info.Size()) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateSkill) + } + return printCreateResult(created, a.flags.output) +} + +func printCreateResult(s *skill_api.Skill, format string) error { + if format == outputJSON { + return printJSON(s) + } + fmt.Printf("Skill %q created.\n", s.Name) + return printSkillDetail(s, outputTable) +} + +// verifyPackageNameMatches refuses --force when the archive's SKILL.md +// declares a different `name` than the positional argument, to avoid +// wiping an unrelated skill on a typo. Returns nil when the archive omits +// `name` (no claim) or when the names agree. +func verifyPackageNameMatches(archivePath, positionalName string) error { + f, openErr := os.Open(archivePath) //nolint:gosec // user-supplied path opened on user's behalf + if openErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot open %s: %s", archivePath, openErr), + "verify the file is readable", + ) + } + defer f.Close() + info, statErr := f.Stat() + if statErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot stat %s: %s", archivePath, statErr), + "verify the file is readable", + ) + } + archiveName, peekErr := skill_api.PeekArchiveSkillName(f, info.Size()) + if peekErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot inspect %s: %s", archivePath, peekErr), + "ensure the archive is a valid .zip containing a SKILL.md file", + ) + } + if archiveName == "" || archiveName == positionalName { + return nil + } + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("--force refused: archive declares name %q which does not match positional argument %q", archiveName, positionalName), + "re-run without --force, or fix the positional name / archive so they agree", + ) +} + +type createMode int + +const ( + modeNone createMode = iota + modeInline + modeFileMd + modeFilePackage +) + +func selectCreateMode(f *createFlags) (createMode, error) { + inlineProvided := f.descriptionSet || f.instructionsSet + fileProvided := f.file != "" + + if inlineProvided && fileProvided { + return modeNone, exterrors.Validation( + exterrors.CodeConflictingArguments, + "--file is mutually exclusive with --description / --instructions", + "pass only one of: inline flags, --file ", + ) + } + + if fileProvided { + switch strings.ToLower(filepath.Ext(f.file)) { + case ".md": + return modeFileMd, nil + case ".zip": + return modeFilePackage, nil + default: + return modeNone, exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("unsupported --file extension %q", filepath.Ext(f.file)), + "use .md for inline metadata or .zip for a package upload", + ) + } + } + + if inlineProvided { + return modeInline, nil + } + return modeNone, nil +} + +func promptForInline(ctx context.Context, f *createFlags) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "no input supplied to skill create", + "pass --description and --instructions, or --file ", + ) + } + defer azdClient.Close() + + if strings.TrimSpace(f.description) == "" { + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Skill description", + HelpMessage: "Short human-readable summary. Sent to the service as `description`.", + Required: true, + }, + }) + if err != nil { + return err + } + f.description = resp.Value + f.descriptionSet = true + } + + if strings.TrimSpace(f.instructions) == "" { + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: "Skill instructions (Markdown body)", + HelpMessage: "Markdown body that defines the skill's behavior. Sent as `instructions`.", + Required: true, + }, + }) + if err != nil { + return err + } + f.instructions = resp.Value + f.instructionsSet = true + } + return nil +} + +func newCreateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &createFlags{} + action := &createAction{flags: flags} + + cmd := &cobra.Command{ + Use: "create ", + Short: "Create a new Foundry skill.", + Long: `Create a new Foundry skill in one of three mutually exclusive modes: + + 1. Inline: --description "..." --instructions "..." + 2. SKILL.md: --file ./SKILL.md (CLI parses YAML front matter + body) + 3. Package: --file ./skill.zip (CLI uploads the archive as-is) + +Pass --force to delete an existing skill of the same name before creating.`, + Example: ` azd ai skill create greet-user --description "Welcomes a new user" --instructions "Greet ..." + azd ai skill create greet-user --file ./SKILL.md + azd ai skill create greet-user --file ./skill.zip --force`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + flags.descriptionSet = cmd.Flags().Changed("description") + flags.instructionsSet = cmd.Flags().Changed("instructions") + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + return action.Run(azdext.WithAccessToken(cmd.Context())) + }, + } + + cmd.Flags().StringVar(&flags.description, "description", "", "Inline mode: human-readable summary of the skill") + cmd.Flags().StringVar(&flags.instructions, "instructions", "", "Inline mode: Markdown body defining skill behavior") + cmd.Flags().StringVar(&flags.file, "file", "", "Path to SKILL.md (.md) or a ZIP package (.zip)") + cmd.Flags().BoolVar(&flags.force, "force", false, "Delete an existing skill of the same name before creating") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputJSON, + }) + return cmd +} + +// readFileWithLimit reads up to 1 MiB from path. SKILL.md is small in practice; +// the cap guards against reading a giant file by accident. +func readFileWithLimit(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot stat %s: %s", path, err), + "verify the path exists and is readable", + ) + } + if info.IsDir() { + return nil, exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("--file %s is a directory; expected a SKILL.md file", path), + "pass a single .md file", + ) + } + const maxBytes = 1 << 20 + if info.Size() > maxBytes { + return nil, exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("%s exceeds the 1 MiB SKILL.md size limit (got %d bytes)", path, info.Size()), + "split the file into smaller assets and use a package upload", + ) + } + data, err := os.ReadFile(path) //nolint:gosec // user-supplied path read on user's behalf + if err != nil { + return nil, exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("cannot read %s: %s", path, err), + "verify the file is readable", + ) + } + return data, nil +} + +func shouldSuppressWarning(noPrompt bool, format string) bool { + return noPrompt || format == outputJSON +} + +func isNotFound(err error) bool { + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + return respErr.StatusCode == 404 + } + return false +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create_test.go new file mode 100644 index 00000000000..c7f71636738 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create_test.go @@ -0,0 +1,125 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "archive/zip" + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// --- createAction.Run early-exit paths (no network) --- + +func TestCreateAction_RejectsInvalidName(t *testing.T) { + a := &createAction{flags: &createFlags{name: "_bad"}} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillName, le.Code) +} + +func TestCreateAction_NoPromptWithNoInput(t *testing.T) { + a := &createAction{flags: &createFlags{name: "my-skill", noPrompt: true}} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeMissingRequiredField, le.Code) +} + +// TestCreateAction_ConflictingFlagsViRun exercises the full Run path (not just +// selectCreateMode) so that the validation is also reached via the command +// entry-point. +func TestCreateAction_ConflictingFlagsViaRun(t *testing.T) { + a := &createAction{flags: &createFlags{ + name: "my-skill", + descriptionSet: true, + description: "desc", + file: "SKILL.md", + noPrompt: true, + }} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeConflictingArguments, le.Code) +} + +// --- verifyPackageNameMatches --- + +func TestVerifyPackageNameMatches_NameMatches(t *testing.T) { + path := writeZipWithSkillMd(t, "my-skill") + require.NoError(t, verifyPackageNameMatches(path, "my-skill")) +} + +func TestVerifyPackageNameMatches_NameMismatch(t *testing.T) { + path := writeZipWithSkillMd(t, "other-skill") + err := verifyPackageNameMatches(path, "my-skill") + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) +} + +func TestVerifyPackageNameMatches_NoSkillMd(t *testing.T) { + // Archive without SKILL.md: no name claim → --force allowed. + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("README.md") + require.NoError(t, err) + _, _ = w.Write([]byte("hi")) + require.NoError(t, zw.Close()) + p := writeTempFile(t, buf.Bytes(), "*.zip") + require.NoError(t, verifyPackageNameMatches(p, "my-skill")) +} + +func TestVerifyPackageNameMatches_MalformedSkillMd(t *testing.T) { + // Malformed SKILL.md: PeekArchiveSkillName now propagates the error, + // so --force must be blocked to prevent accidental skill deletion. + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("SKILL.md") + require.NoError(t, err) + _, _ = w.Write([]byte("not valid front matter")) + require.NoError(t, zw.Close()) + p := writeTempFile(t, buf.Bytes(), "*.zip") + err = verifyPackageNameMatches(p, "my-skill") + require.Error(t, err, "malformed SKILL.md must block --force to prevent accidental deletion") + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) +} + +// helpers + +func writeZipWithSkillMd(t *testing.T, skillName string) string { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create("SKILL.md") + require.NoError(t, err) + _, _ = w.Write([]byte("---\nname: " + skillName + "\n---\nbody\n")) + require.NoError(t, zw.Close()) + return writeTempFile(t, buf.Bytes(), "*.zip") +} + +func writeTempFile(t *testing.T, data []byte, pattern string) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), pattern) + require.NoError(t, err) + _, err = f.Write(data) + require.NoError(t, err) + require.NoError(t, f.Close()) + return filepath.Clean(f.Name()) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go new file mode 100644 index 00000000000..0bd5ab925dd --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type deleteFlags struct { + name string + force bool + noPrompt bool + output string + projectEndpoint string +} + +type deleteAction struct{ flags *deleteFlags } + +// deleteResult is the JSON shape printed when --output=json. +type deleteResult struct { + Name string `json:"name"` + Deleted bool `json:"deleted"` + Cancelled bool `json:"cancelled,omitempty"` +} + +func (a *deleteAction) Run(ctx context.Context) error { + if err := validateSkillName(a.flags.name); err != nil { + return err + } + + if !a.flags.force { + if a.flags.noPrompt { + return exterrors.Validation( + exterrors.CodeMissingForceFlag, + fmt.Sprintf("deleting %q requires confirmation", a.flags.name), + "pass --force to skip confirmation in non-interactive mode", + ) + } + confirmed, err := a.confirmDelete(ctx) + if err != nil { + return err + } + if !confirmed { + return a.printResult(deleteResult{Name: a.flags.name, Cancelled: true}) + } + } + + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + if _, err := skillCtx.client.Delete(ctx, a.flags.name); err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpDeleteSkill) + } + return a.printResult(deleteResult{Name: a.flags.name, Deleted: true}) +} + +func (a *deleteAction) confirmDelete(ctx context.Context) (bool, error) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return false, fmt.Errorf("create azd client for confirmation: %w", err) + } + defer azdClient.Close() + + defaultValue := false + resp, err := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf("Delete skill %q?", a.flags.name), + DefaultValue: &defaultValue, + }, + }) + if err != nil { + return false, err + } + if resp.Value == nil { + return false, nil + } + return *resp.Value, nil +} + +func (a *deleteAction) printResult(res deleteResult) error { + if a.flags.output == outputJSON { + return printJSON(res) + } + if res.Cancelled { + fmt.Printf("Skill %q deletion cancelled.\n", res.Name) + } else { + fmt.Printf("Skill %q deleted.\n", res.Name) + } + return nil +} + +func newDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &deleteFlags{} + action := &deleteAction{flags: flags} + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a Foundry skill.", + Long: `Delete a skill from the resolved Foundry project. + +By default the CLI prompts for confirmation. Pass --force to skip the prompt. +In --no-prompt mode (set globally), --force is required.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + return action.Run(azdext.WithAccessToken(cmd.Context())) + }, + } + + cmd.Flags().BoolVar(&flags.force, "force", false, "Skip the confirmation prompt") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputTable, + }) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete_test.go new file mode 100644 index 00000000000..ab9bed1a01c --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete_test.go @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestDeleteAction_RejectsInvalidName(t *testing.T) { + a := &deleteAction{flags: &deleteFlags{name: "_bad"}} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillName, le.Code) +} + +func TestDeleteAction_NoPromptRequiresForce(t *testing.T) { + a := &deleteAction{flags: &deleteFlags{name: "my-skill", noPrompt: true, force: false}} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeMissingForceFlag, le.Code) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go new file mode 100644 index 00000000000..cccc2e21f31 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go @@ -0,0 +1,333 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type downloadFlags struct { + name string + outputDir string + raw bool + force bool + output string + projectEndpoint string + + outputDirSet bool +} + +type downloadAction struct{ flags *downloadFlags } + +// downloadResult is the JSON shape printed when --output=json. Public contract. +type downloadResult struct { + Skill string `json:"skill"` + OutputDir string `json:"outputDir"` + Files []string `json:"files,omitempty"` + Archive string `json:"archive,omitempty"` + Raw bool `json:"raw"` +} + +func (a *downloadAction) Run(ctx context.Context) error { + if err := validateSkillName(a.flags.name); err != nil { + return err + } + + outputDir := a.flags.outputDir + if outputDir == "" { + outputDir = filepath.Join(".agents", "skills", a.flags.name) + } + absOut, err := filepath.Abs(outputDir) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid --output-dir: %s", err), + "pass a valid filesystem path", + ) + } + + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + + // Pre-flight Get so we know whether to download the package blob or + // materialize a SKILL.md from the metadata returned by the service. + skill, err := skillCtx.client.Get(ctx, a.flags.name) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetSkill) + } + if !skill.HasBlob { + if a.flags.raw { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("skill %q has no archive; --raw cannot be used", a.flags.name), + "omit --raw to materialize a SKILL.md from the skill metadata", + ) + } + return a.writeSkillMd(skill, absOut) + } + + body, err := skillCtx.client.Download(ctx, a.flags.name) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpDownloadSkill) + } + + if a.flags.raw { + return a.writeRaw(body, absOut) + } + return a.writeExtracted(body, absOut) +} + +// writeSkillMd materializes a SKILL.md from the metadata returned by GET +// /skills/{name}. Used when the skill was created inline (no uploaded +// archive) and the service has no blob to stream back. +func (a *downloadAction) writeSkillMd(skill *skill_api.Skill, outputDir string) error { + data, err := skill_api.MarshalSkillMd(&skill_api.SkillMd{ + Name: skill.Name, + Description: skill.Description, + Metadata: skill.Metadata, + Instructions: skill.Instructions, + }) + if err != nil { + return fmt.Errorf("materialize SKILL.md: %w", err) + } + + if err := os.MkdirAll(outputDir, 0700); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + skillMdPath := filepath.Join(outputDir, skill_api.SkillMdFileName) + + // Always Lstat (even with --force) so we never follow a symlink and so we + // refuse to overwrite a non-regular file. + if statInfo, statErr := os.Lstat(skillMdPath); statErr == nil { + if statInfo.Mode()&os.ModeSymlink != 0 { + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + fmt.Sprintf("%s is a symlink; refusing to follow", skillMdPath), + "remove the symlink and re-run", + ) + } + if !statInfo.Mode().IsRegular() { + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + fmt.Sprintf("%s exists and is not a regular file", skillMdPath), + "remove or rename the existing entry and re-run", + ) + } + if !a.flags.force { + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + fmt.Sprintf("%s already exists", skillMdPath), + "pass --force to overwrite", + ) + } + // Remove first so the subsequent O_EXCL open is atomic — closes the + // TOCTOU window where the path could be swapped for a symlink. + if rmErr := os.Remove(skillMdPath); rmErr != nil { + return fmt.Errorf("remove existing SKILL.md: %w", rmErr) + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("stat %s: %w", skillMdPath, statErr) + } + + //nolint:gosec // skillMdPath is built from user-supplied --output-dir + skill name, written on user behalf + f, err := os.OpenFile(skillMdPath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0600) + if err != nil { + return fmt.Errorf("create SKILL.md: %w", err) + } + if _, copyErr := f.Write(data); copyErr != nil { + _ = f.Close() + return fmt.Errorf("write SKILL.md: %w", copyErr) + } + if err := f.Close(); err != nil { + return fmt.Errorf("close SKILL.md: %w", err) + } + + return a.printResult(downloadResult{ + Skill: a.flags.name, OutputDir: outputDir, Files: []string{skill_api.SkillMdFileName}, + }) +} + +func (a *downloadAction) writeRaw(body []byte, outputDir string) error { + if err := os.MkdirAll(outputDir, 0700); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + + // Pick the extension that matches the actual bytes (service is asymmetric). + archiveName := a.flags.name + archiveExtension(skill_api.DetectArchiveFormat(body)) + archivePath := filepath.Join(outputDir, archiveName) + + // Always Lstat (even with --force) so we never follow a symlink and so we + // refuse to overwrite a non-regular file. + if statInfo, statErr := os.Lstat(archivePath); statErr == nil { + if statInfo.Mode()&os.ModeSymlink != 0 { + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + fmt.Sprintf("%s is a symlink; refusing to follow", archivePath), + "remove the symlink and re-run", + ) + } + if !statInfo.Mode().IsRegular() { + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + fmt.Sprintf("%s exists and is not a regular file", archivePath), + "remove or rename the existing entry and re-run", + ) + } + if !a.flags.force { + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + fmt.Sprintf("%s already exists", archivePath), + "pass --force to overwrite", + ) + } + // Remove first so the subsequent O_EXCL open is atomic — closes the + // TOCTOU window where the path could be swapped for a symlink. + if rmErr := os.Remove(archivePath); rmErr != nil { + return fmt.Errorf("remove existing archive: %w", rmErr) + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("stat %s: %w", archivePath, statErr) + } + + //nolint:gosec // archivePath is built from user-supplied --output-dir + skill name, written on user behalf + f, err := os.OpenFile(archivePath, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0600) + if err != nil { + return fmt.Errorf("create archive: %w", err) + } + if _, copyErr := f.Write(body); copyErr != nil { + _ = f.Close() + return fmt.Errorf("write archive: %w", copyErr) + } + if err := f.Close(); err != nil { + return fmt.Errorf("close archive: %w", err) + } + + return a.printResult(downloadResult{ + Skill: a.flags.name, OutputDir: outputDir, Archive: archiveName, Raw: true, + }) +} + +// archiveExtension picks the on-disk extension based on the magic bytes the +// service returned. Falls back to .bin so the file is still preserved. +func archiveExtension(format skill_api.ArchiveFormat) string { + switch format { + case skill_api.ArchiveZip: + return ".zip" + case skill_api.ArchiveTarGz: + return ".tar.gz" + default: + return ".bin" + } +} + +func (a *downloadAction) writeExtracted(body []byte, outputDir string) error { + result, err := skill_api.SafeExtract(body, skill_api.ExtractOptions{ + OutputDir: outputDir, + Force: a.flags.force, + }) + if err != nil { + return classifyExtractError(err, outputDir) + } + return a.printResult(downloadResult{ + Skill: a.flags.name, OutputDir: outputDir, Files: result.Files, + }) +} + +func (a *downloadAction) printResult(res downloadResult) error { + if a.flags.output == outputJSON { + return printJSON(res) + } + if res.Raw { + fmt.Printf("Skill %q downloaded to %s\n", res.Skill, filepath.Join(res.OutputDir, res.Archive)) + } else { + fmt.Printf("Skill %q extracted into %s (%d files)\n", res.Skill, res.OutputDir, len(res.Files)) + for _, name := range res.Files { + fmt.Printf(" %s\n", name) + } + } + return nil +} + +// classifyExtractError wraps SafeExtract sentinels in structured extension +// errors. Unknown errors propagate as-is. +func classifyExtractError(err error, outputDir string) error { + switch { + case errors.Is(err, skill_api.ErrUnsafeEntry): + return exterrors.Validation( + exterrors.CodeSkillArchiveUnsafe, + err.Error(), + "the downloaded archive contains an unsafe entry; do not extract it", + ) + case errors.Is(err, skill_api.ErrLimitExceeded): + return exterrors.Validation( + exterrors.CodeSkillArchiveUnsafe, + err.Error(), + "the archive exceeds the per-skill decompression safety limit", + ) + case errors.Is(err, skill_api.ErrCollision): + return exterrors.Validation( + exterrors.CodeSkillOutputCollision, + err.Error(), + fmt.Sprintf("pass --force to overwrite existing files in %s", outputDir), + ) + case errors.Is(err, skill_api.ErrInvalidArchive): + return exterrors.Validation( + exterrors.CodeInvalidParameter, + err.Error(), + "the service did not return a recognizable ZIP or gzip archive; retry or contact support", + ) + } + return err +} + +func newDownloadCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &downloadFlags{} + action := &downloadAction{flags: flags} + + cmd := &cobra.Command{ + Use: "download ", + Short: "Download a Foundry skill package.", + Long: `Download a skill's package or materialize its SKILL.md. + +If the skill was created from a ` + "`.zip`" + ` archive, the CLI extracts the +archive into --output-dir (default './.agents/skills//') by default. +Pass --raw to write the unmodified archive into --output-dir instead. + +If the skill was created inline (no uploaded archive), the CLI materializes a +SKILL.md file from the skill's metadata into --output-dir. --raw is not +supported in this mode because there is no archive to write. + +Extraction enforces strict safety rules: no absolute paths, no '..' segments, +no symlinks / non-regular entries, and a 10,000-entry / 512 MB cap on the +total uncompressed size.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + flags.outputDirSet = cmd.Flags().Changed("output-dir") + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + return action.Run(azdext.WithAccessToken(cmd.Context())) + }, + } + + cmd.Flags().StringVar(&flags.outputDir, "output-dir", "", "Directory to write the extracted skill (default: ./.agents/skills//)") + cmd.Flags().BoolVar(&flags.raw, "raw", false, "Skip extraction; write the archive as-is to --output-dir") + cmd.Flags().BoolVar(&flags.force, "force", false, "Overwrite existing files in --output-dir") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputTable, + }) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download_test.go new file mode 100644 index 00000000000..dbee83a257d --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download_test.go @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestArchiveExtension(t *testing.T) { + cases := []struct { + format skill_api.ArchiveFormat + want string + }{ + {skill_api.ArchiveZip, ".zip"}, + {skill_api.ArchiveTarGz, ".tar.gz"}, + {skill_api.ArchiveUnknown, ".bin"}, + } + for _, c := range cases { + require.Equal(t, c.want, archiveExtension(c.format), "format=%v", c.format) + } +} + +func TestClassifyExtractError_UnsafeEntry(t *testing.T) { + wrapped := fmt.Errorf("entry escapes: %w", skill_api.ErrUnsafeEntry) + err := classifyExtractError(wrapped, "/tmp/out") + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeSkillArchiveUnsafe, le.Code) +} + +func TestClassifyExtractError_LimitExceeded(t *testing.T) { + wrapped := fmt.Errorf("too big: %w", skill_api.ErrLimitExceeded) + err := classifyExtractError(wrapped, "/tmp/out") + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeSkillArchiveUnsafe, le.Code) +} + +func TestClassifyExtractError_Collision(t *testing.T) { + wrapped := fmt.Errorf("collision: %w", skill_api.ErrCollision) + err := classifyExtractError(wrapped, "/tmp/out") + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeSkillOutputCollision, le.Code) + require.Contains(t, le.Suggestion, "/tmp/out", "suggestion should mention output dir") +} + +func TestClassifyExtractError_InvalidArchive(t *testing.T) { + wrapped := fmt.Errorf("bad header: %w", skill_api.ErrInvalidArchive) + err := classifyExtractError(wrapped, "/tmp/out") + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidParameter, le.Code) +} + +func TestClassifyExtractError_UnknownPassthrough(t *testing.T) { + original := errors.New("io: short read") + err := classifyExtractError(original, "/tmp/out") + require.Same(t, original, err, "unknown errors must propagate unwrapped") +} + +func TestDownloadAction_RejectsInvalidName(t *testing.T) { + a := &downloadAction{flags: &downloadFlags{name: "bad name"}} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillName, le.Code) +} + +func TestDownloadAction_WriteSkillMd(t *testing.T) { + dir := t.TempDir() + a := &downloadAction{flags: &downloadFlags{name: "my-skill", output: outputJSON}} + skill := &skill_api.Skill{ + Name: "my-skill", + Description: "Greets the user", + Metadata: map[string]string{"owner": "alice"}, + Instructions: "# Body\n\nGreet warmly.\n", + } + + require.NoError(t, a.writeSkillMd(skill, dir)) + + got, err := os.ReadFile(filepath.Join(dir, skill_api.SkillMdFileName)) + require.NoError(t, err) + parsed, err := skill_api.ParseSkillMd(got) + require.NoError(t, err) + require.Equal(t, "my-skill", parsed.Name) + require.Equal(t, "Greets the user", parsed.Description) + require.Equal(t, map[string]string{"owner": "alice"}, parsed.Metadata) + require.Equal(t, "# Body\n\nGreet warmly.\n", parsed.Instructions) +} + +func TestDownloadAction_WriteSkillMd_CollisionWithoutForce(t *testing.T) { + dir := t.TempDir() + skillMdPath := filepath.Join(dir, skill_api.SkillMdFileName) + require.NoError(t, os.WriteFile(skillMdPath, []byte("old"), 0600)) + + a := &downloadAction{flags: &downloadFlags{name: "my-skill", output: outputJSON}} + err := a.writeSkillMd(&skill_api.Skill{Name: "my-skill"}, dir) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeSkillOutputCollision, le.Code) +} + +func TestDownloadAction_WriteSkillMd_ForceOverwrite(t *testing.T) { + dir := t.TempDir() + skillMdPath := filepath.Join(dir, skill_api.SkillMdFileName) + require.NoError(t, os.WriteFile(skillMdPath, []byte("old"), 0600)) + + a := &downloadAction{flags: &downloadFlags{name: "my-skill", output: outputJSON, force: true}} + require.NoError(t, a.writeSkillMd(&skill_api.Skill{Name: "my-skill", Description: "new"}, dir)) + + got, err := os.ReadFile(skillMdPath) + require.NoError(t, err) + require.NotEqual(t, "old", string(got), "force should overwrite existing file") + require.Contains(t, string(got), "name: my-skill") +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go new file mode 100644 index 00000000000..810d6271d3f --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type listFlags struct { + output string + projectEndpoint string +} + +type listAction struct{ flags *listFlags } + +func (a *listAction) Run(ctx context.Context) error { + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + items, err := skillCtx.client.ListAll(ctx, skill_api.ListOptions{}, 0) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpListSkills) + } + return printSkillList(items, a.flags.output) +} + +func newListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &listFlags{} + action := &listAction{flags: flags} + + cmd := &cobra.Command{ + Use: "list", + Short: "List Foundry skills in the project.", + Long: `List all skills in the resolved Foundry project.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + flags.output = extCtx.OutputFormat + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + return action.Run(azdext.WithAccessToken(cmd.Context())) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputTable, + }) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go new file mode 100644 index 00000000000..0c90f5c0c60 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type showFlags struct { + name string + output string + projectEndpoint string +} + +type showAction struct{ flags *showFlags } + +func (a *showAction) Run(ctx context.Context) error { + if err := validateSkillName(a.flags.name); err != nil { + return err + } + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + s, err := skillCtx.client.Get(ctx, a.flags.name) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetSkill) + } + return printSkillDetail(s, a.flags.output) +} + +func newShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &showFlags{} + action := &showAction{flags: flags} + + cmd := &cobra.Command{ + Use: "show ", + Short: "Show metadata for a Foundry skill.", + Long: `Show the metadata returned by the service for a skill. + +This command returns metadata only. To retrieve the skill body, use +'azd ai skill download '.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + return action.Run(azdext.WithAccessToken(cmd.Context())) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputJSON, + }) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go new file mode 100644 index 00000000000..c8a329a791c --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go @@ -0,0 +1,183 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type updateFlags struct { + name string + description string + instructions string + file string + output string + projectEndpoint string + + descriptionSet bool + instructionsSet bool +} + +type updateAction struct{ flags *updateFlags } + +func (a *updateAction) Run(ctx context.Context) error { + if err := validateSkillName(a.flags.name); err != nil { + return err + } + if err := a.validateFlags(); err != nil { + return err + } + + skillCtx, err := resolveSkillContext(ctx, a.flags.projectEndpoint) + if err != nil { + return err + } + + // GET-merge-POST so a single-field update doesn't drop the others. + current, err := skillCtx.client.Get(ctx, a.flags.name) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetSkill) + } + + req := skill_api.UpdateRequest{ + Description: current.Description, + Instructions: current.Instructions, + Metadata: current.Metadata, + } + if a.flags.descriptionSet { + req.Description = a.flags.description + } + if a.flags.instructionsSet { + req.Instructions = a.flags.instructions + } + + if a.flags.file != "" { + data, readErr := readFileWithLimit(a.flags.file) + if readErr != nil { + return readErr + } + parsed, parseErr := skill_api.ParseSkillMd(data) + if parseErr != nil { + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("failed to parse %s: %s", a.flags.file, parseErr), + "ensure the file begins with a YAML front matter block delimited by '---'", + ) + } + if parsed.Description != "" { + req.Description = parsed.Description + } + if parsed.Instructions != "" { + req.Instructions = parsed.Instructions + } + if len(parsed.Metadata) > 0 { + req.Metadata = parsed.Metadata + } + } + + updated, err := skillCtx.client.Update(ctx, a.flags.name, req) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpUpdateSkill) + } + + if a.flags.output == outputJSON { + return printJSON(updated) + } + fmt.Printf("Skill %q updated.\n", updated.Name) + return printSkillDetail(updated, outputTable) +} + +func (a *updateAction) validateFlags() error { + inlineProvided := a.flags.descriptionSet || a.flags.instructionsSet + fileProvided := a.flags.file != "" + + if !inlineProvided && !fileProvided { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "no fields to update", + "pass --description, --instructions, and/or --file ", + ) + } + if inlineProvided && fileProvided { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--file is mutually exclusive with --description / --instructions on update", + "pass either inline flags or --file , not both", + ) + } + + if fileProvided { + ext := strings.ToLower(filepath.Ext(a.flags.file)) + switch ext { + case ".md": + return nil + case ".zip": + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + "ZIP packages cannot be applied via `skill update`", + "use `azd ai skill create --file .zip --force` to replace the skill "+ + "(this deletes the existing skill first; skills are not versioned)", + ) + default: + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf("unsupported --file extension %q on update", ext), + "update only accepts .md files", + ) + } + } + return nil +} + +func newUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &updateFlags{} + action := &updateAction{flags: flags} + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update an existing Foundry skill.", + Long: `Update an existing skill's description, instructions, or metadata. + +Pass any subset of: + --description "..." --instructions "..." +or: + --file ./SKILL.md (parsed locally) + +The CLI fetches the current skill, merges your changes locally, then POSTs the +merged payload to the service. + +ZIP packages are not accepted here. To replace a skill's package, use +` + "`azd ai skill create --file .zip --force`" + `. Skills are not +versioned, so that path is destructive: it deletes the existing skill before +re-creating it from the archive.`, + Example: ` azd ai skill update my-skill --description "Updated summary" + azd ai skill update my-skill --file ./SKILL.md`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.name = args[0] + flags.output = extCtx.OutputFormat + flags.descriptionSet = cmd.Flags().Changed("description") + flags.instructionsSet = cmd.Flags().Changed("instructions") + flags.projectEndpoint, _ = cmd.Flags().GetString("project-endpoint") + return action.Run(azdext.WithAccessToken(cmd.Context())) + }, + } + + cmd.Flags().StringVar(&flags.description, "description", "", "New human-readable summary") + cmd.Flags().StringVar(&flags.instructions, "instructions", "", "New Markdown instructions body") + cmd.Flags().StringVar(&flags.file, "file", "", "Path to a SKILL.md file whose values override the current skill") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputJSON, + }) + return cmd +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update_test.go new file mode 100644 index 00000000000..94069257b0d --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update_test.go @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "azureaiskills/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +// --- updateAction.Run early-exit paths (no network) --- + +func TestUpdateAction_RejectsInvalidName(t *testing.T) { + a := &updateAction{flags: &updateFlags{ + name: "_bad", + descriptionSet: true, + description: "new desc", + }} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillName, le.Code) +} + +func TestUpdateAction_ValidInputFailsAtEndpoint(t *testing.T) { + // A fully valid inline update with no endpoint configured must fail at + // endpoint resolution (not at flag validation or name validation). + a := &updateAction{flags: &updateFlags{ + name: "my-skill", + descriptionSet: true, + description: "new desc", + }} + err := a.Run(context.Background()) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeMissingProjectEndpoint, le.Code, + "should fail at endpoint resolution with no project configured") +} + +// TestUpdateAction_ZipSuggestionMentionsDestructive verifies that the error +// message for ZIP files on `update` tells the user the operation is +// destructive (skills are not versioned). +func TestUpdateAction_ZipSuggestionMentionsDestructive(t *testing.T) { + a := &updateAction{flags: &updateFlags{file: "skill.zip"}} + err := a.validateFlags() + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) + require.Contains(t, le.Suggestion, "deletes", + "suggestion must warn that the operation is destructive") +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go new file mode 100644 index 00000000000..6cb1fde67c9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "regexp" + "strings" + + "azureaiskills/internal/exterrors" +) + +// skillNamePattern matches the agent name pattern in azure.ai.agents so users +// see one rule across resource kinds: 1-63 alphanumerics with hyphens only +// in the middle. The service makes the final decision. +var skillNamePattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$`) + +func validateSkillName(name string) error { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return exterrors.Validation( + exterrors.CodeInvalidSkillName, + "skill name must not be empty", + "pass a non-empty argument", + ) + } + if !skillNamePattern.MatchString(trimmed) { + return exterrors.Validation( + exterrors.CodeInvalidSkillName, + fmt.Sprintf("skill name %q is invalid", trimmed), + "use 1-63 alphanumeric characters; hyphens are allowed only in the middle", + ) + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go new file mode 100644 index 00000000000..d9c52a92600 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "fmt" + "testing" + + "azureaiskills/internal/exterrors" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestValidateSkillName(t *testing.T) { + cases := []struct { + name string + wantErr bool + }{ + {"a", false}, + {"my-skill", false}, + {"abc123", false}, + {"Skill1-2-3", false}, + {"", true}, + {" ", true}, + {"-leading-hyphen", true}, + {"trailing-hyphen-", true}, + {"under_score", true}, + {"contains space", true}, + // 63 char limit + {string(makeRune('a', 63)), false}, + {string(makeRune('a', 64)), true}, + } + for _, c := range cases { + err := validateSkillName(c.name) + if c.wantErr { + require.Errorf(t, err, "expected validation error for %q", c.name) + var le *azdext.LocalError + require.True(t, errors.As(err, &le), "expected LocalError for %q", c.name) + require.Equal(t, exterrors.CodeInvalidSkillName, le.Code) + } else { + require.NoErrorf(t, err, "unexpected error for %q", c.name) + } + } +} + +func makeRune(c rune, n int) []rune { + out := make([]rune, n) + for i := range out { + out[i] = c + } + return out +} + +func TestSelectCreateMode_ConflictingArgs(t *testing.T) { + _, err := selectCreateMode(&createFlags{ + descriptionSet: true, + file: "./SKILL.md", + }) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeConflictingArguments, le.Code) +} + +func TestSelectCreateMode_FileMd(t *testing.T) { + mode, err := selectCreateMode(&createFlags{file: "./SKILL.md"}) + require.NoError(t, err) + require.Equal(t, modeFileMd, mode) +} + +func TestSelectCreateMode_FilePackage(t *testing.T) { + for _, f := range []string{"./pkg.zip", "./PKG.ZIP"} { + mode, err := selectCreateMode(&createFlags{file: f}) + require.NoError(t, err, "file %q", f) + require.Equal(t, modeFilePackage, mode, "file %q", f) + } +} + +func TestSelectCreateMode_UnknownExtension(t *testing.T) { + _, err := selectCreateMode(&createFlags{file: "./SKILL.txt"}) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) +} + +func TestSelectCreateMode_InlineOnly(t *testing.T) { + mode, err := selectCreateMode(&createFlags{descriptionSet: true}) + require.NoError(t, err) + require.Equal(t, modeInline, mode) +} + +func TestSelectCreateMode_None(t *testing.T) { + mode, err := selectCreateMode(&createFlags{}) + require.NoError(t, err) + require.Equal(t, modeNone, mode) +} + +func TestUpdateAction_RequiresInput(t *testing.T) { + a := &updateAction{flags: &updateFlags{}} + err := a.validateFlags() + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeMissingRequiredField, le.Code) +} + +func TestUpdateAction_ConflictingArgs(t *testing.T) { + a := &updateAction{flags: &updateFlags{descriptionSet: true, file: "./SKILL.md"}} + err := a.validateFlags() + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeConflictingArguments, le.Code) +} + +func TestUpdateAction_RejectsZipFile(t *testing.T) { + for _, f := range []string{"./pkg.zip", "./PKG.ZIP"} { + a := &updateAction{flags: &updateFlags{file: f}} + err := a.validateFlags() + require.Errorf(t, err, "file %q", f) + var le *azdext.LocalError + require.True(t, errors.As(err, &le), "file %q", f) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code, "file %q", f) + } +} + +func TestUpdateAction_AcceptsMdFile(t *testing.T) { + a := &updateAction{flags: &updateFlags{file: "./SKILL.md"}} + require.NoError(t, a.validateFlags()) +} + +func TestUpdateAction_UnknownExtension(t *testing.T) { + a := &updateAction{flags: &updateFlags{file: "./SKILL.txt"}} + err := a.validateFlags() + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) +} + +func TestShouldSuppressWarning(t *testing.T) { + require.True(t, shouldSuppressWarning(true, outputTable)) + require.True(t, shouldSuppressWarning(false, outputJSON)) + require.False(t, shouldSuppressWarning(false, outputTable)) +} + +func TestTruncate(t *testing.T) { + require.Equal(t, "hello", truncate("hello", 10)) + require.Equal(t, "hi", truncate("hi", 2)) + require.Equal(t, "hel...", truncate("hello-world", 6)) + require.Equal(t, "hello world", truncate("hello\nworld", 60)) +} + +func TestIsNotFound(t *testing.T) { + require.False(t, isNotFound(nil)) + require.False(t, isNotFound(errors.New("oops"))) + require.True(t, isNotFound(&azcore.ResponseError{StatusCode: 404})) + require.False(t, isNotFound(&azcore.ResponseError{StatusCode: 500})) + wrapped := fmt.Errorf("get skill: %w", &azcore.ResponseError{StatusCode: 404}) + require.True(t, isNotFound(wrapped), "wrapped 404 ResponseError must still match") +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go b/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go index c96c2ab6e8a..1ea781a8629 100644 --- a/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/version.go @@ -4,17 +4,19 @@ package cmd import ( - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) + "fmt" -var ( - // Populated at build time - Version = "dev" // Default value for development builds - Commit = "none" - BuildDate = "unknown" + "azureaiskills/internal/version" + + "github.com/spf13/cobra" ) -func newVersionCommand(outputFormat *string) *cobra.Command { - return azdext.NewVersionCommand("azure.ai.skills", Version, outputFormat) +func newVersionCommand() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print the extension version.", + Run: func(cmd *cobra.Command, args []string) { + fmt.Printf("Version: %s\nCommit: %s\nBuild Date: %s\n", version.Version, version.Commit, version.BuildDate) + }, + } } diff --git a/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go new file mode 100644 index 00000000000..f715a56205f --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +// Skill-specific validation error codes. +const ( + CodeInvalidSkillName = "invalid_skill_name" + CodeInvalidSkillFile = "invalid_skill_file" + CodeSkillArchiveUnsafe = "skill_archive_unsafe" + CodeSkillOutputCollision = "skill_output_collision" +) + +// Codes shared across the extension surface. +const ( + CodeConflictingArguments = "conflicting_arguments" + CodeInvalidParameter = "invalid_parameter" + CodeMissingProjectEndpoint = "missing_project_endpoint" + CodeMissingRequiredField = "missing_required_field" + CodeMissingForceFlag = "missing_force_flag" +) + +const ( + //nolint:gosec // error code identifier, not a credential + CodeCredentialCreationFailed = "credential_creation_failed" +) + +// Operation names for ServiceFromAzure errors. Prefixed to the Azure code, +// e.g. "create_skill.NotFound". +const ( + OpCreateSkill = "create_skill" + OpUpdateSkill = "update_skill" + OpDeleteSkill = "delete_skill" + OpGetSkill = "get_skill" + OpListSkills = "list_skills" + OpDownloadSkill = "download_skill" +) diff --git a/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors.go new file mode 100644 index 00000000000..8f127151995 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors.go @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package exterrors provides structured error factories for the +// azure.ai.skills extension. The factories produce *azdext.LocalError or +// *azdext.ServiceError values that the azd host renders consistently across +// extensions and uses to emit stable telemetry. +package exterrors + +import ( + "errors" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// Validation returns a validation error for user-input or flag errors. +func Validation(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryValidation, + Suggestion: suggestion, + } +} + +// Dependency returns a dependency error for missing resources or services. +func Dependency(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryDependency, + Suggestion: suggestion, + } +} + +// Auth returns an auth error for authentication or authorization failures. +func Auth(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryAuth, + Suggestion: suggestion, + } +} + +// ServiceFromAzure converts an Azure SDK error into a structured service error. +// Non-azcore errors are returned unchanged. +func ServiceFromAzure(err error, operation string) error { + if respErr, ok := errors.AsType[*azcore.ResponseError](err); ok { + return &azdext.ServiceError{ + Message: respErr.Error(), + ErrorCode: respErr.ErrorCode, + StatusCode: respErr.StatusCode, + ServiceName: operation, + } + } + return err +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors_test.go b/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors_test.go new file mode 100644 index 00000000000..ec3ea956dad --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/exterrors/errors_test.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +import ( + "errors" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +func TestValidation_BuildsLocalError(t *testing.T) { + err := Validation("code-x", "boom", "do y") + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, "code-x", le.Code) + require.Equal(t, "boom", le.Message) + require.Equal(t, "do y", le.Suggestion) + require.Equal(t, azdext.LocalErrorCategoryValidation, le.Category) +} + +func TestDependency_BuildsLocalError(t *testing.T) { + err := Dependency("code-y", "missing dep", "install z") + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, "code-y", le.Code) + require.Equal(t, azdext.LocalErrorCategoryDependency, le.Category) +} + +func TestAuth_BuildsLocalError(t *testing.T) { + err := Auth("code-z", "unauthorized", "run azd auth login") + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, "code-z", le.Code) + require.Equal(t, azdext.LocalErrorCategoryAuth, le.Category) +} + +func TestServiceFromAzure_WrapsResponseError(t *testing.T) { + reqURL, err := url.Parse("https://example.com/skills/my-skill") + require.NoError(t, err) + respErr := &azcore.ResponseError{ + ErrorCode: "NotFound", + StatusCode: http.StatusNotFound, + RawResponse: &http.Response{ + StatusCode: http.StatusNotFound, + Status: "404 Not Found", + Body: io.NopCloser(strings.NewReader("")), + Request: &http.Request{Method: http.MethodGet, URL: reqURL}, + }, + } + wrapped := ServiceFromAzure(respErr, "OpGetSkill") + var se *azdext.ServiceError + require.True(t, errors.As(wrapped, &se)) + require.Equal(t, "NotFound", se.ErrorCode) + require.Equal(t, http.StatusNotFound, se.StatusCode) + require.Equal(t, "OpGetSkill", se.ServiceName) +} + +func TestServiceFromAzure_PassThroughForNonAzcoreError(t *testing.T) { + original := errors.New("network down") + err := ServiceFromAzure(original, "OpAny") + require.Same(t, original, err, "non-azcore errors must propagate unchanged") +} + +func TestServiceFromAzure_NilError(t *testing.T) { + require.NoError(t, ServiceFromAzure(nil, "OpAny")) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go new file mode 100644 index 00000000000..0f9c6c6a707 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go @@ -0,0 +1,418 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "slices" + "strings" +) + +const ( + DefaultMaxEntries = 10_000 + DefaultMaxTotalUncompressed = 512 * 1024 * 1024 +) + +type ExtractOptions struct { + OutputDir string + Force bool + MaxEntries int + MaxTotalUncompressed int64 +} + +type ExtractResult struct { + Files []string + TotalBytes int64 +} + +var ( + ErrUnsafeEntry = errors.New("unsafe archive entry") + ErrLimitExceeded = errors.New("archive exceeds safety limit") + ErrCollision = errors.New("output collision") + ErrInvalidArchive = errors.New("invalid archive") +) + +type ArchiveFormat int + +const ( + ArchiveUnknown ArchiveFormat = iota + ArchiveZip + ArchiveTarGz +) + +func (f ArchiveFormat) String() string { + switch f { + case ArchiveZip: + return "zip" + case ArchiveTarGz: + return "tar.gz" + default: + return "unknown" + } +} + +// DetectArchiveFormat sniffs the first bytes of data. The Foundry Skills +// service is asymmetric: POST /skills:import requires ZIP (gzip yields 415), +// but GET /skills/{name}:download returns gzip — so we sniff rather than +// trust Content-Type. +func DetectArchiveFormat(data []byte) ArchiveFormat { + switch { + case len(data) >= 4 && bytes.Equal(data[:4], []byte{'P', 'K', 0x03, 0x04}): + return ArchiveZip + case len(data) >= 4 && bytes.Equal(data[:4], []byte{'P', 'K', 0x05, 0x06}): + return ArchiveZip + case len(data) >= 2 && data[0] == 0x1f && data[1] == 0x8b: + return ArchiveTarGz + default: + return ArchiveUnknown + } +} + +// SafeExtract extracts a ZIP or gzip-tar archive into opts.OutputDir. +// +// Two-phase: entries are first written into a temp staging directory and +// validated against the safety rules below; then symlink-escape checks run +// and files are copied into OutputDir. A failed extraction leaves nothing +// in OutputDir. +// +// Rejections (ErrUnsafeEntry): absolute paths, `..` segments, empty names, +// non-regular entries (symlinks, hard links, devices, sockets). +// Caps (ErrLimitExceeded): MaxEntries (default 10,000), +// MaxTotalUncompressed (default 512 MB). +func SafeExtract(data []byte, opts ExtractOptions) (*ExtractResult, error) { + if opts.OutputDir == "" { + return nil, fmt.Errorf("SafeExtract: OutputDir is required") + } + if len(data) == 0 { + return nil, fmt.Errorf("%w: empty body", ErrInvalidArchive) + } + maxEntries := opts.MaxEntries + if maxEntries <= 0 { + maxEntries = DefaultMaxEntries + } + maxBytes := opts.MaxTotalUncompressed + if maxBytes <= 0 { + maxBytes = DefaultMaxTotalUncompressed + } + + staging, err := os.MkdirTemp("", "azd-skill-extract-*") + if err != nil { + return nil, fmt.Errorf("create staging directory: %w", err) + } + cleanupStaging := func() { _ = os.RemoveAll(staging) } + + var ( + files []string + totalBytes int64 + stageErr error + ) + switch DetectArchiveFormat(data) { + case ArchiveZip: + files, totalBytes, stageErr = stageFromZip(data, staging, maxEntries, maxBytes) + case ArchiveTarGz: + files, totalBytes, stageErr = stageFromTarGz(data, staging, maxEntries, maxBytes) + default: + cleanupStaging() + return nil, fmt.Errorf("%w: unrecognized magic bytes", ErrInvalidArchive) + } + if stageErr != nil { + cleanupStaging() + return nil, stageErr + } + + if err := publishToOutputDir(staging, files, opts); err != nil { + cleanupStaging() + return nil, err + } + + cleanupStaging() + return &ExtractResult{Files: files, TotalBytes: totalBytes}, nil +} + +func stageFromZip(data []byte, staging string, maxEntries int, maxBytes int64) ([]string, int64, error) { + zr, err := zip.NewReader(newBytesReaderAt(data), int64(len(data))) + if err != nil { + return nil, 0, fmt.Errorf("%w: %w", ErrInvalidArchive, err) + } + if len(zr.File) > maxEntries { + return nil, 0, fmt.Errorf("%w: entry count %d exceeds %d", ErrLimitExceeded, len(zr.File), maxEntries) + } + + var files []string + var totalBytes int64 + for _, entry := range zr.File { + cleaned, ok := validateEntryName(entry.Name) + if !ok { + return nil, 0, fmt.Errorf("%w: %q", ErrUnsafeEntry, entry.Name) + } + + mode := entry.Mode() + switch { + case mode.IsDir() || strings.HasSuffix(entry.Name, "/"): + if mkErr := os.MkdirAll(filepath.Join(staging, filepath.FromSlash(cleaned)), 0700); mkErr != nil { + return nil, 0, fmt.Errorf("create staging dir %q: %w", cleaned, mkErr) + } + continue + case mode.IsRegular(): + default: + return nil, 0, fmt.Errorf("%w: %q has irregular file mode %v", ErrUnsafeEntry, entry.Name, mode) + } + + written, writeErr := writeStagingEntry(staging, cleaned, func(w io.Writer, limit int64) (int64, error) { + rc, openErr := entry.Open() + if openErr != nil { + return 0, fmt.Errorf("open zip entry %q: %w", cleaned, openErr) + } + defer rc.Close() + return io.Copy(w, io.LimitReader(rc, limit)) + }, maxBytes-totalBytes, int64(entry.UncompressedSize64)) //nolint:gosec // bound-checked inside writeStagingEntry + if writeErr != nil { + return nil, 0, writeErr + } + totalBytes += written + files = append(files, cleaned) + } + return files, totalBytes, nil +} + +func stageFromTarGz(data []byte, staging string, maxEntries int, maxBytes int64) ([]string, int64, error) { + gz, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return nil, 0, fmt.Errorf("%w: %w", ErrInvalidArchive, err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + var files []string + var totalBytes int64 + entryCount := 0 + + for { + hdr, hdrErr := tr.Next() + if errors.Is(hdrErr, io.EOF) { + break + } + if hdrErr != nil { + return nil, 0, fmt.Errorf("read tar entry: %w", hdrErr) + } + if entryCount >= maxEntries { + return nil, 0, fmt.Errorf("%w: entry count exceeds %d", ErrLimitExceeded, maxEntries) + } + entryCount++ + + cleaned, ok := validateEntryName(hdr.Name) + if !ok { + return nil, 0, fmt.Errorf("%w: %q", ErrUnsafeEntry, hdr.Name) + } + + switch hdr.Typeflag { + case tar.TypeReg, tar.TypeRegA: + if hdr.Linkname != "" { + return nil, 0, fmt.Errorf("%w: %q regular-file entry has unexpected Linkname", ErrUnsafeEntry, hdr.Name) + } + case tar.TypeDir: + if mkErr := os.MkdirAll(filepath.Join(staging, filepath.FromSlash(cleaned)), 0700); mkErr != nil { + return nil, 0, fmt.Errorf("create staging dir %q: %w", cleaned, mkErr) + } + continue + default: + return nil, 0, fmt.Errorf("%w: %q has unsupported tar type %c", ErrUnsafeEntry, hdr.Name, hdr.Typeflag) + } + + written, writeErr := writeStagingEntry(staging, cleaned, func(w io.Writer, limit int64) (int64, error) { + return io.Copy(w, io.LimitReader(tr, limit)) + }, maxBytes-totalBytes, hdr.Size) + if writeErr != nil { + return nil, 0, writeErr + } + totalBytes += written + files = append(files, cleaned) + } + return files, totalBytes, nil +} + +// writeStagingEntry creates parent directories and writes one entry into +// staging via writeBody. writeBody must copy at most limit+1 bytes; if it +// writes more than `remaining`, ErrLimitExceeded is returned. +func writeStagingEntry( + staging, relName string, + writeBody func(w io.Writer, limit int64) (int64, error), + remaining int64, + advertisedSize int64, +) (int64, error) { + stagingPath := filepath.Join(staging, filepath.FromSlash(relName)) + if mkErr := os.MkdirAll(filepath.Dir(stagingPath), 0700); mkErr != nil { + return 0, fmt.Errorf("create staging dir for %q: %w", relName, mkErr) + } + if advertisedSize > 0 && advertisedSize > remaining { + return 0, fmt.Errorf("%w: uncompressed size would exceed budget", ErrLimitExceeded) + } + f, fErr := os.OpenFile(stagingPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) //nolint:gosec // staging is our trusted temp dir + if fErr != nil { + return 0, fmt.Errorf("create staging file %q: %w", relName, fErr) + } + written, copyErr := writeBody(f, remaining+1) + closeErr := f.Close() + switch { + case copyErr != nil: + return 0, fmt.Errorf("write %q: %w", relName, copyErr) + case closeErr != nil: + return 0, fmt.Errorf("close staging file %q: %w", relName, closeErr) + case written > remaining: + return 0, fmt.Errorf("%w: uncompressed size would exceed budget", ErrLimitExceeded) + } + return written, nil +} + +func publishToOutputDir(staging string, files []string, opts ExtractOptions) error { + if mkErr := os.MkdirAll(opts.OutputDir, 0700); mkErr != nil { + return fmt.Errorf("create output dir: %w", mkErr) + } + + if !opts.Force { + for _, rel := range files { + dst := filepath.Join(opts.OutputDir, filepath.FromSlash(rel)) + if _, statErr := os.Lstat(dst); statErr == nil { + return fmt.Errorf("%w: %s already exists in %s", ErrCollision, rel, opts.OutputDir) + } else if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("stat %q: %w", dst, statErr) + } + } + } + + // Resolve OutputDir's real path so we can reject entries that would + // escape through a pre-existing symlink in OutputDir. + realOutDir, evalErr := filepath.EvalSymlinks(opts.OutputDir) + if evalErr != nil { + return fmt.Errorf("resolve output dir path: %w", evalErr) + } + + // Preflight: create destination directories and verify every resolved + // destination stays inside OutputDir, before any file is copied. + for _, rel := range files { + dstDir := filepath.Dir(filepath.Join(opts.OutputDir, filepath.FromSlash(rel))) + if mkErr := os.MkdirAll(dstDir, 0700); mkErr != nil { + return fmt.Errorf("create output dir for %q: %w", rel, mkErr) + } + realDstDir, evalErr := filepath.EvalSymlinks(dstDir) + if evalErr != nil { + return fmt.Errorf("resolve destination path for %q: %w", rel, evalErr) + } + if !isUnder(realDstDir, realOutDir) { + return fmt.Errorf("%w: %q destination escapes output directory via symlink", ErrUnsafeEntry, rel) + } + } + + for _, rel := range files { + src := filepath.Join(staging, filepath.FromSlash(rel)) + dst := filepath.Join(opts.OutputDir, filepath.FromSlash(rel)) + if err := copyFile(src, dst); err != nil { + return fmt.Errorf("copy %q to output: %w", rel, err) + } + } + return nil +} + +// validateEntryName cleans and validates an archive entry name. Returns the +// cleaned, slash-separated relative path. Rejects `..` even when surrounding +// segments cancel it out (e.g. `a/../b`) — defense against future bugs in +// path.Clean. +func validateEntryName(name string) (string, bool) { + if name == "" { + return "", false + } + slashed := strings.ReplaceAll(name, "\\", "/") + withoutTrailing := strings.TrimSuffix(slashed, "/") + if withoutTrailing == "" { + return "", false + } + if len(withoutTrailing) >= 2 && withoutTrailing[1] == ':' { + return "", false + } + // Reject UNC paths (\\server\share normalizes to //server/share). + if strings.HasPrefix(withoutTrailing, "//") { + return "", false + } + if strings.HasPrefix(withoutTrailing, "/") { + return "", false + } + if slices.Contains(strings.Split(withoutTrailing, "/"), "..") { + return "", false + } + cleaned := path.Clean(withoutTrailing) + if cleaned == "" || cleaned == "." || cleaned == "/" { + return "", false + } + if path.IsAbs(cleaned) || strings.HasPrefix(cleaned, "/") { + return "", false + } + return cleaned, true +} + +func isUnder(child, parent string) bool { + if child == parent { + return true + } + return strings.HasPrefix(child, parent+string(filepath.Separator)) +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) //nolint:gosec // staging is our trusted temp dir + if err != nil { + return err + } + defer in.Close() + + // Even with --force, never follow a symlink at the destination: O_TRUNC + // would otherwise overwrite whatever the link points at, potentially + // outside OutputDir. Lstat first, reject non-regular entries, and remove + // any pre-existing regular file so the O_EXCL open below owns the path. + if info, lstatErr := os.Lstat(dst); lstatErr == nil { + if !info.Mode().IsRegular() { + return fmt.Errorf( + "%w: destination %q already exists and is not a regular file", + ErrUnsafeEntry, dst, + ) + } + if rmErr := os.Remove(dst); rmErr != nil { + return fmt.Errorf("remove existing destination %q: %w", dst, rmErr) + } + } else if !errors.Is(lstatErr, os.ErrNotExist) { + return fmt.Errorf("stat destination %q: %w", dst, lstatErr) + } + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_EXCL, 0600) //nolint:gosec // user-supplied output dir, written on user behalf + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + return out.Close() +} + +type bytesReaderAt struct{ data []byte } + +func newBytesReaderAt(data []byte) *bytesReaderAt { return &bytesReaderAt{data: data} } + +func (b *bytesReaderAt) ReadAt(p []byte, off int64) (int, error) { + if off < 0 || off > int64(len(b.data)) { + return 0, io.EOF + } + n := copy(p, b.data[off:]) + if n < len(p) { + return n, io.EOF + } + return n, nil +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go new file mode 100644 index 00000000000..198633a4fc1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "archive/zip" + "fmt" + "io" + "path" + "strings" +) + +const ( + peekMaxSkillMdBytes = 1 << 20 // 1 MiB cap on the SKILL.md we read into memory. + peekMaxEntries = 1024 // SKILL.md is at the root; cap deep scans. +) + +// PeekArchiveSkillName returns the `name` declared in the archive's SKILL.md +// front matter, or "" when there's no SKILL.md or no `name` claim. Used by +// the destructive `--force` guard: if the archive claims a different name +// than the positional argument, we refuse the delete-then-create. +// +// Looks for SKILL.md at the archive root or one directory below. +// ZIP-only — the upload surface is ZIP. The caller passes an io.ReaderAt +// + size (typically an *os.File and its stat size) so the archive is not +// slurped into memory: zip.NewReader streams central-directory and entry +// payloads via ReadAt as needed. +func PeekArchiveSkillName(r io.ReaderAt, size int64) (string, error) { + zr, err := zip.NewReader(r, size) + if err != nil { + return "", fmt.Errorf("%w: %w", ErrInvalidArchive, err) + } + + for i, entry := range zr.File { + if i >= peekMaxEntries { + return "", nil + } + if !entry.Mode().IsRegular() || !isSkillMdEntry(entry.Name) { + continue + } + rc, openErr := entry.Open() + if openErr != nil { + return "", fmt.Errorf("open SKILL.md entry: %w", openErr) + } + defer rc.Close() //nolint:gocritic // one entry opened per iteration; loop exits after first SKILL.md + raw, readErr := io.ReadAll(io.LimitReader(rc, peekMaxSkillMdBytes)) + if readErr != nil { + return "", fmt.Errorf("read SKILL.md entry: %w", readErr) + } + md, parseErr := ParseSkillMd(raw) + if parseErr != nil { + return "", fmt.Errorf("parse SKILL.md: %w", parseErr) + } + return md.Name, nil + } + return "", nil +} + +func isSkillMdEntry(name string) bool { + cleaned := path.Clean(strings.TrimLeft(name, "/")) + if cleaned == "SKILL.md" { + return true + } + dir, file := path.Split(cleaned) + if file != "SKILL.md" { + return false + } + dir = strings.TrimSuffix(dir, "/") + return dir != "" && !strings.Contains(dir, "/") +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go new file mode 100644 index 00000000000..a4d0da8dd8d --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPeekArchiveSkillName_RootLevel(t *testing.T) { + archive := makeZip(t, []zipEntry{ + {Name: "SKILL.md", Body: []byte("---\nname: foo\n---\nbody\n")}, + {Name: "other.txt", Body: []byte("ignored")}, + }) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) + require.NoError(t, err) + require.Equal(t, "foo", got) +} + +func TestPeekArchiveSkillName_OneDirDeep(t *testing.T) { + archive := makeZip(t, []zipEntry{ + {Name: "greeting/"}, + {Name: "greeting/SKILL.md", Body: []byte("---\nname: greeting\n---\nbody\n")}, + }) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) + require.NoError(t, err) + require.Equal(t, "greeting", got) +} + +func TestPeekArchiveSkillName_TooDeepIgnored(t *testing.T) { + archive := makeZip(t, []zipEntry{ + {Name: "a/b/SKILL.md", Body: []byte("---\nname: deep\n---\nbody\n")}, + }) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) + require.NoError(t, err) + require.Equal(t, "", got) +} + +func TestPeekArchiveSkillName_NoSkillMd(t *testing.T) { + archive := makeZip(t, []zipEntry{{Name: "README.md", Body: []byte("hi")}}) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) + require.NoError(t, err) + require.Equal(t, "", got) +} + +func TestPeekArchiveSkillName_MissingNameField(t *testing.T) { + archive := makeZip(t, []zipEntry{ + {Name: "SKILL.md", Body: []byte("---\ndescription: hi\n---\nbody\n")}, + }) + got, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) + require.NoError(t, err) + require.Equal(t, "", got) +} + +func TestPeekArchiveSkillName_MalformedYAMLReturnsError(t *testing.T) { + archive := makeZip(t, []zipEntry{ + {Name: "SKILL.md", Body: []byte("not valid front matter")}, + }) + _, err := PeekArchiveSkillName(bytes.NewReader(archive), int64(len(archive))) + require.Error(t, err, "malformed SKILL.md must propagate a parse error") +} + +func TestPeekArchiveSkillName_InvalidZip(t *testing.T) { + _, err := PeekArchiveSkillName(bytes.NewReader([]byte("this is not zip")), int64(len("this is not zip"))) + require.Error(t, err) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go new file mode 100644 index 00000000000..d788822be7b --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +type zipEntry struct { + Name string + Body []byte + Mode os.FileMode // 0 defaults to 0644 (files) / 0755 (dirs) +} + +func makeZip(t *testing.T, entries []zipEntry) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + for _, e := range entries { + mode := e.Mode + isDir := mode.IsDir() || (len(e.Name) > 0 && e.Name[len(e.Name)-1] == '/') + if mode == 0 { + if isDir { + mode = os.ModeDir | 0755 + } else { + mode = 0644 + } + } + hdr := &zip.FileHeader{Name: e.Name, Method: zip.Deflate} + hdr.SetMode(mode) + w, err := zw.CreateHeader(hdr) + require.NoError(t, err) + if !isDir && len(e.Body) > 0 { + _, err := w.Write(e.Body) + require.NoError(t, err) + } + } + require.NoError(t, zw.Close()) + return buf.Bytes() +} + +type tarEntry struct { + Name string + Body []byte +} + +func makeTarGz(t *testing.T, entries []tarEntry) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + for _, e := range entries { + hdr := &tar.Header{Name: e.Name, Mode: 0644, Typeflag: tar.TypeReg, Size: int64(len(e.Body))} + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write(e.Body) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + return buf.Bytes() +} + +func TestSafeExtract_HappyPath(t *testing.T) { + archive := makeZip(t, []zipEntry{ + {Name: "SKILL.md", Body: []byte("---\nname: foo\n---\nbody\n")}, + {Name: "assets/"}, + {Name: "assets/icon.svg", Body: []byte("")}, + {Name: "assets/notes.txt", Body: []byte("hello")}, + }) + dir := t.TempDir() + + res, err := SafeExtract(archive, ExtractOptions{OutputDir: dir}) + require.NoError(t, err) + require.ElementsMatch(t, []string{"SKILL.md", "assets/icon.svg", "assets/notes.txt"}, res.Files) + + got, _ := os.ReadFile(filepath.Join(dir, "assets", "icon.svg")) //nolint:gosec // test artifact + require.Equal(t, "", string(got)) +} + +func TestSafeExtract_RejectsDotDot(t *testing.T) { + archive := makeZip(t, []zipEntry{{Name: "../evil.txt", Body: []byte("nope")}}) + _, err := SafeExtract(archive, ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrUnsafeEntry)) +} + +func TestSafeExtract_RejectsAbsolutePath(t *testing.T) { + archive := makeZip(t, []zipEntry{{Name: "/etc/passwd", Body: []byte("nope")}}) + _, err := SafeExtract(archive, ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrUnsafeEntry)) +} + +func TestSafeExtract_RejectsSymlink(t *testing.T) { + // Build a zip entry whose mode declares a symlink. The CLI must refuse + // to extract this and must not follow the link target. + archive := makeZip(t, []zipEntry{{ + Name: "link", + Body: []byte("target"), + Mode: os.ModeSymlink | 0777, + }}) + _, err := SafeExtract(archive, ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrUnsafeEntry)) +} + +func TestSafeExtract_RejectsWindowsBackslash(t *testing.T) { + archive := makeZip(t, []zipEntry{{Name: `..\evil.txt`, Body: []byte("nope")}}) + _, err := SafeExtract(archive, ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrUnsafeEntry)) +} + +func TestSafeExtract_EnforcesEntryCount(t *testing.T) { + entries := make([]zipEntry, 5) + for i := range entries { + entries[i] = zipEntry{Name: filepath.ToSlash(filepath.Join("entry", string(rune('a'+i))+".txt"))} + } + archive := makeZip(t, entries) + _, err := SafeExtract(archive, ExtractOptions{ + OutputDir: t.TempDir(), + MaxEntries: 2, + }) + require.Error(t, err) + require.True(t, errors.Is(err, ErrLimitExceeded)) +} + +func TestSafeExtract_EnforcesTotalSize(t *testing.T) { + body := bytes.Repeat([]byte("a"), 1024) + archive := makeZip(t, []zipEntry{{Name: "big.txt", Body: body}}) + _, err := SafeExtract(archive, ExtractOptions{ + OutputDir: t.TempDir(), + MaxTotalUncompressed: 100, + }) + require.Error(t, err) + require.True(t, errors.Is(err, ErrLimitExceeded)) +} + +func TestSafeExtract_RejectsCollisionWithoutForce(t *testing.T) { + archive := makeZip(t, []zipEntry{{Name: "SKILL.md", Body: []byte("body")}}) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("old"), 0600)) + + _, err := SafeExtract(archive, ExtractOptions{OutputDir: dir}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrCollision)) + + // Existing file is untouched. + existing, _ := os.ReadFile(filepath.Join(dir, "SKILL.md")) //nolint:gosec // test artifact + require.Equal(t, "old", string(existing)) +} + +func TestSafeExtract_ForceOverwrites(t *testing.T) { + archive := makeZip(t, []zipEntry{{Name: "SKILL.md", Body: []byte("new")}}) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("old"), 0600)) + + _, err := SafeExtract(archive, ExtractOptions{OutputDir: dir, Force: true}) + require.NoError(t, err) + + got, _ := os.ReadFile(filepath.Join(dir, "SKILL.md")) //nolint:gosec // test artifact + require.Equal(t, "new", string(got)) +} + +func TestSafeExtract_InvalidArchive(t *testing.T) { + _, err := SafeExtract([]byte("not a zip stream"), ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrInvalidArchive)) +} + +func TestSafeExtract_EmptyBody(t *testing.T) { + _, err := SafeExtract(nil, ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, err) + require.True(t, errors.Is(err, ErrInvalidArchive)) +} + +func TestSafeExtract_TarGzHappyPath(t *testing.T) { + archive := makeTarGz(t, []tarEntry{ + {Name: "SKILL.md", Body: []byte("---\nname: foo\n---\nbody\n")}, + {Name: "assets/icon.svg", Body: []byte("")}, + }) + dir := t.TempDir() + res, err := SafeExtract(archive, ExtractOptions{OutputDir: dir}) + require.NoError(t, err) + require.ElementsMatch(t, []string{"SKILL.md", "assets/icon.svg"}, res.Files) +} + +func TestDetectArchiveFormat(t *testing.T) { + zipBytes := makeZip(t, []zipEntry{{Name: "SKILL.md", Body: []byte("body")}}) + require.Equal(t, ArchiveZip, DetectArchiveFormat(zipBytes)) + + tarGzBytes := makeTarGz(t, []tarEntry{{Name: "SKILL.md", Body: []byte("body")}}) + require.Equal(t, ArchiveTarGz, DetectArchiveFormat(tarGzBytes)) + + require.Equal(t, ArchiveUnknown, DetectArchiveFormat([]byte("not an archive"))) + require.Equal(t, ArchiveUnknown, DetectArchiveFormat(nil)) +} + +func TestValidateEntryName(t *testing.T) { + cases := []struct { + in string + want string + wantOK bool + }{ + {"SKILL.md", "SKILL.md", true}, + {"assets/icon.svg", "assets/icon.svg", true}, + {"./SKILL.md", "SKILL.md", true}, + {"assets/", "assets", true}, + {"", "", false}, + {".", "", false}, + {"/", "", false}, + {"/etc/passwd", "", false}, + {"../evil", "", false}, + {"a/../b", "", false}, + {`C:\Windows\Temp`, "", false}, + // UNC paths + {"//server/share", "", false}, + {`\\server\share`, "", false}, + } + for _, c := range cases { + got, ok := validateEntryName(c.in) + require.Equal(t, c.wantOK, ok, "input=%q", c.in) + require.Equal(t, c.want, got, "input=%q", c.in) + } +} + +func TestSafeExtract_TarGzRejectsEntryWithLinkname(t *testing.T) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + // Regular-file entry with a non-empty Linkname field (should be rejected). + hdr := &tar.Header{ + Name: "SKILL.md", + Mode: 0644, + Typeflag: tar.TypeReg, + Size: 5, + Linkname: "/etc/passwd", + } + require.NoError(t, tw.WriteHeader(hdr)) + _, err := tw.Write([]byte("hello")) + require.NoError(t, err) + require.NoError(t, tw.Close()) + require.NoError(t, gz.Close()) + + _, extractErr := SafeExtract(buf.Bytes(), ExtractOptions{OutputDir: t.TempDir()}) + require.Error(t, extractErr) + require.True(t, errors.Is(extractErr, ErrUnsafeEntry)) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go new file mode 100644 index 00000000000..134af3f5abb --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go @@ -0,0 +1,371 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/runtime" + "github.com/azure/azure-dev/cli/azd/pkg/azsdk" +) + +const ( + // DataPlaneAPIVersion: skills live under v1; preview opt-in is via the + // Foundry-Features header (SkillsPreviewOptIn). + DataPlaneAPIVersion = "v1" + + FoundryFeaturesHeader = "Foundry-Features" + SkillsPreviewOptIn = "Skills=V1Preview" + + ContentTypeJSON = "application/json" + // ContentTypeZip is the upload content type for POST /skills:import. The + // TypeSpec declares application/gzip, but the live service returns 415 on + // gzip and accepts ZIP per the public docs: + // https://learn.microsoft.com/azure/foundry/agents/how-to/tools/skills. + ContentTypeZip = "application/zip" + // ContentTypeGzip is the observed response content type on + // GET /skills/{name}:download. We accept either format on the wire. + ContentTypeGzip = "application/gzip" + + //nolint:gosec // OAuth scope identifier, not a credential + BearerScope = "https://ai.azure.com/.default" + + userAgentPrefix = "azd-ext-azure-ai-skills" +) + +type Client struct { + endpoint string + pipeline runtime.Pipeline +} + +// NewClient returns a Skills client rooted at endpoint, using cred for +// bearer-token auth. +func NewClient(endpoint string, cred azcore.TokenCredential, extensionVersion string) *Client { + return newClient(endpoint, cred, extensionVersion, false) +} + +func newClient(endpoint string, cred azcore.TokenCredential, extensionVersion string, allowHTTP bool) *Client { + userAgent := userAgentPrefix + if extensionVersion != "" { + userAgent += "/" + extensionVersion + } + + clientOptions := &policy.ClientOptions{ + // IncludeBody is intentionally false: skill bodies carry user-authored + // description / instructions and we don't yet have a sanitizer. + Logging: policy.LogOptions{IncludeBody: false}, + InsecureAllowCredentialWithHTTP: allowHTTP, + PerCallPolicies: []policy.Policy{ + runtime.NewBearerTokenPolicy(cred, []string{BearerScope}, &policy.BearerTokenOptions{ + InsecureAllowCredentialWithHTTP: allowHTTP, + }), + azsdk.NewMsCorrelationPolicy(), + azsdk.NewUserAgentPolicy(userAgent), + }, + } + + pipeline := runtime.NewPipeline( + "azure-ai-skills", + "v1.0.0", + runtime.PipelineOptions{}, + clientOptions, + ) + + return &Client{ + endpoint: strings.TrimRight(endpoint, "/"), + pipeline: pipeline, + } +} + +// CreateInline creates a skill from a JSON body (inline or parsed SKILL.md). +func (c *Client) CreateInline(ctx context.Context, req CreateRequest) (*Skill, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal create request: %w", err) + } + + httpReq, err := runtime.NewRequest(ctx, http.MethodPost, c.buildURL("/skills", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + if err := setJSONBody(httpReq, body); err != nil { + return nil, err + } + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + return decodeSkill(resp.Body) +} + +// CreatePackage uploads a ZIP archive to POST /skills:import. +func (c *Client) CreatePackage(ctx context.Context, archive io.ReadSeeker, archiveSize int64) (*Skill, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodPost, c.buildURL("/skills:import", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + if err := httpReq.SetBody(streaming(archive), ContentTypeZip); err != nil { + return nil, fmt.Errorf("set request body: %w", err) + } + httpReq.Raw().ContentLength = archiveSize + httpReq.Raw().Header.Set("Content-Type", ContentTypeZip) + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + return decodeSkill(resp.Body) +} + +// Get returns the metadata for a skill. +func (c *Client) Get(ctx context.Context, name string) (*Skill, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.skillURL(name, "", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + return decodeSkill(resp.Body) +} + +// Update sends req as POST /skills/{name}. Caller does GET-merge-POST. +func (c *Client) Update(ctx context.Context, name string, req UpdateRequest) (*Skill, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal update request: %w", err) + } + + httpReq, err := runtime.NewRequest(ctx, http.MethodPost, c.skillURL(name, "", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + if err := setJSONBody(httpReq, body); err != nil { + return nil, err + } + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + return decodeSkill(resp.Body) +} + +// Delete removes a skill. +func (c *Client) Delete(ctx context.Context, name string) (*DeleteResponse, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodDelete, c.skillURL(name, "", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK, http.StatusNoContent) { + return nil, runtime.NewResponseError(resp) + } + + if resp.StatusCode == http.StatusNoContent { + return &DeleteResponse{Name: name, Deleted: true}, nil + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + if len(bytes.TrimSpace(raw)) == 0 { + return &DeleteResponse{Name: name, Deleted: true}, nil + } + + var dr DeleteResponse + if err := json.Unmarshal(raw, &dr); err != nil { + return nil, fmt.Errorf("unmarshal delete response: %w", err) + } + if dr.Name == "" { + dr.Name = name + } + return &dr, nil +} + +// List fetches one page of skills. +func (c *Client) List(ctx context.Context, opts ListOptions, afterCursor string) (*PagedSkills, error) { + q := url.Values{} + if opts.Top > 0 { + q.Set("limit", strconv.Itoa(opts.Top)) + } + if opts.OrderBy != "" { + q.Set("order", opts.OrderBy) + } + if afterCursor != "" { + q.Set("after", afterCursor) + } + + httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.buildURL("/skills", q)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + addStandardHeaders(httpReq) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + var wire pagedSkillsWire + if err := json.NewDecoder(resp.Body).Decode(&wire); err != nil { + return nil, fmt.Errorf("decode list response: %w", err) + } + paged := wire.toPagedSkills() + return &paged, nil +} + +// ListAll fetches every page and returns the flattened slice. If limit is +// positive, ListAll stops once that many items are collected. +func (c *Client) ListAll(ctx context.Context, opts ListOptions, limit int) ([]Skill, error) { + var all []Skill + cursor := "" + for { + page, err := c.List(ctx, opts, cursor) + if err != nil { + return nil, err + } + all = append(all, page.Data...) + if limit > 0 && len(all) >= limit { + return all[:limit], nil + } + if !page.HasMore || page.LastID == "" { + return all, nil + } + cursor = page.LastID + } +} + +// Download fetches the skill package and returns the raw bytes. Accepts +// either ContentTypeZip or ContentTypeGzip (the service is asymmetric); the +// caller uses DetectArchiveFormat to interpret the bytes. +func (c *Client) Download(ctx context.Context, name string) ([]byte, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.skillURL(name, ":download", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + addStandardHeaders(httpReq) + httpReq.Raw().Header.Set("Accept", ContentTypeZip+", "+ContentTypeGzip) + + resp, err := c.pipeline.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + if !runtime.HasStatusCode(resp, http.StatusOK) { + return nil, runtime.NewResponseError(resp) + } + + if ct := resp.Header.Get("Content-Type"); ct != "" { + lc := strings.ToLower(ct) + if !strings.HasPrefix(lc, ContentTypeZip) && !strings.HasPrefix(lc, ContentTypeGzip) { + return nil, fmt.Errorf("unexpected download content type %q (want %s or %s)", ct, ContentTypeZip, ContentTypeGzip) + } + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read download body: %w", err) + } + return body, nil +} + +func (c *Client) buildURL(path string, extraQuery url.Values) string { + q := url.Values{} + q.Set("api-version", DataPlaneAPIVersion) + for k, vs := range extraQuery { + for _, v := range vs { + q.Add(k, v) + } + } + return c.endpoint + path + "?" + q.Encode() +} + +func (c *Client) skillURL(name, suffix string, extraQuery url.Values) string { + path := "/skills/" + url.PathEscape(name) + suffix + return c.buildURL(path, extraQuery) +} + +func setJSONBody(req *policy.Request, body []byte) error { + if err := req.SetBody(streaming(bytes.NewReader(body)), ContentTypeJSON); err != nil { + return fmt.Errorf("set request body: %w", err) + } + return nil +} + +func addStandardHeaders(req *policy.Request) { + h := req.Raw().Header + h.Set(FoundryFeaturesHeader, SkillsPreviewOptIn) + if h.Get("Accept") == "" { + h.Set("Accept", ContentTypeJSON) + } +} + +func decodeSkill(body io.Reader) (*Skill, error) { + var wire skillWire + if err := json.NewDecoder(body).Decode(&wire); err != nil { + return nil, fmt.Errorf("decode skill response: %w", err) + } + s := wire.toSkill() + return &s, nil +} + +type readSeekNopCloser struct{ io.ReadSeeker } + +func (readSeekNopCloser) Close() error { return nil } + +func streaming(rs io.ReadSeeker) io.ReadSeekCloser { + return readSeekNopCloser{rs} +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go new file mode 100644 index 00000000000..3d8dde3ebe7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/stretchr/testify/require" +) + +type fakeCredential struct{} + +func (fakeCredential) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "test-token", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +// newTestClient uses the insecure-http constructor so the bearer policy +// doesn't reject the plain-HTTP httptest endpoint. +func newTestClient(t *testing.T, srv *httptest.Server) *Client { + t.Helper() + return newClient(srv.URL, fakeCredential{}, "test", true) +} + +func TestClient_CreateInline_SendsRequiredHeadersAndQuery(t *testing.T) { + var capturedAPI string + var capturedFeatures string + var capturedContentType string + var capturedBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedAPI = r.URL.Query().Get("api-version") + capturedFeatures = r.Header.Get(FoundryFeaturesHeader) + capturedContentType = r.Header.Get("Content-Type") + require.Equal(t, "/skills", r.URL.Path) + require.Equal(t, http.MethodPost, r.Method) + + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &capturedBody)) + + resp := skillWire{SkillID: "sk-1", Name: "my-skill", Description: "from-server"} + w.Header().Set("Content-Type", ContentTypeJSON) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + c := newTestClient(t, srv) + skill, err := c.CreateInline(context.Background(), CreateRequest{ + Name: "my-skill", + Description: "client-desc", + Instructions: "client-body", + }) + require.NoError(t, err) + require.Equal(t, DataPlaneAPIVersion, capturedAPI) + require.Equal(t, SkillsPreviewOptIn, capturedFeatures) + require.Equal(t, ContentTypeJSON, capturedContentType) + require.Equal(t, "my-skill", capturedBody["name"]) + require.Equal(t, "client-desc", capturedBody["description"]) + require.Equal(t, "client-body", capturedBody["instructions"]) + require.Equal(t, "sk-1", skill.SkillID) + require.Equal(t, "from-server", skill.Description) +} + +func TestClient_CreatePackage_SendsZipContentType(t *testing.T) { + var capturedCT string + var capturedPath string + var capturedBytes []byte + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedCT = r.Header.Get("Content-Type") + capturedBytes, _ = io.ReadAll(r.Body) + w.Header().Set("Content-Type", ContentTypeJSON) + _ = json.NewEncoder(w).Encode(skillWire{Name: "my-skill", HasBlob: true}) + })) + defer srv.Close() + + payload := []byte("PK\x03\x04fake-zip") + c := newTestClient(t, srv) + skill, err := c.CreatePackage(context.Background(), strings.NewReader(string(payload)), int64(len(payload))) + require.NoError(t, err) + require.Equal(t, "/skills:import", capturedPath) + require.Equal(t, ContentTypeZip, capturedCT) + require.Equal(t, payload, capturedBytes) + require.True(t, skill.HasBlob) +} + +func TestClient_Get_DecodesSnakeCase(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills/my-skill", r.URL.Path) + require.Equal(t, SkillsPreviewOptIn, r.Header.Get(FoundryFeaturesHeader)) + _, _ = io.WriteString(w, `{"skill_id":"sk-1","name":"my-skill","has_blob":true,"description":"d"}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.Get(context.Background(), "my-skill") + require.NoError(t, err) + require.Equal(t, "sk-1", got.SkillID) + require.True(t, got.HasBlob) + require.Equal(t, "d", got.Description) +} + +func TestClient_List_FlattensPagination(t *testing.T) { + page := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills", r.URL.Path) + switch page { + case 0: + // First page: 2 items, has_more=true, last_id=b. + require.Empty(t, r.URL.Query().Get("after")) + _, _ = io.WriteString(w, `{"data":[{"name":"a"},{"name":"b"}],"has_more":true,"last_id":"b"}`) + case 1: + // Second page: cursor honored, 1 item, has_more=false. + require.Equal(t, "b", r.URL.Query().Get("after")) + _, _ = io.WriteString(w, `{"data":[{"name":"c"}],"has_more":false}`) + default: + t.Fatalf("unexpected extra page request: %d", page) + } + page++ + })) + defer srv.Close() + + c := newTestClient(t, srv) + all, err := c.ListAll(context.Background(), ListOptions{Top: 0}, 0) + require.NoError(t, err) + require.Equal(t, []string{"a", "b", "c"}, []string{all[0].Name, all[1].Name, all[2].Name}) +} + +func TestClient_List_HonorsLimit(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "5", r.URL.Query().Get("limit")) + require.Equal(t, "desc", r.URL.Query().Get("order")) + _, _ = io.WriteString(w, `{"data":[{"name":"a"},{"name":"b"},{"name":"c"}],"has_more":true,"last_id":"c"}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + all, err := c.ListAll(context.Background(), ListOptions{Top: 5, OrderBy: "desc"}, 2) + require.NoError(t, err) + require.Len(t, all, 2) +} + +func TestClient_Download_ValidatesContentType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills/my-skill:download", r.URL.Path) + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, "not zip") + })) + defer srv.Close() + + c := newTestClient(t, srv) + _, err := c.Download(context.Background(), "my-skill") + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected download content type") +} + +func TestClient_Download_ReturnsZipBytes(t *testing.T) { + payload := []byte("PK\x03\x04fake-zip-bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", ContentTypeZip) + _, _ = w.Write(payload) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.Download(context.Background(), "my-skill") + require.NoError(t, err) + require.Equal(t, payload, got) +} + +func TestClient_Delete_ReturnsServiceResponse(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodDelete, r.Method) + require.Equal(t, "/skills/my-skill", r.URL.Path) + _, _ = io.WriteString(w, `{"name":"my-skill","deleted":true}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + resp, err := c.Delete(context.Background(), "my-skill") + require.NoError(t, err) + require.True(t, resp.Deleted) + require.Equal(t, "my-skill", resp.Name) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go new file mode 100644 index 00000000000..886f80ed689 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package skill_api provides a typed REST client for the Foundry Skills +// data-plane surface, plus helpers for parsing SKILL.md files and safely +// extracting downloaded skill packages. +package skill_api + +// Skill is the metadata representation of a Foundry skill. JSON fields are +// camelCase for the published output of the CLI; the wire format is +// snake_case and is translated via skillWire. +type Skill struct { + SkillID string `json:"skillId,omitempty"` + Name string `json:"name"` + HasBlob bool `json:"hasBlob"` + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type skillWire struct { + SkillID string `json:"skill_id,omitempty"` + Name string `json:"name"` + HasBlob bool `json:"has_blob,omitempty"` + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +func (w skillWire) toSkill() Skill { + return Skill{ + SkillID: w.SkillID, + Name: w.Name, + HasBlob: w.HasBlob, + Description: w.Description, + Instructions: w.Instructions, + Metadata: w.Metadata, + } +} + +// CreateRequest is the JSON body for POST /skills. The CLI populates Name +// from the positional argument and never trusts a value from front matter. +type CreateRequest struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// UpdateRequest is the merged JSON body for POST /skills/{name}. +type UpdateRequest struct { + Description string `json:"description,omitempty"` + Instructions string `json:"instructions,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +// DeleteResponse is the JSON body returned by DELETE /skills/{name}. +type DeleteResponse struct { + Name string `json:"name"` + Deleted bool `json:"deleted"` +} + +// PagedSkills is one page of the GET /skills response. +type PagedSkills struct { + Data []Skill `json:"data"` + FirstID string `json:"firstId,omitempty"` + LastID string `json:"lastId,omitempty"` + HasMore bool `json:"hasMore"` +} + +type pagedSkillsWire struct { + Data []skillWire `json:"data"` + FirstID string `json:"first_id,omitempty"` + LastID string `json:"last_id,omitempty"` + HasMore bool `json:"has_more"` +} + +func (w pagedSkillsWire) toPagedSkills() PagedSkills { + out := PagedSkills{ + FirstID: w.FirstID, + LastID: w.LastID, + HasMore: w.HasMore, + } + if len(w.Data) > 0 { + out.Data = make([]Skill, 0, len(w.Data)) + for _, item := range w.Data { + out.Data = append(out.Data, item.toSkill()) + } + } + return out +} + +// ListOptions configures a GET /skills request. Zero values use service defaults. +type ListOptions struct { + Top int + OrderBy string +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go new file mode 100644 index 00000000000..b39e372fb3c --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go @@ -0,0 +1,252 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "go.yaml.in/yaml/v3" +) + +// SkillMdFileName is the canonical on-disk filename for a materialized skill. +const SkillMdFileName = "SKILL.md" + +// MarshalSkillMd serializes a SkillMd back to a SKILL.md document with the +// canonical `---`-delimited YAML front matter followed by the Markdown body. +// The front matter keys are ordered (name, description, metadata) so the +// generated file is stable across invocations. +func MarshalSkillMd(md *SkillMd) ([]byte, error) { + if md == nil { + return nil, fmt.Errorf("MarshalSkillMd: skill is nil") + } + if strings.TrimSpace(md.Name) == "" { + return nil, fmt.Errorf("MarshalSkillMd: skill name is required") + } + + var fmBuf bytes.Buffer + enc := yaml.NewEncoder(&fmBuf) + enc.SetIndent(2) + + root := &yaml.Node{Kind: yaml.MappingNode} + appendStringField(root, "name", md.Name) + if md.Description != "" { + appendStringField(root, "description", md.Description) + } + if len(md.Metadata) > 0 { + appendMetadataField(root, md.Metadata) + } + if err := enc.Encode(root); err != nil { + return nil, fmt.Errorf("marshal front matter: %w", err) + } + if err := enc.Close(); err != nil { + return nil, fmt.Errorf("close front matter encoder: %w", err) + } + + var out bytes.Buffer + out.WriteString(frontMatterDelimiter) + out.WriteByte('\n') + out.Write(fmBuf.Bytes()) + out.WriteString(frontMatterDelimiter) + out.WriteByte('\n') + if md.Instructions != "" { + out.WriteString(md.Instructions) + if !strings.HasSuffix(md.Instructions, "\n") { + out.WriteByte('\n') + } + } + return out.Bytes(), nil +} + +func appendStringField(root *yaml.Node, key, value string) { + root.Content = append(root.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value}, + ) +} + +func appendMetadataField(root *yaml.Node, metadata map[string]string) { + keys := make([]string, 0, len(metadata)) + for k := range metadata { + keys = append(keys, k) + } + sort.Strings(keys) + mapNode := &yaml.Node{Kind: yaml.MappingNode} + for _, k := range keys { + mapNode.Content = append(mapNode.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k}, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: metadata[k]}, + ) + } + root.Content = append(root.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "metadata"}, + mapNode, + ) +} + +// SkillMd is the parsed form of a SKILL.md document: a YAML front matter block +// delimited by `---` lines, followed by a Markdown body that becomes the +// skill's `instructions`. +type SkillMd struct { + Name string + Description string + Metadata map[string]string + Instructions string + RawFrontMatter map[string]any +} + +// ParseSkillMd parses a SKILL.md document. Both `---` delimiters are required. +// Missing or unparsable front matter returns an error suitable for wrapping +// in a structured validation error. +func ParseSkillMd(data []byte) (*SkillMd, error) { + if len(bytes.TrimSpace(data)) == 0 { + return nil, fmt.Errorf("SKILL.md is empty") + } + + openIdx, closeIdx, err := findFrontMatterBounds(data) + if err != nil { + return nil, err + } + + fmBytes := data[openIdx:closeIdx] + bodyStart := closeIdx + len(frontMatterDelimiter) + // Strip a single newline after the closing delimiter so the body doesn't + // start with a blank line the user didn't write. + if bodyStart < len(data) { + if data[bodyStart] == '\r' && bodyStart+1 < len(data) && data[bodyStart+1] == '\n' { + bodyStart += 2 + } else if data[bodyStart] == '\n' { + bodyStart++ + } + } + + var raw map[string]any + if err := yaml.Unmarshal(fmBytes, &raw); err != nil { + return nil, fmt.Errorf("parse SKILL.md front matter: %w", err) + } + if raw == nil { + return nil, fmt.Errorf("SKILL.md front matter is empty") + } + + out := &SkillMd{ + RawFrontMatter: raw, + Instructions: string(data[bodyStart:]), + } + + if v, ok := raw["name"]; ok { + s, err := frontMatterString("name", v) + if err != nil { + return nil, err + } + out.Name = s + } + if v, ok := raw["description"]; ok { + s, err := frontMatterString("description", v) + if err != nil { + return nil, err + } + out.Description = s + } + if v, ok := raw["metadata"]; ok { + m, err := frontMatterStringMap("metadata", v) + if err != nil { + return nil, err + } + out.Metadata = m + } + return out, nil +} + +const frontMatterDelimiter = "---" + +// findFrontMatterBounds returns the byte offsets that bracket the YAML body +// (exclusive of the delimiter lines themselves). Leading blank lines are +// allowed. +func findFrontMatterBounds(data []byte) (open, close int, err error) { + sawOpen := false + lineOffset := 0 + cur := data + lineNum := 0 + for { + nl := bytes.IndexByte(cur, '\n') + var line []byte + var step int + if nl < 0 { + line = cur + step = len(cur) + } else { + line = cur[:nl] + step = nl + 1 + } + trimmed := strings.TrimRight(string(line), "\r") + stripped := strings.TrimSpace(trimmed) + lineNum++ + + if !sawOpen { + if stripped == "" { + lineOffset += step + cur = cur[step:] + if step == 0 { + return 0, 0, fmt.Errorf("SKILL.md must begin with a YAML front matter block delimited by '---'") + } + continue + } + if stripped != frontMatterDelimiter { + return 0, 0, fmt.Errorf("SKILL.md must begin with a YAML front matter block delimited by '---' (got %q on line %d)", trimmed, lineNum) + } + sawOpen = true + open = lineOffset + step + lineOffset += step + cur = cur[step:] + continue + } + + if stripped == frontMatterDelimiter { + close = lineOffset + return open, close, nil + } + + lineOffset += step + cur = cur[step:] + if step == 0 { + break + } + } + return 0, 0, fmt.Errorf("SKILL.md front matter is missing its closing '---' delimiter") +} + +func frontMatterString(field string, v any) (string, error) { + switch typed := v.(type) { + case nil: + return "", nil + case string: + return typed, nil + default: + return "", fmt.Errorf("SKILL.md front matter field %q must be a string", field) + } +} + +func frontMatterStringMap(field string, v any) (map[string]string, error) { + if v == nil { + return nil, nil + } + raw, ok := v.(map[string]any) + if !ok { + return nil, fmt.Errorf("SKILL.md front matter field %q must be a mapping", field) + } + if len(raw) == 0 { + return nil, nil + } + out := make(map[string]string, len(raw)) + for k, val := range raw { + s, err := frontMatterString(field+"."+k, val) + if err != nil { + return nil, err + } + out[k] = s + } + return out, nil +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md_test.go b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md_test.go new file mode 100644 index 00000000000..9444289a73d --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md_test.go @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseSkillMd_Valid(t *testing.T) { + doc := strings.Join([]string{ + "---", + "name: my-skill", + "description: Greets the user", + "metadata:", + " owner: alice", + "---", + "# Skill body", + "", + "Greet the user warmly.", + }, "\n") + + parsed, err := ParseSkillMd([]byte(doc)) + require.NoError(t, err) + require.Equal(t, "my-skill", parsed.Name) + require.Equal(t, "Greets the user", parsed.Description) + require.Equal(t, map[string]string{"owner": "alice"}, parsed.Metadata) + require.True(t, strings.HasPrefix(parsed.Instructions, "# Skill body")) +} + +func TestParseSkillMd_NoFrontMatter(t *testing.T) { + _, err := ParseSkillMd([]byte("Just some Markdown body without front matter.\n")) + require.Error(t, err) + require.Contains(t, err.Error(), "must begin with a YAML front matter block") +} + +func TestParseSkillMd_MissingCloseDelimiter(t *testing.T) { + doc := "---\nname: foo\n# body but no closing ---\n" + _, err := ParseSkillMd([]byte(doc)) + require.Error(t, err) + require.Contains(t, err.Error(), "missing its closing '---' delimiter") +} + +func TestParseSkillMd_Empty(t *testing.T) { + _, err := ParseSkillMd(nil) + require.Error(t, err) +} + +func TestParseSkillMd_InvalidYAML(t *testing.T) { + doc := "---\nname: [unterminated\n---\nbody\n" + _, err := ParseSkillMd([]byte(doc)) + require.Error(t, err) + require.Contains(t, err.Error(), "parse SKILL.md front matter") +} + +func TestParseSkillMd_NonStringField(t *testing.T) { + doc := "---\nname: 123\n---\nbody\n" + _, err := ParseSkillMd([]byte(doc)) + require.Error(t, err) + require.Contains(t, err.Error(), `field "name" must be a string`) +} + +func TestParseSkillMd_BodyOnly(t *testing.T) { + // Front matter present but empty body is allowed: instructions is "". + doc := "---\nname: my-skill\ndescription: Just metadata\n---\n" + parsed, err := ParseSkillMd([]byte(doc)) + require.NoError(t, err) + require.Equal(t, "my-skill", parsed.Name) + require.Equal(t, "Just metadata", parsed.Description) + require.Equal(t, "", parsed.Instructions) +} + +func TestParseSkillMd_LeadingBlankLines(t *testing.T) { + doc := "\n\n---\nname: my-skill\n---\nbody\n" + parsed, err := ParseSkillMd([]byte(doc)) + require.NoError(t, err) + require.Equal(t, "my-skill", parsed.Name) +} + +func TestParseSkillMd_CRLFLineEndings(t *testing.T) { + doc := "---\r\nname: my-skill\r\ndescription: works\r\n---\r\nbody\r\n" + parsed, err := ParseSkillMd([]byte(doc)) + require.NoError(t, err) + require.Equal(t, "my-skill", parsed.Name) + require.Equal(t, "works", parsed.Description) +} + +func TestMarshalSkillMd_RoundTrip(t *testing.T) { + original := &SkillMd{ + Name: "my-skill", + Description: "Greets the user", + Metadata: map[string]string{"owner": "alice", "team": "platform"}, + Instructions: "# Skill body\n\nGreet the user warmly.\n", + } + + data, err := MarshalSkillMd(original) + require.NoError(t, err) + require.True(t, strings.HasPrefix(string(data), "---\n"), + "marshaled doc should start with front matter delimiter") + + parsed, err := ParseSkillMd(data) + require.NoError(t, err) + require.Equal(t, original.Name, parsed.Name) + require.Equal(t, original.Description, parsed.Description) + require.Equal(t, original.Metadata, parsed.Metadata) + require.Equal(t, original.Instructions, parsed.Instructions) +} + +func TestMarshalSkillMd_MinimalFields(t *testing.T) { + data, err := MarshalSkillMd(&SkillMd{Name: "minimal"}) + require.NoError(t, err) + doc := string(data) + require.Contains(t, doc, "name: minimal") + require.NotContains(t, doc, "description:") + require.NotContains(t, doc, "metadata:") +} + +func TestMarshalSkillMd_StableMetadataOrdering(t *testing.T) { + md := &SkillMd{ + Name: "stable", + Metadata: map[string]string{"c": "3", "a": "1", "b": "2"}, + } + first, err := MarshalSkillMd(md) + require.NoError(t, err) + for range 5 { + again, err := MarshalSkillMd(md) + require.NoError(t, err) + require.Equal(t, string(first), string(again), "metadata ordering must be deterministic") + } +} + +func TestMarshalSkillMd_RequiresName(t *testing.T) { + _, err := MarshalSkillMd(&SkillMd{Description: "no name"}) + require.Error(t, err) +} + +func TestMarshalSkillMd_NilInput(t *testing.T) { + _, err := MarshalSkillMd(nil) + require.Error(t, err) +} diff --git a/cli/azd/extensions/azure.ai.skills/internal/version/version.go b/cli/azd/extensions/azure.ai.skills/internal/version/version.go new file mode 100644 index 00000000000..dcd79ce6238 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/version/version.go @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package version + +// Populated at build time via ldflags. +var ( + Version = "dev" + Commit = "none" + BuildDate = "unknown" +) diff --git a/cli/azd/extensions/azure.ai.skills/main.go b/cli/azd/extensions/azure.ai.skills/main.go index 87381e3a398..6e3a091c7d3 100644 --- a/cli/azd/extensions/azure.ai.skills/main.go +++ b/cli/azd/extensions/azure.ai.skills/main.go @@ -4,7 +4,7 @@ package main import ( - "azure.ai.skills/internal/cmd" + "azureaiskills/internal/cmd" "github.com/azure/azure-dev/cli/azd/pkg/azdext" )