Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/azd/extensions/azure.ai.skills/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ Each `--debug` run writes to `azd-ai-skills-<date>.log` in the current working d

- `--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`, `.zip`, or a **directory** whose root contains `SKILL.md`. Mode is inferred from the path: directories take precedence over extension matching so callers can hand the output of `azd ai skill download` straight back. Conflicting modes (inline + `--file`) are rejected. `.md` and inline modes send `inline_content` JSON; `.zip` and directory modes upload `multipart/form-data` with a single `files[]` part. Directory mode packages the directory into an in-memory zip using `skill_api.ArchiveDirectory`, which enforces the same safety caps as `SafeExtract` on the way down (no symlinks / non-regular entries, 10,000-entry / 512 MB total cap).
- `update`: accepts `.md` only. `.zip` and directories are rejected with a structured suggestion to use `create --force` (which deletes the skill and all its versions before re-creating). Pass `--set-default-version <ver>` to repoint `default_version` at an existing immutable version without uploading new content.
- `update`: accepts `.md`, `.zip`, or a **directory** whose root contains `SKILL.md` — the same three shapes as `create`, routed through the same `selectUpdateMode` mode dispatch. `update` is non-destructive: every upload (inline / md / zip / directory) creates a new immutable version via `POST /skills/{name}/versions` and promotes it to `default_version`; prior versions remain reachable. Conflicting modes (inline + `--file`) are rejected. Pass `--set-default-version <ver>` to repoint `default_version` at an existing immutable version without uploading new content. Unlike `create`, `update` has no `--force` flag because there is nothing to delete.
- `download`: writes either an extracted directory (default) or the unmodified zip archive (`--raw`). Pass `--version <ver>` 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
Expand Down
14 changes: 11 additions & 3 deletions cli/azd/extensions/azure.ai.skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ azd ai skill create <name> --file ./skill.zip
azd ai skill create <name> --file ./skill-src/

azd ai skill update <name> [--description "..."] [--instructions "..."] [--file ./SKILL.md]
azd ai skill update <name> --file ./skill.zip
azd ai skill update <name> --file ./skill-src/
azd ai skill update <name> --set-default-version <version>
azd ai skill show <name>
azd ai skill list [--top N] [--orderby <field>]
Expand All @@ -32,9 +34,15 @@ a `SKILL.md`. Directory mode is the round-trip inverse of
`azd ai skill download`: the CLI packages the directory as a zip in memory
and uploads it as multipart/form-data, identical to the `.zip` path.

`update` is inline-only: a `.zip` or a directory is rejected with a pointer
to `azd ai skill create --force` (a destructive delete-then-create), since
swapping the entire package shape is a `create` concern, not an `update`.
`update` is non-destructive and accepts the same four input shapes as
`create` (inline content, `SKILL.md`, `.zip`, or a directory whose root
contains `SKILL.md`). Every upload creates a new immutable version and
promotes it to `default_version`; prior versions remain reachable via
`--set-default-version <ver>` or `azd ai skill download --version <ver>`.
For directories, the CLI packages the folder as a zip in memory and
uploads it as multipart/form-data, so the output of
`azd ai skill download` round-trips back through `update` without a
manual zip step.

All commands accept the standard cross-cutting flags: `-p` / `--project-endpoint`,
`--output table|json`, `--no-prompt`, and `--debug`.
Expand Down
260 changes: 206 additions & 54 deletions cli/azd/extensions/azure.ai.skills/internal/cmd/skill_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package cmd

import (
"bytes"
"context"
"fmt"
"os"
Expand Down Expand Up @@ -36,11 +37,28 @@ type updateFlags struct {

type updateAction struct{ flags *updateFlags }

// updateMode is the dispatch tag selectUpdateMode returns. It mirrors
// createMode so the two commands route the same shapes through the same
// helpers (md/zip/dir) — the difference is that `update` always appends a
// non-destructive new version via POST /skills/{name}/versions and never
// deletes the existing skill.
type updateMode int

const (
updateModeNone updateMode = iota
updateModeSetDefault
updateModeInline
updateModeFileMd
updateModeFilePackage
updateModeFileDirectory
)

func (a *updateAction) Run(ctx context.Context) error {
if err := validateSkillName(a.flags.name); err != nil {
return err
}
if err := a.validateFlags(); err != nil {
mode, err := selectUpdateMode(a.flags)
if err != nil {
return err
}

Expand All @@ -49,39 +67,142 @@ func (a *updateAction) Run(ctx context.Context) error {
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)
switch mode {
case updateModeSetDefault:
return a.runSetDefault(ctx, skillCtx.client)
case updateModeInline, updateModeFileMd:
return a.runInline(ctx, skillCtx.client)
case updateModeFilePackage:
return a.runFilePackage(ctx, skillCtx.client)
case updateModeFileDirectory:
return a.runFileDirectory(ctx, skillCtx.client)
}
return exterrors.Validation(
exterrors.CodeInvalidParameter,
"unsupported update mode",
"this is a bug; please file an issue",
)
}

// runSetDefault is a metadata-only update: POST /skills/{name} with
// { default_version }. No new version is created.
func (a *updateAction) runSetDefault(ctx context.Context, client *skill_api.Client) error {
updated, err := 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)
}

// runInline handles both pure-inline (--description/--instructions) and
// SKILL.md (--file *.md) updates: parse content, POST inline_content JSON.
func (a *updateAction) runInline(ctx context.Context, client *skill_api.Client) error {
content, err := a.buildInlineContent()
if err != nil {
return err
}

version, err := skillCtx.client.CreateVersionInline(ctx, a.flags.name, skill_api.CreateVersionRequest{
version, err := client.CreateVersionInline(ctx, a.flags.name, skill_api.CreateVersionRequest{
InlineContent: content,
Default: true,
})
if err != nil {
return exterrors.ServiceFromAzure(err, exterrors.OpUpdateSkill)
}
return a.printUpdateResult(ctx, client, version)
}

// runFilePackage uploads a single .zip archive as the next default version
// via multipart/form-data. Mirrors createAction.runFilePackage but wraps
// errors with OpUpdateSkill and uses printUpdateResult.
func (a *updateAction) 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.OpUpdateSkill)
}
return a.printUpdateResult(ctx, client, version)
}

// runFileDirectory packages the directory as an in-memory zip and uploads
// it via the same multipart path as runFilePackage. The directory must
// contain a SKILL.md at its root (matching what `azd ai skill download`
// writes by default), so the natural download → edit → update flow works
// without any manual zip step. Mirrors createAction.runFileDirectory but
// wraps errors with OpUpdateSkill and uses printUpdateResult.
func (a *updateAction) runFileDirectory(ctx context.Context, client *skill_api.Client) error {
if _, found, err := skill_api.LocateSkillMdInDir(a.flags.file); err != nil {
return exterrors.Validation(
exterrors.CodeInvalidSkillFile,
fmt.Sprintf("cannot inspect SKILL.md in %s: %s", a.flags.file, err),
"verify the directory is readable and SKILL.md is a regular file",
)
} else if !found {
return exterrors.Validation(
exterrors.CodeInvalidSkillFile,
fmt.Sprintf("--file %s is a directory without a SKILL.md at its root", a.flags.file),
"add a SKILL.md to the directory root (matches `azd ai skill download` output) or pass a .zip archive",
)
}

data, archiveErr := skill_api.ArchiveDirectory(a.flags.file, skill_api.ArchiveOptions{})
if archiveErr != nil {
return classifyArchiveDirectoryError(archiveErr, a.flags.file)
}

archiveName := filepath.Base(filepath.Clean(a.flags.file)) + ".zip"
version, err := client.CreateVersionFromZip(ctx, a.flags.name, archiveName, bytes.NewReader(data), true)
if err != nil {
return exterrors.ServiceFromAzure(err, exterrors.OpUpdateSkill)
}
return a.printUpdateResult(ctx, client, version)
}

// printUpdateResult prints either the created version envelope (JSON) or,
// for human output, a friendly "updated" message followed by the freshly
// loaded Skill so users see the new default_version / latest_version.
//
// Critically, when --output json is in effect we ONLY emit the version
// envelope — never a human-readable line — so the output stays valid JSON
// for callers piping into jq or similar.
func (a *updateAction) printUpdateResult(ctx context.Context, client *skill_api.Client, version *skill_api.SkillVersion) error {
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)
skill, err := client.GetSkill(ctx, a.flags.name)
if err != nil {
// Don't fail the update just because the follow-up GET failed; fall
// back to printing the version envelope instead.
return printSkillVersionDetail(version, outputTable)
}
return printSkillDetail(skill, outputTable)
Expand Down Expand Up @@ -128,70 +249,90 @@ func (a *updateAction) buildInlineContent() (*skill_api.SkillInlineContent, erro
return content, nil
}

// validateFlags is kept for tests that exercise validation in isolation. It
// delegates to selectUpdateMode (which performs the same checks plus mode
// inference) and discards the mode. Production code calls selectUpdateMode
// directly via Run.
func (a *updateAction) validateFlags() error {
inlineProvided := a.flags.descriptionSet || a.flags.instructionsSet
fileProvided := a.flags.file != ""
setDefaultProvided := a.flags.setDefault != ""
_, err := selectUpdateMode(a.flags)
return err
}

// --set-default-version is mutually exclusive with content flags.
// selectUpdateMode validates flag combinations and infers the dispatch
// mode. It mirrors selectCreateMode in skill_create.go so the same input
// shapes (.md / .zip / directory / inline) route the same way on update,
// with the additional --set-default-version branch that is unique to
// update.
func selectUpdateMode(f *updateFlags) (updateMode, error) {
inlineProvided := f.descriptionSet || f.instructionsSet
fileProvided := f.file != ""
setDefaultProvided := f.setDefault != ""

// --set-default-version is a metadata-only update; it cannot be combined
// with content flags. Hand it back as its own mode so Run can dispatch
// the POST /skills/{name} envelope instead of POST /versions.
if setDefaultProvided && (inlineProvided || fileProvided) {
return exterrors.Validation(
return updateModeNone, 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
return updateModeSetDefault, nil
}

if !inlineProvided && !fileProvided {
return exterrors.Validation(
return updateModeNone, exterrors.Validation(
exterrors.CodeMissingRequiredField,
"no fields to update",
"pass --description, --instructions, and/or --file <path>; or use --set-default-version <ver>",
)
}
if inlineProvided && fileProvided {
return exterrors.Validation(
return updateModeNone, exterrors.Validation(
exterrors.CodeConflictingArguments,
"--file is mutually exclusive with --description / --instructions on update",
"pass either inline flags or --file <path>, not both",
)
}

if fileProvided {
// Detect directory uploads before extension matching: update is
// inline-only by design, so a multi-file directory is rejected the
// same way .zip is — with a pointer to `create --force`.
if info, statErr := os.Stat(a.flags.file); statErr == nil && info.IsDir() {
return exterrors.Validation(
exterrors.CodeInvalidSkillFile,
"directory uploads cannot be applied via `skill update`",
"use `azd ai skill create <name> --file <directory> --force` to replace the skill "+
"(this deletes the existing skill and all of its versions first)",
)
// Detect directories before extension matching so callers can point
// --file at the directory `azd ai skill download` extracted (which
// has no file extension at all). Matches selectCreateMode.
info, statErr := os.Stat(f.file)
if statErr == nil && info.IsDir() {
return updateModeFileDirectory, nil
}
ext := strings.ToLower(filepath.Ext(a.flags.file))
ext := strings.ToLower(filepath.Ext(f.file))
switch ext {
case ".md":
return nil
return updateModeFileMd, nil
case ".zip":
return exterrors.Validation(
exterrors.CodeInvalidSkillFile,
"ZIP packages cannot be applied via `skill update`",
"use `azd ai skill create <name> --file <path>.zip --force` to replace the skill "+
"(this deletes the existing skill and all of its versions first)",
)
default:
return exterrors.Validation(
return updateModeFilePackage, nil
}
// A --file value with no extension can only be a directory; if
// stat failed (typically fs.ErrNotExist), surface the stat error
// so users aren't told the extension is unsupported when the path
// is simply missing or unreadable.
if statErr != nil && ext == "" {
return updateModeNone, exterrors.Validation(
exterrors.CodeInvalidSkillFile,
fmt.Sprintf("unsupported --file extension %q on update", ext),
"update only accepts .md files",
fmt.Sprintf("inspect --file %q: %s", f.file, statErr),
"verify the path exists and points to a SKILL.md, a .zip, "+
"or a directory containing SKILL.md",
)
}
return updateModeNone, exterrors.Validation(
exterrors.CodeInvalidSkillFile,
fmt.Sprintf("unsupported --file extension %q on update", ext),
"use .md for inline metadata, .zip for a package upload, "+
"or a directory containing SKILL.md",
)
}
return nil

return updateModeInline, nil
}

func newUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
Expand All @@ -201,17 +342,26 @@ func newUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
cmd := &cobra.Command{
Use: "update <name>",
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.
Long: `Skills are versioned and immutable. ` + "`update`" + ` creates a new version and
sets it as the skill's new default version. ` + "`update`" + ` is non-destructive:
prior versions are preserved and remain reachable.

To repoint default_version at an existing version without uploading new
content, pass --set-default-version <version>.
Accepts the same four input shapes as ` + "`create`" + `:

ZIP packages are not accepted here. To replace the entire skill (deleting all
existing versions), use ` + "`azd ai skill create <name> --file <archive>.zip --force`" + `.`,
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)
4. Directory: --file ./skill-src (CLI packages the directory as a zip and uploads it)

Directory mode requires SKILL.md at the root of the directory — the same
layout that ` + "`azd ai skill download`" + ` writes by default.

To repoint default_version at an existing version without uploading new
content, pass --set-default-version <version>.`,
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 --file ./skill.zip
azd ai skill update my-skill --file ./skill-src/
azd ai skill update my-skill --set-default-version 1`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
Expand All @@ -226,7 +376,9 @@ existing versions), use ` + "`azd ai skill create <name> --file <archive>.zip --

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.file, "file", "",
"Path to a SKILL.md file, a .zip archive, or a directory whose contents become the next version. "+
"Archives and directories must contain a SKILL.md at the root.")
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,
Expand Down
Loading
Loading