Skip to content

Commit 4237ac1

Browse files
huimiuCopilot
andcommitted
fix(skills): switch from gzip+tar to ZIP for package upload/download
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>
1 parent c957348 commit 4237ac1

16 files changed

Lines changed: 313 additions & 368 deletions

File tree

cli/azd/extensions/azure.ai.skills/AGENTS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ Use this file together with `cli/azd/AGENTS.md`. This guide supplements the root
99
Useful places to start:
1010

1111
- `internal/cmd/`: Cobra commands and top-level orchestration
12-
- `internal/pkg/skill_api/`: typed Foundry Skills REST client, models, SKILL.md parser, and safe tar.gz extractor
12+
- `internal/pkg/skill_api/`: typed Foundry Skills REST client, models, SKILL.md parser, and safe ZIP extractor
1313
- `internal/exterrors/`: structured error factories and extension-specific codes
1414

1515
## Relationship to `azure.ai.agents`
@@ -64,9 +64,9 @@ Each `--debug` run writes to `azd-ai-skills-<date>.log` in the current working d
6464
## File handling
6565

6666
- `--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.
67-
- `create`: accepts `.md`, `.tar.gz`, or `.tgz`. Mode is inferred from extension; conflicting modes (inline + `--file`) are rejected.
68-
- `update`: accepts `.md` only. `.tar.gz`/`.tgz` is rejected with a structured suggestion to use `create --force`.
69-
- `download`: writes either an extracted directory (default) or the unmodified gzip archive (`--raw`).
67+
- `create`: accepts `.md` or `.zip`. Mode is inferred from extension; conflicting modes (inline + `--file`) are rejected.
68+
- `update`: accepts `.md` only. `.zip` is rejected with a structured suggestion to use `create --force`.
69+
- `download`: writes either an extracted directory (default) or the unmodified ZIP archive (`--raw`).
7070

7171
## Release preparation
7272

cli/azd/extensions/azure.ai.skills/CHANGELOG.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@
55
- Initial preview release of the `azure.ai.skills` extension.
66
- Adds the `azd ai skill` command group with full CRUD over Foundry Skills:
77
- `azd ai skill create <name>` — inline (`--description` + `--instructions`),
8-
SKILL.md file (`--file ./SKILL.md`), or gzip package (`--file ./skill.tar.gz`).
8+
SKILL.md file (`--file ./SKILL.md`), or ZIP package (`--file ./skill.zip`).
99
- `azd ai skill update <name>` — inline or `--file *.md`.
1010
- `azd ai skill show <name>` — metadata only.
1111
- `azd ai skill list` — paginated, supports `--top` and `--orderby`.
1212
- `azd ai skill download <name>` — extracts to `./.agents/skills/<name>/` by
13-
default, `--raw` keeps the gzip archive.
13+
default, `--raw` keeps the ZIP archive.
1414
- `azd ai skill delete <name>` — confirmation by default, `--force` to skip.
1515
- Shares the Foundry project-endpoint resolution cascade with `azure.ai.agents`,
1616
reading `extensions.ai-skills.project.context.endpoint` first and falling back to

cli/azd/extensions/azure.ai.skills/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ terminal.
99
```bash
1010
azd ai skill create <name> [--description "..." --instructions "..."]
1111
azd ai skill create <name> --file ./SKILL.md
12-
azd ai skill create <name> --file ./skill.tar.gz
12+
azd ai skill create <name> --file ./skill.zip
1313

1414
azd ai skill update <name> [--description "..."] [--instructions "..."] [--file ./SKILL.md]
1515
azd ai skill show <name>

cli/azd/extensions/azure.ai.skills/internal/cmd/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func NewRootCommand() *cobra.Command {
2727
at runtime — from your terminal.
2828
2929
Skills carry either inline JSON (description + Markdown instructions) or a
30-
packaged gzip tarball bundling SKILL.md plus any sibling assets. Use this
30+
packaged ZIP archive bundling SKILL.md plus any sibling assets. Use this
3131
command group to create, update, show, list, download, and delete skills in
3232
a Foundry project.`,
3333
})

cli/azd/extensions/azure.ai.skills/internal/cmd/skill_create.go

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -183,8 +183,8 @@ func (a *createAction) runFilePackage(ctx context.Context, client *skill_api.Cli
183183
if info.IsDir() {
184184
return exterrors.Validation(
185185
exterrors.CodeInvalidSkillFile,
186-
fmt.Sprintf("--file %s is a directory; expected a .tar.gz / .tgz archive", a.flags.file),
187-
"pass a single gzip archive path",
186+
fmt.Sprintf("--file %s is a directory; expected a .zip archive", a.flags.file),
187+
"pass a single .zip archive path",
188188
)
189189
}
190190

@@ -213,7 +213,7 @@ func printCreateResult(s *skill_api.Skill, format string) error {
213213
return printSkillDetail(s, outputTable)
214214
}
215215

216-
// verifyPackageNameMatches peeks SKILL.md from a .tar.gz archive and ensures
216+
// verifyPackageNameMatches peeks SKILL.md from a .zip archive and ensures
217217
// its front-matter `name` (when present) matches the positional argument the
218218
// user supplied. This guards `--force` in package mode against the
219219
// destructive sequence "delete positional name, then upload archive that
@@ -225,22 +225,21 @@ func printCreateResult(s *skill_api.Skill, format string) error {
225225
// archives), or when the embedded name matches positionalName. Returns a
226226
// structured validation error otherwise.
227227
func verifyPackageNameMatches(archivePath, positionalName string) error {
228-
f, openErr := os.Open(archivePath) //nolint:gosec // user-supplied path opened on their behalf
229-
if openErr != nil {
228+
data, readErr := os.ReadFile(archivePath) //nolint:gosec // user-supplied path read on their behalf
229+
if readErr != nil {
230230
return exterrors.Validation(
231231
exterrors.CodeInvalidSkillFile,
232-
fmt.Sprintf("cannot open %s: %s", archivePath, openErr),
232+
fmt.Sprintf("cannot read %s: %s", archivePath, readErr),
233233
"verify the file is readable",
234234
)
235235
}
236-
defer f.Close()
237236

238-
archiveName, peekErr := skill_api.PeekArchiveSkillName(f)
237+
archiveName, peekErr := skill_api.PeekArchiveSkillName(data)
239238
if peekErr != nil {
240239
return exterrors.Validation(
241240
exterrors.CodeInvalidSkillFile,
242241
fmt.Sprintf("cannot inspect %s: %s", archivePath, peekErr),
243-
"ensure the archive is a valid .tar.gz containing a SKILL.md file",
242+
"ensure the archive is a valid .zip containing a SKILL.md file",
244243
)
245244
}
246245
if archiveName == "" || archiveName == positionalName {
@@ -281,19 +280,17 @@ func selectCreateMode(f *createFlags) (createMode, error) {
281280

282281
if fileProvided {
283282
ext := strings.ToLower(filepath.Ext(f.file))
284-
// `.tgz` and `.tar.gz` both work for packages; `.md` for inline.
285-
switch {
286-
case ext == ".md":
283+
// `.zip` for packages; `.md` for SKILL.md inline body.
284+
switch ext {
285+
case ".md":
287286
return modeFileMd, nil
288-
case ext == ".tgz":
289-
return modeFilePackage, nil
290-
case strings.HasSuffix(strings.ToLower(f.file), ".tar.gz"):
287+
case ".zip":
291288
return modeFilePackage, nil
292289
default:
293290
return modeNone, exterrors.Validation(
294291
exterrors.CodeInvalidSkillFile,
295292
fmt.Sprintf("unsupported --file extension %q", ext),
296-
"use .md for inline metadata, or .tar.gz / .tgz for a package upload",
293+
"use .md for inline metadata or .zip for a package upload",
297294
)
298295
}
299296
}
@@ -362,12 +359,12 @@ func newCreateCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
362359
363360
1. Inline: --description "..." --instructions "..."
364361
2. SKILL.md: --file ./SKILL.md (CLI parses YAML front matter + body)
365-
3. Package: --file ./skill.tar.gz (CLI uploads the archive as-is)
362+
3. Package: --file ./skill.zip (CLI uploads the archive as-is)
366363
367364
Pass --force to delete an existing skill of the same name before creating.`,
368365
Example: ` azd ai skill create greet-user --description "Welcomes a new user" --instructions "Greet ..."
369366
azd ai skill create greet-user --file ./SKILL.md
370-
azd ai skill create greet-user --file ./skill.tar.gz --force`,
367+
azd ai skill create greet-user --file ./skill.zip --force`,
371368
Args: cobra.ExactArgs(1),
372369
RunE: func(cmd *cobra.Command, args []string) error {
373370
flags.name = args[0]
@@ -387,7 +384,7 @@ Pass --force to delete an existing skill of the same name before creating.`,
387384
cmd.Flags().StringVar(&flags.instructions, "instructions", "",
388385
"Inline mode: Markdown body defining skill behavior")
389386
cmd.Flags().StringVar(&flags.file, "file", "",
390-
"Path to SKILL.md (.md) or a gzip package (.tar.gz / .tgz)")
387+
"Path to SKILL.md (.md) or a ZIP package (.zip)")
391388
cmd.Flags().BoolVar(&flags.force, "force", false,
392389
"Delete an existing skill of the same name before creating")
393390
azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{

cli/azd/extensions/azure.ai.skills/internal/cmd/skill_download.go

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"context"
88
"errors"
99
"fmt"
10-
"io"
1110
"os"
1211
"path/filepath"
1312

@@ -72,7 +71,7 @@ func (a *downloadAction) Run(ctx context.Context) error {
7271
// Pre-flight via Get so we can detect the "no associated package" case
7372
// before issuing the :download call. The service returns 404 with a
7473
// dedicated error code when the skill was created from inline JSON (or a
75-
// SKILL.md file) rather than a gzip package, but the message is opaque —
74+
// SKILL.md file) rather than a ZIP package, but the message is opaque —
7675
// surfacing the HasBlob check up front gives the user a clearer answer.
7776
skill, err := skillCtx.client.Get(ctx, a.flags.name)
7877
if err != nil {
@@ -82,9 +81,9 @@ func (a *downloadAction) Run(ctx context.Context) error {
8281
return exterrors.Validation(
8382
exterrors.CodeSkillNoPackage,
8483
fmt.Sprintf("skill %q has no downloadable package", a.flags.name),
85-
"only skills created from a `.tar.gz` / `.tgz` archive have a downloadable "+
84+
"only skills created from a `.zip` archive have a downloadable "+
8685
"package. Use `azd ai skill show <name>` to inspect metadata; "+
87-
"re-create with `azd ai skill create <name> --file <archive>.tar.gz --force` "+
86+
"re-create with `azd ai skill create <name> --file <archive>.zip --force` "+
8887
"if you want a downloadable copy.",
8988
)
9089
}
@@ -93,20 +92,19 @@ func (a *downloadAction) Run(ctx context.Context) error {
9392
if err != nil {
9493
return exterrors.ServiceFromAzure(err, exterrors.OpDownloadSkill)
9594
}
96-
defer body.Close()
9795

9896
if a.flags.raw {
9997
return a.writeRaw(body, absOut)
10098
}
10199
return a.writeExtracted(body, absOut)
102100
}
103101

104-
func (a *downloadAction) writeRaw(body io.Reader, outputDir string) error {
102+
func (a *downloadAction) writeRaw(body []byte, outputDir string) error {
105103
if err := os.MkdirAll(outputDir, 0700); err != nil {
106104
return fmt.Errorf("create output dir: %w", err)
107105
}
108106

109-
archivePath := filepath.Join(outputDir, a.flags.name+".tar.gz")
107+
archivePath := filepath.Join(outputDir, a.flags.name+".zip")
110108

111109
// Always Lstat (even with --force) so we never follow a symlink and so we
112110
// refuse to overwrite a non-regular file (directory, device, socket, ...).
@@ -151,7 +149,7 @@ func (a *downloadAction) writeRaw(body io.Reader, outputDir string) error {
151149
if err != nil {
152150
return fmt.Errorf("create archive: %w", err)
153151
}
154-
if _, copyErr := io.Copy(f, body); copyErr != nil {
152+
if _, copyErr := f.Write(body); copyErr != nil {
155153
_ = f.Close()
156154
return fmt.Errorf("write archive: %w", copyErr)
157155
}
@@ -162,13 +160,13 @@ func (a *downloadAction) writeRaw(body io.Reader, outputDir string) error {
162160
res := downloadResult{
163161
Skill: a.flags.name,
164162
OutputDir: outputDir,
165-
Archive: a.flags.name + ".tar.gz",
163+
Archive: a.flags.name + ".zip",
166164
Raw: true,
167165
}
168166
return a.printResult(res)
169167
}
170168

171-
func (a *downloadAction) writeExtracted(body io.Reader, outputDir string) error {
169+
func (a *downloadAction) writeExtracted(body []byte, outputDir string) error {
172170
result, err := skill_api.SafeExtract(body, skill_api.ExtractOptions{
173171
OutputDir: outputDir,
174172
Force: a.flags.force,
@@ -224,11 +222,11 @@ func classifyExtractError(err error, outputDir string) error {
224222
err.Error(),
225223
fmt.Sprintf("pass --force to overwrite existing files in %s", outputDir),
226224
)
227-
case errors.Is(err, skill_api.ErrInvalidGzip):
225+
case errors.Is(err, skill_api.ErrInvalidZip):
228226
return exterrors.Validation(
229227
exterrors.CodeInvalidParameter,
230228
err.Error(),
231-
"the service did not return a gzip archive; retry or contact support",
229+
"the service did not return a valid zip archive; retry or contact support",
232230
)
233231
}
234232
return err
@@ -242,15 +240,15 @@ func newDownloadCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
242240
cmd := &cobra.Command{
243241
Use: "download <name>",
244242
Short: "Download a Foundry skill package.",
245-
Long: `Download a skill's gzip package.
243+
Long: `Download a skill's ZIP package.
246244
247245
By default the CLI extracts the archive into --output-dir (which defaults to
248-
'./.agents/skills/<name>/'). Pass --raw to write the unmodified gzip archive
246+
'./.agents/skills/<name>/'). Pass --raw to write the unmodified ZIP archive
249247
into --output-dir instead.
250248
251249
Extraction enforces strict safety rules: no absolute paths, no '..' segments,
252-
no symlinks, no hard links, no non-regular files, and a 10,000-entry /
253-
512 MB cap on the total uncompressed size.`,
250+
no symlinks / non-regular entries, and a 10,000-entry / 512 MB cap on the
251+
total uncompressed size.`,
254252
Args: cobra.ExactArgs(1),
255253
RunE: func(cmd *cobra.Command, args []string) error {
256254
flags.name = args[0]
@@ -266,7 +264,7 @@ no symlinks, no hard links, no non-regular files, and a 10,000-entry /
266264
cmd.Flags().StringVar(&flags.outputDir, "output-dir", "",
267265
"Directory to write the extracted skill (default: ./.agents/skills/<name>/)")
268266
cmd.Flags().BoolVar(&flags.raw, "raw", false,
269-
"Skip extraction; write the gzip archive as-is to --output-dir")
267+
"Skip extraction; write the ZIP archive as-is to --output-dir")
270268
cmd.Flags().BoolVar(&flags.force, "force", false,
271269
"Overwrite existing files in --output-dir")
272270
azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{

cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,11 @@ func (a *updateAction) validateFlags() error {
131131
switch {
132132
case ext == ".md":
133133
return nil
134-
case ext == ".tgz", strings.HasSuffix(strings.ToLower(a.flags.file), ".tar.gz"):
134+
case ext == ".zip":
135135
return exterrors.Validation(
136136
exterrors.CodeInvalidSkillFile,
137-
"gzip packages cannot be applied via `skill update`",
138-
"use `azd ai skill create <name> --file <path>.tar.gz --force` to replace the skill",
137+
"ZIP packages cannot be applied via `skill update`",
138+
"use `azd ai skill create <name> --file <path>.zip --force` to replace the skill",
139139
)
140140
default:
141141
return exterrors.Validation(
@@ -161,7 +161,7 @@ func newUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
161161
Pass any subset of:
162162
--description "..." --instructions "..."
163163
or:
164-
--file ./SKILL.md (parsed locally; .tar.gz / .tgz is not accepted here)
164+
--file ./SKILL.md (parsed locally; .zip is not accepted here)
165165
166166
The CLI fetches the current skill, merges your changes locally, then POSTs the
167167
merged payload to the service.`,

cli/azd/extensions/azure.ai.skills/internal/cmd/skill_validate_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func TestSelectCreateMode_FileMd(t *testing.T) {
7171
}
7272

7373
func TestSelectCreateMode_FilePackage(t *testing.T) {
74-
for _, f := range []string{"./pkg.tar.gz", "./pkg.tgz", "./PKG.TGZ"} {
74+
for _, f := range []string{"./pkg.zip", "./PKG.ZIP"} {
7575
mode, err := selectCreateMode(&createFlags{file: f})
7676
require.NoError(t, err, "file %q", f)
7777
require.Equal(t, modeFilePackage, mode, "file %q", f)
@@ -116,8 +116,8 @@ func TestUpdateAction_ConflictingArgs(t *testing.T) {
116116
require.Equal(t, exterrors.CodeConflictingArguments, le.Code)
117117
}
118118

119-
func TestUpdateAction_RejectsGzipFile(t *testing.T) {
120-
for _, f := range []string{"./pkg.tar.gz", "./pkg.tgz"} {
119+
func TestUpdateAction_RejectsZipFile(t *testing.T) {
120+
for _, f := range []string{"./pkg.zip", "./PKG.ZIP"} {
121121
a := &updateAction{flags: &updateFlags{file: f}}
122122
err := a.validateFlags()
123123
require.Errorf(t, err, "file %q", f)

cli/azd/extensions/azure.ai.skills/internal/exterrors/codes.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const (
1515
// unreadable, or unsupported file, or when SKILL.md front matter
1616
// fails to parse.
1717
CodeInvalidSkillFile = "invalid_skill_file"
18-
// CodeSkillArchiveUnsafe is used when a downloaded gzip archive
18+
// CodeSkillArchiveUnsafe is used when a downloaded ZIP archive
1919
// contains an unsafe entry (zip-slip, symlink, oversized, etc.).
2020
CodeSkillArchiveUnsafe = "skill_archive_unsafe"
2121
// CodeSkillOutputCollision is used when `skill download` would

0 commit comments

Comments
 (0)