From b367bee446fcd77a0b561a31de295e8522fe20bd Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Wed, 27 May 2026 16:11:43 +0800 Subject: [PATCH 1/9] feat(toolboxes): support skills on toolbox versions Adds the skills field to ToolboxVersionObject and CreateToolboxVersionRequest, plumbed through: - toolbox create --from-file: accepts an optional skills[] block. - toolbox skill add/remove/list: post-creation mutation subgroup (no dedicated REST endpoint; each mutation publishes a new immutable version via createToolboxVersion). skill remove allows removing the last skill (skills are optional content). - connection add/remove: carry forward existing skills verbatim into new versions. - toolbox show / toolbox version list: surface skill count + per-skill table. --- .../azure.ai.toolboxes/internal/cmd/root.go | 1 + .../internal/cmd/toolbox_commands_test.go | 137 ++++++++++ .../internal/cmd/toolbox_connection_add.go | 1 + .../internal/cmd/toolbox_connection_remove.go | 1 + .../internal/cmd/toolbox_create.go | 14 + .../internal/cmd/toolbox_files.go | 37 ++- .../internal/cmd/toolbox_help.go | 11 + .../internal/cmd/toolbox_show.go | 20 ++ .../internal/cmd/toolbox_skill.go | 119 ++++++++ .../internal/cmd/toolbox_skill_add.go | 146 ++++++++++ .../internal/cmd/toolbox_skill_group.go | 49 ++++ .../internal/cmd/toolbox_skill_list.go | 101 +++++++ .../internal/cmd/toolbox_skill_remove.go | 174 ++++++++++++ .../internal/cmd/toolbox_skill_test.go | 148 ++++++++++ .../internal/cmd/toolbox_skill_verbs_test.go | 257 ++++++++++++++++++ .../internal/cmd/toolbox_version_list.go | 22 +- .../internal/exterrors/codes.go | 5 + .../pkg/azure/foundry_toolsets_client.go | 5 + 18 files changed, 1223 insertions(+), 25 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_list.go create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go index 2daac24407a..90a618299ef 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go @@ -48,6 +48,7 @@ an explicit update to retarget the default.`, rootCmd.AddCommand(newToolboxListCommand(extCtx)) rootCmd.AddCommand(newToolboxVersionCommand(extCtx)) rootCmd.AddCommand(newToolboxConnectionCommand(extCtx)) + rootCmd.AddCommand(newToolboxSkillCommand(extCtx)) rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go index 2ef1a1c18c1..e78f6c15dfe 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go @@ -422,6 +422,77 @@ connections: assert.Len(t, client.createVersionCalls[0].req.Tools, 1) } +func TestRunToolboxCreateWith_SkillsFromFile(t *testing.T) { + client := newMockToolboxClient("https://e/") + resolver := newStubConnectionResolver() + resolver.byName["mcp"] = &projectConnection{ + ID: "/c/mcp", Category: connections.ConnectionTypeRemoteTool, Name: "mcp", + Target: "https://mcp.example.com", + } + + inputPath := t.TempDir() + "/create.yaml" + require.NoError(t, os.WriteFile(inputPath, []byte(` +description: tb with skills +connections: + - name: mcp +skills: + - name: pinned + version: "3" + - name: unpinned +`), 0o600)) + + err := runToolboxCreateWith( + t.Context(), client, resolver, "https://e/", "tb", + toolboxCreateFlags{fromFile: inputPath}, + toolboxFlags{output: "json"}, + ) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1) + + skills := client.createVersionCalls[0].req.Skills + require.Len(t, skills, 2) + + byName := map[string]map[string]any{} + for _, s := range skills { + n, _ := s["name"].(string) + byName[n] = s + } + require.Contains(t, byName, "pinned") + require.Contains(t, byName, "unpinned") + assert.Equal(t, "skill_reference", byName["pinned"]["type"]) + assert.Equal(t, "3", byName["pinned"]["version"]) + _, hasVersion := byName["unpinned"]["version"] + assert.False(t, hasVersion, "skill without version must omit the version key") +} + +func TestRunToolboxCreateWith_DuplicateSkillRejected(t *testing.T) { + client := newMockToolboxClient("https://e/") + resolver := newStubConnectionResolver() + resolver.byName["mcp"] = &projectConnection{ + ID: "/c/mcp", Category: connections.ConnectionTypeRemoteTool, Name: "mcp", + Target: "https://mcp.example.com", + } + + inputPath := t.TempDir() + "/create.yaml" + require.NoError(t, os.WriteFile(inputPath, []byte(` +description: tb +connections: + - name: mcp +skills: + - name: dup + - name: dup + version: "2" +`), 0o600)) + + err := runToolboxCreateWith( + t.Context(), client, resolver, "https://e/", "tb", + toolboxCreateFlags{fromFile: inputPath}, + toolboxFlags{output: "json"}, + ) + requireLocalError(t, err, exterrors.CodeDuplicateSkill) + assert.Empty(t, client.createVersionCalls, "no version should be created when local validation fails") +} + func TestRunToolboxCreateWith_AlreadyExists(t *testing.T) { client := newMockToolboxClient("https://e/") client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{Name: "tb", DefaultVersion: "1"}} @@ -514,3 +585,69 @@ func TestRunConnectionRemove_NoPromptWithoutForce(t *testing.T) { ) requireLocalError(t, err, exterrors.CodeMissingForceFlag) } + +// Carry-forward: skills attached to the current default version must survive +// across new versions published by `connection add`. +func TestRunConnectionAddWith_CarriesForwardSkills(t *testing.T) { + skills := []map[string]any{ + {"type": "skill_reference", "name": "alpha", "version": "1"}, + {"type": "skill_reference", "name": "beta"}, + } + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", Description: "first", + Tools: []map[string]any{ + {"type": "mcp", "name": "a", "project_connection_id": "/c/a"}, + }, + Skills: skills, + }} + resolver := newStubConnectionResolver() + resolver.byName["b"] = &projectConnection{ + ID: "/c/b", Category: connections.ConnectionTypeRemoteTool, Name: "b", Target: "https://mcp-b", + } + + err := runConnectionAddWith( + t.Context(), client, resolver, "https://e/", + "tb", "b", connectionAddFlags{}, toolboxFlags{output: "json"}, + ) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1) + assert.Equal(t, skills, client.createVersionCalls[0].req.Skills, + "skills must be carried forward verbatim into the new version") +} + +// Carry-forward: skills attached to the current default version must survive +// across new versions published by `connection remove`. +func TestRunConnectionRemoveWith_CarriesForwardSkills(t *testing.T) { + skills := []map[string]any{ + {"type": "skill_reference", "name": "alpha"}, + } + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{ + {"type": "mcp", "name": "a", "project_connection_id": "/c/a"}, + {"type": "mcp", "name": "b", "project_connection_id": "/c/b"}, + }, + Skills: skills, + }} + resolver := newStubConnectionResolver() + resolver.byName["a"] = &projectConnection{ + ID: "/c/a", Category: connections.ConnectionTypeRemoteTool, Name: "a", + } + + err := runConnectionRemoveWith( + t.Context(), client, resolver, "https://e/", + "tb", "a", connectionRemoveFlags{force: true}, toolboxFlags{output: "json"}, + ) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1) + assert.Equal(t, skills, client.createVersionCalls[0].req.Skills, + "skills must be carried forward verbatim into the new version") +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go index 3394384f5d8..64d9c681048 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go @@ -237,6 +237,7 @@ func runConnectionAddWith( Description: current.Description, Metadata: current.Metadata, Tools: newTools, + Skills: current.Skills, } created, err := client.CreateToolboxVersion(ctx, toolboxName, req) if err != nil { diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go index 246c933092b..34bc2a4e956 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go @@ -166,6 +166,7 @@ func runConnectionRemoveWith( Description: current.Description, Metadata: current.Metadata, Tools: filtered, + Skills: current.Skills, } created, err := client.CreateToolboxVersion(ctx, toolboxName, req) if err != nil { diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go index befe79c88cf..74048a6e870 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go @@ -113,6 +113,7 @@ func runToolboxCreateWith( description := "" entries := []map[string]any{} + skillEntries := []map[string]any{} if strings.TrimSpace(verb.fromFile) != "" { var input toolboxCreateFile @@ -125,6 +126,15 @@ func runToolboxCreateWith( return err } entries = append(entries, resolvedEntries...) + for _, s := range input.Skills { + if err := validateSkillName(s.Name); err != nil { + return err + } + skillEntries = append(skillEntries, buildSkillEntry(skillSpec{ + Name: strings.TrimSpace(s.Name), + Version: strings.TrimSpace(s.Version), + })) + } } if len(entries) == 0 { @@ -138,10 +148,14 @@ func runToolboxCreateWith( if err := validateNoDuplicateConnectionIDs(entries); err != nil { return err } + if err := validateNoDuplicateSkills(skillEntries); err != nil { + return err + } req := &azure.CreateToolboxVersionRequest{ Description: description, Tools: entries, + Skills: skillEntries, } created, err := client.CreateToolboxVersion(ctx, name, req) if err != nil { diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go index f84866d0c7c..1dd366fa84d 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go @@ -25,23 +25,25 @@ type toolboxConnectionSpec struct { InstanceName string `json:"instance_name,omitempty" yaml:"instance_name,omitempty"` } +// toolboxSkillSpec is one skill reference input for the file shape. Empty +// Version means "use the skill's default version". +type toolboxSkillSpec struct { + Name string `json:"name" yaml:"name"` + Version string `json:"version,omitempty" yaml:"version,omitempty"` +} + // toolboxToolsFile is the file shape for `toolbox connection add --from-file`. -// -// Each connections[] item resolves through the project's connections -// data-plane and is converted into a service tool entry. The toolbox's -// existing description and metadata are carried forward; the file does not -// accept `description` (set at create time only in v1). +// Description and skills are not accepted here; use `skill add`/`skill remove` +// to change skills, and set description at create time. type toolboxToolsFile struct { Connections []toolboxConnectionSpec `json:"connections,omitempty" yaml:"connections,omitempty"` } // toolboxCreateFile is the file shape for `toolbox create --from-file`. -// -// description is optional and stored on the initial version. -// connections[] is required and lists existing project connections to attach. type toolboxCreateFile struct { Description string `json:"description,omitempty" yaml:"description,omitempty"` Connections []toolboxConnectionSpec `json:"connections,omitempty" yaml:"connections,omitempty"` + Skills []toolboxSkillSpec `json:"skills,omitempty" yaml:"skills,omitempty"` } // parseToolboxFile reads a JSON or YAML file into out. Unknown fields are @@ -92,15 +94,20 @@ func parseToolboxFile(path string, out any) error { } } -// suggestionForParseError returns a context-aware fix-it hint. The common -// surprise is putting `description` in a `connection add` file (the field -// only applies to `create`); call that out explicitly so the user does not -// have to read the file-shape doc to know why their description was rejected. +// suggestionForParseError returns a context-aware fix-it hint for common +// shape mistakes (e.g. putting `description` or `skills` in a `connection add` +// file). func suggestionForParseError(out any, err error) string { msg := err.Error() - if _, ok := out.(*toolboxToolsFile); ok && strings.Contains(msg, "description") { - return "the 'description' field is only accepted by `toolbox create`; " + - "in v1 a toolbox's description is set at create time and cannot be changed later" + if _, ok := out.(*toolboxToolsFile); ok { + switch { + case strings.Contains(msg, "description"): + return "the 'description' field is only accepted by `toolbox create`; " + + "in v1 a toolbox's description is set at create time and cannot be changed later" + case strings.Contains(msg, "skills"): + return "the 'skills' field is only accepted by `toolbox create`; " + + "skills attached at create time are carried forward across `connection add`/`remove` automatically" + } } return "fix the file and retry; see `azd ai toolbox create --help` " + "or `azd ai toolbox connection add --help` for the supported file shape" diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go index 34ee76bbd8f..751fdca1f1a 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go @@ -20,6 +20,10 @@ func fileShapeBlurb(includeDescription bool) string { { "name": "my-search", "index": "products" }, { "name": "my-bing", "instance_name": "docs-config" }, { "name": "my-a2a" } + ], + "skills": [ + { "name": "my-skill", "version": "2" }, + { "name": "qa-skill" } ] } @@ -33,6 +37,10 @@ Equivalent YAML: - name: my-bing instance_name: docs-config - name: my-a2a + skills: + - name: my-skill + version: "2" + - name: qa-skill Fields: description Optional. Stored on the initial toolbox version. @@ -44,6 +52,9 @@ Fields: Supported connection categories: RemoteTool (MCP), CognitiveSearch (Azure AI Search), RemoteA2A, GroundingWithCustomSearch. + skills Optional. Existing project skills to attach by reference. + Each entry needs 'name'; 'version' is optional (omit to + follow the skill's default version). Project connections must already exist on the Foundry project; this command does not create them. Run 'azd ai agent connection list' to see available diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go index 01ac0100101..2d7bece5f3b 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go @@ -142,6 +142,7 @@ func emitShowTable( fmt.Fprintf(w, "Description\t%s\n", version.Description) fmt.Fprintf(w, "Endpoint\t%s\n", mcpURL) fmt.Fprintf(w, "Tools\t%d\n", len(version.Tools)) + fmt.Fprintf(w, "Skills\t%d\n", len(version.Skills)) if err := w.Flush(); err != nil { return err } @@ -161,6 +162,25 @@ func emitShowTable( return err } } + + if len(version.Skills) > 0 { + fmt.Println() + tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "SKILL\tVERSION\tTYPE") + fmt.Fprintln(tw, "-----\t-------\t----") + for _, sk := range version.Skills { + name, _ := sk["name"].(string) + skType, _ := sk["type"].(string) + ver, _ := sk["version"].(string) + if ver == "" { + ver = "(default)" + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", name, ver, skType) + } + if err := tw.Flush(); err != nil { + return err + } + } return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go new file mode 100644 index 00000000000..c4924c3d3e7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "regexp" + "slices" + "strings" + + "azure.ai.toolboxes/internal/exterrors" +) + +// skillNamePattern matches the SkillName scalar in the Foundry Skills spec: +// lowercase letters / digits / hyphens, must not start or end with a hyphen, +// max 64 chars. Duplicated from azure.ai.skills' validateSkillName because the +// extensions are separate Go modules; keep both in lockstep if the scalar +// changes. +var skillNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$`) + +const skillNameMaxLen = 64 + +// skillSpec is the parsed form of a --skill or skills[] entry. Empty Version +// means "use the skill's default version" per the ToolboxSkillReference +// contract. +type skillSpec struct { + Name string + Version string +} + +// parseSkillFlag parses `` or `@`. Version is opaque and +// passed to the service verbatim. +func parseSkillFlag(s string) (skillSpec, error) { + trimmed := strings.TrimSpace(s) + if trimmed == "" { + return skillSpec{}, exterrors.Validation( + exterrors.CodeInvalidSkillSpec, + "--skill value must not be empty", + "pass --skill [@]", + ) + } + + name := trimmed + version := "" + if at := strings.IndexByte(trimmed, '@'); at >= 0 { + name = trimmed[:at] + version = strings.TrimSpace(trimmed[at+1:]) + if version == "" { + return skillSpec{}, exterrors.Validation( + exterrors.CodeInvalidSkillSpec, + fmt.Sprintf("--skill %q has an empty version after '@'", trimmed), + "either drop the trailing '@' to use the skill's default version, "+ + "or pass @", + ) + } + } + + if err := validateSkillName(name); err != nil { + return skillSpec{}, err + } + return skillSpec{Name: name, Version: version}, nil +} + +// validateSkillName enforces the SkillName regex + length cap. +func validateSkillName(name string) error { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return exterrors.Validation( + exterrors.CodeInvalidSkillName, + "skill name must not be empty", + "pass a non-empty skill name", + ) + } + if len(trimmed) > skillNameMaxLen || !skillNamePattern.MatchString(trimmed) { + return exterrors.Validation( + exterrors.CodeInvalidSkillName, + fmt.Sprintf("skill name %q is invalid", trimmed), + "use 1-64 lowercase letters, digits, and hyphens; "+ + "must not start or end with a hyphen", + ) + } + return nil +} + +// buildSkillEntry returns the wire map for a ToolboxSkillReference (the only +// ToolboxSkill variant in the spec today). +func buildSkillEntry(spec skillSpec) map[string]any { + entry := map[string]any{ + "type": "skill_reference", + "name": spec.Name, + } + if spec.Version != "" { + entry["version"] = spec.Version + } + return entry +} + +// validateNoDuplicateSkills rejects two skills[] entries with the same name. +// The service may also reject this; the local check produces a sharper error. +func validateNoDuplicateSkills(entries []map[string]any) error { + names := make([]string, 0, len(entries)) + for _, e := range entries { + if n, ok := e["name"].(string); ok && n != "" { + names = append(names, n) + } + } + slices.Sort(names) + for i := 1; i < len(names); i++ { + if names[i] == names[i-1] { + return exterrors.Validation( + exterrors.CodeDuplicateSkill, + fmt.Sprintf("skill %q appears more than once in the input", names[i]), + "remove duplicate --skill entries (or duplicate skills[] entries in the file)", + ) + } + } + return nil +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go new file mode 100644 index 00000000000..3fc53df72c1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go @@ -0,0 +1,146 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "slices" + + "azure.ai.toolboxes/internal/exterrors" + "azure.ai.toolboxes/internal/pkg/azure" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// newToolboxSkillAddCommand returns the `skill add` command. +func newToolboxSkillAddCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "add [@]", + Short: "Attach a skill reference to a toolbox.", + Long: `Attach a skill reference to a toolbox. + +Publishes a new default version with the skill appended. When the version is +omitted, the reference resolves to the skill's default version at read time. + +Examples: + + azd ai toolbox skill add research my-skill + azd ai toolbox skill add research my-skill@2 +`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runSkillAdd(cmd.Context(), args[0], args[1], readToolboxFlags(cmd, extCtx)) + }, + } + registerToolboxOutputFlag(cmd) + return cmd +} + +func runSkillAdd(ctx context.Context, toolboxName, rawSkill string, parent toolboxFlags) error { + if err := validateToolboxName(toolboxName); err != nil { + return err + } + if err := validateOutputFormat(parent.output); err != nil { + return err + } + + client, resolved, err := resolveToolboxAndClient(ctx, parent) + if err != nil { + return err + } + logResolvedEndpoint("toolbox skill add", resolved) + + return runSkillAddWith(ctx, client, toolboxName, rawSkill, parent) +} + +// runSkillAddWith is the testable core. +func runSkillAddWith( + ctx context.Context, client toolboxClient, + toolboxName, rawSkill string, parent toolboxFlags, +) error { + spec, err := parseSkillFlag(rawSkill) + if err != nil { + return err + } + + tb, err := client.GetToolbox(ctx, toolboxName) + if err != nil { + return toolboxNotFoundOrService(err, toolboxName, exterrors.OpGetToolbox) + } + current, err := client.GetToolboxVersion(ctx, toolboxName, tb.DefaultVersion) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetToolboxVersion) + } + + if findSkillEntry(current.Skills, spec.Name) >= 0 { + return exterrors.Validation( + exterrors.CodeSkillAlreadyAttached, + fmt.Sprintf( + "skill %q is already attached to toolbox %q's current default version", + spec.Name, toolboxName, + ), + fmt.Sprintf( + "remove the existing reference with `azd ai toolbox skill remove %q %q` first", + toolboxName, spec.Name, + ), + ) + } + + newSkills := slices.Clone(current.Skills) + newSkills = append(newSkills, buildSkillEntry(spec)) + + req := &azure.CreateToolboxVersionRequest{ + Description: current.Description, + Metadata: current.Metadata, + Tools: current.Tools, + Skills: newSkills, + } + created, err := client.CreateToolboxVersion(ctx, toolboxName, req) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateToolboxVersion) + } + if _, err := client.SetDefaultVersion(ctx, toolboxName, created.Version); err != nil { + return exterrors.Dependency( + exterrors.CodeSetDefaultVersionFailed, + fmt.Sprintf( + "toolbox %q version %q was created but could not be promoted to default: %s", + toolboxName, created.Version, err, + ), + fmt.Sprintf( + "run `azd ai toolbox update %q --default-version %q` to retarget the default", + toolboxName, created.Version, + ), + ) + } + + return emitSkillAddResult(toolboxName, created.Version, spec, parent.output) +} + +func emitSkillAddResult(toolboxName, newVersion string, spec skillSpec, output string) error { + if output == "json" { + payload := map[string]any{ + "toolbox": toolboxName, + "version": newVersion, + "skill": spec.Name, + } + if spec.Version != "" { + payload["skill_version"] = spec.Version + } + return emitJSON(payload) + } + + pinned := "" + if spec.Version != "" { + pinned = "@" + spec.Version + } + fmt.Printf( + "Attached skill %s%s to toolbox %s (now at version %s).\n", + spec.Name, pinned, toolboxName, newVersion, + ) + return nil +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go new file mode 100644 index 00000000000..5d2598095c7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// newToolboxSkillCommand returns the `azd ai toolbox skill` parent. +func newToolboxSkillCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + cmd := &cobra.Command{ + Use: "skill", + Short: "Manage skill references attached to a toolbox.", + Long: `Manage skill references attached to a toolbox. + +Each add/remove publishes a new immutable version and retargets the toolbox +default.`, + } + cmd.AddCommand(newToolboxSkillAddCommand(extCtx)) + cmd.AddCommand(newToolboxSkillRemoveCommand(extCtx)) + cmd.AddCommand(newToolboxSkillListCommand(extCtx)) + return cmd +} + +// findSkillEntry returns the index of the first entry in skills[] whose name +// matches, or -1 if absent. +func findSkillEntry(skills []map[string]any, name string) int { + for i, s := range skills { + if n, ok := s["name"].(string); ok && n == name { + return i + } + } + return -1 +} + +// filterOutSkill returns skills[] with the first matching entry stripped. +func filterOutSkill(skills []map[string]any, name string) (result []map[string]any, removed bool) { + idx := findSkillEntry(skills, name) + if idx < 0 { + return skills, false + } + result = make([]map[string]any, 0, len(skills)-1) + result = append(result, skills[:idx]...) + result = append(result, skills[idx+1:]...) + return result, true +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_list.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_list.go new file mode 100644 index 00000000000..e234cb91d30 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_list.go @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "os" + "text/tabwriter" + + "azure.ai.toolboxes/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// newToolboxSkillListCommand returns the `skill list` command. +func newToolboxSkillListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "list ", + Short: "List the skill references attached to a toolbox.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runSkillList(cmd.Context(), args[0], readToolboxFlags(cmd, extCtx)) + }, + } + registerToolboxOutputFlag(cmd) + return cmd +} + +func runSkillList(ctx context.Context, toolboxName string, parent toolboxFlags) error { + if err := validateToolboxName(toolboxName); err != nil { + return err + } + if err := validateOutputFormat(parent.output); err != nil { + return err + } + + client, resolved, err := resolveToolboxAndClient(ctx, parent) + if err != nil { + return err + } + logResolvedEndpoint("toolbox skill list", resolved) + + return runSkillListWith(ctx, client, toolboxName, parent) +} + +func runSkillListWith( + ctx context.Context, client toolboxClient, toolboxName string, parent toolboxFlags, +) error { + tb, err := client.GetToolbox(ctx, toolboxName) + if err != nil { + return toolboxNotFoundOrService(err, toolboxName, exterrors.OpGetToolbox) + } + version, err := client.GetToolboxVersion(ctx, toolboxName, tb.DefaultVersion) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetToolboxVersion) + } + + rows := extractSkillRows(version.Skills) + + if parent.output == "json" { + return emitJSON(map[string]any{"skills": rows}) + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tVERSION\tTYPE") + fmt.Fprintln(w, "----\t-------\t----") + for _, r := range rows { + ver := r["version"] + if ver == "" { + ver = "(default)" + } + fmt.Fprintf(w, "%s\t%s\t%s\n", r["name"], ver, r["type"]) + } + return w.Flush() +} + +// extractSkillRows reduces ToolboxSkill discriminator maps to the fields +// surfaced in `skill list` output. Empty version renders as "(default)" in +// table mode. +func extractSkillRows(skills []map[string]any) []map[string]string { + rows := make([]map[string]string, 0, len(skills)) + for _, s := range skills { + name, _ := s["name"].(string) + if name == "" { + continue + } + skType, _ := s["type"].(string) + ver, _ := s["version"].(string) + rows = append(rows, map[string]string{ + "name": name, + "version": ver, + "type": skType, + }) + } + return rows +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go new file mode 100644 index 00000000000..f81ca106220 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go @@ -0,0 +1,174 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + + "azure.ai.toolboxes/internal/exterrors" + "azure.ai.toolboxes/internal/pkg/azure" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// skillRemoveFlags carries the verb-specific flags for `skill remove`. +type skillRemoveFlags struct { + force bool +} + +// newToolboxSkillRemoveCommand returns the `skill remove` command. +func newToolboxSkillRemoveCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + flags := &skillRemoveFlags{} + + cmd := &cobra.Command{ + Use: "remove ", + Short: "Detach a skill reference from a toolbox.", + Long: `Detach a skill reference from a toolbox. + +Publishes a new default version with the named skill stripped. Removing the +last skill is allowed.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runSkillRemove( + cmd.Context(), args[0], args[1], *flags, readToolboxFlags(cmd, extCtx), + ) + }, + } + cmd.Flags().BoolVar( + &flags.force, "force", false, + "Skip confirmation prompts and apply the removal immediately.", + ) + registerToolboxOutputFlag(cmd) + return cmd +} + +func runSkillRemove( + ctx context.Context, toolboxName, skillName string, + verb skillRemoveFlags, parent toolboxFlags, +) error { + if err := validateToolboxName(toolboxName); err != nil { + return err + } + if err := validateSkillName(skillName); err != nil { + return err + } + if err := validateOutputFormat(parent.output); err != nil { + return err + } + if parent.noPrompt && !verb.force { + return exterrors.Validation( + exterrors.CodeMissingForceFlag, + "--no-prompt requires --force for skill removal", + "add --force to confirm the operation non-interactively", + ) + } + + client, resolved, err := resolveToolboxAndClient(ctx, parent) + if err != nil { + return err + } + logResolvedEndpoint("toolbox skill remove", resolved) + + return runSkillRemoveWith(ctx, client, toolboxName, skillName, verb, parent) +} + +// runSkillRemoveWith is the testable core. +func runSkillRemoveWith( + ctx context.Context, client toolboxClient, + toolboxName, skillName string, + verb skillRemoveFlags, parent toolboxFlags, +) error { + tb, err := client.GetToolbox(ctx, toolboxName) + if err != nil { + return toolboxNotFoundOrService(err, toolboxName, exterrors.OpGetToolbox) + } + current, err := client.GetToolboxVersion(ctx, toolboxName, tb.DefaultVersion) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpGetToolboxVersion) + } + + filtered, removed := filterOutSkill(current.Skills, skillName) + if !removed { + return exterrors.Validation( + exterrors.CodeSkillNotInToolbox, + fmt.Sprintf( + "skill %q is not attached to toolbox %q's current default version", + skillName, toolboxName, + ), + fmt.Sprintf("run 'azd ai toolbox skill list %q'", toolboxName), + ) + } + + if !verb.force { + shouldProceed := true + err := withAzdClient(func(azdClient *azdext.AzdClient) error { + confirmed, err := confirmToolboxDelete( + ctx, + azdClient, + fmt.Sprintf( + "Detach skill %q from toolbox %q (publishes a new version)?", + skillName, toolboxName, + ), + ) + if err != nil { + return err + } + if !confirmed { + shouldProceed = false + fmt.Println("Aborted.") + } + return nil + }) + if err != nil { + return err + } + if !shouldProceed { + return nil + } + } + + req := &azure.CreateToolboxVersionRequest{ + Description: current.Description, + Metadata: current.Metadata, + Tools: current.Tools, + Skills: filtered, + } + created, err := client.CreateToolboxVersion(ctx, toolboxName, req) + if err != nil { + return exterrors.ServiceFromAzure(err, exterrors.OpCreateToolboxVersion) + } + if _, err := client.SetDefaultVersion(ctx, toolboxName, created.Version); err != nil { + return exterrors.Dependency( + exterrors.CodeSetDefaultVersionFailed, + fmt.Sprintf( + "toolbox %q version %q was created but could not be promoted to default: %s", + toolboxName, created.Version, err, + ), + fmt.Sprintf( + "run `azd ai toolbox update %q --default-version %q` to retarget the default", + toolboxName, created.Version, + ), + ) + } + + return emitSkillRemoveResult(toolboxName, created.Version, skillName, parent.output) +} + +func emitSkillRemoveResult(toolboxName, newVersion, skillName, output string) error { + if output == "json" { + return emitJSON(map[string]any{ + "toolbox": toolboxName, + "version": newVersion, + "skill": skillName, + }) + } + fmt.Printf( + "Detached skill %s from toolbox %s (now at version %s).\n", + skillName, toolboxName, newVersion, + ) + return nil +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go new file mode 100644 index 00000000000..8793f883e15 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azure.ai.toolboxes/internal/exterrors" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseSkillFlag(t *testing.T) { + t.Run("bare name", func(t *testing.T) { + spec, err := parseSkillFlag("my-skill") + require.NoError(t, err) + assert.Equal(t, "my-skill", spec.Name) + assert.Empty(t, spec.Version) + }) + + t.Run("name with version", func(t *testing.T) { + spec, err := parseSkillFlag("my-skill@2") + require.NoError(t, err) + assert.Equal(t, "my-skill", spec.Name) + assert.Equal(t, "2", spec.Version) + }) + + t.Run("version with whitespace trimmed", func(t *testing.T) { + spec, err := parseSkillFlag(" qa-skill@ v1.0.0 ") + require.NoError(t, err) + assert.Equal(t, "qa-skill", spec.Name) + assert.Equal(t, "v1.0.0", spec.Version) + }) + + t.Run("empty rejected", func(t *testing.T) { + _, err := parseSkillFlag("") + requireLocalError(t, err, exterrors.CodeInvalidSkillSpec) + }) + + t.Run("whitespace-only rejected", func(t *testing.T) { + _, err := parseSkillFlag(" ") + requireLocalError(t, err, exterrors.CodeInvalidSkillSpec) + }) + + t.Run("trailing @ rejected", func(t *testing.T) { + _, err := parseSkillFlag("my-skill@") + requireLocalError(t, err, exterrors.CodeInvalidSkillSpec) + }) + + t.Run("trailing @whitespace rejected", func(t *testing.T) { + _, err := parseSkillFlag("my-skill@ ") + requireLocalError(t, err, exterrors.CodeInvalidSkillSpec) + }) + + t.Run("uppercase name rejected", func(t *testing.T) { + _, err := parseSkillFlag("MySkill") + requireLocalError(t, err, exterrors.CodeInvalidSkillName) + }) + + t.Run("leading hyphen rejected", func(t *testing.T) { + _, err := parseSkillFlag("-skill") + requireLocalError(t, err, exterrors.CodeInvalidSkillName) + }) + + t.Run("trailing hyphen rejected", func(t *testing.T) { + _, err := parseSkillFlag("skill-") + requireLocalError(t, err, exterrors.CodeInvalidSkillName) + }) + + t.Run("underscore rejected", func(t *testing.T) { + _, err := parseSkillFlag("my_skill") + requireLocalError(t, err, exterrors.CodeInvalidSkillName) + }) + + t.Run("over 64 chars rejected", func(t *testing.T) { + long := "" + for range 65 { + long += "a" + } + _, err := parseSkillFlag(long) + requireLocalError(t, err, exterrors.CodeInvalidSkillName) + }) + + t.Run("exactly 64 chars accepted", func(t *testing.T) { + long := "" + for range 64 { + long += "a" + } + spec, err := parseSkillFlag(long) + require.NoError(t, err) + assert.Equal(t, long, spec.Name) + }) +} + +func TestBuildSkillEntry(t *testing.T) { + t.Run("with version", func(t *testing.T) { + entry := buildSkillEntry(skillSpec{Name: "my-skill", Version: "2"}) + assert.Equal(t, "skill_reference", entry["type"]) + assert.Equal(t, "my-skill", entry["name"]) + assert.Equal(t, "2", entry["version"]) + }) + + t.Run("without version omits version key", func(t *testing.T) { + entry := buildSkillEntry(skillSpec{Name: "my-skill"}) + assert.Equal(t, "skill_reference", entry["type"]) + assert.Equal(t, "my-skill", entry["name"]) + _, hasVersion := entry["version"] + assert.False(t, hasVersion, "version key must be omitted when empty") + }) +} + +func TestValidateNoDuplicateSkills(t *testing.T) { + t.Run("unique names pass", func(t *testing.T) { + err := validateNoDuplicateSkills([]map[string]any{ + {"type": "skill_reference", "name": "a"}, + {"type": "skill_reference", "name": "b"}, + {"type": "skill_reference", "name": "c"}, + }) + require.NoError(t, err) + }) + + t.Run("duplicate names rejected", func(t *testing.T) { + err := validateNoDuplicateSkills([]map[string]any{ + {"type": "skill_reference", "name": "dup"}, + {"type": "skill_reference", "name": "other"}, + {"type": "skill_reference", "name": "dup"}, + }) + le := requireLocalError(t, err, exterrors.CodeDuplicateSkill) + assert.Contains(t, le.Message, "dup") + }) + + t.Run("duplicates differ in version still rejected", func(t *testing.T) { + // Pinning the same skill to two different versions is also a duplicate + // for our purposes; the service is single-row-per-name. + err := validateNoDuplicateSkills([]map[string]any{ + {"type": "skill_reference", "name": "x", "version": "1"}, + {"type": "skill_reference", "name": "x", "version": "2"}, + }) + requireLocalError(t, err, exterrors.CodeDuplicateSkill) + }) + + t.Run("empty list accepted", func(t *testing.T) { + require.NoError(t, validateNoDuplicateSkills(nil)) + require.NoError(t, validateNoDuplicateSkills([]map[string]any{})) + }) +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go new file mode 100644 index 00000000000..459c541f09d --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go @@ -0,0 +1,257 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azure.ai.toolboxes/internal/exterrors" + "azure.ai.toolboxes/internal/pkg/azure" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFindSkillEntry(t *testing.T) { + skills := []map[string]any{ + {"type": "skill_reference", "name": "alpha"}, + {"type": "skill_reference", "name": "beta", "version": "2"}, + {"type": "skill_reference", "name": "gamma"}, + } + assert.Equal(t, 0, findSkillEntry(skills, "alpha")) + assert.Equal(t, 1, findSkillEntry(skills, "beta")) + assert.Equal(t, 2, findSkillEntry(skills, "gamma")) + assert.Equal(t, -1, findSkillEntry(skills, "delta")) + assert.Equal(t, -1, findSkillEntry(nil, "any")) +} + +func TestFilterOutSkill(t *testing.T) { + skills := []map[string]any{ + {"type": "skill_reference", "name": "alpha"}, + {"type": "skill_reference", "name": "beta", "version": "2"}, + {"type": "skill_reference", "name": "gamma"}, + } + + got, removed := filterOutSkill(skills, "beta") + require.True(t, removed) + require.Len(t, got, 2) + assert.Equal(t, "alpha", got[0]["name"]) + assert.Equal(t, "gamma", got[1]["name"]) + + got2, removed2 := filterOutSkill(skills, "missing") + assert.False(t, removed2) + assert.Len(t, got2, 3, "unmodified slice returned when name not found") + + // Removing the only entry returns an empty (not nil) slice — exercises the + // "removing last skill is OK" semantic. + single := []map[string]any{{"type": "skill_reference", "name": "only"}} + got3, removed3 := filterOutSkill(single, "only") + assert.True(t, removed3) + assert.Empty(t, got3) +} + +func TestRunSkillAddWith_AppendsAndCarriesForward(t *testing.T) { + existingTools := []map[string]any{ + {"type": "mcp", "name": "a", "project_connection_id": "/c/a"}, + } + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", Description: "first", + Tools: existingTools, + Skills: []map[string]any{ + {"type": "skill_reference", "name": "already-there"}, + }, + }} + + err := runSkillAddWith(t.Context(), client, "tb", "new-skill@3", toolboxFlags{output: "json"}) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1) + + req := client.createVersionCalls[0].req + assert.Equal(t, "first", req.Description, "description carried forward") + assert.Equal(t, existingTools, req.Tools, "tools carried forward verbatim") + + require.Len(t, req.Skills, 2, "existing skill + new skill") + assert.Equal(t, "already-there", req.Skills[0]["name"]) + assert.Equal(t, "new-skill", req.Skills[1]["name"]) + assert.Equal(t, "3", req.Skills[1]["version"]) + assert.Equal(t, "skill_reference", req.Skills[1]["type"]) + + require.Len(t, client.setDefaultCalls, 1, "new version must be promoted to default") +} + +func TestRunSkillAddWith_NoExistingSkills(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{ + {"type": "mcp", "name": "a", "project_connection_id": "/c/a"}, + }, + // Skills nil — exercises the "first skill on a toolbox without any" path. + }} + + err := runSkillAddWith(t.Context(), client, "tb", "first-skill", toolboxFlags{output: "json"}) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1) + require.Len(t, client.createVersionCalls[0].req.Skills, 1) + assert.Equal(t, "first-skill", client.createVersionCalls[0].req.Skills[0]["name"]) + _, hasVersion := client.createVersionCalls[0].req.Skills[0]["version"] + assert.False(t, hasVersion, "version key must be omitted when @ is not provided") +} + +func TestRunSkillAddWith_AlreadyAttached(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{{"type": "mcp", "name": "a", "project_connection_id": "/c/a"}}, + Skills: []map[string]any{ + {"type": "skill_reference", "name": "dup"}, + }, + }} + + err := runSkillAddWith(t.Context(), client, "tb", "dup@2", toolboxFlags{output: "json"}) + requireLocalError(t, err, exterrors.CodeSkillAlreadyAttached) + assert.Empty(t, client.createVersionCalls, "no version should be published when validation fails") +} + +func TestRunSkillAddWith_InvalidSpec(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{{"type": "mcp", "name": "a"}}, + }} + + err := runSkillAddWith(t.Context(), client, "tb", "BadName@", toolboxFlags{output: "json"}) + requireLocalError(t, err, exterrors.CodeInvalidSkillSpec) +} + +func TestRunSkillRemoveWith_FilteredAndPromoted(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{{"type": "mcp", "name": "a", "project_connection_id": "/c/a"}}, + Skills: []map[string]any{ + {"type": "skill_reference", "name": "keep"}, + {"type": "skill_reference", "name": "drop"}, + }, + }} + + err := runSkillRemoveWith( + t.Context(), client, "tb", "drop", + skillRemoveFlags{force: true}, toolboxFlags{output: "json"}, + ) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1) + skills := client.createVersionCalls[0].req.Skills + require.Len(t, skills, 1) + assert.Equal(t, "keep", skills[0]["name"]) + require.Len(t, client.setDefaultCalls, 1) +} + +// Removing the only skill is allowed (no last-skill block). +func TestRunSkillRemoveWith_LastSkillAllowed(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{{"type": "mcp", "name": "a", "project_connection_id": "/c/a"}}, + Skills: []map[string]any{ + {"type": "skill_reference", "name": "only"}, + }, + }} + + err := runSkillRemoveWith( + t.Context(), client, "tb", "only", + skillRemoveFlags{force: true}, toolboxFlags{output: "json"}, + ) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1) + assert.Empty(t, client.createVersionCalls[0].req.Skills, "removing the last skill is allowed") +} + +func TestRunSkillRemoveWith_NotAttached(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{{"type": "mcp", "name": "a"}}, + Skills: []map[string]any{ + {"type": "skill_reference", "name": "other"}, + }, + }} + + err := runSkillRemoveWith( + t.Context(), client, "tb", "missing", + skillRemoveFlags{force: true}, toolboxFlags{output: "json"}, + ) + requireLocalError(t, err, exterrors.CodeSkillNotInToolbox) +} + +func TestRunSkillRemove_NoPromptWithoutForce(t *testing.T) { + err := runSkillRemove( + t.Context(), "tb", "any-skill", + skillRemoveFlags{force: false}, + toolboxFlags{output: "table", noPrompt: true}, + ) + requireLocalError(t, err, exterrors.CodeMissingForceFlag) +} + +func TestRunSkillListWith_EmitsAllShapes(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{}, + Skills: []map[string]any{ + {"type": "skill_reference", "name": "alpha", "version": "2"}, + {"type": "skill_reference", "name": "beta"}, + }, + }} + + rows := extractSkillRows(client.versionResults["tb/1"].obj.Skills) + require.Len(t, rows, 2) + assert.Equal(t, "alpha", rows[0]["name"]) + assert.Equal(t, "2", rows[0]["version"]) + assert.Equal(t, "skill_reference", rows[0]["type"]) + assert.Equal(t, "beta", rows[1]["name"]) + assert.Empty(t, rows[1]["version"], "empty version means 'use the skill's default'") + + err := runSkillListWith(t.Context(), client, "tb", toolboxFlags{output: "json"}) + require.NoError(t, err) +} + +// extractSkillRows must skip malformed entries (defensive against unexpected +// service responses). +func TestExtractSkillRows_SkipsMalformedEntries(t *testing.T) { + skills := []map[string]any{ + {"type": "skill_reference"}, // missing name + {"type": "skill_reference", "name": ""}, // empty name + {"type": "skill_reference", "name": "ok"}, // valid + {"type": "skill_reference", "name": 42}, // wrong type for name + } + rows := extractSkillRows(skills) + require.Len(t, rows, 1) + assert.Equal(t, "ok", rows[0]["name"]) +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go index d0f62a4c1df..1dc05e6c340 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go @@ -100,13 +100,14 @@ func emitToolboxVersionListJSON(name, defaultVersion string, versions []azure.To items := make([]map[string]any, 0, len(versions)) for _, v := range versions { items = append(items, map[string]any{ - "id": v.ID, - "name": v.Name, - "version": v.Version, - "description": v.Description, - "created_at": v.CreatedAt, - "tools_count": len(v.Tools), - "is_default": v.Version == defaultVersion, + "id": v.ID, + "name": v.Name, + "version": v.Version, + "description": v.Description, + "created_at": v.CreatedAt, + "tools_count": len(v.Tools), + "skills_count": len(v.Skills), + "is_default": v.Version == defaultVersion, }) } @@ -119,8 +120,8 @@ func emitToolboxVersionListJSON(name, defaultVersion string, versions []azure.To func emitToolboxVersionListTable(name, defaultVersion string, versions []azure.ToolboxVersionObject) error { w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintln(w, "VERSION\tDEFAULT\tCREATED\tTOOLS\tDESCRIPTION") - fmt.Fprintln(w, "-------\t-------\t-------\t-----\t-----------") + fmt.Fprintln(w, "VERSION\tDEFAULT\tCREATED\tTOOLS\tSKILLS\tDESCRIPTION") + fmt.Fprintln(w, "-------\t-------\t-------\t-----\t------\t-----------") for _, v := range versions { marker := "" @@ -133,11 +134,12 @@ func emitToolboxVersionListTable(name, defaultVersion string, versions []azure.T } fmt.Fprintf( w, - "%s\t%s\t%s\t%d\t%s\n", + "%s\t%s\t%s\t%d\t%d\t%s\n", v.Version, marker, created, len(v.Tools), + len(v.Skills), v.Description, ) } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go index 2f0026f1c7d..12da870befc 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go @@ -40,6 +40,11 @@ const ( CodeUnsupportedIndexFlag = "unsupported_index_flag" CodeMissingInstanceName = "missing_instance_name" CodeUnsupportedInstanceNameFlag = "unsupported_instance_name_flag" + CodeInvalidSkillName = "invalid_skill_name" + CodeInvalidSkillSpec = "invalid_skill_spec" + CodeDuplicateSkill = "duplicate_skill" + CodeSkillNotInToolbox = "skill_not_in_toolbox" + CodeSkillAlreadyAttached = "skill_already_attached" CodeDuplicateConnection = "duplicate_connection" CodeConnectionNotFound = "connection_not_found" CodeConnectionNotInToolbox = "connection_not_in_toolbox" diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/pkg/azure/foundry_toolsets_client.go b/cli/azd/extensions/azure.ai.toolboxes/internal/pkg/azure/foundry_toolsets_client.go index 3b719e5f839..f65cbf865bf 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/pkg/azure/foundry_toolsets_client.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/pkg/azure/foundry_toolsets_client.go @@ -188,6 +188,9 @@ type CreateToolboxVersionRequest struct { Description string `json:"description,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` Tools []map[string]any `json:"tools"` + // Skills holds ToolboxSkill discriminated objects. []map[string]any keeps + // future ToolboxSkill variants flowing through without recompiling. + Skills []map[string]any `json:"skills,omitempty"` } // ToolboxObject is the lightweight response for a toolbox (no tools list). @@ -206,6 +209,8 @@ type ToolboxVersionObject struct { CreatedAt int64 `json:"created_at"` Metadata map[string]string `json:"metadata,omitempty"` Tools []map[string]any `json:"tools"` + // Skills has no omitempty: the service always emits "skills":[] on reads. + Skills []map[string]any `json:"skills"` } // toolboxURL builds the canonical toolboxes URL with the api-version query. From bc14222380c15816e996d3a6cc0ce88cd8f58bf3 Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Wed, 27 May 2026 17:10:12 +0800 Subject: [PATCH 2/9] fix(toolboxes): apply go fix modernizations Replace strings.IndexByte/slicing with strings.Cut in parseSkillFlag, and string-append loops with strings.Builder in the over/exactly-64-chars tests. CI enforces these via 'go fix ./...'. --- .../internal/cmd/toolbox_skill.go | 6 +++--- .../internal/cmd/toolbox_skill_test.go | 15 ++++++++------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go index c4924c3d3e7..57ba4a6177b 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go @@ -43,9 +43,9 @@ func parseSkillFlag(s string) (skillSpec, error) { name := trimmed version := "" - if at := strings.IndexByte(trimmed, '@'); at >= 0 { - name = trimmed[:at] - version = strings.TrimSpace(trimmed[at+1:]) + if before, after, ok := strings.Cut(trimmed, "@"); ok { + name = before + version = strings.TrimSpace(after) if version == "" { return skillSpec{}, exterrors.Validation( exterrors.CodeInvalidSkillSpec, diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go index 8793f883e15..06f8a67c2ec 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go @@ -4,6 +4,7 @@ package cmd import ( + "strings" "testing" "azure.ai.toolboxes/internal/exterrors" @@ -75,22 +76,22 @@ func TestParseSkillFlag(t *testing.T) { }) t.Run("over 64 chars rejected", func(t *testing.T) { - long := "" + var long strings.Builder for range 65 { - long += "a" + long.WriteString("a") } - _, err := parseSkillFlag(long) + _, err := parseSkillFlag(long.String()) requireLocalError(t, err, exterrors.CodeInvalidSkillName) }) t.Run("exactly 64 chars accepted", func(t *testing.T) { - long := "" + var long strings.Builder for range 64 { - long += "a" + long.WriteString("a") } - spec, err := parseSkillFlag(long) + spec, err := parseSkillFlag(long.String()) require.NoError(t, err) - assert.Equal(t, long, spec.Name) + assert.Equal(t, long.String(), spec.Name) }) } From cef15b033b0888644ea30e916b264b33e5c2b4ee Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Wed, 27 May 2026 17:30:10 +0800 Subject: [PATCH 3/9] fix(toolboxes): address copilot review feedback - parseSkillFlag now trims the name portion after splitting on '@' so input like 'my-skill @2' yields Name:'my-skill' instead of leaving a trailing space (which would break duplicate / remove lookups). - runSkillRemoveWith normalizes skillName with TrimSpace at the top, mirroring validateSkillName's internal trim so a user input of ' beta ' matches the canonical stored entry. - toolbox show skills table reuses extractSkillRows so malformed entries are skipped consistently with skill list. - toolbox create file parse-error hint points users at 'skill add' / 'skill remove' instead of implying create-time is the only way to attach skills. --- .../internal/cmd/toolbox_files.go | 2 +- .../internal/cmd/toolbox_show.go | 10 ++++---- .../internal/cmd/toolbox_skill.go | 2 +- .../internal/cmd/toolbox_skill_remove.go | 4 ++++ .../internal/cmd/toolbox_skill_test.go | 10 ++++++++ .../internal/cmd/toolbox_skill_verbs_test.go | 24 +++++++++++++++++++ 6 files changed, 45 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go index 1dd366fa84d..7aecaaec569 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go @@ -106,7 +106,7 @@ func suggestionForParseError(out any, err error) string { "in v1 a toolbox's description is set at create time and cannot be changed later" case strings.Contains(msg, "skills"): return "the 'skills' field is only accepted by `toolbox create`; " + - "skills attached at create time are carried forward across `connection add`/`remove` automatically" + "use `azd ai toolbox skill add` / `skill remove` to change skills on an existing toolbox" } } return "fix the file and retry; see `azd ai toolbox create --help` " + diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go index 2d7bece5f3b..a39e7463fbb 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_show.go @@ -168,14 +168,14 @@ func emitShowTable( tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) fmt.Fprintln(tw, "SKILL\tVERSION\tTYPE") fmt.Fprintln(tw, "-----\t-------\t----") - for _, sk := range version.Skills { - name, _ := sk["name"].(string) - skType, _ := sk["type"].(string) - ver, _ := sk["version"].(string) + // Use extractSkillRows so malformed entries are skipped consistently + // with `skill list`. + for _, r := range extractSkillRows(version.Skills) { + ver := r["version"] if ver == "" { ver = "(default)" } - fmt.Fprintf(tw, "%s\t%s\t%s\n", name, ver, skType) + fmt.Fprintf(tw, "%s\t%s\t%s\n", r["name"], ver, r["type"]) } if err := tw.Flush(); err != nil { return err diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go index 57ba4a6177b..bd299ac1073 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go @@ -44,7 +44,7 @@ func parseSkillFlag(s string) (skillSpec, error) { name := trimmed version := "" if before, after, ok := strings.Cut(trimmed, "@"); ok { - name = before + name = strings.TrimSpace(before) version = strings.TrimSpace(after) if version == "" { return skillSpec{}, exterrors.Validation( diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go index f81ca106220..6fb1104f556 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "strings" "azure.ai.toolboxes/internal/exterrors" "azure.ai.toolboxes/internal/pkg/azure" @@ -82,6 +83,9 @@ func runSkillRemoveWith( toolboxName, skillName string, verb skillRemoveFlags, parent toolboxFlags, ) error { + // Normalize whitespace so callers that pass `" beta "` match the stored + // entry (validateSkillName trims internally during input validation). + skillName = strings.TrimSpace(skillName) tb, err := client.GetToolbox(ctx, toolboxName) if err != nil { return toolboxNotFoundOrService(err, toolboxName, exterrors.OpGetToolbox) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go index 06f8a67c2ec..19ae9feb348 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_test.go @@ -35,6 +35,16 @@ func TestParseSkillFlag(t *testing.T) { assert.Equal(t, "v1.0.0", spec.Version) }) + t.Run("name with inner whitespace before @ trimmed", func(t *testing.T) { + // Regression: parseSkillFlag must not store a trailing space on Name + // after splitting on '@'. Otherwise the wire entry won't match + // duplicate / remove lookups later. + spec, err := parseSkillFlag("my-skill @2") + require.NoError(t, err) + assert.Equal(t, "my-skill", spec.Name) + assert.Equal(t, "2", spec.Version) + }) + t.Run("empty rejected", func(t *testing.T) { _, err := parseSkillFlag("") requireLocalError(t, err, exterrors.CodeInvalidSkillSpec) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go index 459c541f09d..c727907f86c 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go @@ -187,6 +187,30 @@ func TestRunSkillRemoveWith_LastSkillAllowed(t *testing.T) { assert.Empty(t, client.createVersionCalls[0].req.Skills, "removing the last skill is allowed") } +// Regression: skillName with surrounding whitespace must match the stored +// canonical entry rather than producing a misleading "not in toolbox" error. +func TestRunSkillRemoveWith_TrimsSkillName(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{{"type": "mcp", "name": "a", "project_connection_id": "/c/a"}}, + Skills: []map[string]any{ + {"type": "skill_reference", "name": "beta"}, + }, + }} + + err := runSkillRemoveWith( + t.Context(), client, "tb", " beta ", + skillRemoveFlags{force: true}, toolboxFlags{output: "json"}, + ) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1) + assert.Empty(t, client.createVersionCalls[0].req.Skills) +} + func TestRunSkillRemoveWith_NotAttached(t *testing.T) { client := newMockToolboxClient("https://e/") client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ From 22c4f28ae4ea5c9ac9f1e4e5606a929548594971 Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Thu, 28 May 2026 16:53:26 +0800 Subject: [PATCH 4/9] fix(toolboxes): drop 'v1' framing and batch input on mutation verbs Addresses two open review comments on PR #8396: - therealjohn: drop the 'in v1' phrasing from user-facing strings; it's not a CLI versioning concern, it's a service-side constraint. - trangevi: support batching on mutation verbs so users don't churn versions one item at a time: - toolbox connection remove: variadic positionals (one or many connection names). - toolbox skill add: gains a --from-file mode (skills[] block) alongside the single positional. - toolbox skill remove: variadic positionals. Each invocation publishes exactly one new toolbox version covering the whole batch. Also tightens internal docs to drop a few TypeSpec-name leaks (buildSkillEntry, the parse-error suggestion text) so the verb files don't reach for terms users won't recognize. --- .../internal/cmd/toolbox_commands_test.go | 52 +++-- .../internal/cmd/toolbox_connection_remove.go | 150 +++++++++---- .../internal/cmd/toolbox_files.go | 23 +- .../internal/cmd/toolbox_skill.go | 3 +- .../internal/cmd/toolbox_skill_add.go | 204 ++++++++++++++---- .../internal/cmd/toolbox_skill_remove.go | 118 +++++++--- .../internal/cmd/toolbox_skill_verbs_test.go | 81 +++++-- 7 files changed, 476 insertions(+), 155 deletions(-) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go index e78f6c15dfe..08ec25c6731 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go @@ -310,10 +310,7 @@ func TestRunConnectionRemoveWith_LastToolBlocks(t *testing.T) { ID: "/c/a", Category: connections.ConnectionTypeRemoteTool, Name: "a", } - err := runConnectionRemoveWith( - t.Context(), client, resolver, "https://e/", - "tb", "a", connectionRemoveFlags{force: true}, toolboxFlags{output: "table"}, - ) + err := runConnectionRemoveWith(t.Context(), client, resolver, "https://e/", "tb", []string{"a"}, connectionRemoveFlags{force: true}, toolboxFlags{output: "table"}) requireLocalError(t, err, exterrors.CodeLastToolRemoval) assert.Empty(t, client.createVersionCalls) } @@ -332,10 +329,7 @@ func TestRunConnectionRemoveWith_FilteredAndPromoted(t *testing.T) { ID: "/c/a", Category: connections.ConnectionTypeRemoteTool, Name: "a", } - err := runConnectionRemoveWith( - t.Context(), client, resolver, "https://e/", - "tb", "a", connectionRemoveFlags{force: true}, toolboxFlags{output: "json"}, - ) + err := runConnectionRemoveWith(t.Context(), client, resolver, "https://e/", "tb", []string{"a"}, connectionRemoveFlags{force: true}, toolboxFlags{output: "json"}) require.NoError(t, err) require.Len(t, client.createVersionCalls, 1) assert.Len(t, client.createVersionCalls[0].req.Tools, 1) @@ -355,10 +349,7 @@ func TestRunConnectionRemoveWith_ConnectionNotInToolbox(t *testing.T) { ID: "/c/a", Category: connections.ConnectionTypeRemoteTool, Name: "a", } - err := runConnectionRemoveWith( - t.Context(), client, resolver, "https://e/", - "tb", "a", connectionRemoveFlags{force: true}, toolboxFlags{output: "table"}, - ) + err := runConnectionRemoveWith(t.Context(), client, resolver, "https://e/", "tb", []string{"a"}, connectionRemoveFlags{force: true}, toolboxFlags{output: "table"}) requireLocalError(t, err, exterrors.CodeConnectionNotInToolbox) } @@ -577,8 +568,7 @@ func TestRunToolboxVersionListWith_ListVersionsServiceError(t *testing.T) { } func TestRunConnectionRemove_NoPromptWithoutForce(t *testing.T) { - err := runConnectionRemove( - t.Context(), "tb", "conn", + err := runConnectionRemove(t.Context(), "tb", []string{"conn"}, connectionRemoveFlags{force: false}, toolboxFlags{output: "table", noPrompt: true}, newStubConnectionResolver(), @@ -642,12 +632,38 @@ func TestRunConnectionRemoveWith_CarriesForwardSkills(t *testing.T) { ID: "/c/a", Category: connections.ConnectionTypeRemoteTool, Name: "a", } - err := runConnectionRemoveWith( - t.Context(), client, resolver, "https://e/", - "tb", "a", connectionRemoveFlags{force: true}, toolboxFlags{output: "json"}, - ) + err := runConnectionRemoveWith(t.Context(), client, resolver, "https://e/", "tb", []string{"a"}, connectionRemoveFlags{force: true}, toolboxFlags{output: "json"}) require.NoError(t, err) require.Len(t, client.createVersionCalls, 1) assert.Equal(t, skills, client.createVersionCalls[0].req.Skills, "skills must be carried forward verbatim into the new version") } + +// Batch removal via variadic positionals. +func TestRunConnectionRemoveWith_VariadicPositionals(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{ + {"type": "mcp", "name": "a", "project_connection_id": "/c/a"}, + {"type": "mcp", "name": "b", "project_connection_id": "/c/b"}, + {"type": "mcp", "name": "c", "project_connection_id": "/c/c"}, + }, + }} + resolver := newStubConnectionResolver() + resolver.byName["a"] = &projectConnection{ID: "/c/a", Name: "a", Category: connections.ConnectionTypeRemoteTool} + resolver.byName["b"] = &projectConnection{ID: "/c/b", Name: "b", Category: connections.ConnectionTypeRemoteTool} + + err := runConnectionRemoveWith( + t.Context(), client, resolver, "https://e/", + "tb", []string{"a", "b"}, + connectionRemoveFlags{force: true}, toolboxFlags{output: "json"}, + ) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1, "one new version published for the whole batch") + require.Len(t, client.createVersionCalls[0].req.Tools, 1) + assert.Equal(t, "/c/c", client.createVersionCalls[0].req.Tools[0]["project_connection_id"]) +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go index 34bc2a4e956..830295a5544 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "slices" "strings" "azure.ai.toolboxes/internal/exterrors" @@ -26,17 +27,24 @@ func newToolboxConnectionRemoveCommand(extCtx *azdext.ExtensionContext) *cobra.C flags := &connectionRemoveFlags{} cmd := &cobra.Command{ - Use: "remove ", - Short: "Detach a project connection from a toolbox.", - Long: `Detach a project connection from a toolbox. - -Publishes a new default version with the named connection's tool entry -removed. Refuses to leave the toolbox with zero tools (use 'toolbox delete' -instead).`, - Args: cobra.ExactArgs(2), + Use: "remove ...", + Short: "Detach one or more connections from a toolbox.", + Long: `Detach one or more connections from a toolbox and publish a new version. + +Pass one or more connection short names as positionals. All removals are +applied atomically: each invocation publishes exactly one new toolbox version. + +Refuses to leave the toolbox with zero tools (use 'toolbox delete' instead). + +Examples: + + azd ai toolbox connection remove research my-mcp + azd ai toolbox connection remove research a b c --force +`, + Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { return runConnectionRemove( - cmd.Context(), args[0], args[1], + cmd.Context(), args[0], args[1:], *flags, readToolboxFlags(cmd, extCtx), defaultConnectionResolver{}, @@ -52,7 +60,7 @@ instead).`, } func runConnectionRemove( - ctx context.Context, toolboxName, connName string, + ctx context.Context, toolboxName string, connNames []string, verb connectionRemoveFlags, parent toolboxFlags, resolver connectionResolver, ) error { @@ -62,13 +70,22 @@ func runConnectionRemove( if err := validateOutputFormat(parent.output); err != nil { return err } - if strings.TrimSpace(connName) == "" { + if len(connNames) == 0 { return exterrors.Validation( exterrors.CodeInvalidPositionalArg, - " must not be empty", - "pass the short name of a project connection", + "at least one must be provided", + "pass one or more connection short names", ) } + for _, n := range connNames { + if strings.TrimSpace(n) == "" { + return exterrors.Validation( + exterrors.CodeInvalidPositionalArg, + " must not be empty", + "remove empty entries from the argument list", + ) + } + } if parent.noPrompt && !verb.force { return exterrors.Validation( exterrors.CodeMissingForceFlag, @@ -84,47 +101,52 @@ func runConnectionRemove( logResolvedEndpoint("toolbox connection remove", resolved) return runConnectionRemoveWith(ctx, client, resolver, resolved.Endpoint, - toolboxName, connName, verb, parent) + toolboxName, connNames, verb, parent) } func runConnectionRemoveWith( ctx context.Context, client toolboxClient, resolver connectionResolver, - endpoint, toolboxName, connName string, + endpoint, toolboxName string, connNames []string, verb connectionRemoveFlags, parent toolboxFlags, ) error { - conn, err := resolver.resolveConnection(ctx, endpoint, connName) - if err != nil { - return err - } - tb, err := client.GetToolbox(ctx, toolboxName) if err != nil { return toolboxNotFoundOrService(err, toolboxName, exterrors.OpGetToolbox) } - current, err := client.GetToolboxVersion(ctx, toolboxName, tb.DefaultVersion) if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpGetToolboxVersion) } - filtered, removed := filterOutConnection(current.Tools, conn.ID) - if !removed { - return exterrors.Validation( - exterrors.CodeConnectionNotInToolbox, - fmt.Sprintf( - "connection %q is not attached to toolbox %q's current default version", - connName, toolboxName, - ), - fmt.Sprintf("run 'azd ai toolbox connection list %q'", toolboxName), - ) + // Resolve each name and strip from the tools[]. + filtered := slices.Clone(current.Tools) + removedConns := make([]*projectConnection, 0, len(connNames)) + for _, name := range connNames { + conn, err := resolver.resolveConnection(ctx, endpoint, name) + if err != nil { + return err + } + var didRemove bool + filtered, didRemove = filterOutConnection(filtered, conn.ID) + if !didRemove { + return exterrors.Validation( + exterrors.CodeConnectionNotInToolbox, + fmt.Sprintf( + "connection %q is not attached to toolbox %q's current default version", + name, toolboxName, + ), + fmt.Sprintf("run 'azd ai toolbox connection list %q'", toolboxName), + ) + } + removedConns = append(removedConns, conn) } if len(filtered) == 0 { return exterrors.Validation( exterrors.CodeLastToolRemoval, fmt.Sprintf( - "removing %q would leave toolbox %q with zero tools", - connName, toolboxName, + "removing the listed connections would leave toolbox %q with zero tools", + toolboxName, ), fmt.Sprintf( "delete the toolbox with `azd ai toolbox delete %q` instead", @@ -135,14 +157,14 @@ func runConnectionRemoveWith( if !verb.force { shouldProceed := true + summary := summarizeConnectionNames(removedConns) err := withAzdClient(func(azdClient *azdext.AzdClient) error { confirmed, err := confirmToolboxDelete( ctx, azdClient, fmt.Sprintf( - "Detach connection %q from toolbox %q (publishes a new version)?", - connName, - toolboxName, + "Detach %s from toolbox %q (publishes a new version)?", + summary, toolboxName, ), ) if err != nil { @@ -186,24 +208,60 @@ func runConnectionRemoveWith( ) } - return emitConnectionRemoveResult(toolboxName, created.Version, conn, parent.output) + return emitConnectionRemoveResult(toolboxName, created.Version, removedConns, parent.output) +} + +// summarizeConnectionNames renders "connection \"a\"" or "connections [\"a\", \"b\"]". +func summarizeConnectionNames(conns []*projectConnection) string { + if len(conns) == 1 { + return fmt.Sprintf("connection %q", conns[0].Name) + } + quoted := make([]string, 0, len(conns)) + for _, c := range conns { + quoted = append(quoted, fmt.Sprintf("%q", c.Name)) + } + return "connections [" + strings.Join(quoted, ", ") + "]" } func emitConnectionRemoveResult( - toolboxName, newVersion string, conn *projectConnection, output string, + toolboxName, newVersion string, conns []*projectConnection, output string, ) error { if output == "json" { - payload := map[string]any{ - "toolbox": toolboxName, - "version": newVersion, - "connection": conn.Name, - "connection_id": conn.ID, + if len(conns) == 1 { + return emitJSON(map[string]any{ + "toolbox": toolboxName, + "version": newVersion, + "connection": conns[0].Name, + "connection_id": conns[0].ID, + }) + } + rows := make([]map[string]string, 0, len(conns)) + for _, c := range conns { + rows = append(rows, map[string]string{ + "connection": c.Name, + "connection_id": c.ID, + }) } - return emitJSON(payload) + return emitJSON(map[string]any{ + "toolbox": toolboxName, + "version": newVersion, + "connections": rows, + }) + } + if len(conns) == 1 { + fmt.Printf( + "Detached connection %s from toolbox %s (now at version %s).\n", + conns[0].Name, toolboxName, newVersion, + ) + return nil + } + names := make([]string, 0, len(conns)) + for _, c := range conns { + names = append(names, c.Name) } fmt.Printf( - "Detached connection %s from toolbox %s (now at version %s).\n", - conn.Name, toolboxName, newVersion, + "Detached connections [%s] from toolbox %s (now at version %s).\n", + strings.Join(names, ", "), toolboxName, newVersion, ) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go index 7aecaaec569..a2485e5b21d 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_files.go @@ -39,6 +39,11 @@ type toolboxToolsFile struct { Connections []toolboxConnectionSpec `json:"connections,omitempty" yaml:"connections,omitempty"` } +// toolboxSkillsFile is the file shape for `toolbox skill add --from-file`. +type toolboxSkillsFile struct { + Skills []toolboxSkillSpec `json:"skills,omitempty" yaml:"skills,omitempty"` +} + // toolboxCreateFile is the file shape for `toolbox create --from-file`. type toolboxCreateFile struct { Description string `json:"description,omitempty" yaml:"description,omitempty"` @@ -103,12 +108,20 @@ func suggestionForParseError(out any, err error) string { switch { case strings.Contains(msg, "description"): return "the 'description' field is only accepted by `toolbox create`; " + - "in v1 a toolbox's description is set at create time and cannot be changed later" + "a toolbox's description is set at create time and cannot be changed later" case strings.Contains(msg, "skills"): - return "the 'skills' field is only accepted by `toolbox create`; " + - "use `azd ai toolbox skill add` / `skill remove` to change skills on an existing toolbox" + return "the 'skills' field belongs in a skills file; " + + "use `azd ai toolbox skill add --from-file` instead" + } + } + if _, ok := out.(*toolboxSkillsFile); ok { + switch { + case strings.Contains(msg, "connections"): + return "the 'connections' field belongs in a connections file; " + + "use `azd ai toolbox connection add --from-file` instead" + case strings.Contains(msg, "description"): + return "the 'description' field is only accepted by `toolbox create`" } } - return "fix the file and retry; see `azd ai toolbox create --help` " + - "or `azd ai toolbox connection add --help` for the supported file shape" + return "fix the file and retry; see the verb's --help for the supported file shape" } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go index bd299ac1073..0ae7675e325 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go @@ -83,8 +83,7 @@ func validateSkillName(name string) error { return nil } -// buildSkillEntry returns the wire map for a ToolboxSkillReference (the only -// ToolboxSkill variant in the spec today). +// buildSkillEntry returns the wire map for a skill_reference entry. func buildSkillEntry(spec skillSpec) map[string]any { entry := map[string]any{ "type": "skill_reference", diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go index 3fc53df72c1..9b5eee8cdf8 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "slices" + "strings" "azure.ai.toolboxes/internal/exterrors" "azure.ai.toolboxes/internal/pkg/azure" @@ -15,39 +16,89 @@ import ( "github.com/spf13/cobra" ) +// skillAddFlags carries the verb-specific flags for `skill add`. +type skillAddFlags struct { + fromFile string +} + // newToolboxSkillAddCommand returns the `skill add` command. func newToolboxSkillAddCommand(extCtx *azdext.ExtensionContext) *cobra.Command { extCtx = ensureExtensionContext(extCtx) + flags := &skillAddFlags{} cmd := &cobra.Command{ - Use: "add [@]", - Short: "Attach a skill reference to a toolbox.", - Long: `Attach a skill reference to a toolbox. + Use: "add [skill[@version]]", + Short: "Attach one or more skill references to a toolbox.", + Long: `Attach one or more skill references to a toolbox. + +Pass a single skill as the positional, or many via --from-file. Either way +the invocation publishes exactly one new toolbox version, which becomes the +default. -Publishes a new default version with the skill appended. When the version is -omitted, the reference resolves to the skill's default version at read time. +When the version is omitted, the reference resolves to the skill's default +version at read time. Examples: azd ai toolbox skill add research my-skill azd ai toolbox skill add research my-skill@2 + azd ai toolbox skill add research --from-file ./skills.yaml `, - Args: cobra.ExactArgs(2), + Args: func(cmd *cobra.Command, args []string) error { + fromFile, _ := cmd.Flags().GetString("from-file") + if strings.TrimSpace(fromFile) != "" { + if len(args) != 1 { + return cobra.ExactArgs(1)(cmd, args) + } + return nil + } + if len(args) != 2 { + return cobra.RangeArgs(2, 2)(cmd, args) + } + return nil + }, RunE: func(cmd *cobra.Command, args []string) error { - return runSkillAdd(cmd.Context(), args[0], args[1], readToolboxFlags(cmd, extCtx)) + rawSkill := "" + if len(args) > 1 { + rawSkill = args[1] + } + return runSkillAdd(cmd.Context(), args[0], rawSkill, *flags, readToolboxFlags(cmd, extCtx)) }, } + cmd.Flags().StringVar( + &flags.fromFile, "from-file", "", + "Path to a JSON/YAML file listing skills to attach (skills[] block).", + ) registerToolboxOutputFlag(cmd) return cmd } -func runSkillAdd(ctx context.Context, toolboxName, rawSkill string, parent toolboxFlags) error { +func runSkillAdd( + ctx context.Context, toolboxName, rawSkill string, + verb skillAddFlags, parent toolboxFlags, +) error { if err := validateToolboxName(toolboxName); err != nil { return err } if err := validateOutputFormat(parent.output); err != nil { return err } + hasFile := strings.TrimSpace(verb.fromFile) != "" + hasPos := strings.TrimSpace(rawSkill) != "" + if hasFile && hasPos { + return exterrors.Validation( + exterrors.CodeInvalidPositionalArg, + "do not pass when --from-file is set", + "either pass a single skill positional or use --from-file", + ) + } + if !hasFile && !hasPos { + return exterrors.Validation( + exterrors.CodeInvalidPositionalArg, + " must not be empty", + "pass a skill name or use --from-file", + ) + } client, resolved, err := resolveToolboxAndClient(ctx, parent) if err != nil { @@ -55,15 +106,16 @@ func runSkillAdd(ctx context.Context, toolboxName, rawSkill string, parent toolb } logResolvedEndpoint("toolbox skill add", resolved) - return runSkillAddWith(ctx, client, toolboxName, rawSkill, parent) + return runSkillAddWith(ctx, client, toolboxName, rawSkill, verb, parent) } // runSkillAddWith is the testable core. func runSkillAddWith( ctx context.Context, client toolboxClient, - toolboxName, rawSkill string, parent toolboxFlags, + toolboxName, rawSkill string, + verb skillAddFlags, parent toolboxFlags, ) error { - spec, err := parseSkillFlag(rawSkill) + specs, err := collectSkillSpecs(rawSkill, verb) if err != nil { return err } @@ -77,22 +129,35 @@ func runSkillAddWith( return exterrors.ServiceFromAzure(err, exterrors.OpGetToolboxVersion) } - if findSkillEntry(current.Skills, spec.Name) >= 0 { - return exterrors.Validation( - exterrors.CodeSkillAlreadyAttached, - fmt.Sprintf( - "skill %q is already attached to toolbox %q's current default version", - spec.Name, toolboxName, - ), - fmt.Sprintf( - "remove the existing reference with `azd ai toolbox skill remove %q %q` first", - toolboxName, spec.Name, - ), - ) + // Reject duplicates within the input and against the current default. + seen := map[string]struct{}{} + for _, sk := range current.Skills { + if n, ok := sk["name"].(string); ok && n != "" { + seen[n] = struct{}{} + } + } + for _, sp := range specs { + if _, dup := seen[sp.Name]; dup { + return exterrors.Validation( + exterrors.CodeSkillAlreadyAttached, + fmt.Sprintf( + "skill %q is already attached to toolbox %q's current default version "+ + "(or appears more than once in the input)", + sp.Name, toolboxName, + ), + fmt.Sprintf( + "remove the existing reference with `azd ai toolbox skill remove %q %q` first", + toolboxName, sp.Name, + ), + ) + } + seen[sp.Name] = struct{}{} } newSkills := slices.Clone(current.Skills) - newSkills = append(newSkills, buildSkillEntry(spec)) + for _, sp := range specs { + newSkills = append(newSkills, buildSkillEntry(sp)) + } req := &azure.CreateToolboxVersionRequest{ Description: current.Description, @@ -118,29 +183,92 @@ func runSkillAddWith( ) } - return emitSkillAddResult(toolboxName, created.Version, spec, parent.output) + return emitSkillAddResult(toolboxName, created.Version, specs, parent.output) } -func emitSkillAddResult(toolboxName, newVersion string, spec skillSpec, output string) error { +// collectSkillSpecs picks the active input mode and returns the parsed list. +func collectSkillSpecs(rawSkill string, verb skillAddFlags) ([]skillSpec, error) { + if strings.TrimSpace(verb.fromFile) != "" { + var input toolboxSkillsFile + if err := parseToolboxFile(verb.fromFile, &input); err != nil { + return nil, err + } + if len(input.Skills) == 0 { + return nil, exterrors.Validation( + exterrors.CodeInvalidParameter, + "no skills to add", + "provide at least one skill in 'skills[]'", + ) + } + specs := make([]skillSpec, 0, len(input.Skills)) + for _, s := range input.Skills { + if err := validateSkillName(s.Name); err != nil { + return nil, err + } + specs = append(specs, skillSpec{ + Name: strings.TrimSpace(s.Name), + Version: strings.TrimSpace(s.Version), + }) + } + return specs, nil + } + sp, err := parseSkillFlag(rawSkill) + if err != nil { + return nil, err + } + return []skillSpec{sp}, nil +} + +func emitSkillAddResult(toolboxName, newVersion string, specs []skillSpec, output string) error { if output == "json" { - payload := map[string]any{ - "toolbox": toolboxName, - "version": newVersion, - "skill": spec.Name, + if len(specs) == 1 { + payload := map[string]any{ + "toolbox": toolboxName, + "version": newVersion, + "skill": specs[0].Name, + } + if specs[0].Version != "" { + payload["skill_version"] = specs[0].Version + } + return emitJSON(payload) } - if spec.Version != "" { - payload["skill_version"] = spec.Version + rows := make([]map[string]any, 0, len(specs)) + for _, s := range specs { + row := map[string]any{"name": s.Name} + if s.Version != "" { + row["version"] = s.Version + } + rows = append(rows, row) } - return emitJSON(payload) + return emitJSON(map[string]any{ + "toolbox": toolboxName, + "version": newVersion, + "skills": rows, + }) } - pinned := "" - if spec.Version != "" { - pinned = "@" + spec.Version + if len(specs) == 1 { + pinned := "" + if specs[0].Version != "" { + pinned = "@" + specs[0].Version + } + fmt.Printf( + "Attached skill %s%s to toolbox %s (now at version %s).\n", + specs[0].Name, pinned, toolboxName, newVersion, + ) + return nil + } + names := make([]string, 0, len(specs)) + for _, s := range specs { + entry := s.Name + if s.Version != "" { + entry += "@" + s.Version + } + names = append(names, entry) } fmt.Printf( - "Attached skill %s%s to toolbox %s (now at version %s).\n", - spec.Name, pinned, toolboxName, newVersion, + "Attached skills [%s] to toolbox %s (now at version %s).\n", + strings.Join(names, ", "), toolboxName, newVersion, ) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go index 6fb1104f556..d395c3a5ae0 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go @@ -6,6 +6,7 @@ package cmd import ( "context" "fmt" + "slices" "strings" "azure.ai.toolboxes/internal/exterrors" @@ -26,16 +27,24 @@ func newToolboxSkillRemoveCommand(extCtx *azdext.ExtensionContext) *cobra.Comman flags := &skillRemoveFlags{} cmd := &cobra.Command{ - Use: "remove ", - Short: "Detach a skill reference from a toolbox.", - Long: `Detach a skill reference from a toolbox. + Use: "remove ...", + Short: "Detach one or more skill references from a toolbox.", + Long: `Detach one or more skill references from a toolbox and publish a new version. -Publishes a new default version with the named skill stripped. Removing the -last skill is allowed.`, - Args: cobra.ExactArgs(2), +Pass one or more skill short names as positionals. All removals are applied +atomically: each invocation publishes exactly one new toolbox version. + +Removing the last skill is allowed. + +Examples: + + azd ai toolbox skill remove research my-skill + azd ai toolbox skill remove research a b c --force +`, + Args: cobra.MinimumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { return runSkillRemove( - cmd.Context(), args[0], args[1], *flags, readToolboxFlags(cmd, extCtx), + cmd.Context(), args[0], args[1:], *flags, readToolboxFlags(cmd, extCtx), ) }, } @@ -48,18 +57,27 @@ last skill is allowed.`, } func runSkillRemove( - ctx context.Context, toolboxName, skillName string, + ctx context.Context, toolboxName string, skillNames []string, verb skillRemoveFlags, parent toolboxFlags, ) error { if err := validateToolboxName(toolboxName); err != nil { return err } - if err := validateSkillName(skillName); err != nil { - return err - } if err := validateOutputFormat(parent.output); err != nil { return err } + if len(skillNames) == 0 { + return exterrors.Validation( + exterrors.CodeInvalidPositionalArg, + "at least one must be provided", + "pass one or more skill short names", + ) + } + for _, n := range skillNames { + if err := validateSkillName(n); err != nil { + return err + } + } if parent.noPrompt && !verb.force { return exterrors.Validation( exterrors.CodeMissingForceFlag, @@ -74,18 +92,21 @@ func runSkillRemove( } logResolvedEndpoint("toolbox skill remove", resolved) - return runSkillRemoveWith(ctx, client, toolboxName, skillName, verb, parent) + return runSkillRemoveWith(ctx, client, toolboxName, skillNames, verb, parent) } // runSkillRemoveWith is the testable core. func runSkillRemoveWith( ctx context.Context, client toolboxClient, - toolboxName, skillName string, + toolboxName string, skillNames []string, verb skillRemoveFlags, parent toolboxFlags, ) error { - // Normalize whitespace so callers that pass `" beta "` match the stored - // entry (validateSkillName trims internally during input validation). - skillName = strings.TrimSpace(skillName) + // Normalize whitespace so `" beta "` matches the stored entry. + names := make([]string, 0, len(skillNames)) + for _, n := range skillNames { + names = append(names, strings.TrimSpace(n)) + } + tb, err := client.GetToolbox(ctx, toolboxName) if err != nil { return toolboxNotFoundOrService(err, toolboxName, exterrors.OpGetToolbox) @@ -95,27 +116,32 @@ func runSkillRemoveWith( return exterrors.ServiceFromAzure(err, exterrors.OpGetToolboxVersion) } - filtered, removed := filterOutSkill(current.Skills, skillName) - if !removed { - return exterrors.Validation( - exterrors.CodeSkillNotInToolbox, - fmt.Sprintf( - "skill %q is not attached to toolbox %q's current default version", - skillName, toolboxName, - ), - fmt.Sprintf("run 'azd ai toolbox skill list %q'", toolboxName), - ) + filtered := slices.Clone(current.Skills) + for _, name := range names { + var didRemove bool + filtered, didRemove = filterOutSkill(filtered, name) + if !didRemove { + return exterrors.Validation( + exterrors.CodeSkillNotInToolbox, + fmt.Sprintf( + "skill %q is not attached to toolbox %q's current default version", + name, toolboxName, + ), + fmt.Sprintf("run 'azd ai toolbox skill list %q'", toolboxName), + ) + } } if !verb.force { shouldProceed := true + summary := summarizeSkillNames(names) err := withAzdClient(func(azdClient *azdext.AzdClient) error { confirmed, err := confirmToolboxDelete( ctx, azdClient, fmt.Sprintf( - "Detach skill %q from toolbox %q (publishes a new version)?", - skillName, toolboxName, + "Detach %s from toolbox %q (publishes a new version)?", + summary, toolboxName, ), ) if err != nil { @@ -159,20 +185,46 @@ func runSkillRemoveWith( ) } - return emitSkillRemoveResult(toolboxName, created.Version, skillName, parent.output) + return emitSkillRemoveResult(toolboxName, created.Version, names, parent.output) } -func emitSkillRemoveResult(toolboxName, newVersion, skillName, output string) error { +// summarizeSkillNames renders "skill \"a\"" or "skills [\"a\", \"b\"]". +func summarizeSkillNames(names []string) string { + if len(names) == 1 { + return fmt.Sprintf("skill %q", names[0]) + } + quoted := make([]string, 0, len(names)) + for _, n := range names { + quoted = append(quoted, fmt.Sprintf("%q", n)) + } + return "skills [" + strings.Join(quoted, ", ") + "]" +} + +func emitSkillRemoveResult(toolboxName, newVersion string, names []string, output string) error { if output == "json" { + if len(names) == 1 { + return emitJSON(map[string]any{ + "toolbox": toolboxName, + "version": newVersion, + "skill": names[0], + }) + } return emitJSON(map[string]any{ "toolbox": toolboxName, "version": newVersion, - "skill": skillName, + "skills": names, }) } + if len(names) == 1 { + fmt.Printf( + "Detached skill %s from toolbox %s (now at version %s).\n", + names[0], toolboxName, newVersion, + ) + return nil + } fmt.Printf( - "Detached skill %s from toolbox %s (now at version %s).\n", - skillName, toolboxName, newVersion, + "Detached skills [%s] from toolbox %s (now at version %s).\n", + strings.Join(names, ", "), toolboxName, newVersion, ) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go index c727907f86c..841015d9dbe 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go @@ -4,6 +4,7 @@ package cmd import ( + "os" "testing" "azure.ai.toolboxes/internal/exterrors" @@ -67,7 +68,7 @@ func TestRunSkillAddWith_AppendsAndCarriesForward(t *testing.T) { }, }} - err := runSkillAddWith(t.Context(), client, "tb", "new-skill@3", toolboxFlags{output: "json"}) + err := runSkillAddWith(t.Context(), client, "tb", "new-skill@3", skillAddFlags{}, toolboxFlags{output: "json"}) require.NoError(t, err) require.Len(t, client.createVersionCalls, 1) @@ -97,7 +98,7 @@ func TestRunSkillAddWith_NoExistingSkills(t *testing.T) { // Skills nil — exercises the "first skill on a toolbox without any" path. }} - err := runSkillAddWith(t.Context(), client, "tb", "first-skill", toolboxFlags{output: "json"}) + err := runSkillAddWith(t.Context(), client, "tb", "first-skill", skillAddFlags{}, toolboxFlags{output: "json"}) require.NoError(t, err) require.Len(t, client.createVersionCalls, 1) require.Len(t, client.createVersionCalls[0].req.Skills, 1) @@ -119,7 +120,7 @@ func TestRunSkillAddWith_AlreadyAttached(t *testing.T) { }, }} - err := runSkillAddWith(t.Context(), client, "tb", "dup@2", toolboxFlags{output: "json"}) + err := runSkillAddWith(t.Context(), client, "tb", "dup@2", skillAddFlags{}, toolboxFlags{output: "json"}) requireLocalError(t, err, exterrors.CodeSkillAlreadyAttached) assert.Empty(t, client.createVersionCalls, "no version should be published when validation fails") } @@ -134,7 +135,7 @@ func TestRunSkillAddWith_InvalidSpec(t *testing.T) { Tools: []map[string]any{{"type": "mcp", "name": "a"}}, }} - err := runSkillAddWith(t.Context(), client, "tb", "BadName@", toolboxFlags{output: "json"}) + err := runSkillAddWith(t.Context(), client, "tb", "BadName@", skillAddFlags{}, toolboxFlags{output: "json"}) requireLocalError(t, err, exterrors.CodeInvalidSkillSpec) } @@ -152,8 +153,7 @@ func TestRunSkillRemoveWith_FilteredAndPromoted(t *testing.T) { }, }} - err := runSkillRemoveWith( - t.Context(), client, "tb", "drop", + err := runSkillRemoveWith(t.Context(), client, "tb", []string{"drop"}, skillRemoveFlags{force: true}, toolboxFlags{output: "json"}, ) require.NoError(t, err) @@ -178,8 +178,7 @@ func TestRunSkillRemoveWith_LastSkillAllowed(t *testing.T) { }, }} - err := runSkillRemoveWith( - t.Context(), client, "tb", "only", + err := runSkillRemoveWith(t.Context(), client, "tb", []string{"only"}, skillRemoveFlags{force: true}, toolboxFlags{output: "json"}, ) require.NoError(t, err) @@ -202,8 +201,7 @@ func TestRunSkillRemoveWith_TrimsSkillName(t *testing.T) { }, }} - err := runSkillRemoveWith( - t.Context(), client, "tb", " beta ", + err := runSkillRemoveWith(t.Context(), client, "tb", []string{" beta "}, skillRemoveFlags{force: true}, toolboxFlags{output: "json"}, ) require.NoError(t, err) @@ -224,8 +222,7 @@ func TestRunSkillRemoveWith_NotAttached(t *testing.T) { }, }} - err := runSkillRemoveWith( - t.Context(), client, "tb", "missing", + err := runSkillRemoveWith(t.Context(), client, "tb", []string{"missing"}, skillRemoveFlags{force: true}, toolboxFlags{output: "json"}, ) requireLocalError(t, err, exterrors.CodeSkillNotInToolbox) @@ -233,7 +230,7 @@ func TestRunSkillRemoveWith_NotAttached(t *testing.T) { func TestRunSkillRemove_NoPromptWithoutForce(t *testing.T) { err := runSkillRemove( - t.Context(), "tb", "any-skill", + t.Context(), "tb", []string{"any-skill"}, skillRemoveFlags{force: false}, toolboxFlags{output: "table", noPrompt: true}, ) @@ -279,3 +276,61 @@ func TestExtractSkillRows_SkipsMalformedEntries(t *testing.T) { require.Len(t, rows, 1) assert.Equal(t, "ok", rows[0]["name"]) } + +// Batch removal via variadic positionals. +func TestRunSkillRemoveWith_VariadicPositionals(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{{"type": "mcp", "name": "a", "project_connection_id": "/c/a"}}, + Skills: []map[string]any{ + {"type": "skill_reference", "name": "alpha"}, + {"type": "skill_reference", "name": "beta"}, + {"type": "skill_reference", "name": "gamma"}, + }, + }} + + err := runSkillRemoveWith(t.Context(), client, "tb", []string{"alpha", "gamma"}, + skillRemoveFlags{force: true}, toolboxFlags{output: "json"}, + ) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1, "one new version published for the whole batch") + require.Len(t, client.createVersionCalls[0].req.Skills, 1) + assert.Equal(t, "beta", client.createVersionCalls[0].req.Skills[0]["name"]) +} + +// Batch attachment via --from-file. +func TestRunSkillAddWith_FromFile(t *testing.T) { + client := newMockToolboxClient("https://e/") + client.getResults["tb"] = toolboxGetResult{obj: &azure.ToolboxObject{ + Name: "tb", DefaultVersion: "1", + }} + client.versionResults["tb/1"] = toolboxVersionResult{obj: &azure.ToolboxVersionObject{ + Name: "tb", Version: "1", + Tools: []map[string]any{{"type": "mcp", "name": "a"}}, + }} + + inputPath := t.TempDir() + "/skills.yaml" + require.NoError(t, os.WriteFile(inputPath, []byte(` +skills: + - name: alpha + - name: beta + version: "2" +`), 0o600)) + + err := runSkillAddWith(t.Context(), client, "tb", "", + skillAddFlags{fromFile: inputPath}, + toolboxFlags{output: "json"}, + ) + require.NoError(t, err) + require.Len(t, client.createVersionCalls, 1) + require.Len(t, client.createVersionCalls[0].req.Skills, 2) + names := []string{ + client.createVersionCalls[0].req.Skills[0]["name"].(string), + client.createVersionCalls[0].req.Skills[1]["name"].(string), + } + assert.ElementsMatch(t, []string{"alpha", "beta"}, names) +} From 9c0e75628f485e04e3c0a6f4dab07bbca3f044e8 Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Thu, 28 May 2026 16:58:05 +0800 Subject: [PATCH 5/9] fix(toolboxes): stop auto-promoting default version; rename update -> publish Addresses trangevi's review on PR #8396: toolboxes are shared resources, so a connection-add or skill-mutation should not silently redirect every other consumer to the new version. The PATCH that promotes a version is now a deliberate user gesture via the new toolbox publish verb. - connection add, connection remove, skill add, skill remove: drop the SetDefaultVersion call after publishing a new toolbox version. - Rename toolbox update --default-version to toolbox publish (positional shape matches trangevi's wording). - Mutation success messages now say 'Published toolbox version ' and append 'The default version is unchanged; run azd ai toolbox publish ... to promote.' - All error suggestions and help text that mentioned 'toolbox update --default-version' now point at 'toolbox publish'. - Drop two error codes that are no longer reachable (CodeMissingUpdateField, CodeSetDefaultVersionFailed). - Tests: invert setDefaultCalls length-1 assertions to assert.Empty, replace runToolboxUpdate test with the equivalent for runToolboxPublish, update the delete-suggestion substring check. --- .../azure.ai.toolboxes/internal/cmd/root.go | 2 +- .../internal/cmd/toolbox_commands_test.go | 17 ++-- .../internal/cmd/toolbox_connection_add.go | 18 +--- .../internal/cmd/toolbox_connection_remove.go | 37 +++----- .../internal/cmd/toolbox_create.go | 2 +- .../internal/cmd/toolbox_delete.go | 2 +- .../internal/cmd/toolbox_help.go | 4 +- .../internal/cmd/toolbox_publish.go | 80 ++++++++++++++++++ .../internal/cmd/toolbox_skill_add.go | 43 ++++------ .../internal/cmd/toolbox_skill_remove.go | 29 ++----- .../internal/cmd/toolbox_skill_verbs_test.go | 4 +- .../internal/cmd/toolbox_update.go | 84 ------------------- .../internal/cmd/toolbox_version_list.go | 2 +- .../internal/exterrors/codes.go | 2 - 14 files changed, 137 insertions(+), 189 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go delete mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go index 90a618299ef..874948e3210 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go @@ -42,7 +42,7 @@ an explicit update to retarget the default.`, registerToolboxOutputFlag(rootCmd) rootCmd.AddCommand(newToolboxCreateCommand(extCtx)) - rootCmd.AddCommand(newToolboxUpdateCommand(extCtx)) + rootCmd.AddCommand(newToolboxPublishCommand(extCtx)) rootCmd.AddCommand(newToolboxDeleteCommand(extCtx)) rootCmd.AddCommand(newToolboxShowCommand(extCtx)) rootCmd.AddCommand(newToolboxListCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go index 08ec25c6731..d2407adb0fa 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go @@ -43,7 +43,7 @@ func TestRunToolboxDeleteWith_Branches(t *testing.T) { toolboxDeleteFlags{version: "2", force: true}, toolboxFlags{output: "table"}, ) le := requireLocalError(t, err, exterrors.CodeDefaultVersionDelete) - assert.Contains(t, le.Suggestion, "default-version") + assert.Contains(t, le.Suggestion, "azd ai toolbox publish") assert.Empty(t, client.deleteVersionCalls, "service must not be called") }) @@ -223,7 +223,7 @@ func TestRunConnectionAddWith_AppendsAndPromotesDefault(t *testing.T) { require.Len(t, client.createVersionCalls, 1) assert.Equal(t, "first", client.createVersionCalls[0].req.Description, "description carried forward") assert.Len(t, client.createVersionCalls[0].req.Tools, 2) - require.Len(t, client.setDefaultCalls, 1, "default version must be retargeted") + assert.Empty(t, client.setDefaultCalls, "mutation verbs no longer auto-promote default") } func TestRunConnectionAddWith_ConnectionNotFound(t *testing.T) { @@ -283,7 +283,7 @@ func TestRunConnectionAddWith_FromFileAddsMultipleToolsSingleVersion(t *testing. require.NoError(t, err) require.Len(t, client.createVersionCalls, 1, "single version increment for batch input") assert.Len(t, client.createVersionCalls[0].req.Tools, 3, "existing + 2 additions") - require.Len(t, client.setDefaultCalls, 1) + assert.Empty(t, client.setDefaultCalls) } // Public entry-point validation: empty connection without --from-file. @@ -333,7 +333,7 @@ func TestRunConnectionRemoveWith_FilteredAndPromoted(t *testing.T) { require.NoError(t, err) require.Len(t, client.createVersionCalls, 1) assert.Len(t, client.createVersionCalls[0].req.Tools, 1) - require.Len(t, client.setDefaultCalls, 1) + assert.Empty(t, client.setDefaultCalls) } func TestRunConnectionRemoveWith_ConnectionNotInToolbox(t *testing.T) { @@ -378,13 +378,12 @@ func TestRunConnectionListWith_EmitsAllShapes(t *testing.T) { require.NoError(t, err) } -func TestRunToolboxUpdate_MissingDefaultVersion(t *testing.T) { - err := runToolboxUpdate( - t.Context(), "tb", - toolboxUpdateFlags{}, +func TestRunToolboxPublish_EmptyVersionRejected(t *testing.T) { + err := runToolboxPublish( + t.Context(), "tb", "", toolboxFlags{output: "table"}, ) - requireLocalError(t, err, exterrors.CodeMissingUpdateField) + requireLocalError(t, err, exterrors.CodeInvalidPositionalArg) } func TestRunToolboxCreateWith_FromFileCreatesInitialVersion(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go index 64d9c681048..f2a1aba97e1 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go @@ -244,20 +244,6 @@ func runConnectionAddWith( return exterrors.ServiceFromAzure(err, exterrors.OpCreateToolboxVersion) } - if _, err := client.SetDefaultVersion(ctx, toolboxName, created.Version); err != nil { - return exterrors.Dependency( - exterrors.CodeSetDefaultVersionFailed, - fmt.Sprintf( - "toolbox %q version %q was created but could not be promoted to default: %s", - toolboxName, created.Version, err, - ), - fmt.Sprintf( - "run `azd ai toolbox update %q --default-version %q` to retarget the default", - toolboxName, created.Version, - ), - ) - } - return emitConnectionAddResult(toolboxName, created.Version, addedConnectionNames, parent.output, endpoint) } @@ -311,10 +297,12 @@ func emitConnectionAddResult( return emitJSON(payload) } - fmt.Printf("Attached connection(s) to toolbox %s (now at version %s).\n", toolboxName, newVersion) + fmt.Printf("Published toolbox %s version %s.\n", toolboxName, newVersion) if len(connectionNames) > 0 { fmt.Printf("Connections: %s\n", strings.Join(connectionNames, ", ")) } fmt.Printf("Endpoint: %s\n", mcpURL) + fmt.Printf("The default version is unchanged; "+ + "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go index 830295a5544..d22da14e21e 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go @@ -194,19 +194,6 @@ func runConnectionRemoveWith( if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpCreateToolboxVersion) } - if _, err := client.SetDefaultVersion(ctx, toolboxName, created.Version); err != nil { - return exterrors.Dependency( - exterrors.CodeSetDefaultVersionFailed, - fmt.Sprintf( - "toolbox %q version %q was created but could not be promoted to default: %s", - toolboxName, created.Version, err, - ), - fmt.Sprintf( - "run `azd ai toolbox update %q --default-version %q` to retarget the default", - toolboxName, created.Version, - ), - ) - } return emitConnectionRemoveResult(toolboxName, created.Version, removedConns, parent.output) } @@ -250,18 +237,20 @@ func emitConnectionRemoveResult( } if len(conns) == 1 { fmt.Printf( - "Detached connection %s from toolbox %s (now at version %s).\n", - conns[0].Name, toolboxName, newVersion, + "Published toolbox %s version %s (detached connection %s).\n", + toolboxName, newVersion, conns[0].Name, + ) + } else { + names := make([]string, 0, len(conns)) + for _, c := range conns { + names = append(names, c.Name) + } + fmt.Printf( + "Published toolbox %s version %s (detached connections [%s]).\n", + toolboxName, newVersion, strings.Join(names, ", "), ) - return nil - } - names := make([]string, 0, len(conns)) - for _, c := range conns { - names = append(names, c.Name) } - fmt.Printf( - "Detached connections [%s] from toolbox %s (now at version %s).\n", - strings.Join(names, ", "), toolboxName, newVersion, - ) + fmt.Printf("The default version is unchanged; "+ + "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go index 74048a6e870..e2e8420974c 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go @@ -105,7 +105,7 @@ func runToolboxCreateWith( return exterrors.Validation( exterrors.CodeInvalidToolboxName, fmt.Sprintf("toolbox %q already exists", name), - "run 'azd ai toolbox update' or 'connection add/remove' to change it", + "run 'azd ai toolbox publish' or 'connection add/remove' to change it", ) } else if !isAzureNotFound(err) { return exterrors.ServiceFromAzure(err, exterrors.OpGetToolbox) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go index 3b07df9fbc4..783859a038e 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go @@ -156,7 +156,7 @@ func runDeleteToolboxVersion( "version %q is the default for toolbox %q and other versions exist", verb.version, name, ), - "retarget the default with `azd ai toolbox update --default-version ` first", + "retarget the default with `azd ai toolbox publish ` first", ) } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go index 751fdca1f1a..05ea4b5f24c 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_help.go @@ -92,8 +92,8 @@ Fields: CognitiveSearch (Azure AI Search), RemoteA2A, GroundingWithCustomSearch. -The toolbox's existing description is carried forward unchanged; use -'azd ai toolbox update' to change it. +The toolbox's existing description is carried forward unchanged; the +description is set at create time and cannot be changed later. Project connections must already exist on the Foundry project; this command does not create them. Run 'azd ai agent connection list' to see available diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go new file mode 100644 index 00000000000..618c9b4e886 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "strings" + + "azure.ai.toolboxes/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// newToolboxPublishCommand returns the `azd ai toolbox publish ` +// command. This is the only verb that mutates the toolbox's default_version +// pointer; all other mutation verbs publish new immutable versions but leave +// the default alone. +func newToolboxPublishCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "publish ", + Short: "Promote a toolbox version to be the default.", + Long: `Promote a published version of a toolbox to be its default. + +Agents and other consumers that reference the toolbox by name resolve to the +default version. 'connection add', 'connection remove', 'skill add', and +'skill remove' publish new versions but never change the default; use this +verb when you're ready to make a version live. + +Examples: + + azd ai toolbox publish research 3 +`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runToolboxPublish(cmd.Context(), args[0], args[1], readToolboxFlags(cmd, extCtx)) + }, + } + registerToolboxOutputFlag(cmd) + return cmd +} + +func runToolboxPublish( + ctx context.Context, name, version string, parent toolboxFlags, +) error { + if err := validateToolboxName(name); err != nil { + return err + } + if err := validateOutputFormat(parent.output); err != nil { + return err + } + if strings.TrimSpace(version) == "" { + return exterrors.Validation( + exterrors.CodeInvalidPositionalArg, + " must not be empty", + "pass the version identifier to publish", + ) + } + + client, resolved, err := resolveToolboxAndClient(ctx, parent) + if err != nil { + return err + } + logResolvedEndpoint("toolbox publish", resolved) + + result, err := client.SetDefaultVersion(ctx, name, version) + if err != nil { + return toolboxNotFoundOrService(err, name, exterrors.OpSetDefaultVersion) + } + + if parent.output == "json" { + return emitJSON(result) + } + fmt.Printf("Toolbox %s default version set to %s.\n", name, result.DefaultVersion) + return nil +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go index 9b5eee8cdf8..89a32f44981 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go @@ -169,19 +169,6 @@ func runSkillAddWith( if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpCreateToolboxVersion) } - if _, err := client.SetDefaultVersion(ctx, toolboxName, created.Version); err != nil { - return exterrors.Dependency( - exterrors.CodeSetDefaultVersionFailed, - fmt.Sprintf( - "toolbox %q version %q was created but could not be promoted to default: %s", - toolboxName, created.Version, err, - ), - fmt.Sprintf( - "run `azd ai toolbox update %q --default-version %q` to retarget the default", - toolboxName, created.Version, - ), - ) - } return emitSkillAddResult(toolboxName, created.Version, specs, parent.output) } @@ -253,22 +240,24 @@ func emitSkillAddResult(toolboxName, newVersion string, specs []skillSpec, outpu pinned = "@" + specs[0].Version } fmt.Printf( - "Attached skill %s%s to toolbox %s (now at version %s).\n", - specs[0].Name, pinned, toolboxName, newVersion, + "Published toolbox %s version %s (attached skill %s%s).\n", + toolboxName, newVersion, specs[0].Name, pinned, ) - return nil - } - names := make([]string, 0, len(specs)) - for _, s := range specs { - entry := s.Name - if s.Version != "" { - entry += "@" + s.Version + } else { + names := make([]string, 0, len(specs)) + for _, s := range specs { + entry := s.Name + if s.Version != "" { + entry += "@" + s.Version + } + names = append(names, entry) } - names = append(names, entry) + fmt.Printf( + "Published toolbox %s version %s (attached skills [%s]).\n", + toolboxName, newVersion, strings.Join(names, ", "), + ) } - fmt.Printf( - "Attached skills [%s] to toolbox %s (now at version %s).\n", - strings.Join(names, ", "), toolboxName, newVersion, - ) + fmt.Printf("The default version is unchanged; "+ + "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go index d395c3a5ae0..e509776bfa0 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go @@ -171,19 +171,6 @@ func runSkillRemoveWith( if err != nil { return exterrors.ServiceFromAzure(err, exterrors.OpCreateToolboxVersion) } - if _, err := client.SetDefaultVersion(ctx, toolboxName, created.Version); err != nil { - return exterrors.Dependency( - exterrors.CodeSetDefaultVersionFailed, - fmt.Sprintf( - "toolbox %q version %q was created but could not be promoted to default: %s", - toolboxName, created.Version, err, - ), - fmt.Sprintf( - "run `azd ai toolbox update %q --default-version %q` to retarget the default", - toolboxName, created.Version, - ), - ) - } return emitSkillRemoveResult(toolboxName, created.Version, names, parent.output) } @@ -217,14 +204,16 @@ func emitSkillRemoveResult(toolboxName, newVersion string, names []string, outpu } if len(names) == 1 { fmt.Printf( - "Detached skill %s from toolbox %s (now at version %s).\n", - names[0], toolboxName, newVersion, + "Published toolbox %s version %s (detached skill %s).\n", + toolboxName, newVersion, names[0], + ) + } else { + fmt.Printf( + "Published toolbox %s version %s (detached skills [%s]).\n", + toolboxName, newVersion, strings.Join(names, ", "), ) - return nil } - fmt.Printf( - "Detached skills [%s] from toolbox %s (now at version %s).\n", - strings.Join(names, ", "), toolboxName, newVersion, - ) + fmt.Printf("The default version is unchanged; "+ + "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go index 841015d9dbe..733f4b8b82d 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go @@ -82,7 +82,7 @@ func TestRunSkillAddWith_AppendsAndCarriesForward(t *testing.T) { assert.Equal(t, "3", req.Skills[1]["version"]) assert.Equal(t, "skill_reference", req.Skills[1]["type"]) - require.Len(t, client.setDefaultCalls, 1, "new version must be promoted to default") + assert.Empty(t, client.setDefaultCalls, "mutation verbs no longer auto-promote default") } func TestRunSkillAddWith_NoExistingSkills(t *testing.T) { @@ -161,7 +161,7 @@ func TestRunSkillRemoveWith_FilteredAndPromoted(t *testing.T) { skills := client.createVersionCalls[0].req.Skills require.Len(t, skills, 1) assert.Equal(t, "keep", skills[0]["name"]) - require.Len(t, client.setDefaultCalls, 1) + assert.Empty(t, client.setDefaultCalls) } // Removing the only skill is allowed (no last-skill block). diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go deleted file mode 100644 index 6a30ebf6a41..00000000000 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "context" - "fmt" - "strings" - - "azure.ai.toolboxes/internal/exterrors" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -// toolboxUpdateFlags carries the verb-specific flags for `toolbox update`. -type toolboxUpdateFlags struct { - defaultVersion string -} - -// newToolboxUpdateCommand returns the `azd ai toolbox update ` command. -// Only --default-version is supported. -func newToolboxUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - extCtx = ensureExtensionContext(extCtx) - flags := &toolboxUpdateFlags{} - - cmd := &cobra.Command{ - Use: "update ", - Short: "Update a toolbox (currently: retarget the default version).", - Long: `Update a toolbox. - -Only --default-version is supported today. To change the tool list, publish a -new version with 'connection add' or 'connection remove'.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runToolboxUpdate(cmd.Context(), args[0], *flags, readToolboxFlags(cmd, extCtx)) - }, - } - - cmd.Flags().StringVar( - &flags.defaultVersion, "default-version", "", - "Version string to mark as the default for this toolbox.", - ) - registerToolboxOutputFlag(cmd) - - return cmd -} - -func runToolboxUpdate( - ctx context.Context, name string, verb toolboxUpdateFlags, parent toolboxFlags, -) error { - if err := validateToolboxName(name); err != nil { - return err - } - if err := validateOutputFormat(parent.output); err != nil { - return err - } - - if strings.TrimSpace(verb.defaultVersion) == "" { - return exterrors.Validation( - exterrors.CodeMissingUpdateField, - "no fields to update", - "specify --default-version", - ) - } - - client, resolved, err := resolveToolboxAndClient(ctx, parent) - if err != nil { - return err - } - logResolvedEndpoint("toolbox update", resolved) - - result, err := client.SetDefaultVersion(ctx, name, verb.defaultVersion) - if err != nil { - return toolboxNotFoundOrService(err, name, exterrors.OpSetDefaultVersion) - } - - if parent.output == "json" { - return emitJSON(result) - } - fmt.Printf("Toolbox %s default version set to %s.\n", name, result.DefaultVersion) - return nil -} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go index 1dc05e6c340..61c2fefc039 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go @@ -30,7 +30,7 @@ func newToolboxVersionListCommand(extCtx *azdext.ExtensionContext) *cobra.Comman Long: `List published versions for a toolbox. Shows one row per published version and marks which one is currently the -default. Use this when choosing a target for 'toolbox update --default-version'.`, +default. Use this when choosing a target for 'toolbox publish'.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runToolboxVersionList(cmd.Context(), args[0], readToolboxFlags(cmd, extCtx)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go index 12da870befc..2af71356051 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go @@ -31,7 +31,6 @@ const ( const ( CodeToolboxNotFound = "toolbox_not_found" CodeInvalidToolboxName = "invalid_toolbox_name" - CodeMissingUpdateField = "missing_update_field" CodeDefaultVersionDelete = "default_version_delete" CodeOnlyVersionDelete = "only_version_delete" CodeMissingForceFlag = "missing_force_flag" @@ -51,7 +50,6 @@ const ( CodeConnectionMissingTarget = "connection_missing_target" CodeLastToolRemoval = "last_tool_removal" CodePendingToolboxStoreFailed = "pending_toolbox_store_failed" - CodeSetDefaultVersionFailed = "set_default_version_failed" ) // Operation names for [ServiceFromAzure] errors. From 1bc3ae6357f14be8be0044723008a72e52810d58 Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Thu, 28 May 2026 17:52:27 +0800 Subject: [PATCH 6/9] revert(toolboxes): keep toolbox update --default-version instead of rename to publish Reverts only the verb rename from PR review comment #4. The behavior change (no auto-promote default on mutation verbs) and the help/error/success-message rewording stay. Rationale: toolbox update --default-version is a superset of a hypothetical publish (extensible to future patchable fields). It also parallels azure.ai.skills' skill update --set-default-version, which keeps the two extensions visually consistent. Behavior unchanged from previous commit: - connection add/remove, skill add/remove still publish new versions without promoting. - All mutation success messages still tell the user the default is unchanged and point at the explicit promote step (now spelled 'azd ai toolbox update --default-version '). - Help text on root.go, connection group, skill group, connection add, skill add all reflect 'default unchanged' semantics. Restores: toolbox_update.go, runToolboxUpdate, toolboxUpdateFlags, CodeMissingUpdateField, TestRunToolboxUpdate_MissingDefaultVersion. Removes: toolbox_publish.go and its newToolboxPublishCommand. --- .../azure.ai.toolboxes/internal/cmd/root.go | 9 +- .../internal/cmd/toolbox_commands_test.go | 39 +++++++-- .../internal/cmd/toolbox_connection.go | 5 +- .../internal/cmd/toolbox_connection_add.go | 8 +- .../internal/cmd/toolbox_connection_remove.go | 2 +- .../internal/cmd/toolbox_create.go | 3 +- .../internal/cmd/toolbox_delete.go | 2 +- .../internal/cmd/toolbox_publish.go | 80 ------------------ .../internal/cmd/toolbox_skill_add.go | 7 +- .../internal/cmd/toolbox_skill_group.go | 5 +- .../internal/cmd/toolbox_skill_remove.go | 2 +- .../internal/cmd/toolbox_update.go | 84 +++++++++++++++++++ .../internal/cmd/toolbox_version_list.go | 2 +- .../internal/exterrors/codes.go | 1 + 14 files changed, 141 insertions(+), 108 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go index 874948e3210..b64c3c9c362 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go @@ -16,9 +16,10 @@ func NewRootCommand() *cobra.Command { Long: `Manage Foundry toolboxes. A toolbox is a versioned, named collection of connection-backed tools that -agents reference at run time. Each version is immutable and carries the full -tool list; mutations publish a new version and (after the first one) require -an explicit update to retarget the default.`, +agents reference at run time. Each version is immutable: mutations (connection +add/remove, skill add/remove) publish a new version but never change which +version is the default. Use 'azd ai toolbox update --default-version' to +promote a version.`, }) rootCmd.SilenceUsage = true @@ -42,7 +43,7 @@ an explicit update to retarget the default.`, registerToolboxOutputFlag(rootCmd) rootCmd.AddCommand(newToolboxCreateCommand(extCtx)) - rootCmd.AddCommand(newToolboxPublishCommand(extCtx)) + rootCmd.AddCommand(newToolboxUpdateCommand(extCtx)) rootCmd.AddCommand(newToolboxDeleteCommand(extCtx)) rootCmd.AddCommand(newToolboxShowCommand(extCtx)) rootCmd.AddCommand(newToolboxListCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go index d2407adb0fa..6b45f0b8e88 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go @@ -43,7 +43,7 @@ func TestRunToolboxDeleteWith_Branches(t *testing.T) { toolboxDeleteFlags{version: "2", force: true}, toolboxFlags{output: "table"}, ) le := requireLocalError(t, err, exterrors.CodeDefaultVersionDelete) - assert.Contains(t, le.Suggestion, "azd ai toolbox publish") + assert.Contains(t, le.Suggestion, "azd ai toolbox update") assert.Empty(t, client.deleteVersionCalls, "service must not be called") }) @@ -310,7 +310,12 @@ func TestRunConnectionRemoveWith_LastToolBlocks(t *testing.T) { ID: "/c/a", Category: connections.ConnectionTypeRemoteTool, Name: "a", } - err := runConnectionRemoveWith(t.Context(), client, resolver, "https://e/", "tb", []string{"a"}, connectionRemoveFlags{force: true}, toolboxFlags{output: "table"}) + err := runConnectionRemoveWith( + t.Context(), client, resolver, "https://e/", + "tb", []string{"a"}, + connectionRemoveFlags{force: true}, + toolboxFlags{output: "table"}, + ) requireLocalError(t, err, exterrors.CodeLastToolRemoval) assert.Empty(t, client.createVersionCalls) } @@ -329,7 +334,12 @@ func TestRunConnectionRemoveWith_FilteredAndPromoted(t *testing.T) { ID: "/c/a", Category: connections.ConnectionTypeRemoteTool, Name: "a", } - err := runConnectionRemoveWith(t.Context(), client, resolver, "https://e/", "tb", []string{"a"}, connectionRemoveFlags{force: true}, toolboxFlags{output: "json"}) + err := runConnectionRemoveWith( + t.Context(), client, resolver, "https://e/", + "tb", []string{"a"}, + connectionRemoveFlags{force: true}, + toolboxFlags{output: "json"}, + ) require.NoError(t, err) require.Len(t, client.createVersionCalls, 1) assert.Len(t, client.createVersionCalls[0].req.Tools, 1) @@ -349,7 +359,12 @@ func TestRunConnectionRemoveWith_ConnectionNotInToolbox(t *testing.T) { ID: "/c/a", Category: connections.ConnectionTypeRemoteTool, Name: "a", } - err := runConnectionRemoveWith(t.Context(), client, resolver, "https://e/", "tb", []string{"a"}, connectionRemoveFlags{force: true}, toolboxFlags{output: "table"}) + err := runConnectionRemoveWith( + t.Context(), client, resolver, "https://e/", + "tb", []string{"a"}, + connectionRemoveFlags{force: true}, + toolboxFlags{output: "table"}, + ) requireLocalError(t, err, exterrors.CodeConnectionNotInToolbox) } @@ -378,12 +393,13 @@ func TestRunConnectionListWith_EmitsAllShapes(t *testing.T) { require.NoError(t, err) } -func TestRunToolboxPublish_EmptyVersionRejected(t *testing.T) { - err := runToolboxPublish( - t.Context(), "tb", "", +func TestRunToolboxUpdate_MissingDefaultVersion(t *testing.T) { + err := runToolboxUpdate( + t.Context(), "tb", + toolboxUpdateFlags{}, toolboxFlags{output: "table"}, ) - requireLocalError(t, err, exterrors.CodeInvalidPositionalArg) + requireLocalError(t, err, exterrors.CodeMissingUpdateField) } func TestRunToolboxCreateWith_FromFileCreatesInitialVersion(t *testing.T) { @@ -631,7 +647,12 @@ func TestRunConnectionRemoveWith_CarriesForwardSkills(t *testing.T) { ID: "/c/a", Category: connections.ConnectionTypeRemoteTool, Name: "a", } - err := runConnectionRemoveWith(t.Context(), client, resolver, "https://e/", "tb", []string{"a"}, connectionRemoveFlags{force: true}, toolboxFlags{output: "json"}) + err := runConnectionRemoveWith( + t.Context(), client, resolver, "https://e/", + "tb", []string{"a"}, + connectionRemoveFlags{force: true}, + toolboxFlags{output: "json"}, + ) require.NoError(t, err) require.Len(t, client.createVersionCalls, 1) assert.Equal(t, skills, client.createVersionCalls[0].req.Skills, diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go index 8575017c315..a47efd10371 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go @@ -24,8 +24,9 @@ func newToolboxConnectionCommand(extCtx *azdext.ExtensionContext) *cobra.Command Tools are project connections. Supported categories: RemoteTool (MCP), CognitiveSearch (Azure AI Search), RemoteA2A, and GroundingWithCustomSearch. -Each mutation publishes a new immutable version and retargets the toolbox -default.`, +Each mutation publishes a new immutable version; the toolbox's default version +is unchanged. Use 'azd ai toolbox update --default-version' to promote a +version.`, } cmd.AddCommand(newToolboxConnectionAddCommand(extCtx)) cmd.AddCommand(newToolboxConnectionRemoveCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go index f2a1aba97e1..47e5e094c9c 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go @@ -31,7 +31,7 @@ func newToolboxConnectionAddCommand(extCtx *azdext.ExtensionContext) *cobra.Comm cmd := &cobra.Command{ Use: "add [connection]", Short: "Attach one or more connections to a toolbox.", - Long: `Attach one or more tools to a toolbox and publish a new default version. + Long: `Attach one or more tools to a toolbox and publish a new version. This command has two modes: @@ -42,7 +42,6 @@ Single-connection mode: Pass the project connection's short name as the positional. --index is required when the connection's category is CognitiveSearch (Azure AI Search). --instance-name is required when the category is GroundingWithCustomSearch. -Only one tool is appended; the new version becomes the default. File mode: @@ -52,6 +51,9 @@ Provide a JSON or YAML file with multiple connections. All inputs from a single invocation publish exactly one new toolbox version, so adding three connections this way produces v(N+1), not v(N+3). +The new version is published but the toolbox's default version is unchanged; +run 'azd ai toolbox update --default-version ' to promote it. + ` + fileShapeBlurb(false) + ` At least one connection must be provided. @@ -303,6 +305,6 @@ func emitConnectionAddResult( } fmt.Printf("Endpoint: %s\n", mcpURL) fmt.Printf("The default version is unchanged; "+ - "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) + "run `azd ai toolbox update %q --default-version %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go index d22da14e21e..06feff982f5 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go @@ -251,6 +251,6 @@ func emitConnectionRemoveResult( ) } fmt.Printf("The default version is unchanged; "+ - "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) + "run `azd ai toolbox update %q --default-version %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go index e2e8420974c..6b2d77daf5b 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go @@ -105,7 +105,8 @@ func runToolboxCreateWith( return exterrors.Validation( exterrors.CodeInvalidToolboxName, fmt.Sprintf("toolbox %q already exists", name), - "run 'azd ai toolbox publish' or 'connection add/remove' to change it", + "use 'connection add/remove' or 'skill add/remove' to publish a new version, "+ + "then 'azd ai toolbox update --default-version ' to promote it", ) } else if !isAzureNotFound(err) { return exterrors.ServiceFromAzure(err, exterrors.OpGetToolbox) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go index 783859a038e..07008c6611d 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go @@ -156,7 +156,7 @@ func runDeleteToolboxVersion( "version %q is the default for toolbox %q and other versions exist", verb.version, name, ), - "retarget the default with `azd ai toolbox publish ` first", + "retarget the default with `azd ai toolbox update --default-version ` first", ) } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go deleted file mode 100644 index 618c9b4e886..00000000000 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "context" - "fmt" - "strings" - - "azure.ai.toolboxes/internal/exterrors" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -// newToolboxPublishCommand returns the `azd ai toolbox publish ` -// command. This is the only verb that mutates the toolbox's default_version -// pointer; all other mutation verbs publish new immutable versions but leave -// the default alone. -func newToolboxPublishCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - extCtx = ensureExtensionContext(extCtx) - - cmd := &cobra.Command{ - Use: "publish ", - Short: "Promote a toolbox version to be the default.", - Long: `Promote a published version of a toolbox to be its default. - -Agents and other consumers that reference the toolbox by name resolve to the -default version. 'connection add', 'connection remove', 'skill add', and -'skill remove' publish new versions but never change the default; use this -verb when you're ready to make a version live. - -Examples: - - azd ai toolbox publish research 3 -`, - Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - return runToolboxPublish(cmd.Context(), args[0], args[1], readToolboxFlags(cmd, extCtx)) - }, - } - registerToolboxOutputFlag(cmd) - return cmd -} - -func runToolboxPublish( - ctx context.Context, name, version string, parent toolboxFlags, -) error { - if err := validateToolboxName(name); err != nil { - return err - } - if err := validateOutputFormat(parent.output); err != nil { - return err - } - if strings.TrimSpace(version) == "" { - return exterrors.Validation( - exterrors.CodeInvalidPositionalArg, - " must not be empty", - "pass the version identifier to publish", - ) - } - - client, resolved, err := resolveToolboxAndClient(ctx, parent) - if err != nil { - return err - } - logResolvedEndpoint("toolbox publish", resolved) - - result, err := client.SetDefaultVersion(ctx, name, version) - if err != nil { - return toolboxNotFoundOrService(err, name, exterrors.OpSetDefaultVersion) - } - - if parent.output == "json" { - return emitJSON(result) - } - fmt.Printf("Toolbox %s default version set to %s.\n", name, result.DefaultVersion) - return nil -} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go index 89a32f44981..7eb90f7849c 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go @@ -32,8 +32,9 @@ func newToolboxSkillAddCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Long: `Attach one or more skill references to a toolbox. Pass a single skill as the positional, or many via --from-file. Either way -the invocation publishes exactly one new toolbox version, which becomes the -default. +the invocation publishes exactly one new toolbox version. The toolbox's +default version is unchanged; run +'azd ai toolbox update --default-version ' to promote it. When the version is omitted, the reference resolves to the skill's default version at read time. @@ -258,6 +259,6 @@ func emitSkillAddResult(toolboxName, newVersion string, specs []skillSpec, outpu ) } fmt.Printf("The default version is unchanged; "+ - "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) + "run `azd ai toolbox update %q --default-version %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go index 5d2598095c7..55c9e6151f9 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go @@ -16,8 +16,9 @@ func newToolboxSkillCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Short: "Manage skill references attached to a toolbox.", Long: `Manage skill references attached to a toolbox. -Each add/remove publishes a new immutable version and retargets the toolbox -default.`, +Each add/remove publishes a new immutable version; the toolbox's default +version is unchanged. Use 'azd ai toolbox update --default-version' to +promote a version.`, } cmd.AddCommand(newToolboxSkillAddCommand(extCtx)) cmd.AddCommand(newToolboxSkillRemoveCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go index e509776bfa0..bc4eba91bc1 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go @@ -214,6 +214,6 @@ func emitSkillRemoveResult(toolboxName, newVersion string, names []string, outpu ) } fmt.Printf("The default version is unchanged; "+ - "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) + "run `azd ai toolbox update %q --default-version %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go new file mode 100644 index 00000000000..6a30ebf6a41 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "strings" + + "azure.ai.toolboxes/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// toolboxUpdateFlags carries the verb-specific flags for `toolbox update`. +type toolboxUpdateFlags struct { + defaultVersion string +} + +// newToolboxUpdateCommand returns the `azd ai toolbox update ` command. +// Only --default-version is supported. +func newToolboxUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + flags := &toolboxUpdateFlags{} + + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a toolbox (currently: retarget the default version).", + Long: `Update a toolbox. + +Only --default-version is supported today. To change the tool list, publish a +new version with 'connection add' or 'connection remove'.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runToolboxUpdate(cmd.Context(), args[0], *flags, readToolboxFlags(cmd, extCtx)) + }, + } + + cmd.Flags().StringVar( + &flags.defaultVersion, "default-version", "", + "Version string to mark as the default for this toolbox.", + ) + registerToolboxOutputFlag(cmd) + + return cmd +} + +func runToolboxUpdate( + ctx context.Context, name string, verb toolboxUpdateFlags, parent toolboxFlags, +) error { + if err := validateToolboxName(name); err != nil { + return err + } + if err := validateOutputFormat(parent.output); err != nil { + return err + } + + if strings.TrimSpace(verb.defaultVersion) == "" { + return exterrors.Validation( + exterrors.CodeMissingUpdateField, + "no fields to update", + "specify --default-version", + ) + } + + client, resolved, err := resolveToolboxAndClient(ctx, parent) + if err != nil { + return err + } + logResolvedEndpoint("toolbox update", resolved) + + result, err := client.SetDefaultVersion(ctx, name, verb.defaultVersion) + if err != nil { + return toolboxNotFoundOrService(err, name, exterrors.OpSetDefaultVersion) + } + + if parent.output == "json" { + return emitJSON(result) + } + fmt.Printf("Toolbox %s default version set to %s.\n", name, result.DefaultVersion) + return nil +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go index 61c2fefc039..1dc05e6c340 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go @@ -30,7 +30,7 @@ func newToolboxVersionListCommand(extCtx *azdext.ExtensionContext) *cobra.Comman Long: `List published versions for a toolbox. Shows one row per published version and marks which one is currently the -default. Use this when choosing a target for 'toolbox publish'.`, +default. Use this when choosing a target for 'toolbox update --default-version'.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runToolboxVersionList(cmd.Context(), args[0], readToolboxFlags(cmd, extCtx)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go index 2af71356051..60d1604128b 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/exterrors/codes.go @@ -31,6 +31,7 @@ const ( const ( CodeToolboxNotFound = "toolbox_not_found" CodeInvalidToolboxName = "invalid_toolbox_name" + CodeMissingUpdateField = "missing_update_field" CodeDefaultVersionDelete = "default_version_delete" CodeOnlyVersionDelete = "only_version_delete" CodeMissingForceFlag = "missing_force_flag" From d1b37d929c2282ef43b32025a9dbb49091d3eedb Mon Sep 17 00:00:00 2001 From: Zhijie Huang Date: Thu, 28 May 2026 18:08:39 +0800 Subject: [PATCH 7/9] fix(toolboxes): address copilot review feedback - parseSkillFlag / validateNoDuplicateSkills error messages and suggestions no longer reference a non-existent --skill flag (skills are positional or in skills[] of --from-file). - runConnectionRemoveWith now trims whitespace from each connection name (parity with skill remove). User input ' foo ' now matches the stored entry. - Root, connection group, and skill group help text now show the full 'azd ai toolbox update --default-version ' form so it's copy-pastable. --- .../azure.ai.toolboxes/internal/cmd/root.go | 4 ++-- .../internal/cmd/toolbox_connection.go | 4 ++-- .../internal/cmd/toolbox_connection_remove.go | 11 +++++++++-- .../internal/cmd/toolbox_skill.go | 14 +++++++------- .../internal/cmd/toolbox_skill_group.go | 4 ++-- 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go index b64c3c9c362..e3c14543d7e 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go @@ -18,8 +18,8 @@ func NewRootCommand() *cobra.Command { A toolbox is a versioned, named collection of connection-backed tools that agents reference at run time. Each version is immutable: mutations (connection add/remove, skill add/remove) publish a new version but never change which -version is the default. Use 'azd ai toolbox update --default-version' to -promote a version.`, +version is the default. Use 'azd ai toolbox update --default-version ' +to promote a version.`, }) rootCmd.SilenceUsage = true diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go index a47efd10371..a5059d0bb69 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go @@ -25,8 +25,8 @@ func newToolboxConnectionCommand(extCtx *azdext.ExtensionContext) *cobra.Command Tools are project connections. Supported categories: RemoteTool (MCP), CognitiveSearch (Azure AI Search), RemoteA2A, and GroundingWithCustomSearch. Each mutation publishes a new immutable version; the toolbox's default version -is unchanged. Use 'azd ai toolbox update --default-version' to promote a -version.`, +is unchanged. Use 'azd ai toolbox update --default-version ' +to promote a version.`, } cmd.AddCommand(newToolboxConnectionAddCommand(extCtx)) cmd.AddCommand(newToolboxConnectionRemoveCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go index 06feff982f5..0dbd30ccc75 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go @@ -110,6 +110,13 @@ func runConnectionRemoveWith( verb connectionRemoveFlags, parent toolboxFlags, ) error { + // Normalize whitespace so callers that pass `" foo "` match the stored + // entry. Parity with `skill remove`. + names := make([]string, 0, len(connNames)) + for _, n := range connNames { + names = append(names, strings.TrimSpace(n)) + } + tb, err := client.GetToolbox(ctx, toolboxName) if err != nil { return toolboxNotFoundOrService(err, toolboxName, exterrors.OpGetToolbox) @@ -121,8 +128,8 @@ func runConnectionRemoveWith( // Resolve each name and strip from the tools[]. filtered := slices.Clone(current.Tools) - removedConns := make([]*projectConnection, 0, len(connNames)) - for _, name := range connNames { + removedConns := make([]*projectConnection, 0, len(names)) + for _, name := range names { conn, err := resolver.resolveConnection(ctx, endpoint, name) if err != nil { return err diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go index 0ae7675e325..c44464b09d5 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill.go @@ -21,9 +21,9 @@ var skillNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$`) const skillNameMaxLen = 64 -// skillSpec is the parsed form of a --skill or skills[] entry. Empty Version -// means "use the skill's default version" per the ToolboxSkillReference -// contract. +// skillSpec is the parsed form of a positional skill argument or a skills[] +// file entry. Empty Version means "use the skill's default version" per the +// ToolboxSkillReference contract. type skillSpec struct { Name string Version string @@ -36,8 +36,8 @@ func parseSkillFlag(s string) (skillSpec, error) { if trimmed == "" { return skillSpec{}, exterrors.Validation( exterrors.CodeInvalidSkillSpec, - "--skill value must not be empty", - "pass --skill [@]", + " must not be empty", + "pass a skill name as [@]", ) } @@ -49,7 +49,7 @@ func parseSkillFlag(s string) (skillSpec, error) { if version == "" { return skillSpec{}, exterrors.Validation( exterrors.CodeInvalidSkillSpec, - fmt.Sprintf("--skill %q has an empty version after '@'", trimmed), + fmt.Sprintf(" %q has an empty version after '@'", trimmed), "either drop the trailing '@' to use the skill's default version, "+ "or pass @", ) @@ -110,7 +110,7 @@ func validateNoDuplicateSkills(entries []map[string]any) error { return exterrors.Validation( exterrors.CodeDuplicateSkill, fmt.Sprintf("skill %q appears more than once in the input", names[i]), - "remove duplicate --skill entries (or duplicate skills[] entries in the file)", + "remove duplicate skills[] entries from the input file", ) } } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go index 55c9e6151f9..80e4f895798 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go @@ -17,8 +17,8 @@ func newToolboxSkillCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Long: `Manage skill references attached to a toolbox. Each add/remove publishes a new immutable version; the toolbox's default -version is unchanged. Use 'azd ai toolbox update --default-version' to -promote a version.`, +version is unchanged. Use 'azd ai toolbox update --default-version ' +to promote a version.`, } cmd.AddCommand(newToolboxSkillAddCommand(extCtx)) cmd.AddCommand(newToolboxSkillRemoveCommand(extCtx)) From fad4224595b5326e656e63c3fd53a3574585ca33 Mon Sep 17 00:00:00 2001 From: trangevi Date: Thu, 28 May 2026 10:51:36 -0700 Subject: [PATCH 8/9] Change `update` to `publish` for modifying the default version of a toolbox. `update` can be added back if toolboxes ever become mutable. This provides consistency with other UX experience Signed-off-by: trangevi --- .../azure.ai.toolboxes/internal/cmd/root.go | 6 +- .../internal/cmd/toolbox_commands_test.go | 15 ++-- .../internal/cmd/toolbox_connection.go | 4 +- .../internal/cmd/toolbox_connection_add.go | 12 +-- .../internal/cmd/toolbox_connection_remove.go | 12 +-- .../internal/cmd/toolbox_create.go | 8 +- .../internal/cmd/toolbox_delete.go | 2 +- .../internal/cmd/toolbox_publish.go | 74 ++++++++++++++++ .../internal/cmd/toolbox_skill_add.go | 10 +-- .../internal/cmd/toolbox_skill_group.go | 4 +- .../internal/cmd/toolbox_skill_remove.go | 12 +-- .../internal/cmd/toolbox_skill_verbs_test.go | 4 +- .../internal/cmd/toolbox_update.go | 84 ------------------- .../internal/cmd/toolbox_version_list.go | 2 +- 14 files changed, 119 insertions(+), 130 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go delete mode 100644 cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go index e3c14543d7e..dfcffe5fdd2 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/root.go @@ -17,8 +17,8 @@ func NewRootCommand() *cobra.Command { A toolbox is a versioned, named collection of connection-backed tools that agents reference at run time. Each version is immutable: mutations (connection -add/remove, skill add/remove) publish a new version but never change which -version is the default. Use 'azd ai toolbox update --default-version ' +add/remove, skill add/remove) create a new version but never change which +version is the default. Use 'azd ai toolbox publish ' to promote a version.`, }) @@ -43,7 +43,7 @@ to promote a version.`, registerToolboxOutputFlag(rootCmd) rootCmd.AddCommand(newToolboxCreateCommand(extCtx)) - rootCmd.AddCommand(newToolboxUpdateCommand(extCtx)) + rootCmd.AddCommand(newToolboxPublishCommand(extCtx)) rootCmd.AddCommand(newToolboxDeleteCommand(extCtx)) rootCmd.AddCommand(newToolboxShowCommand(extCtx)) rootCmd.AddCommand(newToolboxListCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go index 99d09b7acf9..0c5dd080944 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_commands_test.go @@ -43,7 +43,7 @@ func TestRunToolboxDeleteWith_Branches(t *testing.T) { toolboxDeleteFlags{version: "2", force: true}, toolboxFlags{output: "table"}, ) le := requireLocalError(t, err, exterrors.CodeDefaultVersionDelete) - assert.Contains(t, le.Suggestion, "azd ai toolbox update") + assert.Contains(t, le.Suggestion, "azd ai toolbox publish") assert.Empty(t, client.deleteVersionCalls, "service must not be called") }) @@ -406,10 +406,9 @@ func TestRunConnectionListWith_EmitsAllShapes(t *testing.T) { require.NoError(t, err) } -func TestRunToolboxUpdate_MissingDefaultVersion(t *testing.T) { - err := runToolboxUpdate( - t.Context(), "tb", - toolboxUpdateFlags{}, +func TestRunToolboxPublish_WhitespaceVersion(t *testing.T) { + err := runToolboxPublish( + t.Context(), "tb", " ", toolboxFlags{output: "table"}, ) requireLocalError(t, err, exterrors.CodeMissingUpdateField) @@ -819,7 +818,7 @@ func TestRunConnectionRemove_NoPromptWithoutForce(t *testing.T) { } // Carry-forward: skills attached to the current default version must survive -// across new versions published by `connection add`. +// across new versions created by `connection add`. func TestRunConnectionAddWith_CarriesForwardSkills(t *testing.T) { skills := []map[string]any{ {"type": "skill_reference", "name": "alpha", "version": "1"}, @@ -852,7 +851,7 @@ func TestRunConnectionAddWith_CarriesForwardSkills(t *testing.T) { } // Carry-forward: skills attached to the current default version must survive -// across new versions published by `connection remove`. +// across new versions created by `connection remove`. func TestRunConnectionRemoveWith_CarriesForwardSkills(t *testing.T) { skills := []map[string]any{ {"type": "skill_reference", "name": "alpha"}, @@ -910,7 +909,7 @@ func TestRunConnectionRemoveWith_VariadicPositionals(t *testing.T) { connectionRemoveFlags{force: true}, toolboxFlags{output: "json"}, ) require.NoError(t, err) - require.Len(t, client.createVersionCalls, 1, "one new version published for the whole batch") + require.Len(t, client.createVersionCalls, 1, "one new version created for the whole batch") require.Len(t, client.createVersionCalls[0].req.Tools, 1) assert.Equal(t, "/c/c", client.createVersionCalls[0].req.Tools[0]["project_connection_id"]) } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go index a5059d0bb69..1dd34b255d8 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection.go @@ -24,8 +24,8 @@ func newToolboxConnectionCommand(extCtx *azdext.ExtensionContext) *cobra.Command Tools are project connections. Supported categories: RemoteTool (MCP), CognitiveSearch (Azure AI Search), RemoteA2A, and GroundingWithCustomSearch. -Each mutation publishes a new immutable version; the toolbox's default version -is unchanged. Use 'azd ai toolbox update --default-version ' +Each mutation creates a new immutable version; the toolbox's default version +is unchanged. Use 'azd ai toolbox publish ' to promote a version.`, } cmd.AddCommand(newToolboxConnectionAddCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go index 831c8da1601..da8ddc412f4 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_add.go @@ -31,7 +31,7 @@ func newToolboxConnectionAddCommand(extCtx *azdext.ExtensionContext) *cobra.Comm cmd := &cobra.Command{ Use: "add [connection]", Short: "Attach one or more connections to a toolbox.", - Long: `Attach one or more tools to a toolbox and publish a new version. + Long: `Attach one or more tools to a toolbox and create a new version. This command has two modes: @@ -48,11 +48,11 @@ File mode: azd ai toolbox connection add --from-file Provide a JSON or YAML file with multiple connections. All inputs from a -single invocation publish exactly one new toolbox version, so adding three +single invocation create exactly one new toolbox version, so adding three connections this way produces v(N+1), not v(N+3). -The new version is published but the toolbox's default version is unchanged; -run 'azd ai toolbox update --default-version ' to promote it. +The new version is created but the toolbox's default version is unchanged; +run 'azd ai toolbox publish ' to promote it. ` + fileShapeBlurb(false) + ` @@ -300,12 +300,12 @@ func emitConnectionAddResult( return emitJSON(payload) } - fmt.Printf("Published toolbox %s version %s.\n", toolboxName, newVersion) + fmt.Printf("Created toolbox %s version %s.\n", toolboxName, newVersion) if len(connectionNames) > 0 { fmt.Printf("Connections: %s\n", strings.Join(connectionNames, ", ")) } fmt.Printf("Endpoint: %s\n", mcpURL) fmt.Printf("The default version is unchanged; "+ - "run `azd ai toolbox update %q --default-version %q` to promote.\n", toolboxName, newVersion) + "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go index d3f49ead960..bc5b9b7eebf 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_connection_remove.go @@ -29,10 +29,10 @@ func newToolboxConnectionRemoveCommand(extCtx *azdext.ExtensionContext) *cobra.C cmd := &cobra.Command{ Use: "remove ...", Short: "Detach one or more connections from a toolbox.", - Long: `Detach one or more connections from a toolbox and publish a new version. + Long: `Detach one or more connections from a toolbox and create a new version. Pass one or more connection short names as positionals. All removals are -applied atomically: each invocation publishes exactly one new toolbox version. +applied atomically: each invocation creates exactly one new toolbox version. Refuses to leave the toolbox with zero tools (use 'toolbox delete' instead). @@ -170,7 +170,7 @@ func runConnectionRemoveWith( ctx, azdClient, fmt.Sprintf( - "Detach %s from toolbox %q (publishes a new version)?", + "Detach %s from toolbox %q (creates a new version)?", summary, toolboxName, ), ) @@ -245,7 +245,7 @@ func emitConnectionRemoveResult( } if len(conns) == 1 { fmt.Printf( - "Published toolbox %s version %s (detached connection %s).\n", + "Created toolbox %s version %s (detached connection %s).\n", toolboxName, newVersion, conns[0].Name, ) } else { @@ -254,11 +254,11 @@ func emitConnectionRemoveResult( names = append(names, c.Name) } fmt.Printf( - "Published toolbox %s version %s (detached connections [%s]).\n", + "Created toolbox %s version %s (detached connections [%s]).\n", toolboxName, newVersion, strings.Join(names, ", "), ) } fmt.Printf("The default version is unchanged; "+ - "run `azd ai toolbox update %q --default-version %q` to promote.\n", toolboxName, newVersion) + "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go index 243795860d3..76d0648d667 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_create.go @@ -29,8 +29,8 @@ func newToolboxCreateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { cmd := &cobra.Command{ Use: "create --from-file ", - Short: "Create a toolbox and publish its initial version from a file.", - Long: `Create a toolbox and publish its initial version. + Short: "Create a toolbox and its initial version from a file.", + Long: `Create a toolbox and its initial version. The Foundry service requires the initial version to ship with at least one tool entry, so 'create' takes its inputs from a JSON or YAML file via @@ -105,8 +105,8 @@ func runToolboxCreateWith( return exterrors.Validation( exterrors.CodeInvalidToolboxName, fmt.Sprintf("toolbox %q already exists", name), - "use 'connection add/remove' or 'skill add/remove' to publish a new version, "+ - "then 'azd ai toolbox update --default-version ' to promote it", + "use 'connection add/remove' or 'skill add/remove' to create a new version, "+ + "then 'azd ai toolbox publish ' to promote it", ) } else if !isAzureNotFound(err) { return exterrors.ServiceFromAzure(err, exterrors.OpGetToolbox) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go index 07008c6611d..783859a038e 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_delete.go @@ -156,7 +156,7 @@ func runDeleteToolboxVersion( "version %q is the default for toolbox %q and other versions exist", verb.version, name, ), - "retarget the default with `azd ai toolbox update --default-version ` first", + "retarget the default with `azd ai toolbox publish ` first", ) } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go new file mode 100644 index 00000000000..36c1a9d48c0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_publish.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "strings" + + "azure.ai.toolboxes/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +// newToolboxPublishCommand returns the `azd ai toolbox publish ` command. +func newToolboxPublishCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + + cmd := &cobra.Command{ + Use: "publish ", + Short: "Set the default version for a toolbox.", + Long: `Set the default version for a toolbox. + +This promotes a previously created version so that consumers referencing the +toolbox without an explicit version will receive it. To create a new version, +use 'connection add', 'connection remove', 'skill add', or 'skill remove'.`, + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + return runToolboxPublish(cmd.Context(), args[0], args[1], readToolboxFlags(cmd, extCtx)) + }, + } + + registerToolboxOutputFlag(cmd) + + return cmd +} + +func runToolboxPublish( + ctx context.Context, name string, version string, parent toolboxFlags, +) error { + if err := validateToolboxName(name); err != nil { + return err + } + if err := validateOutputFormat(parent.output); err != nil { + return err + } + + if strings.TrimSpace(version) == "" { + return exterrors.Validation( + exterrors.CodeMissingUpdateField, + "version must not be empty", + "pass the version to promote as the second positional argument", + ) + } + + client, resolved, err := resolveToolboxAndClient(ctx, parent) + if err != nil { + return err + } + logResolvedEndpoint("toolbox publish", resolved) + + result, err := client.SetDefaultVersion(ctx, name, version) + if err != nil { + return toolboxNotFoundOrService(err, name, exterrors.OpSetDefaultVersion) + } + + if parent.output == "json" { + return emitJSON(result) + } + fmt.Printf("Toolbox %s default version set to %s.\n", name, result.DefaultVersion) + return nil +} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go index 7eb90f7849c..6e8514f7217 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_add.go @@ -32,9 +32,9 @@ func newToolboxSkillAddCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Long: `Attach one or more skill references to a toolbox. Pass a single skill as the positional, or many via --from-file. Either way -the invocation publishes exactly one new toolbox version. The toolbox's +the invocation creates exactly one new toolbox version. The toolbox's default version is unchanged; run -'azd ai toolbox update --default-version ' to promote it. +'azd ai toolbox publish ' to promote it. When the version is omitted, the reference resolves to the skill's default version at read time. @@ -241,7 +241,7 @@ func emitSkillAddResult(toolboxName, newVersion string, specs []skillSpec, outpu pinned = "@" + specs[0].Version } fmt.Printf( - "Published toolbox %s version %s (attached skill %s%s).\n", + "Created toolbox %s version %s (attached skill %s%s).\n", toolboxName, newVersion, specs[0].Name, pinned, ) } else { @@ -254,11 +254,11 @@ func emitSkillAddResult(toolboxName, newVersion string, specs []skillSpec, outpu names = append(names, entry) } fmt.Printf( - "Published toolbox %s version %s (attached skills [%s]).\n", + "Created toolbox %s version %s (attached skills [%s]).\n", toolboxName, newVersion, strings.Join(names, ", "), ) } fmt.Printf("The default version is unchanged; "+ - "run `azd ai toolbox update %q --default-version %q` to promote.\n", toolboxName, newVersion) + "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go index 80e4f895798..0e7d814dbd1 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_group.go @@ -16,8 +16,8 @@ func newToolboxSkillCommand(extCtx *azdext.ExtensionContext) *cobra.Command { Short: "Manage skill references attached to a toolbox.", Long: `Manage skill references attached to a toolbox. -Each add/remove publishes a new immutable version; the toolbox's default -version is unchanged. Use 'azd ai toolbox update --default-version ' +Each add/remove creates a new immutable version; the toolbox's default +version is unchanged. Use 'azd ai toolbox publish ' to promote a version.`, } cmd.AddCommand(newToolboxSkillAddCommand(extCtx)) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go index bc4eba91bc1..4ff881f2750 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_remove.go @@ -29,10 +29,10 @@ func newToolboxSkillRemoveCommand(extCtx *azdext.ExtensionContext) *cobra.Comman cmd := &cobra.Command{ Use: "remove ...", Short: "Detach one or more skill references from a toolbox.", - Long: `Detach one or more skill references from a toolbox and publish a new version. + Long: `Detach one or more skill references from a toolbox and create a new version. Pass one or more skill short names as positionals. All removals are applied -atomically: each invocation publishes exactly one new toolbox version. +atomically: each invocation creates exactly one new toolbox version. Removing the last skill is allowed. @@ -140,7 +140,7 @@ func runSkillRemoveWith( ctx, azdClient, fmt.Sprintf( - "Detach %s from toolbox %q (publishes a new version)?", + "Detach %s from toolbox %q (creates a new version)?", summary, toolboxName, ), ) @@ -204,16 +204,16 @@ func emitSkillRemoveResult(toolboxName, newVersion string, names []string, outpu } if len(names) == 1 { fmt.Printf( - "Published toolbox %s version %s (detached skill %s).\n", + "Created toolbox %s version %s (detached skill %s).\n", toolboxName, newVersion, names[0], ) } else { fmt.Printf( - "Published toolbox %s version %s (detached skills [%s]).\n", + "Created toolbox %s version %s (detached skills [%s]).\n", toolboxName, newVersion, strings.Join(names, ", "), ) } fmt.Printf("The default version is unchanged; "+ - "run `azd ai toolbox update %q --default-version %q` to promote.\n", toolboxName, newVersion) + "run `azd ai toolbox publish %q %q` to promote.\n", toolboxName, newVersion) return nil } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go index 733f4b8b82d..f6170ce4cd2 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_skill_verbs_test.go @@ -122,7 +122,7 @@ func TestRunSkillAddWith_AlreadyAttached(t *testing.T) { err := runSkillAddWith(t.Context(), client, "tb", "dup@2", skillAddFlags{}, toolboxFlags{output: "json"}) requireLocalError(t, err, exterrors.CodeSkillAlreadyAttached) - assert.Empty(t, client.createVersionCalls, "no version should be published when validation fails") + assert.Empty(t, client.createVersionCalls, "no version should be created when validation fails") } func TestRunSkillAddWith_InvalidSpec(t *testing.T) { @@ -297,7 +297,7 @@ func TestRunSkillRemoveWith_VariadicPositionals(t *testing.T) { skillRemoveFlags{force: true}, toolboxFlags{output: "json"}, ) require.NoError(t, err) - require.Len(t, client.createVersionCalls, 1, "one new version published for the whole batch") + require.Len(t, client.createVersionCalls, 1, "one new version created for the whole batch") require.Len(t, client.createVersionCalls[0].req.Skills, 1) assert.Equal(t, "beta", client.createVersionCalls[0].req.Skills[0]["name"]) } diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go deleted file mode 100644 index 6a30ebf6a41..00000000000 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_update.go +++ /dev/null @@ -1,84 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "context" - "fmt" - "strings" - - "azure.ai.toolboxes/internal/exterrors" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -// toolboxUpdateFlags carries the verb-specific flags for `toolbox update`. -type toolboxUpdateFlags struct { - defaultVersion string -} - -// newToolboxUpdateCommand returns the `azd ai toolbox update ` command. -// Only --default-version is supported. -func newToolboxUpdateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - extCtx = ensureExtensionContext(extCtx) - flags := &toolboxUpdateFlags{} - - cmd := &cobra.Command{ - Use: "update ", - Short: "Update a toolbox (currently: retarget the default version).", - Long: `Update a toolbox. - -Only --default-version is supported today. To change the tool list, publish a -new version with 'connection add' or 'connection remove'.`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - return runToolboxUpdate(cmd.Context(), args[0], *flags, readToolboxFlags(cmd, extCtx)) - }, - } - - cmd.Flags().StringVar( - &flags.defaultVersion, "default-version", "", - "Version string to mark as the default for this toolbox.", - ) - registerToolboxOutputFlag(cmd) - - return cmd -} - -func runToolboxUpdate( - ctx context.Context, name string, verb toolboxUpdateFlags, parent toolboxFlags, -) error { - if err := validateToolboxName(name); err != nil { - return err - } - if err := validateOutputFormat(parent.output); err != nil { - return err - } - - if strings.TrimSpace(verb.defaultVersion) == "" { - return exterrors.Validation( - exterrors.CodeMissingUpdateField, - "no fields to update", - "specify --default-version", - ) - } - - client, resolved, err := resolveToolboxAndClient(ctx, parent) - if err != nil { - return err - } - logResolvedEndpoint("toolbox update", resolved) - - result, err := client.SetDefaultVersion(ctx, name, verb.defaultVersion) - if err != nil { - return toolboxNotFoundOrService(err, name, exterrors.OpSetDefaultVersion) - } - - if parent.output == "json" { - return emitJSON(result) - } - fmt.Printf("Toolbox %s default version set to %s.\n", name, result.DefaultVersion) - return nil -} diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go index 2b02fe15863..68af58cf641 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/cmd/toolbox_version_list.go @@ -30,7 +30,7 @@ func newToolboxVersionListCommand(extCtx *azdext.ExtensionContext) *cobra.Comman Long: `List published versions for a toolbox. Shows one row per published version and marks which one is currently the -default. Use this when choosing a target for 'toolbox update --default-version'.`, +default. Use this when choosing a target for 'toolbox publish'.`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { return runToolboxVersionList(cmd.Context(), args[0], readToolboxFlags(cmd, extCtx)) From fe061e7a576dfc7fcd21e9bdd9b1e0b6396fa114 Mon Sep 17 00:00:00 2001 From: trangevi Date: Thu, 28 May 2026 13:16:11 -0700 Subject: [PATCH 9/9] Linter Signed-off-by: trangevi --- .../internal/pkg/azure/foundry_toolsets_client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.toolboxes/internal/pkg/azure/foundry_toolsets_client.go b/cli/azd/extensions/azure.ai.toolboxes/internal/pkg/azure/foundry_toolsets_client.go index e60b94d5a57..127cb57261d 100644 --- a/cli/azd/extensions/azure.ai.toolboxes/internal/pkg/azure/foundry_toolsets_client.go +++ b/cli/azd/extensions/azure.ai.toolboxes/internal/pkg/azure/foundry_toolsets_client.go @@ -221,7 +221,7 @@ type ToolboxVersionObject struct { Description string `json:"description,omitempty"` CreatedAt int64 `json:"created_at"` Metadata map[string]string `json:"metadata,omitempty"` - Tools []map[string]any `json:"tools"` + Tools []map[string]any `json:"tools"` // Skills has no omitempty: the service always emits "skills":[] on reads. Skills []map[string]any `json:"skills"` Policies *ToolboxPolicies `json:"policies,omitempty"`