diff --git a/cli/azd/docs/design/ai-skill-design-spec.md b/cli/azd/docs/design/ai-skill-design-spec.md new file mode 100644 index 00000000000..d3eeff4317e --- /dev/null +++ b/cli/azd/docs/design/ai-skill-design-spec.md @@ -0,0 +1,402 @@ + + +# Design Spec: `azd ai skill` Commands + +## 1. Summary + +This spec covers the Foundry Skill commands that ship in the new +`azure.ai.skills` extension: + +- `azd ai skill create ` (three mutually exclusive input modes). +- `azd ai skill update ` (in-place metadata or body update). +- `azd ai skill show ` (metadata only). +- `azd ai skill list` (paginated). +- `azd ai skill download ` (default extracts the server gzip; `--raw` keeps it). +- `azd ai skill delete ` (confirmation by default; `--force` to skip). + +These commands are pure CLI integration on top of the existing Foundry Skills +data plane. No new server work is required. + +A Skill on the Foundry platform is a reusable behavioral guideline that an +agent can attach at runtime. The Skill payload is either inline JSON +(description + Markdown instructions) or a packaged gzip tarball that bundles +`SKILL.md` plus any sibling assets the skill ships. + +## 2. Scope and Non-Goals + +In scope: + +- The six subcommands above and their flag surface. +- File-input handling for `--file`: `SKILL.md` and packaged archives on + `create`, `SKILL.md` on `update`. +- Gzip extraction behavior for `download`, including safe extraction guarantees. +- Reuse of the existing endpoint-resolution cascade and cross-cutting flags. + +Out of scope: + +- Any change to the Foundry Skill REST surface. +- Wiring a skill into an agent build. `download` writes files to disk; what + the developer does with them is their responsibility. +- Tracking `--file` as a manifest. The file is read at invocation time only. + +## 3. Extension Placement and Surface + +Skill commands ship in a new, standalone `azure.ai.skills` extension +(namespace `ai.skill`), separate from `azure.ai.agents`. Commands surface as +`azd ai skill `. Publishing the extension adds one new entry to +`registry.json`; no changes to `azure.ai.agents` are required. + +Internally, files land under +`cli/azd/extensions/azure.ai.skills/internal/cmd/skill_*.go`. A typed client +lives at `cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/`. The +extension owns no persistent state of its own; project context resolution is +covered in §4. + +## 4. Endpoint Resolution and Cross-Cutting Flags + +All skill commands resolve the Foundry project endpoint via the standard +5-level cascade already implemented for the extension: + +1. `-p` / `--project-endpoint` flag on the invoked command. +2. Active azd env value (`AZURE_AI_PROJECT_ENDPOINT`). +3. Global config under `extensions.ai-skills.project.context.endpoint` + in `~/.azd/config.json`, falling back to + `extensions.ai-agents.project.context.endpoint` when the skills-owned key + is unset (so users who configured the endpoint via `azure.ai.agents` are + not forced to re-run `set`). +4. Host environment variable `FOUNDRY_PROJECT_ENDPOINT`. +5. Structured error with an actionable suggestion. + +Each skill command accepts the cross-cutting flag set: `-p`, `--output`, +`--no-prompt`, and `--debug`. Resource-returning commands default to `json` +and allow `table` as an opt-in view. Verb-specific flags layer on top. + +## 5. Data-Plane Surface + +| Verb | HTTP method | Path | Notes | +|------|-------------|------|-------| +| Create (inline / parsed) | POST | `/skills` | JSON body | +| Create (package) | POST | `/skills:import` | `application/gzip` body | +| Show | GET | `/skills/{name}` | Metadata only | +| Update (inline / parsed) | POST | `/skills/{name}` | JSON body | +| List | GET | `/skills` | Paginated; supports `top`, `orderby`, `skip`, etc. | +| Delete | DELETE | `/skills/{name}` | | +| Download | GET | `/skills/{name}:download` | Returns `application/gzip` | + +Auth uses the same bearer-token policy the agent client uses +(scope `https://ai.azure.com/.default`). The User-Agent header carries the +extension version, consistent with the rest of the AI extension surface. + +## 6. Command Behavior + +### 6.1 `azd ai skill create ` + +Three mutually exclusive input modes. The CLI rejects (does not silently +merge) any combination that supplies more than one mode. + +| Mode | Flags | Wire format | +|------|-------|-------------| +| Inline | `--description --instructions ` | `POST /skills` (JSON) | +| File: `SKILL.md` | `--file ./path/to/SKILL.md` | `POST /skills` (JSON, CLI parses YAML front matter + body) | +| File: gzip package | `--file ./path/to/skill.tar.gz` | `POST /skills:import` (`application/gzip`) | + +Flag and argument shape: + +| Flag | Type | Required | Notes | +|------|------|----------|-------| +| `` | positional | yes | Validated per §6.7. | +| `--description` | string | inline mode only | Plain text; max length matches server limit. | +| `--instructions` | string | inline mode only | Markdown body. | +| `--file` | path | file modes only | Mode picked from extension (`.md`, `.tar.gz`, or `.tgz`). Must exist and be readable; validated before any network call. | +| `--force` | bool | no | When the name already exists, replace it (delete-then-create). | +| `-p`, `--output`, `--no-prompt`, `--debug` | | no | Cross-cutting (§4). | + +Mode selection logic: + +1. If both `--file` and (`--description` or `--instructions`) are supplied, + exit non-zero with a validation error pointing at the conflicting flags. +2. If only `--file` is supplied, branch on extension: + - `.md`: parse as `SKILL.md`, build the JSON body locally, send to + `POST /skills`. + - `.tar.gz` / `.tgz`: stream the file bytes to `POST /skills:import` with + `Content-Type: application/gzip`. The CLI does not inspect the contents of + the archive before upload, so server-side validation owns the package contents. + - Any other extension is rejected with a validation error before any + network call. +3. If only inline flags are supplied, both `--description` and `--instructions` + are required. Missing either is a validation error. +4. If neither mode is supplied: + - With prompting enabled (TTY, `--no-prompt` not set): prompt for description + and instructions interactively. + - With `--no-prompt` set: exit non-zero with a structured error listing the + missing inputs. + +`SKILL.md` parsing: + +- The file must start with a YAML front matter block delimited by `---`. +- The CLI extracts `name`, `description`, and any other metadata fields the + Skills service recognizes. The remaining Markdown body becomes + `instructions`. +- If the front matter `name` is present and differs from the positional + ``, the positional wins and a one-line warning is printed to stderr. + Suppressed under `--no-prompt` or `--output json`. +- If the front matter is missing or unparsable, the command fails with a + validation error that points to the problematic line. + +`--force` semantics on create: + +- Without `--force`: a name collision returns a `409`-shaped structured error + with the suggestion to use `--force` or `update`. +- With `--force`: the CLI issues a `DELETE` followed by the appropriate + create call. The two requests are not transactional; if the delete succeeds + but the create fails, the original skill is gone and the error message says so. + +### 6.2 `azd ai skill update ` + +Flags: + +| Flag | Type | Notes | +|------|------|-------| +| `` | positional, required | The skill on the server. | +| `--description` | string | Optional. | +| `--instructions` | string | Optional. | +| `--file` | path | Mutually exclusive with `--description` / `--instructions`. | + +Behavior: + +1. The CLI GETs the current skill, merges omitted fields locally, then POSTs + the merged payload to `/skills/{name}`. +2. `--file` accepts `.md` only. The CLI parses front matter and body into the + JSON update payload. `.tar.gz` / `.tgz` on `update` is rejected with a + validation error suggesting `create --force`. +3. If no field flags and no `--file` are supplied, the command exits non-zero + with a validation error. +4. If the skill does not exist, the initial GET returns 404 and the command + fails with a "not found" error. + +`--force` is not available on `update`; the target skill must already exist. + +### 6.3 `azd ai skill show ` + +Returns metadata only. The Skill body lives behind `download`; `show` keeps +its output focused so that humans and coding agents can scan it without +streaming kilobytes of Markdown to the terminal. + +JSON output is the verbatim metadata returned by the service. Table output +prints a fixed key/value layout (`Name`, `Description`, `Created`, `Updated`, +plus any additional first-class fields the service exposes). + +### 6.4 `azd ai skill list` + +Flags: + +| Flag | Type | Notes | +|------|------|-------| +| `--top` | int | Maximum number of results to return. | +| `--orderby` | string | Forwarded to the service. | +| Cross-cutting | | See §4. | + +Behavior: + +1. Without `--top`, the CLI iterates all pages transparently into one flat list. + With `--top`, it fetches up to that many items and stops. +2. JSON output emits a top-level array (pagination hidden). Table output + prints columns `NAME`, `DESCRIPTION`, `UPDATED` (long descriptions truncated). +3. Pagination errors mid-iteration surface as a normal error; partial results + are not printed. + +### 6.5 `azd ai skill download ` + +Flags: + +| Flag | Type | Default | Notes | +|------|------|---------|-------| +| `` | positional, required | | | +| `--output-dir` | path | `./.agents/skills//` (resolved against the current working directory) | Where to write the extracted files (or the raw archive). | +| `--raw` | bool | `false` | Skip extraction; write the gzip archive as-is. | +| `--force` | bool | `false` | Overwrite existing files in the output directory. | + +Default behavior (no `--raw`): + +1. Stream the gzip response body to a temp file inside the OS temp directory. +2. Verify the response Content-Type starts with `application/gzip` (or the + equivalent server-confirmed content type). Otherwise, fail with a clear + error. +3. Walk the tar entries once to validate the complete archive and build an + extraction plan before writing to `--output-dir`. For each entry: + - Reject absolute paths and any path containing `..` components (zip-slip guard). + - Reject symlinks, hard links, and entries other than regular files and directories. + - Without `--force`, refuse to overwrite existing files (exit non-zero, + listing the colliding path). +4. Extract into a staging directory first; copy to `--output-dir` only after + full validation. A failed extraction must not write any files to `--output-dir`. +5. Files use the user's umask. Executable bits from the tar are dropped. +6. Decompression is bounded at 10,000 entries / 512 MB uncompressed. + Exceeding either limit aborts with `CodeSkillArchiveUnsafe`. + +`--raw` behavior: + +1. The CLI ensures `--output-dir` exists. +2. The gzip archive is written to `/.tar.gz`. Without + `--force`, an existing file of the same name causes a clean error. + +`--output-dir` resolution: + +- Relative paths resolve against cwd, not the azd project root. +- Default `./.agents/skills//` groups downloads under one directory. + Projects can add `.agents/` to `.gitignore`. + +### 6.6 `azd ai skill delete ` + +Flags: + +| Flag | Default | Notes | +|------|---------|-------| +| `--force` | `false` | Skip the confirmation prompt. | +| Cross-cutting | | See §4. | + +Behavior: + +1. Interactive mode: prompt `Delete skill ""? [y/N]:`. Declining returns + exit 0 (not `exterrors.Cancelled`). JSON mode emits the cancelled shape from §7. +2. `--no-prompt` without `--force`: refuse to delete (exit non-zero). +3. `--force`: skip the prompt regardless of TTY state. +4. Non-existent skill: 404 surfaced as a "not found" error. + +### 6.7 Skill Name Validation + +The CLI validates `` on every command that takes it: + +- Non-empty after trim. +- Matches the service-documented regex, or the fallback + `^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$` (1–63 characters, + alphanumeric with internal hyphens only). This aligns with the agent name + pattern in `azure.ai.agents` (`agent_yaml/parse.go`) so users see one rule + across resource kinds. The service makes the final decision. + +## 7. Output Contracts + +JSON shapes for all commands with `--output json` are part of the public +contract and must not change without a deprecation step. The shapes: + +- `create`: passthrough of the created `Skill` resource returned by the service. +- `update`: passthrough of the updated `Skill` resource returned by the service. +- `show`: passthrough of the service `Skill` resource. +- `list`: a top-level array of `Skill` resources. Pagination is hidden. +- `download`: `{ "skill": "", "outputDir": "", + "files": ["", ...], "raw": false }` on extraction; + `{ "skill": "", "outputDir": "", + "archive": "", "raw": true }` with `--raw`. +- `delete`: `{ "deleted": true, "name": "" }`. Cancelled (interactive + `n`) emits `{ "deleted": false, "cancelled": true, "name": "" }` + with exit code `0`. + +Errors use `exterrors` so the azd host renders them consistently with the +rest of the extension. Reuse existing codes such as `CodeConflictingArguments` +and `CodeInvalidParameter` for generic flag problems. New skill-specific error +codes added by this work: + +- `CodeInvalidSkillName` +- `CodeInvalidSkillFile` (front matter parse failure, unsupported extension) +- `CodeSkillArchiveUnsafe` (rejected tar entry on extraction) +- `CodeSkillOutputCollision` (file exists, `--force` not set) + +These plug into the existing `exterrors.Validation` factory. + +## 8. Test Plan + +Unit tests (no network): + +- Mode-selection matrix for `create`: every combination of inline flags and + `--file` produces the expected outcome (success, conflict error, missing + field error). +- `SKILL.md` parsing: valid front matter, missing front matter, invalid + YAML, name-mismatch warning suppression under `--no-prompt` and + `--output json`. +- Pagination flattening for `list`: multi-page server response collapses into + one array in JSON output and one continuous table in table output. +- `download` extraction safety: tar entries containing `..`, absolute paths, + symlinks, and oversized entries are all rejected and leave the output + directory untouched. +- `download --raw` writes exactly the bytes returned by the service. +- `delete` cancellation paths: interactive `n`, `--no-prompt` without + `--force`, and `--force` all produce the documented exit codes and output; + interactive `n` is a successful no-op, not an `exterrors.Cancelled` error. +- Endpoint resolver: skill commands inherit the resolver and produce the + documented suggestion when nothing resolves. + +End-to-end (against a recorded fixture): + +- `create --file ./SKILL.md` then `show` then `download` round-trip; assert + the downloaded `SKILL.md` content equals the uploaded content. +- `create --file ./skill.tar.gz` then `download` (extract) then `download + --raw`; assert both forms are consistent. +- `list` with more than one page of results. + +## 9. Impact on Existing Commands + +No existing command's behavior, flags, or resolver logic changes. The new +`azure.ai.skills` extension introduces its own `skill_api` package and does +not modify `azure.ai.agents`. The `AgentCardSkill` type in `azure.ai.agents` +(agent-card capability) is unrelated, lives in a different module, and has no +symbol conflict with the new client. + +## 10. Telemetry + +One event per command, reusing the extension's existing telemetry surface: + +- `azd.ai.skill.create` (`mode`: `inline` / `file-md` / `file-gzip`, + `forced`: bool). +- `azd.ai.skill.update` (`mode`: `inline` / `file-md`, + `fieldsTouched`: count). Gzip updates are rejected at the flag layer + (§6.2), so `file-gzip` is not a valid emitted value here. +- `azd.ai.skill.show` / `azd.ai.skill.list` (`resolvedSource`). +- `azd.ai.skill.download` (`raw`: bool, `forced`: bool, `extractedFileCount`). +- `azd.ai.skill.delete` (`forced`: bool, `cancelled`: bool). + +No skill names, no descriptions, no instructions, no file paths, no project +endpoint values are emitted. Project-endpoint hostnames, if needed for +debugging, are hashed. + +## 11. Security Considerations + +- **Tar extraction.** Full rejection rules and decompression limits in §6.5. +- **File write permissions.** User's umask; executable bits dropped. +- **Auth.** Reuses the existing bearer-token pipeline; no new secret writes. +- **Argument echoing.** The Azure SDK Go logging policy supports + `IncludeBody: true` under `--debug`, which writes request and response + bodies to the extension's debug log. The new `skill_api` client opts out + of body logging until the extension ships a sanitizer that redacts + user-authored `description` and `instructions` fields (and any other + free-text fields the skill service exposes). Until that sanitizer is in + place, skill request bodies are never written to + `azd-ai-skills-.log`. + +## 12. Reference: Command Summary + +```bash +# Create (three mutually exclusive modes) +azd ai skill create --description "..." --instructions "..." \ + [-p ] [--output table|json] [--no-prompt] [--debug] [--force] +azd ai skill create --file ./SKILL.md \ + [-p ] [--output table|json] [--no-prompt] [--debug] [--force] +azd ai skill create --file ./skill.tar.gz \ + [-p ] [--output table|json] [--no-prompt] [--debug] [--force] + +# Update (any subset of fields; --file accepts .md only, mutually exclusive with inline flags) +azd ai skill update [--description "..."] [--instructions "..."] \ + [--file ] [-p ] [--output table|json] [--no-prompt] [--debug] + +# Show / list / delete +azd ai skill show [-p ] [--output table|json] [--no-prompt] [--debug] +azd ai skill list [--top N] [--orderby ] \ + [-p ] [--output table|json] [--no-prompt] [--debug] +azd ai skill delete [--force] [-p ] [--output table|json] \ + [--no-prompt] [--debug] + +# Download (default extracts; --raw keeps the gzip archive) +azd ai skill download [--output-dir ] [--raw] [--force] \ + [-p ] [--output table|json] [--no-prompt] [--debug] +``` + +Resolution cascade: see §4.