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..dc4f5bad98d --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/AGENTS.md @@ -0,0 +1,80 @@ +# 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. `.md` and inline modes send `inline_content` JSON; `.zip` is uploaded as `multipart/form-data` with a single `files[]` part. +- `update`: accepts `.md` only. `.zip` is rejected with a structured suggestion to use `create --force` (which deletes the skill and all its versions before re-creating). Pass `--set-default-version ` to repoint `default_version` at an existing immutable version without uploading new content. +- `download`: writes either an extracted directory (default) or the unmodified zip archive (`--raw`). Pass `--version ` to download a non-default version. The server always returns `application/zip` (from `GET /skills/{name}/content` or `GET /skills/{name}/versions/{version}/content`). + +## Versioning + +Skill versions are immutable. The Skill resource itself only carries +`id`, `name`, `description`, `default_version`, `latest_version`, and +`created_at`; per-version content lives in `inline_content` (or uploaded +files) on each `SkillVersion`. + +## 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..8c0bf211fc0 100644 --- a/cli/azd/extensions/azure.ai.skills/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.skills/CHANGELOG.md @@ -1,3 +1,29 @@ # 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 on top of the versioned Foundry + Skills API (`Foundry-Features: Skills=V1Preview`): + - `azd ai skill create ` — creates a skill and uploads its first + default version via `POST /skills/{name}/versions`. Modes: inline + (`--description` + `--instructions`), SKILL.md file (`--file ./SKILL.md`), + or ZIP package via `multipart/form-data` (`--file ./skill.zip`). + - `azd ai skill update ` — uploads a new default version using the + same inline / SKILL.md modes; ZIP is rejected with a pointer to + `create --force`. Pass `--set-default-version ` to repoint + `default_version` at an existing version without uploading new content + (`POST /skills/{name}`). + - `azd ai skill show ` — returns `Skill { id, name, description, + default_version, latest_version, created_at }`. + - `azd ai skill list` — paginated, supports `--top` and `--orderby`. + - `azd ai skill download ` — downloads the zip content from + `GET /skills/{name}/content`, or `GET /skills/{name}/versions/{version}/content` + when `--version` is passed. Extracts into `./.agents/skills//` by + default; `--raw` writes the unmodified zip archive. + - `azd ai skill delete ` — confirmation by default, `--force` to skip. +- Skill names follow the agentskills.io spec: + `^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$`, max 64 chars (lowercase only). +- 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..2c2e69e4917 100644 --- a/cli/azd/extensions/azure.ai.skills/README.md +++ b/cli/azd/extensions/azure.ai.skills/README.md @@ -1,3 +1,82 @@ -# 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 update --set-default-version +azd ai skill show +azd ai skill list [--top N] [--orderby ] +azd ai skill download [--version ] [--output-dir ] [--raw] [--force] +azd ai skill delete [--force] +``` + +Skills are **versioned and immutable**. `create` uploads the first default +version; `update` uploads a new default version (or, with +`--set-default-version`, just repoints `default_version` at an existing +version). Names follow the agentskills.io spec +(`^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$`, max 64 chars). + +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..53ffd7717a5 100644 --- a/cli/azd/extensions/azure.ai.skills/ci-build.ps1 +++ b/cli/azd/extensions/azure.ai.skills/ci-build.ps1 @@ -6,7 +6,6 @@ param( [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..fe75c1a8975 100644 --- a/cli/azd/extensions/azure.ai.skills/cspell.yaml +++ b/cli/azd/extensions/azure.ai.skills/cspell.yaml @@ -1,2 +1,16 @@ import: ../../.vscode/cspell.yaml -words: [] +words: + # Skill commands + - agentskills + - azureaiskills + - exterrors + - foundry + - foundrysdk + - orderby + - repoint + - repoints + - 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..f426ff3a255 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/output.go @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + "time" + + "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.ID != "" { + fmt.Fprintf(tw, "ID\t%s\n", s.ID) + } + if s.Description != "" { + fmt.Fprintf(tw, "Description\t%s\n", s.Description) + } + if s.DefaultVersion != "" { + fmt.Fprintf(tw, "Default Version\t%s\n", s.DefaultVersion) + } + if s.LatestVersion != "" { + fmt.Fprintf(tw, "Latest Version\t%s\n", s.LatestVersion) + } + if s.CreatedAt != 0 { + fmt.Fprintf(tw, "Created At\t%s\n", formatUnix(s.CreatedAt)) + } + return nil +} + +func printSkillVersionDetail(v *skill_api.SkillVersion, format string) error { + if format == outputJSON { + return printJSON(v) + } + tw := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) + defer tw.Flush() + fmt.Fprintf(tw, "Name\t%s\n", v.Name) + if v.Version != "" { + fmt.Fprintf(tw, "Version\t%s\n", v.Version) + } + if v.ID != "" { + fmt.Fprintf(tw, "Version ID\t%s\n", v.ID) + } + if v.SkillID != "" { + fmt.Fprintf(tw, "Skill ID\t%s\n", v.SkillID) + } + if v.Description != "" { + fmt.Fprintf(tw, "Description\t%s\n", v.Description) + } + if v.CreatedAt != 0 { + fmt.Fprintf(tw, "Created At\t%s\n", formatUnix(v.CreatedAt)) + } + 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\tDEFAULT\tLATEST") + fmt.Fprintln(tw, "----\t-----------\t-------\t------") + for _, s := range items { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, truncate(s.Description, 60), s.DefaultVersion, s.LatestVersion) + } + return nil +} + +func formatUnix(seconds int64) string { + if seconds <= 0 { + return "" + } + return time.Unix(seconds, 0).UTC().Format(time.RFC3339) +} + +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..4158071ffb9 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/output_test.go @@ -0,0 +1,38 @@ +// 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", DefaultVersion: "1", LatestVersion: "1"}, + {Name: "bravo", Description: "second skill with quite a long description that should be truncated", DefaultVersion: "2", LatestVersion: "3"}, + } + require.NoError(t, writeSkillTable(&buf, skills)) + + out := buf.String() + require.True(t, strings.Contains(out, "NAME"), out) + require.True(t, strings.Contains(out, "DEFAULT"), out) + require.True(t, strings.Contains(out, "LATEST"), 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) +} + +func TestFormatUnix(t *testing.T) { + require.Equal(t, "", formatUnix(0)) + require.Equal(t, "", formatUnix(-1)) + require.Equal(t, "1970-01-01T00:00:01Z", formatUnix(1)) +} 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..49be8ad8e7d --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go @@ -0,0 +1,466 @@ +// 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 is destructive: we delete the existing skill by positional name + // before any guarantee the supplied file targets the same one. For both + // package (.zip) and SKILL.md inputs, peek the embedded `name` and bail + // before deletion if it disagrees with the positional argument. + if a.flags.force { + if err := verifyFileNameMatches(a.flags.file, a.flags.name, mode); err != nil { + return err + } + } + + if a.flags.force { + if _, delErr := skillCtx.client.DeleteSkill(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 ", + ) + } + + version, err := client.CreateVersionInline(ctx, a.flags.name, skill_api.CreateVersionRequest{ + InlineContent: &skill_api.SkillInlineContent{ + Description: a.flags.description, + Instructions: a.flags.instructions, + }, + Default: true, + }) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateSkill) + } + return a.printCreateResult(ctx, client, version) +} + +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, + ) + } + + version, err := client.CreateVersionInline(ctx, a.flags.name, skill_api.CreateVersionRequest{ + InlineContent: &skill_api.SkillInlineContent{ + Description: parsed.Description, + Instructions: parsed.Instructions, + Metadata: parsed.Metadata, + }, + Default: true, + }) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateSkill) + } + return a.printCreateResult(ctx, client, version) +} + +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() + + version, err := client.CreateVersionFromZip(ctx, a.flags.name, filepath.Base(a.flags.file), f, true) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateSkill) + } + return a.printCreateResult(ctx, client, version) +} + +// printCreateResult prints either the created version envelope or, when the +// caller wants a human-readable summary, the freshly-loaded Skill that the +// version belongs to so users see default_version / latest_version. +func (a *createAction) printCreateResult(ctx context.Context, client *skill_api.Client, version *skill_api.SkillVersion) error { + if a.flags.output == outputJSON { + return printJSON(version) + } + fmt.Printf("Skill %q version %q created.\n", a.flags.name, version.Version) + skill, err := client.GetSkill(ctx, a.flags.name) + if err != nil { + // Don't fail the create just because the follow-up GET failed; fall + // back to printing the version envelope instead. + return printSkillVersionDetail(version, outputTable) + } + return printSkillDetail(skill, outputTable) +} + +// verifyFileNameMatches refuses --force when the user-supplied file's embedded +// `name` disagrees with the positional argument, so a typo can't wipe an +// unrelated skill. Inline-mode (no --file) is always allowed. +func verifyFileNameMatches(filePath, positionalName string, mode createMode) error { + switch mode { + case modeFilePackage: + return verifyPackageNameMatches(filePath, positionalName) + case modeFileMd: + return verifyMdNameMatches(filePath, positionalName) + } + return nil +} + +// verifyMdNameMatches reads the SKILL.md front matter and refuses --force on +// a `name` mismatch. Returns nil when SKILL.md omits `name` or the names agree. +func verifyMdNameMatches(filePath, positionalName string) error { + data, err := readFileWithLimit(filePath) + 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", filePath, parseErr), + "ensure the file begins with a YAML front matter block delimited by '---'", + ) + } + if parsed.Name == "" || parsed.Name == positionalName { + return nil + } + return exterrors.Validation( + exterrors.CodeInvalidSkillFile, + fmt.Sprintf( + "--force refused: SKILL.md declares name %q which does not match positional argument %q", + parsed.Name, positionalName, + ), + "re-run without --force, or fix the positional name / SKILL.md so they agree", + ) +} + +// 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 as inline_content.description on the new version.", + 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 inline_content.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 multipart/form-data) + +Skills are versioned. ` + "`create`" + ` creates a new skill (if it does not exist) +and uploads its first version as the default. 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..c2d9887ddc8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create_test.go @@ -0,0 +1,158 @@ +// 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) +} + +// --- verifyFileNameMatches (md mode) --- + +func TestVerifyFileNameMatches_MdMatch(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "SKILL.md") + require.NoError(t, os.WriteFile(p, []byte("---\nname: my-skill\n---\nbody"), 0600)) + require.NoError(t, verifyFileNameMatches(p, "my-skill", modeFileMd)) +} + +func TestVerifyFileNameMatches_MdMismatch(t *testing.T) { + dir := t.TempDir() + p := filepath.Join(dir, "SKILL.md") + require.NoError(t, os.WriteFile(p, []byte("---\nname: other-skill\n---\nbody"), 0600)) + err := verifyFileNameMatches(p, "my-skill", modeFileMd) + require.Error(t, err) + var le *azdext.LocalError + require.True(t, errors.As(err, &le)) + require.Equal(t, exterrors.CodeInvalidSkillFile, le.Code) +} + +func TestVerifyFileNameMatches_MdNoName(t *testing.T) { + // SKILL.md without a name claim must allow --force. + dir := t.TempDir() + p := filepath.Join(dir, "SKILL.md") + require.NoError(t, os.WriteFile(p, []byte("---\ndescription: hi\n---\nbody"), 0600)) + require.NoError(t, verifyFileNameMatches(p, "my-skill", modeFileMd)) +} + +func TestVerifyFileNameMatches_InlineNoCheck(t *testing.T) { + // Inline mode (no --file) is always allowed. + require.NoError(t, verifyFileNameMatches("", "my-skill", modeInline)) +} + +// 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..75111e2ea7c --- /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.DeleteSkill(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..c39e7c0e01f --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go @@ -0,0 +1,239 @@ +// 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 + version 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"` + Version string `json:"version,omitempty"` + 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 + } + + var body []byte + if a.flags.version != "" { + body, err = skillCtx.client.DownloadVersionContent(ctx, a.flags.name, a.flags.version) + } else { + body, err = skillCtx.client.DownloadSkillContent(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) +} + +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) + } + + archiveName := a.flags.name + ".zip" + 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, Version: a.flags.version, OutputDir: outputDir, Archive: archiveName, Raw: true, + }) +} + +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, Version: a.flags.version, OutputDir: outputDir, Files: result.Files, + }) +} + +func (a *downloadAction) printResult(res downloadResult) error { + if a.flags.output == outputJSON { + return printJSON(res) + } + versionSuffix := "" + if res.Version != "" { + versionSuffix = fmt.Sprintf(" (version %s)", res.Version) + } + if res.Raw { + fmt.Printf("Skill %q%s downloaded to %s\n", res.Skill, versionSuffix, filepath.Join(res.OutputDir, res.Archive)) + } else { + fmt.Printf("Skill %q%s extracted into %s (%d files)\n", res.Skill, versionSuffix, 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 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 the zip content for a skill and extract it into --output-dir +(default ` + "`./.agents/skills//`" + `). Pass --raw to write the unmodified +zip archive instead of extracting it. Pass --version to download a +specific version rather than the skill's default. + +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.version, "version", "", "Download a specific version (defaults to the skill's default_version)") + 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 zip 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..02d4cc4c501 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download_test.go @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "testing" + + "azureaiskills/internal/exterrors" + "azureaiskills/internal/pkg/skill_api" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/require" +) + +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) +} 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..ba4850aa983 --- /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.ListAllSkills(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..4ba8ae94b9a --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go @@ -0,0 +1,61 @@ +// 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.GetSkill(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 — including +default_version and latest_version. To retrieve skill content, 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..a26867b369a --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go @@ -0,0 +1,223 @@ +// 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" +) + +// updateAction implements `azd ai skill update`. Skills are versioned, so an +// "update" creates a new immutable version and sets it as the default. To +// repoint default_version at an existing version without uploading new +// content, pass --set-default-version . +type updateFlags struct { + name string + description string + instructions string + file string + setDefault 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 + } + + // --set-default-version is a metadata-only update: POST /skills/{name} + // with { default_version }. No new version is created. + if a.flags.setDefault != "" { + updated, err := skillCtx.client.UpdateSkillDefaultVersion(ctx, a.flags.name, a.flags.setDefault) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpUpdateSkill) + } + if a.flags.output == outputJSON { + return printJSON(updated) + } + fmt.Printf("Skill %q default_version set to %q.\n", updated.Name, updated.DefaultVersion) + return printSkillDetail(updated, outputTable) + } + + content, err := a.buildInlineContent() + if err != nil { + return err + } + + version, err := skillCtx.client.CreateVersionInline(ctx, a.flags.name, skill_api.CreateVersionRequest{ + InlineContent: content, + Default: true, + }) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpUpdateSkill) + } + + if a.flags.output == outputJSON { + return printJSON(version) + } + fmt.Printf("Skill %q updated; new version %q is now the default.\n", a.flags.name, version.Version) + skill, err := skillCtx.client.GetSkill(ctx, a.flags.name) + if err != nil { + return printSkillVersionDetail(version, outputTable) + } + return printSkillDetail(skill, outputTable) +} + +func (a *updateAction) buildInlineContent() (*skill_api.SkillInlineContent, error) { + content := &skill_api.SkillInlineContent{ + Description: a.flags.description, + Instructions: a.flags.instructions, + } + + if a.flags.file != "" { + data, readErr := readFileWithLimit(a.flags.file) + if readErr != nil { + return nil, readErr + } + parsed, parseErr := skill_api.ParseSkillMd(data) + if parseErr != nil { + return nil, 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 '---'", + ) + } + content.Description = parsed.Description + content.Instructions = parsed.Instructions + content.Metadata = parsed.Metadata + } + + if strings.TrimSpace(content.Description) == "" { + return nil, exterrors.Validation( + exterrors.CodeMissingRequiredField, + "update requires a non-empty description", + "pass --description, or use --file with a SKILL.md that supplies one", + ) + } + if strings.TrimSpace(content.Instructions) == "" { + return nil, exterrors.Validation( + exterrors.CodeMissingRequiredField, + "update requires non-empty instructions", + "pass --instructions, or use --file with a SKILL.md body", + ) + } + return content, nil +} + +func (a *updateAction) validateFlags() error { + inlineProvided := a.flags.descriptionSet || a.flags.instructionsSet + fileProvided := a.flags.file != "" + setDefaultProvided := a.flags.setDefault != "" + + // --set-default-version is mutually exclusive with content flags. + if setDefaultProvided && (inlineProvided || fileProvided) { + return exterrors.Validation( + exterrors.CodeConflictingArguments, + "--set-default-version cannot be combined with --description / --instructions / --file", + "pass --set-default-version on its own, or omit it to create a new default version", + ) + } + if setDefaultProvided { + return nil + } + + if !inlineProvided && !fileProvided { + return exterrors.Validation( + exterrors.CodeMissingRequiredField, + "no fields to update", + "pass --description, --instructions, and/or --file ; or use --set-default-version ", + ) + } + 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 and all of its versions first)", + ) + 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: "Create a new default version for a Foundry skill.", + Long: `Skills are versioned and immutable. ` + "`update`" + ` creates a new version from +inline content (--description / --instructions) or a SKILL.md file and sets +it as the skill's new default version. + +To repoint default_version at an existing version without uploading new +content, pass --set-default-version . + +ZIP packages are not accepted here. To replace the entire skill (deleting all +existing versions), use ` + "`azd ai skill create --file .zip --force`" + `.`, + Example: ` azd ai skill update my-skill --description "Updated summary" --instructions "..." + azd ai skill update my-skill --file ./SKILL.md + azd ai skill update my-skill --set-default-version 1`, + 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 for the next version") + cmd.Flags().StringVar(&flags.instructions, "instructions", "", "New Markdown instructions body for the next version") + cmd.Flags().StringVar(&flags.file, "file", "", "Path to a SKILL.md file whose values become the next version's inline content") + cmd.Flags().StringVar(&flags.setDefault, "set-default-version", "", "Set the skill's default_version to an existing version without uploading new content") + 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..7d73413ed7f --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update_test.go @@ -0,0 +1,76 @@ +// 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", + instructionsSet: true, + instructions: "new instructions", + }} + 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 (delete-then-create at the skill level). +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") +} + +func TestUpdateAction_SetDefaultVersion_AcceptsAlone(t *testing.T) { + a := &updateAction{flags: &updateFlags{setDefault: "2"}} + require.NoError(t, a.validateFlags()) +} + +func TestUpdateAction_SetDefaultVersion_ConflictsWithContent(t *testing.T) { + a := &updateAction{flags: &updateFlags{setDefault: "2", descriptionSet: true}} + 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) +} 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..3378e9eb1c4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "regexp" + "strings" + + "azureaiskills/internal/exterrors" +) + +// skillNamePattern matches the SkillName scalar from the Foundry Skills +// API spec (agentskills.io alignment): lowercase letters / digits / hyphens, +// must not start or end with a hyphen, max 64 chars. The service makes the +// final decision. +var skillNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$`) + +const skillNameMaxLen = 64 + +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 len(trimmed) > skillNameMaxLen || !skillNamePattern.MatchString(trimmed) { + return exterrors.Validation( + exterrors.CodeInvalidSkillName, + fmt.Sprintf("skill name %q is invalid", trimmed), + "use 1-64 lowercase letters, digits, and hyphens; must not start or end with a hyphen", + ) + } + 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..ebdcb64cd64 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go @@ -0,0 +1,169 @@ +// 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}, + // new spec: lowercase only — uppercase rejected. + {"Skill1", true}, + {"UPPER", true}, + // 64 char limit (was 63 in old spec) + {string(makeRune('a', 64)), false}, + {string(makeRune('a', 65)), 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..a427996d970 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go @@ -0,0 +1,419 @@ +// 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 +// download endpoints (GET /skills/{name}/content and +// GET /skills/{name}/versions/{version}/content) return application/zip; +// the gzip-tar branch remains for resilience against legacy or alternate +// content paths. +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..b0b9cfa9004 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go @@ -0,0 +1,515 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "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 response content type for /skills/{name}/content + // and /skills/{name}/versions/{version}/content. It is also the part + // content-type the client uses when uploading a single zip via multipart. + ContentTypeZip = "application/zip" + + // MaxDownloadBytes caps the wire size of /content responses to bound + // memory before extraction enforces its own uncompressed cap. Set to + // match the archive uncompressed limit; a legitimate zip is far smaller + // after compression, so this only trips on egregious responses. + MaxDownloadBytes = 512 * 1024 * 1024 + + //nolint:gosec // OAuth scope identifier, not a credential + BearerScope = "https://ai.azure.com/.default" + + userAgentPrefix = "azd-ext-azure-ai-skills" +) + +// downloadByteCap is the active per-response cap used by downloadContent. +// Defaults to MaxDownloadBytes; tests may override it via withDownloadCap. +var downloadByteCap int64 = MaxDownloadBytes + +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, + } +} + +// CreateVersionInline POSTs application/json to /skills/{name}/versions. +// If the skill does not exist yet, the service auto-creates it. +func (c *Client) CreateVersionInline(ctx context.Context, name string, req CreateVersionRequest) (*SkillVersion, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("marshal create version request: %w", err) + } + + httpReq, err := runtime.NewRequest(ctx, http.MethodPost, c.versionsURL(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, http.StatusCreated) { + return nil, runtime.NewResponseError(resp) + } + return decodeJSON[SkillVersion](resp.Body) +} + +// CreateVersionFromZip uploads a single .zip archive as multipart/form-data +// to /skills/{name}/versions. The server extracts the archive and validates +// the contents (SKILL.md, etc.). +func (c *Client) CreateVersionFromZip( + ctx context.Context, name, fileName string, archive io.Reader, makeDefault bool, +) (*SkillVersion, error) { + buf := &bytes.Buffer{} + mw := multipart.NewWriter(buf) + + // files[] part — a single zip with application/zip content type. + partHeader := textproto.MIMEHeader{} + partHeader.Set("Content-Disposition", + fmt.Sprintf(`form-data; name="files"; filename=%q`, fileName)) + partHeader.Set("Content-Type", ContentTypeZip) + part, err := mw.CreatePart(partHeader) + if err != nil { + return nil, fmt.Errorf("create multipart files part: %w", err) + } + if _, err := io.Copy(part, archive); err != nil { + return nil, fmt.Errorf("copy archive to multipart: %w", err) + } + + if makeDefault { + if err := mw.WriteField("default", "true"); err != nil { + return nil, fmt.Errorf("write multipart default field: %w", err) + } + } + if err := mw.Close(); err != nil { + return nil, fmt.Errorf("close multipart writer: %w", err) + } + + httpReq, err := runtime.NewRequest(ctx, http.MethodPost, c.versionsURL(name, "", nil)) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + if err := httpReq.SetBody(streaming(bytes.NewReader(buf.Bytes())), mw.FormDataContentType()); err != nil { + return nil, fmt.Errorf("set request body: %w", err) + } + httpReq.Raw().ContentLength = int64(buf.Len()) + 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 decodeJSON[SkillVersion](resp.Body) +} + +// GetSkill returns the metadata for a skill. +func (c *Client) GetSkill(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 decodeJSON[Skill](resp.Body) +} + +// UpdateSkillDefaultVersion repoints the skill's default_version to an +// existing version identifier. The skill resource carries no other mutable +// metadata; per-version content is immutable, so all other updates go +// through CreateVersionInline / CreateVersionFromZip. +func (c *Client) UpdateSkillDefaultVersion(ctx context.Context, name, version string) (*Skill, error) { + body, err := json.Marshal(UpdateSkillRequest{DefaultVersion: version}) + if err != nil { + return nil, fmt.Errorf("marshal update skill 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 decodeJSON[Skill](resp.Body) +} + +// DeleteSkill removes a skill and all of its versions. +func (c *Client) DeleteSkill(ctx context.Context, name string) (*DeleteSkillResponse, 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 &DeleteSkillResponse{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 &DeleteSkillResponse{Name: name, Deleted: true}, nil + } + + var dr DeleteSkillResponse + 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 +} + +// ListSkills fetches one page of skills. +func (c *Client) ListSkills(ctx context.Context, opts ListOptions, afterCursor string) (*PagedResult[Skill], error) { + q := pagingQuery(opts, 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) + } + return decodeJSON[PagedResult[Skill]](resp.Body) +} + +// ListAllSkills fetches every page and returns the flattened slice. If limit +// is positive, ListAllSkills stops once that many items are collected. +func (c *Client) ListAllSkills(ctx context.Context, opts ListOptions, limit int) ([]Skill, error) { + var all []Skill + cursor := "" + for { + page, err := c.ListSkills(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 + } +} + +// GetSkillVersion retrieves a specific version envelope. +func (c *Client) GetSkillVersion(ctx context.Context, name, version string) (*SkillVersion, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.versionsURL(name, "/"+url.PathEscape(version), 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 decodeJSON[SkillVersion](resp.Body) +} + +// ListSkillVersions fetches one page of versions for a skill. +func (c *Client) ListSkillVersions( + ctx context.Context, name string, opts ListOptions, afterCursor string, +) (*PagedResult[SkillVersion], error) { + q := pagingQuery(opts, afterCursor) + httpReq, err := runtime.NewRequest(ctx, http.MethodGet, c.versionsURL(name, "", 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) + } + return decodeJSON[PagedResult[SkillVersion]](resp.Body) +} + +// DeleteSkillVersion deletes a single version. +func (c *Client) DeleteSkillVersion(ctx context.Context, name, version string) (*DeleteSkillVersionResponse, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodDelete, c.versionsURL(name, "/"+url.PathEscape(version), 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 &DeleteSkillVersionResponse{Name: name, Version: version, 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 &DeleteSkillVersionResponse{Name: name, Version: version, Deleted: true}, nil + } + + var dr DeleteSkillVersionResponse + if err := json.Unmarshal(raw, &dr); err != nil { + return nil, fmt.Errorf("unmarshal delete response: %w", err) + } + if dr.Name == "" { + dr.Name = name + } + if dr.Version == "" { + dr.Version = version + } + return &dr, nil +} + +// DownloadSkillContent fetches the zip content for the default version of a +// skill. The server always returns application/zip. +func (c *Client) DownloadSkillContent(ctx context.Context, name string) ([]byte, error) { + return c.downloadContent(ctx, c.skillURL(name, "/content", nil)) +} + +// DownloadVersionContent fetches the zip content for a specific version. +func (c *Client) DownloadVersionContent(ctx context.Context, name, version string) ([]byte, error) { + return c.downloadContent(ctx, c.versionsURL(name, "/"+url.PathEscape(version)+"/content", nil)) +} + +func (c *Client) downloadContent(ctx context.Context, fullURL string) ([]byte, error) { + httpReq, err := runtime.NewRequest(ctx, http.MethodGet, fullURL) + if err != nil { + return nil, fmt.Errorf("new request: %w", err) + } + addStandardHeaders(httpReq) + httpReq.Raw().Header.Set("Accept", ContentTypeZip) + + 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 != "" { + if !strings.HasPrefix(strings.ToLower(ct), ContentTypeZip) { + return nil, fmt.Errorf("unexpected download content type %q (want %s)", ct, ContentTypeZip) + } + } + + // Fail fast on a server-declared oversize before reading the body. + if resp.ContentLength > downloadByteCap { + return nil, fmt.Errorf( + "download size %d exceeds the %d byte limit", + resp.ContentLength, downloadByteCap, + ) + } + + // Read one extra byte so we can distinguish "exactly at limit" from + // "tried to send more than the limit". + body, err := io.ReadAll(io.LimitReader(resp.Body, downloadByteCap+1)) + if err != nil { + return nil, fmt.Errorf("read download body: %w", err) + } + if int64(len(body)) > downloadByteCap { + return nil, fmt.Errorf("download exceeds the %d byte limit", downloadByteCap) + } + 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 { + return c.buildURL("/skills/"+url.PathEscape(name)+suffix, extraQuery) +} + +func (c *Client) versionsURL(name, suffix string, extraQuery url.Values) string { + return c.buildURL("/skills/"+url.PathEscape(name)+"/versions"+suffix, extraQuery) +} + +func pagingQuery(opts ListOptions, afterCursor string) url.Values { + q := url.Values{} + if opts.Limit > 0 { + q.Set("limit", strconv.Itoa(opts.Limit)) + } + if opts.Order != "" { + q.Set("order", opts.Order) + } + if afterCursor != "" { + q.Set("after", afterCursor) + } + return q +} + +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 decodeJSON[T any](body io.Reader) (*T, error) { + var out T + if err := json.NewDecoder(body).Decode(&out); err != nil { + return nil, fmt.Errorf("decode response: %w", err) + } + return &out, 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..f1a924b690e --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go @@ -0,0 +1,370 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "context" + "encoding/json" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "strconv" + "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_CreateVersionInline_SendsRequestEnvelope(t *testing.T) { + var capturedAPI string + var capturedFeatures string + var capturedContentType string + var capturedPath 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") + capturedPath = r.URL.Path + require.Equal(t, http.MethodPost, r.Method) + + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &capturedBody)) + + w.Header().Set("Content-Type", ContentTypeJSON) + w.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(w, `{"id":"ver_1","skill_id":"sk_1","name":"my-skill","version":"1","description":"d","created_at":1}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.CreateVersionInline(context.Background(), "my-skill", CreateVersionRequest{ + InlineContent: &SkillInlineContent{Description: "d", Instructions: "body"}, + Default: true, + }) + require.NoError(t, err) + require.Equal(t, "/skills/my-skill/versions", capturedPath) + require.Equal(t, DataPlaneAPIVersion, capturedAPI) + require.Equal(t, SkillsPreviewOptIn, capturedFeatures) + require.Equal(t, ContentTypeJSON, capturedContentType) + + inline, ok := capturedBody["inline_content"].(map[string]any) + require.True(t, ok, "inline_content must be present") + require.Equal(t, "d", inline["description"]) + require.Equal(t, "body", inline["instructions"]) + require.Equal(t, true, capturedBody["default"]) + + require.Equal(t, "ver_1", got.ID) + require.Equal(t, "1", got.Version) + require.Equal(t, "sk_1", got.SkillID) +} + +func TestClient_CreateVersionFromZip_SendsMultipart(t *testing.T) { + var capturedPath string + var capturedCT string + var capturedFiles []string + var capturedDefault string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedPath = r.URL.Path + capturedCT = r.Header.Get("Content-Type") + + _, params, err := mime.ParseMediaType(capturedCT) + require.NoError(t, err) + mr := multipart.NewReader(r.Body, params["boundary"]) + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + require.NoError(t, err) + data, _ := io.ReadAll(part) + switch part.FormName() { + case "files": + capturedFiles = append(capturedFiles, part.FileName()) + require.Equal(t, ContentTypeZip, part.Header.Get("Content-Type")) + require.Equal(t, "PK\x03\x04fake", string(data)) + case "default": + capturedDefault = string(data) + } + } + + w.Header().Set("Content-Type", ContentTypeJSON) + _, _ = io.WriteString(w, `{"id":"ver_1","skill_id":"sk_1","name":"my-skill","version":"1","description":"d","created_at":1}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.CreateVersionFromZip( + context.Background(), "my-skill", "my-skill.zip", + strings.NewReader("PK\x03\x04fake"), true, + ) + require.NoError(t, err) + require.Equal(t, "/skills/my-skill/versions", capturedPath) + require.True(t, strings.HasPrefix(capturedCT, "multipart/form-data"), capturedCT) + require.Equal(t, []string{"my-skill.zip"}, capturedFiles) + require.Equal(t, "true", capturedDefault) + require.Equal(t, "1", got.Version) +} + +func TestClient_GetSkill_DecodesEnvelope(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, `{"id":"sk_1","name":"my-skill","description":"d","default_version":"2","latest_version":"3","created_at":42}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.GetSkill(context.Background(), "my-skill") + require.NoError(t, err) + require.Equal(t, "sk_1", got.ID) + require.Equal(t, "2", got.DefaultVersion) + require.Equal(t, "3", got.LatestVersion) + require.Equal(t, int64(42), got.CreatedAt) +} + +func TestClient_UpdateSkillDefaultVersion_SendsBody(t *testing.T) { + var captured map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/skills/my-skill", r.URL.Path) + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &captured)) + _, _ = io.WriteString(w, `{"id":"sk_1","name":"my-skill","description":"d","default_version":"2","latest_version":"3","created_at":1}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.UpdateSkillDefaultVersion(context.Background(), "my-skill", "2") + require.NoError(t, err) + require.Equal(t, "2", captured["default_version"]) + require.Equal(t, "2", got.DefaultVersion) +} + +func TestClient_ListSkills_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: + require.Empty(t, r.URL.Query().Get("after")) + _, _ = io.WriteString(w, `{"data":[{"name":"a"},{"name":"b"}],"has_more":true,"last_id":"b"}`) + case 1: + 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.ListAllSkills(context.Background(), ListOptions{}, 0) + require.NoError(t, err) + require.Equal(t, []string{"a", "b", "c"}, []string{all[0].Name, all[1].Name, all[2].Name}) +} + +func TestClient_ListSkills_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.ListAllSkills(context.Background(), ListOptions{Limit: 5, Order: "desc"}, 2) + require.NoError(t, err) + require.Len(t, all, 2) +} + +func TestClient_DownloadSkillContent_ValidatesContentType(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills/my-skill/content", r.URL.Path) + w.Header().Set("Content-Type", "text/plain") + _, _ = io.WriteString(w, "not zip") + })) + defer srv.Close() + + c := newTestClient(t, srv) + _, err := c.DownloadSkillContent(context.Background(), "my-skill") + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected download content type") +} + +func TestClient_DownloadSkillContent_ReturnsZipBytes(t *testing.T) { + payload := []byte("PK\x03\x04fake-zip-bytes") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills/my-skill/content", r.URL.Path) + w.Header().Set("Content-Type", ContentTypeZip) + _, _ = w.Write(payload) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.DownloadSkillContent(context.Background(), "my-skill") + require.NoError(t, err) + require.Equal(t, payload, got) +} + +func TestClient_DownloadSkillContent_RejectsOversizeContentLength(t *testing.T) { + withDownloadCap(t, 16) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body := strings.Repeat("A", 17) + w.Header().Set("Content-Type", ContentTypeZip) + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + _, _ = io.WriteString(w, body) + })) + defer srv.Close() + + c := newTestClient(t, srv) + _, err := c.DownloadSkillContent(context.Background(), "my-skill") + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds") +} + +func TestClient_DownloadSkillContent_RejectsOversizeStreamingBody(t *testing.T) { + withDownloadCap(t, 16) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", ContentTypeZip) + // No Content-Length: force the streaming-limit path. + // Stream chunks until the client closes the connection. + flusher, _ := w.(http.Flusher) + chunk := []byte("AAAAAAAA") // 8 bytes + for range 16 { + if _, err := w.Write(chunk); err != nil { + return + } + if flusher != nil { + flusher.Flush() + } + } + })) + defer srv.Close() + + c := newTestClient(t, srv) + _, err := c.DownloadSkillContent(context.Background(), "my-skill") + require.Error(t, err) + require.Contains(t, err.Error(), "exceeds") +} + +func TestClient_DownloadSkillContent_AcceptsAtLimit(t *testing.T) { + withDownloadCap(t, 16) + payload := []byte("PK\x03\x04tinybody") + 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.DownloadSkillContent(context.Background(), "my-skill") + require.NoError(t, err) + require.Equal(t, payload, got) +} + +// withDownloadCap lowers downloadByteCap for the duration of a single test so +// the cap can be exercised without writing MaxDownloadBytes through httptest. +func withDownloadCap(t *testing.T, cap int64) { + t.Helper() + prev := downloadByteCap + downloadByteCap = cap + t.Cleanup(func() { downloadByteCap = prev }) +} + +func TestClient_DownloadVersionContent_TargetsVersionPath(t *testing.T) { + payload := []byte("PK\x03\x04v2") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills/my-skill/versions/2/content", r.URL.Path) + w.Header().Set("Content-Type", ContentTypeZip) + _, _ = w.Write(payload) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.DownloadVersionContent(context.Background(), "my-skill", "2") + require.NoError(t, err) + require.Equal(t, payload, got) +} + +func TestClient_DeleteSkill_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, `{"id":"sk_1","name":"my-skill","deleted":true}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + resp, err := c.DeleteSkill(context.Background(), "my-skill") + require.NoError(t, err) + require.True(t, resp.Deleted) + require.Equal(t, "my-skill", resp.Name) + require.Equal(t, "sk_1", resp.ID) +} + +func TestClient_DeleteSkillVersion_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/versions/2", r.URL.Path) + _, _ = io.WriteString(w, `{"id":"ver_1","name":"my-skill","version":"2","deleted":true}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + resp, err := c.DeleteSkillVersion(context.Background(), "my-skill", "2") + require.NoError(t, err) + require.True(t, resp.Deleted) + require.Equal(t, "2", resp.Version) +} + +func TestClient_ListSkillVersions_Decodes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills/my-skill/versions", r.URL.Path) + _, _ = io.WriteString(w, `{"data":[{"name":"my-skill","version":"1"},{"name":"my-skill","version":"2"}],"has_more":false}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + page, err := c.ListSkillVersions(context.Background(), "my-skill", ListOptions{}, "") + require.NoError(t, err) + require.Len(t, page.Data, 2) + require.Equal(t, "1", page.Data[0].Version) + require.Equal(t, "2", page.Data[1].Version) +} + +func TestClient_GetSkillVersion_Decodes(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/skills/my-skill/versions/2", r.URL.Path) + _, _ = io.WriteString(w, `{"id":"ver_2","skill_id":"sk_1","name":"my-skill","version":"2","description":"d","created_at":1}`) + })) + defer srv.Close() + + c := newTestClient(t, srv) + got, err := c.GetSkillVersion(context.Background(), "my-skill", "2") + require.NoError(t, err) + require.Equal(t, "2", got.Version) + require.Equal(t, "ver_2", got.ID) +} 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..50ec3e2c748 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go @@ -0,0 +1,92 @@ +// 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. +// +// The wire shapes mirror the versioned Skills API from +// azure-rest-api-specs (Foundry data-plane, Skills V1Preview): +// each skill has 1+ immutable versions, and skill content lives on a +// SkillVersion (via inline_content or uploaded files), not on the Skill +// resource itself. +package skill_api + +// Skill is the top-level skill resource. Content lives on a SkillVersion, +// so this struct intentionally does not carry description/instructions +// other than the human-readable label. +type Skill struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + CreatedAt int64 `json:"created_at"` + DefaultVersion string `json:"default_version"` + LatestVersion string `json:"latest_version"` +} + +// SkillVersion is an immutable version of a skill. The wire response does +// not echo back inline_content / files, only the version envelope. +type SkillVersion struct { + ID string `json:"id"` + SkillID string `json:"skill_id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + CreatedAt int64 `json:"created_at"` +} + +// SkillInlineContent is the JSON body that backs a skill version when +// the caller does not upload files. Matches the agentskills.io SKILL.md +// specification field-for-field. +type SkillInlineContent struct { + Description string `json:"description"` + Instructions string `json:"instructions"` + License string `json:"license,omitempty"` + Compatibility string `json:"compatibility,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + AllowedTools []string `json:"allowed_tools,omitempty"` +} + +// CreateVersionRequest is the JSON body for +// POST /skills/{name}/versions with application/json content type. +// When the skill does not yet exist, the service auto-creates it. +type CreateVersionRequest struct { + InlineContent *SkillInlineContent `json:"inline_content,omitempty"` + Default bool `json:"default,omitempty"` +} + +// UpdateSkillRequest is the JSON body for POST /skills/{name}. +// Only the default version pointer is mutable on the Skill itself. +type UpdateSkillRequest struct { + DefaultVersion string `json:"default_version"` +} + +// DeleteSkillResponse is returned by DELETE /skills/{name}. +type DeleteSkillResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Deleted bool `json:"deleted"` +} + +// DeleteSkillVersionResponse is returned by DELETE /skills/{name}/versions/{version}. +type DeleteSkillVersionResponse struct { + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Deleted bool `json:"deleted"` +} + +// PagedResult is the standard Foundry paged-list envelope used by +// GET /skills and GET /skills/{name}/versions. +type PagedResult[T any] struct { + Data []T `json:"data"` + FirstID string `json:"first_id,omitempty"` + LastID string `json:"last_id,omitempty"` + HasMore bool `json:"has_more"` +} + +// ListOptions configures a paged list request. Zero values use service defaults. +type ListOptions struct { + Limit int + Order string // "asc" | "desc" +} 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..662371f8ee2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package skill_api + +import ( + "bytes" + "fmt" + "strings" + + "go.yaml.in/yaml/v3" +) + +// SkillMdFileName is the canonical on-disk filename for a SKILL.md document. +const SkillMdFileName = "SKILL.md" + +// 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 version's `inline_content.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..52523f37f8a --- /dev/null +++ b/cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md_test.go @@ -0,0 +1,89 @@ +// 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) +} 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" )