feat(skills): add azd ai skill command group#8224
Conversation
Introduces a new standalone `azure.ai.skills` extension that exposes `azd ai skill create | update | show | list | download | delete` for managing Foundry Skills from any directory. Implements the design in PR #8204 / issue #8142: - New extension under `cli/azd/extensions/azure.ai.skills` (namespace `ai.skill`, id `azure.ai.skills`, version `0.0.1-preview`). - Typed Foundry Skills data-plane client in `internal/pkg/skill_api` with SKILL.md YAML front matter parser and two-phase safe gzip-tar extractor (zip-slip guard, no symlinks / hard links, 10,000-entry / 512 MB caps, staging + atomic copy, `--force` for collisions). - Three mutually exclusive `create` modes: inline (`--description` + `--instructions`), `--file SKILL.md` (parsed locally), and `--file *.tar.gz` / `*.tgz` (streamed as `application/gzip` to `POST /skills:import`). `--force` does delete-then-create. - `update` accepts inline flags or `--file *.md` only; gzip is rejected with a structured suggestion to use `create --force`. - `download` extracts by default into `./.agents/skills/<name>/` and supports `--raw` to write the unmodified archive. - `delete` confirms by default; `--force` skips, and `--no-prompt` without `--force` errors. Interactive `n` returns exit 0. - Endpoint resolution shares the 5-level cascade with `azure.ai.agents` via a read-only fallback to `extensions.ai-agents.project.context.endpoint` when the new `extensions.ai-skills.project.context.endpoint` key is unset. - Bearer scope `https://ai.azure.com/.default`, `Foundry-Features: Skills=V1Preview` header, API version `2025-11-15-preview`. Debug log file `azd-ai-skills-<date>.log`. `IncludeBody` opt-out on the HTTP pipeline until a sanitizer for `description` / `instructions` lands. - Skill name regex aligned with `agent_yaml.ValidateAgentName`: `^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\$`. Closes #8142. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
azd ai skill command group (preview)azd ai skill command group
77c69a9 to
28ca3b2
Compare
…SafeExtract P1 - Endpoint validation (Finding 1): - Add validateEndpoint() to endpoint.go that rejects non-https URLs and empty hosts before sending Azure bearer tokens to any resolved endpoint. - Called at all five resolution levels (flag, azd env, global config x2, host env var) so misconfigured endpoints fail with a clear message rather than an opaque SDK error. - Host-suffix validation intentionally deferred to HTTP layer per design. - Add TestResolveProjectEndpoint_InvalidScheme covering http/ftp/no-scheme/empty-host cases. P1 - Symlink escape in SafeExtract (Finding 2): - archive.go copy phase was vulnerable: if OutputDir contained a pre-existing symlink to a directory outside OutputDir, MkdirAll + copyFile would follow it silently, writing extracted files outside the intended destination. - Fix: resolve OutputDir with EvalSymlinks once; before each copyFile resolve the destination directory and assert it is under the real output dir. - Add isUnder() helper for path containment check. - Add TestSafeExtract_RejectsSymlinkParentEscape (skipped on Windows).
28ca3b2 to
4757f72
Compare
The Skills data-plane lives under api-version=v1; the preview opt-in is communicated via the Foundry-Features: Skills=V1Preview header. Using 2025-11-15-preview as the api-version returns 400 UnsupportedApiVersion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Skills created from inline JSON or SKILL.md have no downloadable package; the server returns an opaque 404 'does not have an associated package'. Pre-flight Get the skill so the download command can return a structured CodeSkillNoPackage validation error with an actionable suggestion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
256aec8 to
c957348
Compare
The live Foundry Skills service implements POST /skills:import and
GET /skills/{name}:download with application/zip, not application/gzip
as the upstream TypeSpec declares. Verified via 415 Unsupported Media
Type on gzip uploads. Public docs confirm:
https://learn.microsoft.com/azure/foundry/agents/how-to/tools/skills
Changes:
- skill_api: replace archive/tar+compress/gzip with archive/zip
- skill_api: Download now returns []byte (archive/zip needs io.ReaderAt)
- skill_api: rename ContentTypeGzip -> ContentTypeZip, ErrInvalidGzip ->
ErrInvalidZip
- cmd: accept '.zip' for --file; reject '.tar.gz'/'.tgz'
- cmd: writeRaw now writes '<name>.zip'
- tests: rewrite archive_test.go and archive_peek_test.go for ZIP
- docs (AGENTS.md, README.md, CHANGELOG.md): s/gzip,tar.gz/zip/g
The design spec (PR #8204) will need a follow-up to match.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Foundry Skills surface is asymmetric on archive format:
- POST /skills:import requires application/zip (gzip yields 415)
- GET /skills/{name}:download returns application/gzip
Make SafeExtract sniff magic bytes (PK or 1f 8b) and dispatch to either
the zip or the gzip+tar handler. Download() now accepts both
Content-Type values. The raw download filename uses .zip or .tar.gz
based on the detected format.
Also:
- Add DetectArchiveFormat() public helper for callers that need the
format (e.g. the --raw filename picker).
- Add gitignore for local test artifacts (zips, tar.gz, debug logs).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
📋 Prioritization NoteThanks for the contribution! The linked issue isn't in the current milestone yet. |
Remove restating-the-code comments, narrative section headers, and verbose struct-field docs. Keep design-rationale notes (TypeSpec/docs mismatch on archive format, --force destructive sequence, defensive path validation) and important safety annotations. Net: -396 lines of comments, no behavior change.
There was a problem hiding this comment.
Pull request overview
Adds a new first-party azure.ai.skills azd extension that exposes the standalone azd ai skill command group for managing Foundry Skills from the terminal, including endpoint resolution, typed REST client calls, SKILL.md parsing, and safe package download/extraction.
Changes:
- Introduces the
azd ai skillCobra command tree (create|update|show|list|download|delete) with structured errors and output formatting. - Adds a typed Skills data-plane REST client plus SKILL.md parsing and archive handling helpers (including “safe extract”).
- Adds extension packaging/build assets (extension.yaml, versioning, build scripts, lint config, and docs).
Reviewed changes
Copilot reviewed 42 out of 43 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| cli/azd/extensions/azure.ai.skills/version.txt | Initial extension version. |
| cli/azd/extensions/azure.ai.skills/README.md | Extension overview, command surface, endpoint resolution, dev workflow. |
| cli/azd/extensions/azure.ai.skills/main.go | Extension binary entry point. |
| cli/azd/extensions/azure.ai.skills/internal/version/version.go | Build-time version variables. |
| cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md.go | SKILL.md YAML-front-matter + body parser. |
| cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/skill_md_test.go | Unit tests for SKILL.md parsing. |
| cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/models.go | Public/CLI JSON models + wire conversions. |
| cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client.go | Typed REST client for Skills (create/update/get/list/download/delete). |
| cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/client_test.go | Client request/response behavior tests via httptest server. |
| cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go | Archive format detection + safe extraction implementation. |
| cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_test.go | Tests for safe extraction and validation limits. |
| cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek.go | ZIP SKILL.md name peek helper for --force safety checks. |
| cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive_peek_test.go | Tests for archive name peek behavior. |
| cli/azd/extensions/azure.ai.skills/internal/exterrors/errors.go | Structured error helpers and Azure SDK error conversion. |
| cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go | Extension-specific error codes and operation names. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/version.go | version subcommand. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate.go | Skill name validation. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go | Tests for name validation + flag-mode validation helpers. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go | skill update implementation (GET-merge-POST). |
| cli/azd/extensions/azure.ai.skills/internal/cmd/skill_show.go | skill show implementation. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/skill_list.go | skill list implementation (paged flattening). |
| cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go | skill download implementation (raw vs extracted). |
| cli/azd/extensions/azure.ai.skills/internal/cmd/skill_delete.go | skill delete implementation with confirmation behavior. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go | skill create implementation (inline, SKILL.md, ZIP package). |
| cli/azd/extensions/azure.ai.skills/internal/cmd/skill_context.go | Endpoint + credential + client construction. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/root.go | Root command wiring, persistent flags, and host commands. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/output.go | JSON/table output helpers for skills. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/output_test.go | Tests for table output rendering. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go | Endpoint resolution cascade + URL validation. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint_test.go | Tests for endpoint resolution and validation. |
| cli/azd/extensions/azure.ai.skills/internal/cmd/debug.go | Debug logging setup for stdlib log + Azure SDK logger. |
| cli/azd/extensions/azure.ai.skills/go.sum | Extension module dependency lockfile. |
| cli/azd/extensions/azure.ai.skills/go.mod | Extension module definition and dependencies. |
| cli/azd/extensions/azure.ai.skills/extension.yaml | Extension metadata and examples for azd. |
| cli/azd/extensions/azure.ai.skills/cspell.yaml | Spellcheck configuration for extension-specific terms. |
| cli/azd/extensions/azure.ai.skills/ci-test.ps1 | CI test runner for the extension. |
| cli/azd/extensions/azure.ai.skills/ci-build.ps1 | CI build script for the extension binary. |
| cli/azd/extensions/azure.ai.skills/CHANGELOG.md | Initial release notes for the extension. |
| cli/azd/extensions/azure.ai.skills/build.sh | Local multi-platform build helper (bash). |
| cli/azd/extensions/azure.ai.skills/build.ps1 | Local multi-platform build helper (PowerShell). |
| cli/azd/extensions/azure.ai.skills/AGENTS.md | Extension-specific contributor/agent guidance. |
| cli/azd/extensions/azure.ai.skills/.golangci.yaml | Extension-local golangci-lint configuration. |
| cli/azd/extensions/azure.ai.skills/.gitignore | Ignores local build/test artifacts for the extension. |
Comments suppressed due to low confidence (3)
cli/azd/extensions/azure.ai.skills/internal/pkg/skill_api/archive.go:389
copyFilewrites todstviaos.OpenFile(..., O_TRUNC, ...), which will follow a symlink if one exists atdst. For a “safe extraction” helper, this should defensively refuse to write through symlinks (and other non-regular file types), including in--forcemode.
cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go:287- The command help text says
downloadretrieves a “ZIP package” and--rawwrites the “ZIP archive as-is”, but the implementation explicitly supports ZIP or gzip-tar downloads (and even chooses the extension based on magic bytes). Update the Long/flag help strings to refer to a generic “archive/package” to avoid contradicting actual behavior.
cli/azd/extensions/azure.ai.skills/internal/cmd/endpoint.go:133 validateEndpointreturns plainfmt.Errorferrors for invalid user input. Since endpoint validation errors are user-fixable and should produce stable telemetry, consider returning a structuredexterrors.Validation(e.g.,CodeInvalidParameter) instead of an untyped error so the azd host categorizes and renders it consistently.
jongio
left a comment
There was a problem hiding this comment.
Clean extension structure - follows the azure.ai.agents patterns well, archive security is solid (zip-slip guard, symlink rejection, staging directory, size caps), and the test coverage across client/archive/parser/validation is thorough.
One nit on Go 1.26 style below. The Copilot bot's comment about the unused bufio.Scanner in findFrontMatterBounds is valid too - that variable gets .Buffer() called so it compiles, but it's never actually used for scanning.
…t symlinked copy destinations, use errors.AsType
trangevi
left a comment
There was a problem hiding this comment.
Please add tests where possible
- skill_download: drop 're-create with create --force' hint from the no-package error; users downloading the skill don't have it locally to re-create. - skill_update: in 'update' long help, route ZIP package updates to 'create --force' and call out that skills are not versioned so it's a destructive (delete-then-recreate) path.
83d3aa4 to
3d7bb9a
Compare
Resolve conflicts in cli/azd/extensions/azure.ai.skills/. - Keep PR's full `azd ai skill` command-group implementation, build scripts, CHANGELOG and README. - Adopt `tags: [ai, skill]` from main's extension.yaml scaffold. - Drop main's scaffold context.go/metadata.go; PR wires metadata via `azdext.NewMetadataCommand` in root.go and has no `context` subcommand. - Bump `github.com/azure/azure-dev/cli/azd` to v1.25.0 to match main and re-tidy go.sum.
2357b7b to
1b7af85
Compare
Restore the original context.go and metadata.go files that were deleted, to respect the existing template structure. Rename skill_context.go to skill_client.go to avoid naming overlap with the restored context command.
…ght, exterrors Add unit tests for previously uncovered pure-logic helpers: - cmd/skill_download_test.go: archiveExtension format mapping, classifyExtractError sentinel-to-LocalError translation (unsafe/limit/collision/invalid + pass-through), and downloadAction.Run name validation short-circuit. - cmd/skill_delete_test.go: deleteAction.Run rejects invalid names early and surfaces CodeMissingForceFlag when --no-prompt is set without --force. - cmd/skill_validate_test.go: extend TestIsNotFound to cover real *azcore.ResponseError (404 vs 500, wrapped). - exterrors/errors_test.go: validation/dependency/auth factories and ServiceFromAzure (wraps *azcore.ResponseError, pass-through, nil); package now at 100% coverage.
go fix -diff was reporting two conflicting suggestions on the same loop (slicescontains and stringsseq), causing the analyzer to exit non-zero. With bash -e in the lint-go workflow that non-zero exit short-circuited the script, breaking CI even though there was no real diff to print. Replacing the manual loop with slices.Contains resolves the conflict so go fix -diff exits 0.
wbreza
left a comment
There was a problem hiding this comment.
Thanks for the scope and care on this PR, @huimiu — there's a lot of solid work here: thorough archive safety guards, well-structured exterrors, good test patterns where they exist, and clean alignment with the azure.ai.agents extension. The notes below are intended to be additive to the existing feedback from @trangevi, @therealjohn, @jongio, and Copilot — I tried hard not to duplicate anything they already raised. Where I've flagged concerns, I've tried to explain the why and point at references so the reasoning is portable to similar code later.
🔴 HIGH — Two functional bugs worth fixing before merge
1. update silently drops the Instructions field
internal/cmd/skill_update.go — in the GET-merge-POST flow, UpdateRequest is initialized with Description and Metadata from current, but Instructions is not carried forward. If a user runs azd ai skill update <name> --description "new" without providing --instructions, the merge writes back an empty Instructions field, silently wiping the existing body.
req := skill_api.UpdateRequest{
Description: current.Description,
Metadata: current.Metadata,
// Instructions: current.Instructions, // <-- missing
}This is the classic GET-modify-PUT trap when a partial-update API is layered on top of a full-replace endpoint. Worth adding a regression test in a new skill_update_test.go that exercises "update only description, verify instructions preserved." See AGENTS.md → Error Handling / partial updates for the established pattern.
2. PeekArchiveSkillName swallows errors → create --force safety guard can be bypassed
internal/pkg/skill_api/archive_peek.go — the function returns ("", nil) on three error paths (entry.Open(), io.ReadAll(), ParseSkillMd()). The create --force flow relies on this name-match check to confirm "the archive I'm about to delete-then-recreate actually claims to be the skill the user named." A malformed archive currently returns "no name claim" (✓ from the caller's POV), so --force proceeds and deletes the existing skill anyway.
There are two reasonable fixes:
- Strict (safer default): propagate the error so the caller refuses the destructive op until the archive is readable.
- Lenient with telemetry: log a clear warning so the user sees that the safety check was skipped, and require an extra flag (e.g.
--allow-unverified) before proceeding.
Either way, please don't let an unreadable archive look identical to "intentionally unnamed archive" — those should be distinguishable outcomes.
🟠 MEDIUM — Security & resource hardening on the extraction path
(All below are new findings — not duplicates of Copilot's symlink/copyFile or verifyPackageNameMatches OOM comments.)
- UNC path in zip-slip guard —
archive.goValidateEntryName: after normalizing backslashes to forward slashes, the function checks for Windows drive letters (C:) but not UNC roots (\\server\share→//server/share). On Windows this can extract outsideOutputDir. Suggest adding astrings.HasPrefix(slashed, "//")rejection after the drive-letter check, plus a test alongside the existingC:\Windows\Tempcase. - Tar entry-count off-by-one —
archive.gostageFromTarGz:entryCount++thenif entryCount > maxEntriesacceptsmaxEntries + 1entries before rejecting. Zip side useslen(zr.File) > maxEntrieson the full slice (correct). Change tar to>=or move increment after the check for symmetry. - File-handle leak in
PeekArchiveSkillName—archive_peek.go: whenio.ReadAllorParseSkillMdfails, the function returns without closing the entry reader. Pair the open withdefer rc.Close()immediately after a successfulentry.Open(). - Tar
hdr.Linknamenot asserted empty —archive.gostageFromTarGz: thedefault:branch in the typeflag switch catchesTypeSymlink/TypeLink/etc., which is correct, but an explicitif hdr.Linkname != "" { return error }for the regular-file branch makes the intent obvious and protects against future tar variants that smuggle link intent in extension headers (PAX/GNU). - Partial output dir not cleaned on extraction failure —
skill_download.go: whenSafeExtractfails mid-stream, the user-visibleOutputDirmay contain partially-extracted files. Either (a) extract into a sibling temp dir and atomic-rename on success, or (b)os.RemoveAll(opts.OutputDir)on error when the directory was created by this invocation. This also helps with--forcere-runs.
🟠 MEDIUM — Test coverage on the two largest command files
skill_create.go (~409 LOC, 3 mutually-exclusive modes + --force delete-then-create sequence + name-match guard) and skill_update.go (~181 LOC, GET-merge-POST + flag validation + gzip rejection) currently have no _test.go companions. Given how much of the user-facing surface area lives in these two files — and that bug #1 above sits squarely inside one of them — these are the highest-leverage tests to add. The existing archive_test.go is a great template for the table-driven style.
Suggested table-driven cases at minimum:
skill_create_test.go: each mode (inline /.md/.zip), conflicting flag combos,--forcewith name match,--forcewith name mismatch,--no-promptwithout required flags.skill_update_test.go: merge preserves all current fields when only one is changed,.ziprejection error message, conflicting flags.
🟡 MEDIUM — PR description vs. implementation alignment
The PR description and CHANGELOG say create accepts a "gzip package", but selectCreateMode() only matches .zip, and upload Content-Type is hardcoded to application/zip. Downloads correctly auto-detect both formats. Either:
- update the PR description / CHANGELOG / README to say "ZIP package on upload; ZIP or gzip-tar on download", or
- extend
selectCreateMode()to accept.tar.gz/.tgzand set the right content type.
This pairs with @copilot's earlier note on models.go package doc and README --file ./skill.zip.
🟢 LOW / NIT — Suggestions, not blockers
- Cobra
MarkFlagsMutuallyExclusive—skill_create.gohand-rolls theinlineProvided && fileProvidedcheck. Cobra's built-in is more durable as flags accrete:cmd.MarkFlagsMutuallyExclusive("description", "file")(and similar forinstructions/file). Worth a tiny follow-up. --forcesemantics diverge across commands —create --force= destructive replace,delete --force= skip confirm,download --force= overwrite. All three are reasonable in isolation, but consider whethercreate --forcedeserves a more explicit name (--replace/--recreate) given that it deletes user data. At minimum, the create flag's help text could lead with "destroys the existing skill."updateerror message — when rejecting.zipon update, the message points atcreate --forcebut doesn't mention that the path is destructive (skills aren't versioned). Adding "(this deletes the existing skill first)" to the suggestion would close the loop with @trangevi's earlier comment about destructive ops.- Endpoint cascade as shared helper — the 5-level cascade (
flag → env → ai-skills config → ai-agents config → host env) is going to want to live incli/azd/pkg/azdext/the moment a third AI extension shows up. Not for this PR, but worth filing a follow-up so it doesn't get copy-pasted. exterrorsduplication withazure.ai.agents—Validation/Dependency/Auth/ServiceFromAzurefactories are near-identical across the two extensions. Same recommendation as above: candidate forcli/azd/pkg/azdext/exterrorsin a follow-up so a future error-handling tweak doesn't need parallel PRs.
Net: two real correctness bugs (#1 and #2) and a couple of Windows-path / resource-cleanup hardening items are the things I'd most like to see land before this merges. Everything else is polish or follow-up. Really nice extension overall — happy to dig into any of these if helpful.
Co-authored-by: wbreza <6540159+wbreza@users.noreply.github.com>
Addressed in the latest commit: HIGH bugs fixed:
Security hardening:
Tests added:
Items I've deferred as follow-ups (as suggested): partial-OutputDir cleanup on failure, endpoint cascade extraction to
Added tests in the latest commit:
|
wbreza
left a comment
There was a problem hiding this comment.
Great turnaround, @huimiu — verified ad851b7d against my prior findings. All HIGH issues fixed correctly and most MEDIUMs landed cleanly. Approving from my side.
Verification
| Prior finding | Status |
|---|---|
🔴 update drops Instructions field |
✅ current.Instructions now carried forward; skill_update_test.go covers the preservation case |
🔴 PeekArchiveSkillName swallows errors → --force bypass |
✅ Errors propagate; TestVerifyPackageNameMatches_MalformedSkillMd confirms --force is blocked when the archive is unreadable |
| 🟠 UNC path bypass in zip-slip guard | ✅ Explicit // prefix rejection added; tests for both //server/share and \\server\share |
| 🟠 Tar entry-count off-by-one | ✅ Check before increment; now symmetric with the zip path |
🟠 File-handle leak in PeekArchiveSkillName |
✅ defer rc.Close() immediately after open |
🟠 Tar hdr.Linkname not asserted empty |
✅ Explicit check on the regular-file branch + TestSafeExtract_TarGzRejectsEntryWithLinkname |
| 🟠 Missing tests for create/update | ✅ Both skill_create_test.go and skill_update_test.go added — table-driven, cover conflicting flags, name mismatch under --force, and the field-preservation case |
| 🟡 Gzip-vs-ZIP doc inconsistency | ✅ models.go package doc and archive_peek.go now state the ZIP-upload / ZIP-or-gzip-download asymmetry explicitly |
@therealjohn's --top / --orderby removal |
✅ Both flags gone from skill_list.go |
One deferred item (non-blocking)
The "partial OutputDir not cleaned on extraction failure" MEDIUM in skill_download.go isn't part of this commit. Fine to defer — the staging directory is cleaned up, and the user can re-run with --force to recover. Worth a follow-up issue when you have the bandwidth; an atomic temp-dir-then-rename on the publish phase would close it cleanly.
No new issues introduced
Skimmed all 9 changed files. No regressions, no over-corrections, no broken callers. Error propagation reads correctly throughout.
LGTM. ✅
Address PR #8224 review feedback from @therealjohn: `azd ai skill download` previously errored when a skill had no uploaded archive (`!hasBlob`). The expected behavior is for `download` to always produce a file on disk for a single skill. When the skill has no archive blob, materialize a `SKILL.md` file into --output-dir from the metadata returned by `GET /skills/{name}` (name, description, metadata, instructions). The existing archive path is unchanged. `--raw` is rejected for blob-less skills since there is no archive to write. - Add `Instructions` field to the `Skill` model so it round-trips through the wire decode. - Add `MarshalSkillMd` with stable metadata ordering and a round-trip test. - Remove the now-unused `CodeSkillNoPackage` error code.
Address PR #8224 review feedback from @trangevi: the file contains the `skillContext` struct and `resolveSkillContext` helper, so `skill_context.go` is a more descriptive name than `skill_client.go`. The existing `context.go` template command is unaffected — Go only requires file names within a package to be unique, which they are.
Update the azure.ai.skills extension to the versioned Skills API
introduced in azure-rest-api-specs#43283.
API surface changes:
- Skill: { id, name, description, default_version, latest_version, created_at }
(no more inline instructions/metadata/has_blob on the skill itself).
- SkillVersion / SkillInlineContent: new types backing the new
POST /skills/{name}/versions create endpoint (JSON or multipart).
- Routes:
- create : POST /skills/{name}/versions (was POST /skills, POST /skills:import)
- update : POST /skills/{name}/versions for new default version,
POST /skills/{name} for repoint via --set-default-version
(was POST /skills/{name} with full payload)
- delete : DELETE /skills/{name} (response now includes id)
- list : GET /skills (envelope unchanged)
- show : GET /skills/{name} (envelope changed)
- download: GET /skills/{name}/content (was /skills/{name}:download);
new --version flag → /skills/{name}/versions/{version}/content
- Name validation: lowercase-only, ^[a-z0-9]([a-z0-9-]*[a-z0-9])?\$, max 64
(agentskills.io spec via SkillName scalar).
- Preview opt-in (Foundry-Features: Skills=V1Preview) and api-version=v1
remain unchanged.
CLI surface preserved: create / update / show / list / download / delete.
Inline + SKILL.md modes wrap content in inline_content JSON; ZIP create uses
multipart/form-data with a single files[] part. Update --file .zip is still
rejected with a pointer to create --force.
Drop the inline-skill → SKILL.md materialization fallback in download
(MarshalSkillMd) since the server always returns application/zip now.
- CHANGELOG: remove duplicated # Release History header block. - ci-build.ps1: read version.txt from the extension directory; the parent `cli/azd/extensions/version.txt` does not exist, so the default `Get-Content` call would fail when `-Version` is not passed explicitly. - skill_create: extend `--force` name-mismatch refusal to SKILL.md inputs. Previously only ZIP packages were peeked before the destructive delete; a typo with `--file SKILL.md --force` could delete an unrelated skill, then warn after the fact. Now both modes share a `verifyFileNameMatches` pre-check. - skill_api/client: cap `downloadContent` at 512 MiB on the wire to bound memory before extraction enforces its own uncompressed cap. Fast-fails on `Content-Length` and also bounds streaming reads via `io.LimitReader` so a malicious or runaway server cannot exhaust process memory. - Tests: cover SKILL.md `--force` mismatch, `--force` allow on no-name SKILL.md, inline mode bypass, and three new client-level cases (oversize Content-Length, oversize streaming body, acceptance at the limit).
wbreza
left a comment
There was a problem hiding this comment.
Re-review on top of the versioned-API refactor (commits since my approval at ad851b7: 7e185c9, e8abae7, c040787, 3463d4d).
The refactor reads cleanly — naming is consistent across client/cmd, the multipart shape matches the new POST /skills/{name}/versions contract, the --set-default-version repoint path is well-isolated from content upload, new client methods have unit tests, and the safe-extract guards survived intact. Nice work threading the new contract through create / update / download without bloating the surface area.
A handful of findings worth considering as follow-ups — none block this PR on my read, but H1 and H2 in particular are worth filing issues for even if you don't want to address them in this PR.
Higher-impact (worth a follow-up issue at minimum)
-
CreateVersionFromZipbuffers the full archive in memory —client.go(around the new multipart build). The pre-refactorCreatePackagetook anio.ReadSeekerand streamed viahttpReq.SetBody(streaming(archive), …). The new path doesio.Copy(part, archive)into abytes.Bufferand thenbytes.NewReader(buf.Bytes()). Given the documented 512 MB archive ceiling, a near-cap upload allocates ≥512 MB heap (often 2× duringbuf.Bytes()), plus pipeline retry buffering. Easy OOM on constrained runners. Considerio.Pipe— a goroutine writes thefiles[]part +defaultfield to the pipe writer, pass the pipe reader tohttpReq.SetBody, setContentLength = -1. Restoring theio.ReadSeekerparameter would also let the pipeline rewind on retry. -
POST /skills/{name}/versionshas no idempotency guard — the Azure SDK pipeline retries automatically. A client-side timeout on a request the server actually processed will create a second immutable version, and because bothCreateVersionInlineandCreateVersionFromZipsetdefault: true, the retry silently re-flipsdefault_versionto the duplicate. The user is now running on a version they didn't intend. Options: send anIdempotency-Keyif the service supports it, or setpolicy.RetryOptions{MaxRetries: 0}for these two requests so retries are explicit. Worth checking with the service team which they prefer.
Medium
-
--forceis delete-then-create with no rollback, and the blast radius grew under versioning —DeleteSkillnow wipes the skill and every version. If the subsequentCreateVersion*fails (network, 5xx, validation), all prior versions are gone.verifyFileNameMatchesprotects against the wrong-target case but not the upload-failure case. Two ideas: (a) on--forcewithout--no-prompt, prompt with the version count anddefault_versionname being deleted; (b) longer-term, implement--forceas "upload new non-default version → repoint default → delete old versions" so an upload failure leaves the original intact. -
SKILL.md
license,compatibility,allowed_toolsare silently dropped on upload —SkillInlineContent(models.go:51-54) exposes these fields on the wire, butParseSkillMd(skill_md.go:31-87) only extractsname/description/metadata, and neitherrunFileMdnorbuildInlineContentcopies them through. Round-trip a SKILL.md and these fields disappear, even though README/AGENTS claim agentskills.io conformance. Either extendParseSkillMd+ propagate (recommended), or drop the fields fromSkillInlineContentuntil they're plumbed end-to-end. Worth a round-trip test either way. -
--set-default-versionis not pre-flighted —UpdateSkillDefaultVersionblindly POSTs{default_version: X}. With no client-sideGetSkillVersionfirst, "skill not found" / "version not found" / "version exists but half-deleted" can all surface as a generic server 400. A short pre-flight call + aCodeSkillVersionNotFounderror code would let users distinguish these cases. (Also no test for the error path.) -
updateinline validation is split across two stages with a misleading error —validateFlagsaccepts at least one of--description/--instructions, butbuildInlineContentrequires both non-empty. Runningazd ai skill update foo --description "new"passes the first gate, then fails at build time with "update requires non-empty instructions" — without ever explaining that update needs both. Help text reads(--description / --instructions)where the/looks like "or". Easiest fix: tightenvalidateFlagsto require both when inline mode is used; update help to(--description AND --instructions). -
The gzip-tar branch in
archive.gois dead under the new content-type check —downloadContent(client.go:425-432) hard-rejects anything that isn'tapplication/zip, butSafeExtract/DetectArchiveFormatstill carry anArchiveTarGzbranch with a comment claiming "resilience". That branch can never trigger. Either restoreAccept: application/zip, application/gzipon the download (real resilience), or deleteArchiveTarGz+ the magic-byte sniff and callzip.NewReaderdirectly. Pick one, drop the other.
Low / cleanup
client.gopackage-leveldownloadByteCapis a mutable global mutated by tests. Safe today only because nothing callst.Parallel(). Promote to aClientfield with aWithMaxDownloadBytesoption.skill_md.goSkillMdFileNameconstant is dead afterc040787removed the materialize path. Delete.printCreateResult(skill_create.go) and the analogous post-update GET inskill_update.gosilently swallow the follow-upGetSkillerror. Afmt.Fprintf(os.Stderr, "Warning: ... follow-up GET failed: %v\n", err)would surface transient permission/network issues without failing the command.skill_download.goaccepts--version ""/--version " "and silently downloads the default version.strings.TrimSpace+ reject empty would catch unset-$VERscripting bugs.- Worth adding a test that asserts
update <name> --description "x"(no--instructions) is rejected — documents the contract once you decide how to fix #6. --versionflag help ondownloadcould note "must exist on the server" so users don't expect arbitrary strings.
Not blocking — your call on which of these are in-scope for this PR vs follow-ups. Happy to file the issues myself if useful.
|
/check-enforcer override |
Closes #8142. Implements the design from #8204.
Summary
New standalone
azure.ai.skillsextension exposingazd ai skill create | update | show | list | download | deleteon top of the versioned Skills API introduced in Azure/azure-rest-api-specs#43283 (Foundry-Features: Skills=V1Preview).Commands
create <name>: creates the skill and uploads its first default version viaPOST /skills/{name}/versions. Three mutually exclusive modes:--description "..." --instructions "..."-> JSONinline_content--file ./SKILL.md-> parsed locally, sent asinline_content--file ./skill.zip->multipart/form-datawith a singlefiles[]part--forcedoes delete-then-create.update <name>: uploads a new default version (same inline / SKILL.md modes).--set-default-version <ver>repointsdefault_versionat an existing immutable version viaPOST /skills/{name}without uploading new content. ZIP is rejected here with a pointer tocreate --force.show <name>/list:GET /skills/{name}/GET /skills.download <name>:GET /skills/{name}/content(alwaysapplication/zip). Extracts into./.agents/skills/<name>/with the existing safe-extraction guards (zip-slip, no symlinks, 10K-entry / 512 MB caps).--rawkeeps the archive;--version <ver>targets/versions/{version}/content.delete <name>:DELETE /skills/{name}. Confirms by default;--forceskips,--no-promptrequires--force.Conventions
^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$, max 64 chars (lowercase only).azure.ai.agentsvia read-only fallback toextensions.ai-agents.project.context.endpoint.Test
Install ai.skill extension

Create skill

Create skill (zip)

Show skill

List skills

Download skill

Update skill

Delete skill
