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
34 changes: 31 additions & 3 deletions cli/azd/extensions/azure.ai.skills/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ terminal.
## Commands

```bash
azd ai skill add <name> [--description "..." --instructions "..."]
azd ai skill add <name> --file ./SKILL.md
azd ai skill add <name> --file ./skill.zip
azd ai skill add <name> --file ./skill-src/

azd ai skill create <name> [--description "..." --instructions "..."]
azd ai skill create <name> --file ./SKILL.md
azd ai skill create <name> --file ./skill.zip
Expand All @@ -28,6 +33,12 @@ version; `update` uploads a new default version (or, with
version). Names follow the agentskills.io spec
(`^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$`, max 64 chars).

`add` is declarative. It adds or updates a
`host: azure.ai.skill` service in the current project's `azure.yaml` without
mutating the remote skill. Run `azd deploy <name>` or `azd up` afterward to
reconcile it. Existing `uses:`, `project:`, and unowned service fields are
preserved.

`create` accepts inline content (`--description` / `--instructions`), a
single `SKILL.md` file, a `.zip` package, or a directory whose root contains
a `SKILL.md`. Directory mode is the round-trip inverse of
Expand All @@ -49,8 +60,14 @@ All commands accept the standard cross-cutting flags: `-p` / `--project-endpoint

## Composing skills in `azure.yaml`

Declare a skill as its own service to reconcile it with `azd deploy` or
`azd up`:
Use the owning extension to add a skill service, then declare the dependency
from each consuming agent:

```bash
azd ai skill add triage-rules \
--description "Rules for triaging incoming issues" \
--instructions "Classify the issue, identify its owner, and recommend next steps."
```

```yaml
services:
Expand All @@ -67,11 +84,18 @@ services:

support-agent:
host: azure.ai.agent
kind: hosted
name: support-agent
project: ./agents/support-agent
image: ghcr.io/example/support-agent:latest
Comment thread
Copilot marked this conversation as resolved.
uses:
- triage-rules
skill: triage-rules
```

The skill command does not infer which agents consume the skill. Add the skill
service name to each consuming agent's `uses:` list to declare deployment
ordering explicitly.

`instructions` can also reference a `.md` or `.txt` file. To preserve a
complete skill package, use `archive` instead of the inline fields:

Expand All @@ -87,6 +111,10 @@ Relative instruction and archive paths resolve from the service's `project`
path when set, otherwise from the directory containing `azure.yaml`. Parent
traversal (`..`) is rejected.

When `add` receives a ZIP or directory, the source must be inside that service
directory. The command stores a portable forward-slash relative reference and
rejects host-name collisions instead of overwriting another service type.

Deploying the skill creates a new immutable default version and publishes
readiness markers for dependent agent services. A consuming agent must list
the skill service in `uses:` so azd deploys it first. Removing the service from
Expand Down
3 changes: 3 additions & 0 deletions cli/azd/extensions/azure.ai.skills/extension.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ tags:
- ai
- skill
examples:
- name: add
description: Add a skill service to the current azure.yaml.
usage: azd ai skill add my-skill --file ./skills/my-skill
- name: list
description: List skills in the current Foundry project.
usage: azd ai skill list
Expand Down
5 changes: 3 additions & 2 deletions cli/azd/extensions/azure.ai.skills/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ at runtime — from your terminal.

Skills carry either inline JSON (description + Markdown instructions) or a
packaged ZIP archive bundling SKILL.md plus any sibling assets. Use this
command group to create, update, show, list, download, and delete skills in
a Foundry project.`,
command group to compose skills into azure.yaml or create, update, show, list,
download, and delete skills in a Foundry project.`,
})
rootCmd.SilenceUsage = true
rootCmd.SilenceErrors = true
Expand All @@ -49,6 +49,7 @@ a Foundry project.`,
rootCmd.AddCommand(newMetadataCommand(rootCmd))
rootCmd.AddCommand(newContextCommand())

rootCmd.AddCommand(newAddCommand(extCtx))
rootCmd.AddCommand(newCreateCommand(extCtx))
rootCmd.AddCommand(newUpdateCommand(extCtx))
rootCmd.AddCommand(newShowCommand(extCtx))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,9 @@ func prepareSkillArchive(path string) (*preparedSkillArchive, error) {
if err != nil {
return nil, classifyArchiveDirectoryError(err, path)
}
if err := validateSkillArchiveUploadSize(path, int64(len(data))); err != nil {
return nil, err
}
return &preparedSkillArchive{
Name: filepath.Base(filepath.Clean(path)) + ".zip",
Reader: io.NopCloser(bytes.NewReader(data)),
Expand All @@ -459,12 +462,8 @@ func prepareSkillArchive(path string) (*preparedSkillArchive, error) {
"set archive to a .zip file or a directory containing SKILL.md",
)
}
if info.Size() > skill_api.MaxUploadBytes {
return nil, exterrors.Validation(
exterrors.CodeInvalidSkillFile,
fmt.Sprintf("skill archive %s exceeds the 25 MB upload size limit", path),
"reduce the archive size to 25 MB or less",
)
if err := validateSkillArchiveUploadSize(path, info.Size()); err != nil {
return nil, err
}
file, err := os.Open(path) //nolint:gosec // user-authored azure.yaml path opened on user's behalf
if err != nil {
Expand All @@ -480,6 +479,17 @@ func prepareSkillArchive(path string) (*preparedSkillArchive, error) {
}, nil
}

func validateSkillArchiveUploadSize(path string, size int64) error {
if size <= skill_api.MaxUploadBytes {
return nil
}
return exterrors.Validation(
exterrors.CodeInvalidSkillFile,
fmt.Sprintf("skill archive %s exceeds the 25 MB upload size limit", path),
"reduce the archive size to 25 MB or less",
)
}

// hasParentTraversal reports whether a relative path contains a ".." segment
// that could escape its base directory, treating both '/' and '\' as separators.
func hasParentTraversal(p string) bool {
Expand All @@ -493,7 +503,7 @@ func hasParentTraversal(p string) bool {

func isInstructionFilePath(instructions string) bool {
value := strings.TrimSpace(instructions)
if strings.ContainsAny(value, "\r\n") {
if strings.ContainsAny(value, " \t\r\n") {
return false
}
switch strings.ToLower(filepath.Ext(value)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,15 @@ func TestResolveSkillInstructions_MultilineBodyEndingInFileExtensionIsInline(t *
assert.Equal(t, instructions, got)
}

func TestResolveSkillInstructions_SingleLineBodyEndingInFileExtensionIsInline(t *testing.T) {
t.Parallel()

instructions := "Follow README.md"
got, err := resolveSkillInstructions("", &azdext.ServiceConfig{Name: "inline"}, instructions)
require.NoError(t, err)
assert.Equal(t, instructions, got)
}

func TestResolveSkillInstructions_FilePath(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -375,6 +384,17 @@ func TestPrepareSkillArchive_RejectsOversizedZip(t *testing.T) {
assert.Nil(t, archive)
}

func TestValidateSkillArchiveUploadSize_RejectsOversizedArchive(t *testing.T) {
t.Parallel()

require.NoError(t, validateSkillArchiveUploadSize("skill.zip", skill_api.MaxUploadBytes))
require.ErrorContains(
t,
validateSkillArchiveUploadSize("skill.zip", skill_api.MaxUploadBytes+1),
"exceeds the 25 MB upload size limit",
)
}

func TestPrepareSkillArchive_RejectsNonRegularZip(t *testing.T) {
t.Parallel()
if runtime.GOOS == "windows" {
Expand Down
211 changes: 211 additions & 0 deletions cli/azd/extensions/azure.ai.skills/internal/cmd/skill_add.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
"os"

"azureaiskills/internal/exterrors"

"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/spf13/cobra"
)

type addFlags struct {
name string
description string
instructions string
file string
output string
noPrompt bool

descriptionSet bool
instructionsSet bool
}

type addAction struct {
flags *addFlags
upsert func(context.Context, skillServiceDeclaration) (*skillServiceUpsertResult, error)
writer io.Writer
errorWriter io.Writer
}

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

declaration, err := a.buildDeclaration()
if err != nil {
return err
}
if a.upsert == nil {
return fmt.Errorf("skill service upsert is not configured")
}
result, err := a.upsert(ctx, declaration)
if err != nil {
return err
}
if result == nil {
return fmt.Errorf("skill service upsert returned no result")
}
writer := a.writer
if writer == nil {
writer = io.Discard
}
return writeSkillServiceUpsertResult(writer, result, a.flags.output)
}

func (a *addAction) buildDeclaration() (skillServiceDeclaration, error) {
mode, err := selectCreateMode(&createFlags{
description: a.flags.description,
instructions: a.flags.instructions,
file: a.flags.file,
descriptionSet: a.flags.descriptionSet,
instructionsSet: a.flags.instructionsSet,
})
if err != nil {
return skillServiceDeclaration{}, err
}

declaration := skillServiceDeclaration{Name: a.flags.name}
switch mode {
case modeInline:
declaration.Config = skillServiceConfig{
Description: a.flags.description,
Instructions: a.flags.instructions,
Comment thread
glharper marked this conversation as resolved.
}
case modeFileMd:
parsed, err := loadSkillMd(a.flags.file)
if err != nil {
return skillServiceDeclaration{}, err
}
if parsed.Name != "" &&
parsed.Name != a.flags.name &&
!shouldSuppressWarning(a.flags.noPrompt, a.flags.output) {
errorWriter := a.errorWriter
if errorWriter == nil {
errorWriter = io.Discard
}
fmt.Fprintf(
errorWriter,
"Warning: SKILL.md front matter `name: %q` does not match positional argument %q; using %q\n",
parsed.Name,
a.flags.name,
a.flags.name,
)
}
declaration.Config = skillServiceConfig{
Description: parsed.Description,
Instructions: parsed.Instructions,
License: parsed.License,
Compatibility: parsed.Compatibility,
Metadata: parsed.Metadata,
Tools: parsed.AllowedTools,
}
case modeFilePackage, modeFileDirectory:
declaration.ArchiveSource = a.flags.file
case modeNone:
return skillServiceDeclaration{}, exterrors.Validation(
exterrors.CodeMissingRequiredField,
"no content supplied to skill add",
"pass --description and --instructions, or --file <path>",
)
default:
return skillServiceDeclaration{}, exterrors.Validation(
exterrors.CodeInvalidParameter,
"unsupported skill add mode",
"this is a bug; please file an issue",
)
}

if declaration.ArchiveSource == "" {
if err := validateSkillServiceConfig(declaration.Name, &declaration.Config); err != nil {
return skillServiceDeclaration{}, err
}
}
return declaration, nil
}

func writeSkillServiceUpsertResult(
writer io.Writer,
result *skillServiceUpsertResult,
format string,
) error {
if format == outputJSON {
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
encoder.SetEscapeHTML(false)
return encoder.Encode(result)
}

action := "updated"
if result.Created {
action = "added"
}
_, err := fmt.Fprintf(writer, "Skill service %q %s in azure.yaml.\n", result.Name, action)
return err
}

func newAddCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
flags := &addFlags{}
action := &addAction{
flags: flags,
upsert: upsertSkillServiceToProject,
writer: os.Stdout,
errorWriter: os.Stderr,
}

cmd := &cobra.Command{
Use: "add <name>",
Short: "Add or update a Foundry skill service in azure.yaml.",
Long: `Add or update a host: azure.ai.skill service in the current azd
project's azure.yaml.

This command is declarative: it only updates azure.yaml and does not create or
modify the remote Foundry skill. Run azd deploy <name> or azd up to reconcile
the service after adding it.

Accepted content shapes:

1. Inline: --description "..." --instructions "..."
2. SKILL.md: --file ./SKILL.md
3. Package: --file ./skill.zip
4. Directory: --file ./skill-src

Inline and SKILL.md inputs are stored as service properties. ZIP and directory
inputs are stored as portable archive references. Updating an existing skill
service preserves uses:, project:, and fields owned by other extensions.`,
Example: ` azd ai skill add triage-rules --description "Triage issues" --instructions "Classify each issue."
azd ai skill add triage-rules --file ./SKILL.md
azd ai skill add triage-rules --file ./skills/triage-rules
azd deploy triage-rules`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
flags.name = args[0]
flags.output = extCtx.OutputFormat
flags.noPrompt = extCtx.NoPrompt
flags.descriptionSet = cmd.Flags().Changed("description")
flags.instructionsSet = cmd.Flags().Changed("instructions")
return action.Run(azdext.WithAccessToken(cmd.Context()))
},
}

cmd.Flags().StringVar(&flags.description, "description", "", "Inline mode: human-readable summary of the skill")
cmd.Flags().StringVar(&flags.instructions, "instructions", "", "Inline mode: Markdown body defining skill behavior")
cmd.Flags().StringVar(
&flags.file,
"file",
"",
"Path to SKILL.md, a .zip package, or a directory containing SKILL.md at its root",
)
azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{
Name: "output", AllowedValues: []string{outputJSON, outputTable}, Default: outputJSON,
})
return cmd
}
Loading
Loading