diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 01ce06ca473..e77f8e29c61 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -8,6 +8,8 @@ ### Breaking Changes + - [[#9045]](https://github.com/Azure/azure-dev/pull/9045) The `--host` skill flag on `azd tool install`, `azd tool upgrade`, and `azd tool uninstall` has been renamed to `--agent`. Installed skills in `azd tool list --output json` and `azd tool check --output json` now expand into one row per agent and include the `agent` field. Update scripts and JSON consumers accordingly. + ### Bugs Fixed ### Other Changes diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index a693627261c..820485fb4fd 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -507,7 +507,7 @@ func (a *extensionListAction) Run(ctx context.Context) (*actions.ActionResult, e // Status indicator constants for extension list display. const ( statusUpToDate = "Up to date" - statusUpdate = "Update available" + statusUpgrade = "Upgrade available" statusIncompat = "Incompatible" statusNotInstall = "Not installed" ) @@ -518,7 +518,7 @@ func extensionStatus(installed, updateAvailable, incompatible bool) string { case incompatible: return statusIncompat case updateAvailable: - return statusUpdate + return statusUpgrade case installed: return statusUpToDate default: @@ -531,7 +531,7 @@ func extensionStatusColor(s string) string { switch s { case statusUpToDate: return output.WithSuccessFormat(s) - case statusUpdate: + case statusUpgrade: return output.WithWarningFormat(s) case statusIncompat: return output.WithErrorFormat(s) diff --git a/cli/azd/cmd/extension_test.go b/cli/azd/cmd/extension_test.go index fe048a30a42..914557eef6d 100644 --- a/cli/azd/cmd/extension_test.go +++ b/cli/azd/cmd/extension_test.go @@ -659,7 +659,7 @@ func TestExtensionStatus(t *testing.T) { }{ {"not installed", false, false, false, statusNotInstall}, {"up to date", true, false, false, statusUpToDate}, - {"update available", true, true, false, statusUpdate}, + {"update available", true, true, false, statusUpgrade}, {"incompatible", true, false, true, statusIncompat}, } for _, tt := range tests { @@ -679,7 +679,7 @@ func TestExtensionStatusColor(t *testing.T) { // Verify no panics and non-empty colored output for each status value. for _, s := range []string{ - statusUpToDate, statusUpdate, statusIncompat, statusNotInstall, + statusUpToDate, statusUpgrade, statusIncompat, statusNotInstall, } { result := extensionStatusColor(s) assert.NotEmpty(t, result, "color function should return non-empty for %q", s) diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index fcecd77f739..ad27672a49e 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6466,12 +6466,22 @@ const completionSpec: Fig.Spec = { subcommands: [ { name: ['check'], - description: 'Check for tool updates.', + description: 'Check for tool upgrades.', }, { name: ['install'], description: 'Install specified tools.', options: [ + { + name: ['--agent'], + description: 'Install the skill for the specified agent(s): copilot, claude. Use --agent all for every detected agent (skill tools only)', + isRepeatable: true, + args: [ + { + name: 'agent', + }, + ], + }, { name: ['--all'], description: 'Install all recommended tools', @@ -6480,16 +6490,6 @@ const completionSpec: Fig.Spec = { name: ['--dry-run'], description: 'Preview what would be installed without making changes', }, - { - name: ['--host'], - description: 'Install the skill for the specified agent host(s): copilot, claude. Use --host all for every detected host (skill tools only)', - isRepeatable: true, - args: [ - { - name: 'host', - }, - ], - }, ], args: { name: 'tool-name...', @@ -6511,6 +6511,16 @@ const completionSpec: Fig.Spec = { name: ['uninstall'], description: 'Uninstall installed tools.', options: [ + { + name: ['--agent'], + description: 'Uninstall the skill from the specified agent(s): copilot, claude. Use --agent all (or omit --agent) to remove the skill from every agent it is installed through (skill tools only)', + isRepeatable: true, + args: [ + { + name: 'agent', + }, + ], + }, { name: ['--all'], description: 'Uninstall all installed tools', @@ -6519,16 +6529,6 @@ const completionSpec: Fig.Spec = { name: ['--dry-run'], description: 'Preview what would be uninstalled without making changes', }, - { - name: ['--host'], - description: 'Uninstall the skill from the specified agent host(s): copilot, claude. Use --host all (or omit --host) to remove the skill from every host it is installed through (skill tools only)', - isRepeatable: true, - args: [ - { - name: 'host', - }, - ], - }, ], args: { name: 'tool-name...', @@ -6540,19 +6540,23 @@ const completionSpec: Fig.Spec = { description: 'Upgrade installed tools.', options: [ { - name: ['--dry-run'], - description: 'Preview what would be upgraded without making changes', - }, - { - name: ['--host'], - description: 'Upgrade the skill for the specified agent host(s): copilot, claude. Use --host all for every detected host (skill tools only)', + name: ['--agent'], + description: 'Upgrade the skill for the specified agent(s): copilot, claude. Use --agent all for every detected agent (skill tools only)', isRepeatable: true, args: [ { - name: 'host', + name: 'agent', }, ], }, + { + name: ['--all'], + description: 'Upgrade all installed tools', + }, + { + name: ['--dry-run'], + description: 'Preview what would be upgraded without making changes', + }, ], args: { name: 'tool-name...', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool-check.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool-check.snap index 8174a352e61..590c6418d69 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-tool-check.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-check.snap @@ -1,5 +1,5 @@ -Check for tool updates. +Check for tool upgrades. Usage azd tool check [flags] diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool-install.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool-install.snap index 2c564e2819f..3e5ef8fd181 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-tool-install.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-install.snap @@ -5,9 +5,9 @@ Usage azd tool install [tool-name...] [flags] Flags - --all : Install all recommended tools - --dry-run : Preview what would be installed without making changes - --host strings : Install the skill for the specified agent host(s): copilot, claude. Use --host all for every detected host (skill tools only) + --agent strings : Install the skill for the specified agent(s): copilot, claude. Use --agent all for every detected agent (skill tools only) + --all : Install all recommended tools + --dry-run : Preview what would be installed without making changes Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool-uninstall.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool-uninstall.snap index 7de1ca1d07a..a07b948a4b9 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-tool-uninstall.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-uninstall.snap @@ -5,9 +5,9 @@ Usage azd tool uninstall [tool-name...] [flags] Flags - --all : Uninstall all installed tools - --dry-run : Preview what would be uninstalled without making changes - --host strings : Uninstall the skill from the specified agent host(s): copilot, claude. Use --host all (or omit --host) to remove the skill from every host it is installed through (skill tools only) + --agent strings : Uninstall the skill from the specified agent(s): copilot, claude. Use --agent all (or omit --agent) to remove the skill from every agent it is installed through (skill tools only) + --all : Uninstall all installed tools + --dry-run : Preview what would be uninstalled without making changes Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap index 0ba250acb14..7e96fbf113d 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap @@ -5,8 +5,9 @@ Usage azd tool upgrade [tool-name...] [flags] Flags - --dry-run : Preview what would be upgraded without making changes - --host strings : Upgrade the skill for the specified agent host(s): copilot, claude. Use --host all for every detected host (skill tools only) + --agent strings : Upgrade the skill for the specified agent(s): copilot, claude. Use --agent all for every detected agent (skill tools only) + --all : Upgrade all installed tools + --dry-run : Preview what would be upgraded without making changes Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool.snap index 097005f2557..e632e65b445 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-tool.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool.snap @@ -5,7 +5,7 @@ Usage azd tool [command] Available Commands - check : Check for tool updates. + check : Check for tool upgrades. install : Install specified tools. list : List all tools with status. show : Show details for a specific tool. diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index f0dddc144a0..4a21662a01f 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "errors" "fmt" "io" "maps" @@ -154,7 +155,7 @@ func toolActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { group.Add("check", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ Use: "check", - Short: "Check for tool updates.", + Short: "Check for tool upgrades.", }, OutputFormats: []output.Format{output.JsonFormat, output.TableFormat}, DefaultFormat: output.TableFormat, @@ -310,14 +311,18 @@ func (a *toolAction) Run(ctx context.Context) (*actions.ActionResult, error) { return a.manager.InstallTools(ctx, allIDs) } - _ = runToolOperation(ctx, tools, operationFn, "Installing", "install", a.console) + _ = runToolOperation(ctx, tools, operationFn, "Installing", "install", a.console, false) // runToolOperation already displayed warnings; we intentionally // discard the outcome here — child tasks have surfaced what the user // needs to see, and this caller does not propagate the task error. + header := "Your tool is installed." + if len(tools) > 1 { + header = "Your tools are installed." + } return &actions.ActionResult{ Message: &actions.ResultMessage{ - Header: "Tool installation complete", + Header: header, }, }, nil } @@ -327,12 +332,19 @@ func (a *toolAction) Run(ctx context.Context) (*actions.ActionResult, error) { // --------------------------------------------------------------------------- type toolListItem struct { - Id string `json:"id"` - Name string `json:"name"` + Id string `json:"id"` + Name string `json:"name"` + // Agent is the agent CLI a skill row is installed through (e.g. + // "copilot"), empty for non-skill tools. + Agent string `json:"agent,omitempty"` Category string `json:"category"` Priority string `json:"priority"` Status string `json:"status"` Version string `json:"version"` + // DisplayName is the NAME cell shown in the table: a skill row is + // prefixed with its agent label (e.g. "[Copilot] Azure Skills"), other + // rows use the plain name. Excluded from JSON, which carries Name + Agent. + DisplayName string `json:"-"` } type toolListAction struct { @@ -380,6 +392,25 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) rows := make([]toolListItem, 0, len(statuses)) for _, s := range statuses { + // A skill installed on one or more agents expands into one row per + // agent, each prefixed with the agent label (e.g. "[Copilot] ..."). + if s.Tool.Category == tool.ToolCategorySkill && len(s.SkillAgents) > 0 { + for _, h := range s.SkillAgents { + rows = append(rows, toolListItem{ + Id: s.Tool.Id, + Name: s.Tool.Name, + Agent: h.Agent, + DisplayName: fmt.Sprintf("[%s] %s", + skillAgentDisplayName(s.Tool, h.Agent), s.Tool.Name), + Category: string(s.Tool.Category), + Priority: string(s.Tool.Priority), + Status: "Installed", + Version: h.Version, + }) + } + continue + } + status := "Not installed" version := "" if s.Installed { @@ -388,12 +419,13 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) } rows = append(rows, toolListItem{ - Id: s.Tool.Id, - Name: s.Tool.Name, - Category: string(s.Tool.Category), - Priority: string(s.Tool.Priority), - Status: status, - Version: version, + Id: s.Tool.Id, + Name: s.Tool.Name, + DisplayName: s.Tool.Name, + Category: string(s.Tool.Category), + Priority: string(s.Tool.Priority), + Status: status, + Version: version, }) } @@ -412,11 +444,12 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) Priority: 1, }, { - Column: output.Column{Heading: "NAME", ValueTemplate: "{{.Name}}"}, + Column: output.Column{Heading: "NAME", ValueTemplate: "{{.DisplayName}}"}, Priority: 2, CardTitle: true, Wrappable: true, Truncatable: true, + ColorFunc: colorAgentPrefix, }, { Column: output.Column{Heading: "STATUS", ValueTemplate: "{{.Status}}"}, @@ -437,11 +470,6 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) Priority: 3, Truncatable: true, }, - { - Column: output.Column{Heading: "PRIORITY", ValueTemplate: "{{.Priority}}"}, - Priority: 3, - Truncatable: true, - }, } formatErr = prettyFormatter.Format(rows, a.writer, output.PrettyTableFormatterOptions{ @@ -462,7 +490,7 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) type toolInstallFlags struct { all bool - hosts []string + agents []string dryRun bool } @@ -472,9 +500,9 @@ func newToolInstallFlags(cmd *cobra.Command) *toolInstallFlags { &flags.all, "all", false, "Install all recommended tools", ) cmd.Flags().StringSliceVar( - &flags.hosts, "host", nil, - "Install the skill for the specified agent host(s): copilot, claude. "+ - "Use --host all for every detected host (skill tools only)", + &flags.agents, "agent", nil, + "Install the skill for the specified agent(s): copilot, claude. "+ + "Use --agent all for every detected agent (skill tools only)", ) cmd.Flags().BoolVar( &flags.dryRun, "dry-run", false, @@ -517,6 +545,11 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err } if len(ids) == 0 { + if a.formatter.Kind() == output.JsonFormat { + // Keep a stable array schema for automation instead of a + // consoleMessage object. + return nil, a.formatter.Format([]*toolInstallResultItem{}, a.writer, nil) + } a.console.Message(ctx, output.WithSuccessFormat("Nothing to install.")) return nil, nil } @@ -527,10 +560,12 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err return a.dryRun(ctx, ids) } - a.console.MessageUxItem(ctx, &ux.MessageTitle{ - Title: "Install Azure development tools (azd tool install)", - TitleNote: "Installs specified tools onto the local machine", - }) + if a.formatter.Kind() != output.JsonFormat { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Install Azure development tools (azd tool install)", + TitleNote: "Installs specified tools onto the local machine", + }) + } tools := make([]*tool.ToolDefinition, 0, len(ids)) resolvedIDs := make([]string, 0, len(ids)) @@ -548,23 +583,49 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err idAttrs := toolIDUsageAttrs(a.flags.dryRun, resolvedIDs) tracing.SetUsageAttributes(idAttrs...) - // Resolve which agent host(s) to install skills for, based on the - // --host flag. When no host is given and several are detected, the + // Resolve which agents to install skills for, based on the + // --agent flag. When no agent is given and several are detected, the // user is asked to choose explicitly. - hostOpts, hostErr := a.resolveHostOptions(ctx, tools) - if hostErr != nil { - return nil, hostErr + agentOpts, agentErr := a.resolveAgentOptions(ctx, tools) + if agentErr != nil { + return nil, agentErr } - operationFn := func(ctx context.Context, allIDs []string) ([]*tool.InstallResult, error) { - return a.manager.InstallTools(ctx, allIDs, hostOpts...) + // In JSON mode, capture skill agent CLI output so it never leaks onto + // stdout ahead of the structured result. + if a.formatter.Kind() == output.JsonFormat { + agentOpts = append(agentOpts, tool.WithQuiet()) } start := time.Now() - outcome := runToolOperation(ctx, tools, operationFn, "Installing", "install", a.console) - installResults := outcome.Items - rawResults := outcome.Results - opErr := outcome.Err + + var ( + installResults []*toolInstallResultItem + rawResults []*tool.InstallResult + opErr error + ) + + if useStepSpinner(a.console, a.formatter, tools) { + // Tools render live per-step progress with the step spinner (like + // azd provision). JSON output is gated out of this path and + // handled below via the per-tool results. + rawResults, opErr = runStepSpinner( + ctx, a.console, tools, + func(ctx context.Context, ids []string, progress ...tool.InstallOption) ([]*tool.InstallResult, error) { + return a.manager.InstallTools(ctx, ids, append(slices.Clone(agentOpts), progress...)...) + }, + ) + } else { + operationFn := func(ctx context.Context, allIDs []string) ([]*tool.InstallResult, error) { + return a.manager.InstallTools(ctx, allIDs, agentOpts...) + } + outcome := runToolOperation(ctx, tools, operationFn, "Installing", "install", a.console, + a.formatter.Kind() == output.JsonFormat) + installResults = outcome.Items + rawResults = outcome.Results + opErr = outcome.Err + } + emitToolInstallTelemetry(rawResults, time.Since(start), opErr, tools) if len(rawResults) == 1 { @@ -577,22 +638,36 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err // When one or more tools failed, surface the error so the command // exits non-zero and the success header is NOT printed. The per-tool - // failures and a summary warning were already shown by - // runToolOperation. + // failures were already shown inline. if opErr != nil { return nil, opErr } + header := "Your tool is installed." + if len(tools) > 1 { + header = "Your tools are installed." + } + + // Surface any per-tool follow-up guidance (e.g. how to start using a skill) + // after the success header. + var notes []string + for _, t := range tools { + if t.SpinnerNote != "" { + notes = append(notes, t.SpinnerNote) + } + } + return &actions.ActionResult{ Message: &actions.ResultMessage{ - Header: "Tool installation complete", + Header: header, + FollowUp: strings.Join(notes, "\n"), }, }, nil } -// allHostsKeyword is the reserved --host value that selects every -// detected agent host. -const allHostsKeyword = "all" +// allAgentsKeyword is the reserved --agent value that selects every +// detected agent. +const allAgentsKeyword = "all" // firstSkillTool returns the first skill tool among tools, or nil when // none are present. @@ -605,91 +680,91 @@ func firstSkillTool(tools []*tool.ToolDefinition) *tool.ToolDefinition { return nil } -// resolveExplicitSkillHosts maps an explicit --host flag value to install +// resolveExplicitSkillAgents maps an explicit --agent flag value to install // options. The reserved value "all" installs through every available -// host (resolved at install time); otherwise the named hosts are passed +// agent (resolved at install time); otherwise the named agents are passed // through for the installer to validate. Shared by the install and // upgrade actions. -func resolveExplicitSkillHosts(hosts []string) ([]tool.InstallOption, error) { - // --host all selects every detected host. It cannot be mixed with - // specific host names. - if slices.Contains(hosts, allHostsKeyword) { - if len(hosts) > 1 { +func resolveExplicitSkillAgents(agents []string) ([]tool.InstallOption, error) { + // --agent all selects every detected agent. It cannot be mixed with + // specific agent names. + if slices.Contains(agents, allAgentsKeyword) { + if len(agents) > 1 { return nil, fmt.Errorf( - "--host all cannot be combined with specific hosts", + "--agent all cannot be combined with specific agents", ) } - return []tool.InstallOption{tool.WithAllAvailableHosts()}, nil + return []tool.InstallOption{tool.WithAllAvailableAgents()}, nil } - // The installer validates that each named host is configured and on + // The installer validates that each named agent is configured and on // PATH, surfacing a descriptive error otherwise. - return []tool.InstallOption{tool.WithHosts(hosts...)}, nil + return []tool.InstallOption{tool.WithAgents(agents...)}, nil } -// resolveHostOptions determines which agentic CLI host(s) a skill should -// be installed for. With --host it targets the named host(s); --host all -// targets every detected host. Without --host, a skill pulled in by a +// resolveAgentOptions determines which agent CLI(s) a skill should +// be installed for. With --agent it targets the named agent(s); --agent all +// targets every detected agent. Without --agent, a skill pulled in by a // batch (--all or the interactive picker) installs through every -// available host, while an explicitly-named skill with several detected -// hosts returns guidance asking the user to choose. It returns the +// available agent, while an explicitly-named skill with several detected +// agents returns guidance asking the user to choose. It returns the // install options to pass to the installer (nil selects the single -// preferred host). +// preferred agent). // -// When an explicitly-named skill has several hosts on PATH, an -// interactive terminal is prompted to choose which host(s) to install -// for (we still print a --host hint); in non-interactive mode it falls -// back to a guidance error telling the user to re-run with --host. -func (a *toolInstallAction) resolveHostOptions( +// When an explicitly-named skill has several agents on PATH, an +// interactive terminal is prompted to choose which agents to install +// for (we still print a --agent hint); in non-interactive mode it falls +// back to a guidance error telling the user to re-run with --agent. +func (a *toolInstallAction) resolveAgentOptions( ctx context.Context, tools []*tool.ToolDefinition, ) ([]tool.InstallOption, error) { - explicit := len(a.flags.hosts) > 0 + explicit := len(a.flags.agents) > 0 skill := firstSkillTool(tools) if explicit && skill == nil { - return nil, fmt.Errorf("--host only applies to skill tools") + return nil, fmt.Errorf("--agent only applies to skill tools") } if skill == nil { return nil, nil } if explicit { - // "all" expands to every detected host and is validated at - // install time. Specific host names are checked here so an - // unusable host (unknown name or not on PATH) can fall back to + // "all" expands to every detected agent and is validated at + // install time. Specific agent names are checked here so an + // unusable agent (unknown name or not on PATH) can fall back to // an interactive picker instead of hard-failing. - if !slices.Contains(a.flags.hosts, allHostsKeyword) { - if opts, handled, err := a.resolveUnavailableHostPrompt(ctx, skill); handled || err != nil { + if !slices.Contains(a.flags.agents, allAgentsKeyword) { + if opts, handled, err := a.resolveUnavailableAgentPrompt(ctx, skill); handled || err != nil { return opts, err } } - return resolveExplicitSkillHosts(a.flags.hosts) + return resolveExplicitSkillAgents(a.flags.agents) } - // No --host. A skill the user did not name explicitly (batch --all or - // interactive selection) installs through every available host, - // resolved at install time so host CLIs installed earlier in the same + // No --agent. A skill the user did not name explicitly (batch --all or + // interactive selection) installs through every available agent, + // resolved at install time so agent CLIs installed earlier in the same // batch are picked up. This is also why --all does not abort when - // several hosts are present. + // several agents are present. if !slices.Contains(a.args, skill.Id) { - return []tool.InstallOption{tool.WithAllAvailableHosts()}, nil + return []tool.InstallOption{tool.WithAllAvailableAgents()}, nil } - // Explicitly-named skill: when multiple hosts are detected we cannot + // Explicitly-named skill: when multiple agents are detected we cannot // safely guess which the user wants. - present := a.manager.AvailableSkillHosts(ctx, skill) + present, presentName := a.manager.AvailableSkillAgents(ctx, skill) if len(present) > 1 { - // Interactive terminal: prompt the user to pick the host(s), - // after surfacing the --host hint so they learn the shortcut too. - if a.console.IsSpinnerInteractive() && !a.console.IsNoPromptMode() { - a.console.Message(ctx, fmt.Sprintf( - "Multiple agent hosts detected. You can install "+ - "directly with `azd tool install %s --host ` "+ - "or `azd tool install %s --host all`.", - skill.Id, skill.Id, - )) - - opts, err := a.promptForSkillHosts(ctx, skill, present) + // Interactive terminal: prompt the user to pick the agent(s), + // after surfacing the --agent hint so they learn the shortcut too. + if promptAllowed(a.console, a.formatter) { + a.console.Message(ctx, "Multiple AI agents detected.\n"+ + output.WithGrayFormat("Tip: Use `")+ + output.WithHighLightFormat("--agent ")+ + output.WithGrayFormat("` or `")+ + output.WithHighLightFormat("--agent all")+ + output.WithGrayFormat("` to select a specific agent or all agents.\n")) + + opts, err := a.promptForSkillAgents(ctx, skill, present, presentName) if err != nil { return nil, err } @@ -700,98 +775,208 @@ func (a *toolInstallAction) resolveHostOptions( } return nil, &internal.ErrorWithSuggestion{ - Err: fmt.Errorf("multiple agent hosts detected for %s", skill.Name), + Err: fmt.Errorf("multiple AI agents detected for %s", skill.Name), Message: fmt.Sprintf( - "Detected multiple hosts: %s", strings.Join(present, ", "), + "Detected multiple agents: %s", strings.Join(presentName, ", "), ), Suggestion: fmt.Sprintf( - "Specify which host(s) to install for:\n\n"+ - " azd tool install %s --host \n\n"+ - " azd tool install %s --host all", + "Specify which agents to install for:\n\n"+ + " azd tool install %s --agent \n\n"+ + " azd tool install %s --agent all", skill.Id, skill.Id, ), } } - // Zero or one host detected: keep the single preferred-host default. + // Zero or one agent detected: keep the single preferred-agent default. return nil, nil } -// resolveUnavailableHostPrompt handles an explicit --host whose named -// host(s) are not usable (unknown name or not on PATH). In an -// interactive terminal it tells the user the requested host is -// unavailable and prompts them to pick from the hosts detected on PATH; -// the chosen host(s) are returned with handled=true. When no supported -// host is on PATH at all it defers to the installer's install guidance -// (handled=true via WithAllAvailableHosts). In non-interactive mode, or -// when every requested host is already available, it returns +// resolveUnavailableAgentPrompt handles an explicit --agent whose named +// agents are not usable (unknown name or not on PATH). In an +// interactive terminal it tells the user the requested agent is +// unavailable and prompts them to pick from the agents detected on PATH; +// the chosen agents are returned with handled=true. When no supported +// agent is on PATH at all it defers to the installer's install guidance +// (handled=true via WithAllAvailableAgents). In non-interactive mode, or +// when every requested agent is already available, it returns // handled=false so the caller validates the request as usual. -func (a *toolInstallAction) resolveUnavailableHostPrompt( +func (a *toolInstallAction) resolveUnavailableAgentPrompt( ctx context.Context, skill *tool.ToolDefinition, ) (opts []tool.InstallOption, handled bool, err error) { - if !a.console.IsSpinnerInteractive() || a.console.IsNoPromptMode() { + if !promptAllowed(a.console, a.formatter) { return nil, false, nil } - available := a.manager.AvailableSkillHosts(ctx, skill) + available, availableNames := a.manager.AvailableSkillAgents(ctx, skill) var unavailable []string - for _, host := range a.flags.hosts { - if !slices.Contains(available, host) { - unavailable = append(unavailable, fmt.Sprintf("%q", host)) + for _, agent := range a.flags.agents { + // Match case-insensitively, mirroring findSkillAgent and the --agent + // contract, so e.g. "--agent Copilot" is not falsely reported + // unavailable (and does not open another prompt) when the installer + // would accept it. + if !slices.ContainsFunc(available, func(cmd string) bool { + return strings.EqualFold(cmd, agent) + }) { + unavailable = append(unavailable, fmt.Sprintf("%q", agent)) } } if len(unavailable) == 0 { return nil, false, nil } - // No usable host on PATH — defer to the installer's install guidance - // (recommends installing a CLI host first) by targeting every - // available host. + // No usable agent on PATH — defer to the installer's install guidance + // (recommends installing a CLI agent first) by targeting every + // available agent. if len(available) == 0 { - return []tool.InstallOption{tool.WithAllAvailableHosts()}, true, nil + return []tool.InstallOption{tool.WithAllAvailableAgents()}, true, nil } a.console.Message(ctx, fmt.Sprintf( - "Host %s is not available for %s. Choose from the hosts detected "+ + "Agent %s is not available for %s. Choose from the agents detected "+ "on your PATH:", strings.Join(unavailable, ", "), skill.Name, )) - picked, err := a.promptForSkillHosts(ctx, skill, available) + picked, err := a.promptForSkillAgents(ctx, skill, available, availableNames) if err != nil { return nil, false, err } // Nothing selected — let the caller surface the installer's - // validation error for the originally requested host. + // validation error for the originally requested agent. if picked == nil { return nil, false, nil } return picked, true, nil } -// promptForSkillHosts shows an interactive multi-select over the given -// available hosts and returns the matching install option, or (nil, nil) +// promptForSkillAgents shows an interactive multi-select over the given +// available agents and returns the matching install option, or (nil, nil) // when the user selects nothing so callers can fall back to their own -// guidance. -func (a *toolInstallAction) promptForSkillHosts( +// guidance. commands and names are index-aligned (from AvailableSkillAgents): +// the picker displays the friendly name for each agent and maps the selection +// back to its command so the installer resolves it by command. +func (a *toolInstallAction) promptForSkillAgents( ctx context.Context, skill *tool.ToolDefinition, - available []string, + commands []string, + names []string, ) ([]tool.InstallOption, error) { + toCommand := make(map[string]string, len(names)) + for i, name := range names { + toCommand[name] = commands[i] + } + selected, err := a.console.MultiSelect(ctx, input.ConsoleOptions{ Message: fmt.Sprintf( - "Select the agent host(s) to install %s for", skill.Name, + "Select the agents to install %s for", skill.Name, ), - Options: available, - DefaultValue: []string{available[0]}, + Options: names, + DefaultValue: []string{names[0]}, }) if err != nil { - return nil, fmt.Errorf("selecting hosts: %w", err) + return nil, fmt.Errorf("selecting agents: %w", err) } if len(selected) == 0 { return nil, nil } - return []tool.InstallOption{tool.WithHosts(selected...)}, nil + + picked := make([]string, len(selected)) + for i, name := range selected { + picked[i] = toCommand[name] + } + return []tool.InstallOption{tool.WithAgents(picked...)}, nil +} + +// detectAllTools runs manager.DetectAll to get the status of every tool, +// showing a detection spinner with the given text. The spinner is skipped when +// the output format is JSON: it writes control bytes to stdout and would +// corrupt the machine-readable stream (matching tool list/check, which also +// bypass the spinner in JSON mode). +func detectAllTools( + ctx context.Context, + manager *tool.Manager, + formatter output.Formatter, + spinnerText string, +) ([]*tool.ToolStatus, error) { + if formatter != nil && formatter.Kind() == output.JsonFormat { + return manager.DetectAll(ctx) + } + + var statuses []*tool.ToolStatus + spinner := uxlib.NewSpinner(&uxlib.SpinnerOptions{ + Text: spinnerText, + ClearOnStop: true, + }) + if err := spinner.Run(ctx, func(ctx context.Context) error { + var detectErr error + statuses, detectErr = manager.DetectAll(ctx) + return detectErr + }); err != nil { + return nil, err + } + return statuses, nil +} + +// promptAllowed reports whether azd may show an interactive prompt for a tool +// operation. Prompting requires an interactive terminal, that --no-prompt is +// not set, and that output is not JSON: a JSON-mode prompt writes control bytes +// to the same stdout the result array is serialized to, so the output would no +// longer be a single valid JSON document. When prompting is not allowed, callers +// must require an explicit target (tool IDs / --agent / --all) instead. +func promptAllowed(console input.Console, formatter output.Formatter) bool { + return console.IsSpinnerInteractive() && + !console.IsNoPromptMode() && + (formatter == nil || formatter.Kind() != output.JsonFormat) +} + +// useStepSpinner reports whether a tool operation should render live +// per-step progress with the step spinner (like azd provision) instead of +// the batch task list. It applies to any interactive, non-JSON operation. +func useStepSpinner( + console input.Console, + formatter output.Formatter, + tools []*tool.ToolDefinition, +) bool { + return len(tools) > 0 && + formatter.Kind() != output.JsonFormat && + console.IsSpinnerInteractive() +} + +// runStepSpinner runs a tool install/upgrade/uninstall with a live per-step +// step spinner (like azd provision): the installer renders each step via the +// console (each targeted agent for a skill tool, or the tool itself +// otherwise). run performs the manager call with the step-progress option +// appended to its own options. It returns the per-tool results (for +// telemetry) and an aggregate error when any step failed. +func runStepSpinner( + ctx context.Context, + console input.Console, + tools []*tool.ToolDefinition, + run func(context.Context, []string, ...tool.InstallOption) ([]*tool.InstallResult, error), +) ([]*tool.InstallResult, error) { + ids := make([]string, len(tools)) + for i, t := range tools { + ids[i] = t.Id + } + + results, err := run(ctx, ids, tool.WithStepProgress(console), tool.WithInput(console.Handles().Stdin)) + if err != nil { + return results, err + } + + // Per-step failures were shown inline by the installer; surface an + // aggregate error so the command still exits non-zero. + var failed []error + for _, r := range results { + if r.Error != nil { + failed = append(failed, r.Error) + } + } + if len(failed) > 0 { + return results, errors.Join(failed...) + } + return results, nil } // dryRun detects the current status of the requested tools and @@ -851,20 +1036,52 @@ func (a *toolInstallAction) dryRun( return nil, nil } +// noToolTargetError builds the guidance error returned when a tool operation +// cannot determine which tools to act on: prompting is unavailable (a +// non-interactive terminal or --no-prompt) and the user gave neither tool IDs +// nor --all. All three commands (install, upgrade, uninstall) require an +// explicit target here rather than guessing, matching azd's --no-prompt +// contract of failing with a structured error instead of an implicit default. +func noToolTargetError(command string) error { + return &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("no tools specified to %s", command), + Message: fmt.Sprintf("A tool ID is required to %s", command), + Suggestion: fmt.Sprintf( + "Specify one or more tool IDs, or --all:\n\n"+ + " azd tool %s [ ...]\n\n"+ + " azd tool %s --all", + command, command, + ), + } +} + +// toolIDsWithAllError is returned when a tool operation is given both explicit +// tool IDs and --all. --all applies only when no tool ID is specified, so the +// combination is rejected rather than silently ignoring one of them +func toolIDsWithAllError(command string) error { + return &internal.ErrorWithSuggestion{ + Err: fmt.Errorf( + "cannot specify both tool IDs and --all: %w", + internal.ErrInvalidFlagCombination), + Message: "Tool IDs and --all cannot be combined.", + Suggestion: fmt.Sprintf( + "Use either:\n"+ + " azd tool %s \n"+ + " azd tool %s --all", + command, command), + } +} + // resolveToolIds determines which tool IDs to install based on flags and arguments. func (a *toolInstallAction) resolveToolIds(ctx context.Context) ([]string, error) { + if len(a.args) > 0 && a.flags.all { + return nil, toolIDsWithAllError("install") + } + // --all: install all recommended tools that are not already installed. if a.flags.all { - var statuses []*tool.ToolStatus - spinner := uxlib.NewSpinner(&uxlib.SpinnerOptions{ - Text: "Detecting tool status...", - ClearOnStop: true, - }) - if err := spinner.Run(ctx, func(ctx context.Context) error { - var detectErr error - statuses, detectErr = a.manager.DetectAll(ctx) - return detectErr - }); err != nil { + statuses, err := detectAllTools(ctx, a.manager, a.formatter, "Detecting tool status...") + if err != nil { return nil, fmt.Errorf("detecting tools: %w", err) } @@ -883,16 +1100,8 @@ func (a *toolInstallAction) resolveToolIds(ctx context.Context) ([]string, error } // Interactive: let the user pick from uninstalled tools. - var statuses []*tool.ToolStatus - spinner := uxlib.NewSpinner(&uxlib.SpinnerOptions{ - Text: "Detecting tool status...", - ClearOnStop: true, - }) - if err := spinner.Run(ctx, func(ctx context.Context) error { - var detectErr error - statuses, detectErr = a.manager.DetectAll(ctx) - return detectErr - }); err != nil { + statuses, err := detectAllTools(ctx, a.manager, a.formatter, "Detecting tool status...") + if err != nil { return nil, fmt.Errorf("detecting tools: %w", err) } @@ -907,6 +1116,13 @@ func (a *toolInstallAction) resolveToolIds(ctx context.Context) ([]string, error return nil, nil } + // Non-interactive (no TTY), --no-prompt, or JSON output: the picker can't + // run (or would corrupt JSON), so require an explicit target (tool IDs or + // --all) rather than implicitly installing the recommended set. + if !promptAllowed(a.console, a.formatter) { + return nil, noToolTargetError("install") + } + choices := make([]*uxlib.MultiSelectChoice, len(uninstalled)) for i, s := range uninstalled { choices[i] = &uxlib.MultiSelectChoice{ @@ -942,20 +1158,25 @@ func (a *toolInstallAction) resolveToolIds(ctx context.Context) ([]string, error // --------------------------------------------------------------------------- type toolUpgradeFlags struct { + all bool dryRun bool - hosts []string + agents []string } func newToolUpgradeFlags(cmd *cobra.Command) *toolUpgradeFlags { flags := &toolUpgradeFlags{} + cmd.Flags().BoolVar( + &flags.all, "all", false, + "Upgrade all installed tools", + ) cmd.Flags().BoolVar( &flags.dryRun, "dry-run", false, "Preview what would be upgraded without making changes", ) cmd.Flags().StringSliceVar( - &flags.hosts, "host", nil, - "Upgrade the skill for the specified agent host(s): copilot, claude. "+ - "Use --host all for every detected host (skill tools only)", + &flags.agents, "agent", nil, + "Upgrade the skill for the specified agent(s): copilot, claude. "+ + "Use --agent all for every detected agent (skill tools only)", ) return flags } @@ -1001,7 +1222,26 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err // for upgrading. fromVersions := make(map[string]string) - if len(a.args) > 0 { + if len(a.args) > 0 && a.flags.all { + return nil, toolIDsWithAllError("upgrade") + } + + switch { + case a.flags.all: + // --all: upgrade every installed tool. + statuses, err := a.detectInstalledTools(ctx) + if err != nil { + return nil, err + } + for _, s := range statuses { + if s.Installed { + toolsToUpgrade = append(toolsToUpgrade, s.Tool) + if s.Tool != nil { + fromVersions[s.Tool.Id] = s.InstalledVersion + } + } + } + case len(a.args) > 0: for _, id := range a.args { toolDef, findErr := a.manager.FindTool(id) if findErr != nil { @@ -1013,30 +1253,47 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err fromVersions[toolDef.Id] = status.InstalledVersion } } - } else { - var statuses []*tool.ToolStatus - spinner := uxlib.NewSpinner(&uxlib.SpinnerOptions{ - Text: "Detecting installed tools...", - ClearOnStop: true, - }) - if err := spinner.Run(ctx, func(ctx context.Context) error { - var detectErr error - statuses, detectErr = a.manager.DetectAll(ctx) - return detectErr - }); err != nil { - return nil, fmt.Errorf("detecting installed tools: %w", err) + default: + // No args: prompt the user to pick from installed tools (like + // `azd tool install`). When prompting is unavailable, require an + // explicit target instead of upgrading everything implicitly. + statuses, err := a.detectInstalledTools(ctx) + if err != nil { + return nil, err } + var installed []*tool.ToolStatus for _, s := range statuses { if s.Installed { - toolsToUpgrade = append(toolsToUpgrade, s.Tool) - if s.Tool != nil { - fromVersions[s.Tool.Id] = s.InstalledVersion - } + installed = append(installed, s) + } + } + // Non-interactive (no TTY), --no-prompt, or JSON output: the picker + // can't run (or would corrupt JSON), so require an explicit target + // (tool IDs or --all) rather than implicitly upgrading every tool. + if len(installed) > 0 && !promptAllowed(a.console, a.formatter) { + return nil, noToolTargetError("upgrade") + } + chosen := installed + if promptAllowed(a.console, a.formatter) && len(installed) > 0 { + chosen, err = a.promptForUpgradeTools(ctx, installed) + if err != nil { + return nil, err + } + } + for _, s := range chosen { + toolsToUpgrade = append(toolsToUpgrade, s.Tool) + if s.Tool != nil { + fromVersions[s.Tool.Id] = s.InstalledVersion } } } if len(toolsToUpgrade) == 0 { + if a.formatter.Kind() == output.JsonFormat { + // Keep a stable array schema for automation instead of a + // consoleMessage object. + return nil, a.formatter.Format([]*toolInstallResultItem{}, a.writer, nil) + } a.console.Message(ctx, output.WithGrayFormat( "No installed tools to upgrade.", )) @@ -1057,24 +1314,49 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err return a.dryRun(ctx, toolsToUpgrade) } - a.console.MessageUxItem(ctx, &ux.MessageTitle{ - Title: "Upgrade Azure development tools (azd tool upgrade)", - TitleNote: "Upgrades installed tools to their latest versions", - }) + if a.formatter.Kind() != output.JsonFormat { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Upgrade Azure development tools (azd tool upgrade)", + TitleNote: "Upgrades installed tools to their latest versions", + }) + } - operationFn := func(ctx context.Context, allIDs []string) ([]*tool.InstallResult, error) { - hostOpts, hostErr := a.resolveHostOptions(toolsToUpgrade) - if hostErr != nil { - return nil, hostErr - } - return a.manager.UpgradeTools(ctx, allIDs, hostOpts...) + agentOpts, agentErr := a.resolveAgentOptions(toolsToUpgrade) + if agentErr != nil { + return nil, agentErr + } + + // In JSON mode, capture skill agent CLI output so it never leaks onto + // stdout ahead of the structured result. + if a.formatter.Kind() == output.JsonFormat { + agentOpts = append(agentOpts, tool.WithQuiet()) } start := time.Now() - outcome := runToolOperation(ctx, toolsToUpgrade, operationFn, "Upgrading", "upgrade", a.console) - upgradeResults := outcome.Items - rawResults := outcome.Results - opErr := outcome.Err + + var ( + upgradeResults []*toolInstallResultItem + rawResults []*tool.InstallResult + opErr error + ) + + if useStepSpinner(a.console, a.formatter, toolsToUpgrade) { + rawResults, opErr = runStepSpinner( + ctx, a.console, toolsToUpgrade, + func(ctx context.Context, ids []string, progress ...tool.InstallOption) ([]*tool.InstallResult, error) { + return a.manager.UpgradeTools(ctx, ids, append(slices.Clone(agentOpts), progress...)...) + }, + ) + } else { + operationFn := func(ctx context.Context, allIDs []string) ([]*tool.InstallResult, error) { + return a.manager.UpgradeTools(ctx, allIDs, agentOpts...) + } + outcome := runToolOperation(ctx, toolsToUpgrade, operationFn, "Upgrading", "upgrade", a.console, + a.formatter.Kind() == output.JsonFormat) + upgradeResults = outcome.Items + rawResults = outcome.Results + opErr = outcome.Err + } emitToolInstallTelemetry(rawResults, time.Since(start), opErr, toolsToUpgrade) if len(rawResults) == 1 { @@ -1103,31 +1385,129 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err return nil, opErr } + // Choose the success header based on whether anything actually changed. + // A tool is "already up to date" when the installer flagged it. For skills + // this flag is authoritative (set per agent, so an upgrade on any agent + // clears it), so we trust it as-is. For non-skill tools — which never set + // the flag — fall back to comparing the version detected before the + // upgrade (fromVersions) with the one detected after (InstalledVersion); a + // missing version on either side counts as a change, so azd never claims + // "up to date" without evidence. + allUpToDate := len(rawResults) > 0 + for _, r := range rawResults { + upToDate := r.AlreadyUpToDate + if !upToDate && r.Tool != nil && r.Tool.Category != tool.ToolCategorySkill { + before := fromVersions[r.Tool.Id] + upToDate = before != "" && r.InstalledVersion != "" && + before == r.InstalledVersion + } + if !upToDate { + allUpToDate = false + break + } + } + + header := "Tool is upgraded." + if allUpToDate { + header = "Tool is already up to date." + } + if len(rawResults) > 1 { + header = "Tools are upgraded." + if allUpToDate { + header = "Tools are already up to date." + } + } + // For a single tool, include the resulting version in the done message, + // e.g. "Tool is upgraded to v2.0.0." or + // "Tool is already up to date (v1.1.75).". + if len(rawResults) == 1 && rawResults[0].InstalledVersion != "" { + version := rawResults[0].InstalledVersion + if allUpToDate { + header = fmt.Sprintf("Tool is already up to date (v%s).", version) + } else { + header = fmt.Sprintf("Tool is upgraded to v%s.", version) + } + } + return &actions.ActionResult{ Message: &actions.ResultMessage{ - Header: "Tool upgrade complete", + Header: header, }, }, nil } -// resolveHostOptions determines which agentic CLI host(s) a skill should -// be upgraded for, based on the --host flag. --host all targets every -// detected host; specific names target those hosts. When --host is -// omitted it returns no options, letting the installer upgrade every host +// detectInstalledTools runs DetectAll behind a spinner and returns the full +// set of tool statuses. Used by the --all and interactive upgrade paths. +func (a *toolUpgradeAction) detectInstalledTools(ctx context.Context) ([]*tool.ToolStatus, error) { + statuses, err := detectAllTools(ctx, a.manager, a.formatter, "Detecting installed tools...") + if err != nil { + return nil, fmt.Errorf("detecting installed tools: %w", err) + } + return statuses, nil +} + +// promptForUpgradeTools shows an interactive multi-select of the installed +// tools (all pre-selected) and returns the statuses the user chose to +// upgrade, mirroring the selection prompt in `azd tool install`. +func (a *toolUpgradeAction) promptForUpgradeTools( + ctx context.Context, + installed []*tool.ToolStatus, +) ([]*tool.ToolStatus, error) { + choices := make([]*uxlib.MultiSelectChoice, len(installed)) + for i, s := range installed { + choices[i] = &uxlib.MultiSelectChoice{ + Value: s.Tool.Id, + Label: s.Tool.Name, + Selected: true, + } + } + + multiSelect := uxlib.NewMultiSelect(&uxlib.MultiSelectOptions{ + Writer: a.console.Handles().Stdout, + Reader: a.console.Handles().Stdin, + Message: "Select tools to upgrade", + Choices: choices, + }) + + selected, err := multiSelect.Ask(ctx) + if err != nil { + return nil, fmt.Errorf("selecting tools: %w", err) + } + + byID := make(map[string]*tool.ToolStatus, len(installed)) + for _, s := range installed { + byID[s.Tool.Id] = s + } + + var chosen []*tool.ToolStatus + for _, choice := range selected { + if choice.Selected { + if s, ok := byID[choice.Value]; ok { + chosen = append(chosen, s) + } + } + } + return chosen, nil +} + +// resolveAgentOptions determines which agent CLI(s) a skill should +// be upgraded for, based on the --agent flag. --agent all targets every +// detected agent; specific names target those agents. When --agent is +// omitted it returns no options, letting the installer upgrade every agent // the skill is already installed through. -func (a *toolUpgradeAction) resolveHostOptions( +func (a *toolUpgradeAction) resolveAgentOptions( tools []*tool.ToolDefinition, ) ([]tool.InstallOption, error) { - if len(a.flags.hosts) == 0 { + if len(a.flags.agents) == 0 { return nil, nil } skill := firstSkillTool(tools) if skill == nil { - return nil, fmt.Errorf("--host only applies to skill tools") + return nil, fmt.Errorf("--agent only applies to skill tools") } - return resolveExplicitSkillHosts(a.flags.hosts) + return resolveExplicitSkillAgents(a.flags.agents) } // dryRun detects the current status of the tools and displays what @@ -1184,7 +1564,7 @@ func (a *toolUpgradeAction) dryRun( type toolUninstallFlags struct { all bool - hosts []string + agents []string dryRun bool } @@ -1194,9 +1574,9 @@ func newToolUninstallFlags(cmd *cobra.Command) *toolUninstallFlags { &flags.all, "all", false, "Uninstall all installed tools", ) cmd.Flags().StringSliceVar( - &flags.hosts, "host", nil, - "Uninstall the skill from the specified agent host(s): copilot, claude. "+ - "Use --host all (or omit --host) to remove the skill from every host it is "+ + &flags.agents, "agent", nil, + "Uninstall the skill from the specified agent(s): copilot, claude. "+ + "Use --agent all (or omit --agent) to remove the skill from every agent it is "+ "installed through (skill tools only)", ) cmd.Flags().BoolVar( @@ -1240,6 +1620,11 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e } if len(ids) == 0 { + if a.formatter.Kind() == output.JsonFormat { + // Keep a stable array schema for automation instead of a + // consoleMessage object. + return nil, a.formatter.Format([]*toolInstallResultItem{}, a.writer, nil) + } a.console.Message(ctx, output.WithGrayFormat("No installed tools to uninstall.")) return nil, nil } @@ -1264,24 +1649,49 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e return a.dryRun(ctx, tools) } - a.console.MessageUxItem(ctx, &ux.MessageTitle{ - Title: "Uninstall Azure development tools (azd tool uninstall)", - TitleNote: "Uninstalls specified tools from the local machine", - }) + if a.formatter.Kind() != output.JsonFormat { + a.console.MessageUxItem(ctx, &ux.MessageTitle{ + Title: "Uninstall Azure development tools (azd tool uninstall)", + TitleNote: "Uninstalls specified tools from the local machine", + }) + } - operationFn := func(ctx context.Context, allIDs []string) ([]*tool.InstallResult, error) { - hostOpts, hostErr := a.resolveHostOptions(tools) - if hostErr != nil { - return nil, hostErr - } - return a.manager.UninstallTools(ctx, allIDs, hostOpts...) + agentOpts, agentErr := a.resolveAgentOptions(tools) + if agentErr != nil { + return nil, agentErr + } + + // In JSON mode, capture skill agent CLI output so it never leaks onto + // stdout ahead of the structured result. + if a.formatter.Kind() == output.JsonFormat { + agentOpts = append(agentOpts, tool.WithQuiet()) } start := time.Now() - outcome := runToolOperation(ctx, tools, operationFn, "Uninstalling", "uninstall", a.console) - uninstallResults := outcome.Items - rawResults := outcome.Results - opErr := outcome.Err + + var ( + uninstallResults []*toolInstallResultItem + rawResults []*tool.InstallResult + opErr error + ) + + if useStepSpinner(a.console, a.formatter, tools) { + rawResults, opErr = runStepSpinner( + ctx, a.console, tools, + func(ctx context.Context, ids []string, progress ...tool.InstallOption) ([]*tool.InstallResult, error) { + return a.manager.UninstallTools(ctx, ids, append(slices.Clone(agentOpts), progress...)...) + }, + ) + } else { + operationFn := func(ctx context.Context, allIDs []string) ([]*tool.InstallResult, error) { + return a.manager.UninstallTools(ctx, allIDs, agentOpts...) + } + outcome := runToolOperation(ctx, tools, operationFn, "Uninstalling", "uninstall", a.console, + a.formatter.Kind() == output.JsonFormat) + uninstallResults = outcome.Items + rawResults = outcome.Results + opErr = outcome.Err + } emitToolInstallTelemetry(rawResults, time.Since(start), opErr, tools) if len(rawResults) == 1 { @@ -1300,31 +1710,35 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e return nil, opErr } + header := "Your tool is uninstalled." + if len(tools) > 1 { + header = "Your tools are uninstalled." + } return &actions.ActionResult{ Message: &actions.ResultMessage{ - Header: "Tool uninstall complete", + Header: header, }, }, nil } -// resolveHostOptions determines which agentic CLI host(s) a skill should -// be uninstalled from, based on the --host flag. --host all targets every -// detected host; specific names target those hosts. When --host is +// resolveAgentOptions determines which agent CLI(s) a skill should +// be uninstalled from, based on the --agent flag. --agent all targets every +// detected agent; specific names target those agents. When --agent is // omitted it returns no options, letting the installer remove the skill -// from every host it is installed through. -func (a *toolUninstallAction) resolveHostOptions( +// from every agent it is installed through. +func (a *toolUninstallAction) resolveAgentOptions( tools []*tool.ToolDefinition, ) ([]tool.InstallOption, error) { - if len(a.flags.hosts) == 0 { + if len(a.flags.agents) == 0 { return nil, nil } skill := firstSkillTool(tools) if skill == nil { - return nil, fmt.Errorf("--host only applies to skill tools") + return nil, fmt.Errorf("--agent only applies to skill tools") } - return resolveExplicitSkillHosts(a.flags.hosts) + return resolveExplicitSkillAgents(a.flags.agents) } // resolveToolIds determines which tool IDs to uninstall based on flags @@ -1332,6 +1746,10 @@ func (a *toolUninstallAction) resolveHostOptions( // mutates) select every installed tool; otherwise the interactive path // lets the user pick from installed tools. func (a *toolUninstallAction) resolveToolIds(ctx context.Context) ([]string, error) { + if len(a.args) > 0 && a.flags.all { + return nil, toolIDsWithAllError("uninstall") + } + // Positional args: uninstall specified tools by ID. if len(a.args) > 0 { return a.args, nil @@ -1339,16 +1757,8 @@ func (a *toolUninstallAction) resolveToolIds(ctx context.Context) ([]string, err // --all, --dry-run, and the interactive picker all need the current // installed set. - var statuses []*tool.ToolStatus - spinner := uxlib.NewSpinner(&uxlib.SpinnerOptions{ - Text: "Detecting installed tools...", - ClearOnStop: true, - }) - if err := spinner.Run(ctx, func(ctx context.Context) error { - var detectErr error - statuses, detectErr = a.manager.DetectAll(ctx) - return detectErr - }); err != nil { + statuses, err := detectAllTools(ctx, a.manager, a.formatter, "Detecting installed tools...") + if err != nil { return nil, fmt.Errorf("detecting installed tools: %w", err) } @@ -1365,7 +1775,7 @@ func (a *toolUninstallAction) resolveToolIds(ctx context.Context) ([]string, err // --all selects every installed tool. --dry-run does the same without // prompting: a preview never mutates anything, so it defaults to all - // installed tools (a skill is previewed against the host(s) it is + // installed tools (a skill is previewed against the agents it is // installed through) instead of asking the user to pick. if a.flags.all || a.flags.dryRun { ids := make([]string, 0, len(installed)) @@ -1375,6 +1785,15 @@ func (a *toolUninstallAction) resolveToolIds(ctx context.Context) ([]string, err return ids, nil } + // Uninstall is destructive, so — unlike `azd tool install`/`upgrade`, which + // only add — it must never treat "no target" as "all". When prompting is + // unavailable (a non-interactive terminal, --no-prompt, or JSON output) and + // the user gave neither tool IDs nor --all, fail with explicit guidance + // instead of silently removing every installed tool. + if !promptAllowed(a.console, a.formatter) { + return nil, noToolTargetError("uninstall") + } + // Interactive: let the user pick from installed tools. Nothing is // pre-selected so uninstall is always an explicit choice. choices := make([]*uxlib.MultiSelectChoice, len(installed)) @@ -1457,14 +1876,21 @@ func (a *toolUninstallAction) dryRun( // --------------------------------------------------------------------------- type toolCheckItem struct { - Id string `json:"id"` - Name string `json:"name"` + Id string `json:"id"` + Name string `json:"name"` + // Agent is the agent CLI a skill row is checked through (e.g. + // "copilot"), empty for non-skill tools. + Agent string `json:"agent,omitempty"` InstalledVersion string `json:"installedVersion"` LatestVersion string `json:"latestVersion"` UpdateAvailable bool `json:"updateAvailable"` - // Status is a human-readable installation/update status indicator. + // Status is a human-readable installation/upgrade status indicator. // Populated only for pretty-table rendering; omitted from JSON. Status string `json:"-"` + // DisplayName is the NAME cell shown in the table: a skill row is + // prefixed with its agent label (e.g. "[Copilot] Azure Skills"), other + // rows use the plain name. Excluded from JSON, which carries Name + Agent. + DisplayName string `json:"-"` } type toolCheckAction struct { @@ -1492,7 +1918,7 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error var results []*tool.UpdateCheckResult if a.formatter.Kind() != output.JsonFormat { spinner := uxlib.NewSpinner(&uxlib.SpinnerOptions{ - Text: "Checking for updates...", + Text: "Checking for upgrades...", ClearOnStop: true, }) if err := spinner.Run(ctx, func(ctx context.Context) error { @@ -1500,13 +1926,13 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error results, detectErr = a.manager.CheckForUpdates(ctx) return detectErr }); err != nil { - return nil, fmt.Errorf("checking for updates: %w", err) + return nil, fmt.Errorf("checking for upgrades: %w", err) } } else { var err error results, err = a.manager.CheckForUpdates(ctx) if err != nil { - return nil, fmt.Errorf("checking for updates: %w", err) + return nil, fmt.Errorf("checking for upgrades: %w", err) } } @@ -1516,9 +1942,29 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error if r.UpdateAvailable { updatesAvailable++ } + // A skill installed on one or more agents expands into one row per + // agent, each prefixed with the agent label and carrying that agent's + // installed version and upgrade status. + if r.Tool.Category == tool.ToolCategorySkill && len(r.SkillAgents) > 0 { + for _, h := range r.SkillAgents { + rows = append(rows, toolCheckItem{ + Id: r.Tool.Id, + Name: r.Tool.Name, + Agent: h.Agent, + DisplayName: fmt.Sprintf("[%s] %s", + skillAgentDisplayName(r.Tool, h.Agent), r.Tool.Name), + InstalledVersion: h.CurrentVersion, + LatestVersion: r.LatestVersion, + UpdateAvailable: h.UpdateAvailable, + Status: toolCheckStatus(h.CurrentVersion != "", h.UpdateAvailable), + }) + } + continue + } rows = append(rows, toolCheckItem{ Id: r.Tool.Id, Name: r.Tool.Name, + DisplayName: r.Tool.Name, InstalledVersion: r.CurrentVersion, LatestVersion: r.LatestVersion, UpdateAvailable: r.UpdateAvailable, @@ -1544,11 +1990,12 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error Priority: 1, }, { - Column: output.Column{Heading: "NAME", ValueTemplate: "{{.Name}}"}, + Column: output.Column{Heading: "NAME", ValueTemplate: "{{.DisplayName}}"}, Priority: 2, CardTitle: true, Wrappable: true, Truncatable: true, + ColorFunc: colorAgentPrefix, }, { Column: output.Column{Heading: "STATUS", ValueTemplate: "{{.Status}}"}, @@ -1594,8 +2041,12 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error if hasUpdates { a.console.Message(ctx, "") a.console.Message(ctx, fmt.Sprintf( - "Run %s to upgrade all installed tools.", - output.WithHighLightFormat("azd tool upgrade"), + "To upgrade: %s", + output.WithHighLightFormat("azd tool upgrade "), + )) + a.console.Message(ctx, fmt.Sprintf( + "To upgrade all: %s", + output.WithHighLightFormat("azd tool upgrade --all"), )) } } @@ -1898,6 +2349,9 @@ type toolOpOutcome struct { // - title: verb for task titles (e.g. "Installing", "Upgrading") // - action: action label for result items (e.g. "install", "upgrade") // - console: for displaying warnings on partial failure +// - quiet: when true (JSON output) the per-tool TaskList is routed to +// io.Discard so its progress/control bytes never corrupt the +// machine-readable stream; the results are still collected for the caller. func runToolOperation( ctx context.Context, tools []*tool.ToolDefinition, @@ -1905,6 +2359,7 @@ func runToolOperation( title string, action string, console input.Console, + quiet bool, ) toolOpOutcome { // Collect all IDs and run the operation once. ids := make([]string, len(tools)) @@ -1931,9 +2386,13 @@ func runToolOperation( requestedIDs[t.Id] = true } - taskList := uxlib.NewTaskList( - &uxlib.TaskListOptions{ContinueOnError: true}, - ) + taskListOptions := &uxlib.TaskListOptions{ContinueOnError: true} + if quiet { + // JSON output: suppress the TaskList UI so it cannot write progress or + // control bytes to stdout ahead of the JSON payload. + taskListOptions.Writer = io.Discard + } + taskList := uxlib.NewTaskList(taskListOptions) for _, t := range tools { capturedTool := t @@ -2046,7 +2505,7 @@ func runToolOperation( } taskErr := taskList.Run() - if taskErr != nil { + if taskErr != nil && !quiet { // Build the past participle: "install" -> "installed", // "upgrade" -> "upgraded". Appending only "d" would be wrong, // so append "ed" unless the verb already ends in "e". @@ -2054,6 +2513,9 @@ func runToolOperation( if strings.HasSuffix(action, "e") { participle = action + "d" } + // Skipped in quiet (JSON) mode: AskerConsole.Message would emit a + // standalone consoleMessage object ahead of the result array, breaking + // single-document JSON output. console.Message(ctx, output.WithWarningFormat( "\nSome tools could not be %s. Run 'azd tool list' for details.", participle, )) @@ -2106,14 +2568,59 @@ func toolStatusColor(s string) string { } // toolCheckStatus returns a human-readable status string for the tool check -// table, reusing the extension status vocabulary for consistency. +// table. func toolCheckStatus(installed, updateAvailable bool) string { switch { case !installed: return statusNotInstall case updateAvailable: - return statusUpdate + return statusUpgrade default: return statusUpToDate } } + +// skillAgentDisplayName maps an installed skill agent's command identity (e.g. +// "copilot") to the agent's display name from the tool's manifest (e.g. +// "GitHub Copilot CLI"), used to prefix skill rows in the list/check tables. +// It falls back to the command when no configured agent matches. +func skillAgentDisplayName(t *tool.ToolDefinition, command string) string { + for _, agent := range t.SkillAgents { + if agent.Command == command { + return agent.DisplayName + } + } + return command +} + +// colorAgentPrefix colors a leading "[agent]" label (as prepended to skill +// names for the list/check tables) so the agent stands out, leaving the rest +// of the name — and any name without a bracket label — unchanged. It is the +// NAME column's ColorFunc: the pretty table applies it per rendered line after +// layout, so the cell value itself stays plain and the table can wrap and +// align it correctly at narrow terminal widths (embedding ANSI in the value +// would suppress wrapping and break the alignment of later columns). +// +// Because it runs per line, it must also color a label the table wrapped +// across lines (e.g. "[GitHub Copilot" then "CLI] Azure Skills"): the opening +// line ("[" with no "]") is colored whole, and a continuation line carrying +// the label tail is colored up to and including its "]". +func colorAgentPrefix(s string) string { + switch { + case strings.HasPrefix(s, "["): + // Whole label on this line ("[..]"), or the first line of a label the + // table wrapped (no "]" yet, so it continues on the next line). + if end := strings.IndexByte(s, ']'); end >= 0 { + return output.WithWarningFormat(s[:end+1]) + s[end+1:] + } + return output.WithWarningFormat(s) + case !strings.ContainsRune(s, '[') && strings.ContainsRune(s, ']'): + // Continuation line carrying a wrapped label's tail: color up to and + // including "]". Guarded by "no '[' on this line" so plain tool names + // (which carry no brackets at all) are never touched. + end := strings.IndexByte(s, ']') + return output.WithWarningFormat(s[:end+1]) + s[end+1:] + default: + return s + } +} diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 315c669fc82..2b619e69b0b 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -4,15 +4,19 @@ package cmd import ( + "bytes" "context" + "encoding/json" "errors" "io" + "strings" "testing" "time" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/tracing" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" + "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/tool" @@ -59,6 +63,7 @@ func TestRunToolOperationUnsuccessfulResultReturnsError(t *testing.T) { "Installing", "install", console, + false, ) results, err := outcome.Items, outcome.Err @@ -69,6 +74,36 @@ func TestRunToolOperationUnsuccessfulResultReturnsError(t *testing.T) { assert.Contains(t, console.Output()[0], "Some tools could not be") } +// TestRunToolOperation_Quiet_SuppressesFailureWarning verifies that in quiet +// (JSON) mode the failure warning is NOT emitted via console.Message — which +// would otherwise inject a standalone consoleMessage object ahead of the JSON +// result array and break single-document JSON output. +func TestRunToolOperation_Quiet_SuppressesFailureWarning(t *testing.T) { + toolDef := &tool.ToolDefinition{ + Id: "az-cli", + Name: "Azure CLI", + } + console := mockinput.NewMockConsole() + + outcome := runToolOperation( + t.Context(), + []*tool.ToolDefinition{toolDef}, + func(ctx context.Context, ids []string) ([]*tool.InstallResult, error) { + return []*tool.InstallResult{{Tool: toolDef, Success: false}}, nil + }, + "Installing", + "install", + console, + true, // quiet (JSON) + ) + + require.Error(t, outcome.Err, "the operation still reports the failure via Err") + for _, line := range console.Output() { + assert.NotContains(t, line, "Some tools could not be", + "quiet mode must not emit the failure warning to the console") + } +} + func TestRunToolOperationSuccessfulResultReturnsNoError(t *testing.T) { toolDef := &tool.ToolDefinition{ Id: "az-cli", @@ -93,6 +128,7 @@ func TestRunToolOperationSuccessfulResultReturnsNoError(t *testing.T) { "Installing", "install", console, + false, ) results, err := outcome.Items, outcome.Err @@ -334,9 +370,9 @@ func (m *cmdMockDetector) DetectAll( return results, nil } -func (m *cmdMockDetector) DetectSkillHosts( +func (m *cmdMockDetector) DetectSkillAgents( ctx context.Context, t *tool.ToolDefinition, -) ([]tool.InstalledSkillHost, error) { +) ([]tool.InstalledSkillAgent, error) { return nil, nil } @@ -350,7 +386,7 @@ type cmdMockInstaller struct { uninstall func( ctx context.Context, t *tool.ToolDefinition, opts ...tool.InstallOption, ) (*tool.InstallResult, error) - availableSkillHosts func(ctx context.Context, t *tool.ToolDefinition) []string + availableSkillAgents func(ctx context.Context, t *tool.ToolDefinition) (commands []string, names []string) } func (m *cmdMockInstaller) Install( @@ -371,11 +407,35 @@ func (m *cmdMockInstaller) Upgrade( return &tool.InstallResult{Tool: t, Success: true}, nil } -func (m *cmdMockInstaller) AvailableSkillHosts(ctx context.Context, t *tool.ToolDefinition) []string { - if m.availableSkillHosts != nil { - return m.availableSkillHosts(ctx, t) +func (m *cmdMockInstaller) AvailableSkillAgents( + ctx context.Context, + t *tool.ToolDefinition, +) (commands []string, names []string) { + if m.availableSkillAgents != nil { + return m.availableSkillAgents(ctx, t) } - return nil + return nil, nil +} + +// mockAvailableSkillAgents returns commands unchanged plus the display name for +// each, derived from the tool's SkillAgents (falling back to the command when +// no agent matches). It mirrors installer.AvailableSkillAgents so the mock +// yields the same (commands, names) shape from a plain list of commands. +func mockAvailableSkillAgents(td *tool.ToolDefinition, commands []string) ([]string, []string) { + if len(commands) == 0 { + return nil, nil + } + names := make([]string, len(commands)) + for i, c := range commands { + names[i] = c + for _, h := range td.SkillAgents { + if h.Command == c { + names[i] = h.DisplayName + break + } + } + } + return commands, names } func (m *cmdMockInstaller) Uninstall( @@ -508,7 +568,45 @@ func TestToolInstallAction_AllFailureBatch_EmitsCorrectAggregates(t *testing.T) "failed_ids must be a sorted, comma-joined list matching the failed tools") } -// TestToolInstallAction_Failure_ReturnsErrorNotSuccess is a regression test +// TestToolInstallAction_JsonFormat_NoBannerLeak verifies finding #1: in JSON +// mode the install command must not emit the MessageTitle banner (which the +// format-aware console would serialize as a `{"type":"consoleMessage"}` object +// ahead of the results array, breaking pure-JSON parsing). list/check already +// avoid this; install/upgrade/uninstall must match. +func TestToolInstallAction_JsonFormat_NoBannerLeak(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + installer := &cmdMockInstaller{ + install: func(_ context.Context, td *tool.ToolDefinition, _ ...tool.InstallOption) (*tool.InstallResult, error) { + return &tool.InstallResult{Tool: td, Success: true, InstalledVersion: "2.0.0"}, nil + }, + } + manager := tool.NewManager(&cmdMockDetector{}, installer, nil) + + console := mockinput.NewMockConsole() + var buf bytes.Buffer + action := newToolInstallAction( + []string{"az-cli"}, + &toolInstallFlags{}, + manager, + console, + &output.JsonFormatter{}, + &buf, + ) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + + for _, line := range console.Output() { + assert.NotContains(t, line, "Install Azure development tools", + "the title banner must be suppressed in JSON mode") + } + + var items []toolInstallResultItem + require.NoError(t, json.Unmarshal(buf.Bytes(), &items), + "JSON output must be a single valid JSON document with no banner leak") +} + // for a bug where a failed install still returned a success ActionResult, // causing the UX middleware to print "SUCCESS: Tool installation complete" // after the per-tool failures. On failure the action must return a non-nil @@ -647,14 +745,18 @@ func TestToolUpgradeAction_FailureDoesNotEmitToVersion(t *testing.T) { } // --------------------------------------------------------------------------- -// resolveHostOptions — --host / --all-hosts flag handling +// resolveAgentOptions — --agent / --all-agents flag handling // --------------------------------------------------------------------------- -func TestResolveHostOptions(t *testing.T) { +func TestResolveAgentOptions(t *testing.T) { skill := &tool.ToolDefinition{ Id: "azure-skills", Name: "Azure Skills", Category: tool.ToolCategorySkill, + SkillAgents: []tool.SkillAgent{ + {DisplayName: "GitHub Copilot CLI", Command: "copilot"}, + {DisplayName: "Claude Code CLI", Command: "claude"}, + }, } nonSkill := &tool.ToolDefinition{ Id: "azure-mcp-server", @@ -663,8 +765,8 @@ func TestResolveHostOptions(t *testing.T) { newAction := func(args []string, flags *toolInstallFlags, present []string) *toolInstallAction { installer := &cmdMockInstaller{ - availableSkillHosts: func(_ context.Context, _ *tool.ToolDefinition) []string { - return present + availableSkillAgents: func(_ context.Context, td *tool.ToolDefinition) ([]string, []string) { + return mockAvailableSkillAgents(td, present) }, } manager := tool.NewManager(&cmdMockDetector{}, installer, nil) @@ -676,73 +778,78 @@ func TestResolveHostOptions(t *testing.T) { ctx := context.Background() - t.Run("HostWithoutSkillTool", func(t *testing.T) { - a := newAction(nil, &toolInstallFlags{hosts: []string{"copilot"}}, nil) - _, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{nonSkill}) + t.Run("AgentWithoutSkillTool", func(t *testing.T) { + a := newAction(nil, &toolInstallFlags{agents: []string{"copilot"}}, nil) + _, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{nonSkill}) require.Error(t, err) assert.Contains(t, err.Error(), "only applies to skill tools") }) - t.Run("HostAllCannotMixWithSpecificHosts", func(t *testing.T) { - a := newAction(nil, &toolInstallFlags{hosts: []string{"all", "copilot"}}, []string{"copilot", "claude"}) - _, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + t.Run("AgentAllCannotMixWithSpecificAgents", func(t *testing.T) { + a := newAction(nil, &toolInstallFlags{agents: []string{"all", "copilot"}}, []string{"copilot", "claude"}) + _, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.Error(t, err) - assert.Contains(t, err.Error(), "cannot be combined with specific hosts") + assert.Contains(t, err.Error(), "cannot be combined with specific agents") }) - t.Run("ExplicitHostsReturnsOptions", func(t *testing.T) { - a := newAction(nil, &toolInstallFlags{hosts: []string{"claude"}}, []string{"copilot", "claude"}) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + t.Run("ExplicitAgentsReturnsOptions", func(t *testing.T) { + a := newAction(nil, &toolInstallFlags{agents: []string{"claude"}}, []string{"copilot", "claude"}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) }) - t.Run("HostAllReturnsDeferredOption", func(t *testing.T) { - a := newAction(nil, &toolInstallFlags{hosts: []string{"all"}}, []string{"copilot", "claude"}) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + t.Run("AgentAllReturnsDeferredOption", func(t *testing.T) { + a := newAction(nil, &toolInstallFlags{agents: []string{"all"}}, []string{"copilot", "claude"}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) }) - t.Run("HostAllDefersEvenWhenNoneDetected", func(t *testing.T) { - // --host all resolves at install time, so it returns an option - // even when no host is on PATH yet (the installer surfaces the - // no-host guidance later). - a := newAction(nil, &toolInstallFlags{hosts: []string{"all"}}, nil) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + t.Run("AgentAllDefersEvenWhenNoneDetected", func(t *testing.T) { + // --agent all resolves at install time, so it returns an option + // even when no agent is on PATH yet (the installer surfaces the + // no-agent guidance later). + a := newAction(nil, &toolInstallFlags{agents: []string{"all"}}, nil) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) }) - t.Run("ExplicitlyNamedMultipleHostsAsksToChoose", func(t *testing.T) { + t.Run("ExplicitlyNamedMultipleAgentsAsksToChoose", func(t *testing.T) { // `azd tool install azure-skills` (skill named in args) with - // several hosts present in a non-interactive terminal must surface + // several agents present in a non-interactive terminal must surface // the guidance error asking the user to choose. a := newAction([]string{"azure-skills"}, &toolInstallFlags{}, []string{"copilot", "claude"}) - _, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + _, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.Error(t, err) var sug *internal.ErrorWithSuggestion require.ErrorAs(t, err, &sug) - assert.Contains(t, sug.Message, "copilot, claude") - assert.Contains(t, sug.Suggestion, "--host all") + assert.Contains(t, sug.Message, "GitHub Copilot CLI, Claude Code CLI") + assert.Contains(t, sug.Suggestion, "--agent all") }) - t.Run("ExplicitlyNamedMultipleHostsInteractivePrompts", func(t *testing.T) { + t.Run("ExplicitlyNamedMultipleAgentsInteractivePrompts", func(t *testing.T) { // In an interactive terminal the user is prompted to pick the - // host(s) instead of erroring out. + // agents instead of erroring out. The picker shows friendly Agent + // display names, and the user's selection maps back to the command. a := newAction([]string{"azure-skills"}, &toolInstallFlags{}, []string{"copilot", "claude"}) mockConsole := a.console.(*mockinput.MockConsole) mockConsole.SetTerminal(true) + var prompted []string mockConsole.WhenMultiSelect(func(options input.ConsoleOptions) bool { + prompted = options.Options return true - }).Respond([]string{"claude"}) + }).Respond([]string{"Claude Code CLI"}) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) + // The picker offers friendly display names, not command identities. + assert.Equal(t, []string{"GitHub Copilot CLI", "Claude Code CLI"}, prompted) }) - t.Run("ExplicitlyNamedMultipleHostsPromptErrorPropagates", func(t *testing.T) { + t.Run("ExplicitlyNamedMultipleAgentsPromptErrorPropagates", func(t *testing.T) { // A failing prompt surfaces the error rather than silently // falling back. a := newAction([]string{"azure-skills"}, &toolInstallFlags{}, []string{"copilot", "claude"}) @@ -754,14 +861,14 @@ func TestResolveHostOptions(t *testing.T) { return []string(nil), errors.New("prompt boom") }) - _, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + _, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.Error(t, err) assert.Contains(t, err.Error(), "prompt boom") }) - t.Run("ExplicitlyNamedMultipleHostsEmptySelectionFallsBackToError", func(t *testing.T) { + t.Run("ExplicitlyNamedMultipleAgentsEmptySelectionFallsBackToError", func(t *testing.T) { // Selecting nothing in the picker falls back to the guidance - // error telling the user to re-run with --host. + // error telling the user to re-run with --agent. a := newAction([]string{"azure-skills"}, &toolInstallFlags{}, []string{"copilot", "claude"}) mockConsole := a.console.(*mockinput.MockConsole) mockConsole.SetTerminal(true) @@ -769,20 +876,20 @@ func TestResolveHostOptions(t *testing.T) { return true }).Respond([]string{}) - _, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + _, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.Error(t, err) var sug *internal.ErrorWithSuggestion require.ErrorAs(t, err, &sug) - assert.Contains(t, sug.Suggestion, "--host all") + assert.Contains(t, sug.Suggestion, "--agent all") }) - t.Run("ExplicitUnavailableHostInteractivePrompts", func(t *testing.T) { - // `--host gemini` names a host that isn't supported/available. - // In an interactive terminal we prompt over the hosts that ARE + t.Run("ExplicitUnavailableAgentInteractivePrompts", func(t *testing.T) { + // `--agent gemini` names an agent that isn't supported/available. + // In an interactive terminal we prompt over the agents that ARE // on PATH instead of hard-failing. a := newAction( []string{"azure-skills"}, - &toolInstallFlags{hosts: []string{"gemini"}}, + &toolInstallFlags{agents: []string{"gemini"}}, []string{"copilot", "claude"}, ) mockConsole := a.console.(*mockinput.MockConsole) @@ -791,21 +898,22 @@ func TestResolveHostOptions(t *testing.T) { mockConsole.WhenMultiSelect(func(options input.ConsoleOptions) bool { prompted = options.Options return true - }).Respond([]string{"copilot"}) + }).Respond([]string{"GitHub Copilot CLI"}) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) - // The picker offered the available hosts, not the bogus request. - assert.Equal(t, []string{"copilot", "claude"}, prompted) + // The picker offered the available agents (friendly display names), + // not the bogus request. + assert.Equal(t, []string{"GitHub Copilot CLI", "Claude Code CLI"}, prompted) }) - t.Run("ExplicitUnavailableHostNonInteractivePassesThrough", func(t *testing.T) { + t.Run("ExplicitUnavailableAgentNonInteractivePassesThrough", func(t *testing.T) { // Without a TTY we cannot prompt, so the request is passed // through unchanged for the installer to validate and reject. a := newAction( []string{"azure-skills"}, - &toolInstallFlags{hosts: []string{"gemini"}}, + &toolInstallFlags{agents: []string{"gemini"}}, []string{"copilot", "claude"}, ) mockConsole := a.console.(*mockinput.MockConsole) @@ -815,19 +923,19 @@ func TestResolveHostOptions(t *testing.T) { return true }).Respond([]string{"copilot"}) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) assert.False(t, prompted, "must not prompt without a terminal") }) - t.Run("ExplicitUnavailableHostNoneOnPathDefersToGuidance", func(t *testing.T) { - // `--host gemini` with no supported host on PATH: skip the picker - // and target every available host so the installer surfaces its - // install-a-CLI-host guidance. + t.Run("ExplicitUnavailableAgentNoneOnPathDefersToGuidance", func(t *testing.T) { + // `--agent gemini` with no supported agent on PATH: skip the picker + // and target every available agent so the installer surfaces its + // install-a-CLI-agent guidance. a := newAction( []string{"azure-skills"}, - &toolInstallFlags{hosts: []string{"gemini"}}, + &toolInstallFlags{agents: []string{"gemini"}}, nil, ) mockConsole := a.console.(*mockinput.MockConsole) @@ -838,18 +946,18 @@ func TestResolveHostOptions(t *testing.T) { return true }).Respond([]string{"copilot"}) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) - assert.False(t, prompted, "must not prompt when no host is available") + assert.False(t, prompted, "must not prompt when no agent is available") }) - t.Run("ExplicitUnavailableHostMultiSelectErrorPropagates", func(t *testing.T) { - // A failing picker during the unavailable-host fallback surfaces + t.Run("ExplicitUnavailableAgentMultiSelectErrorPropagates", func(t *testing.T) { + // A failing picker during the unavailable-agent fallback surfaces // the error. a := newAction( []string{"azure-skills"}, - &toolInstallFlags{hosts: []string{"gemini"}}, + &toolInstallFlags{agents: []string{"gemini"}}, []string{"copilot", "claude"}, ) mockConsole := a.console.(*mockinput.MockConsole) @@ -860,17 +968,17 @@ func TestResolveHostOptions(t *testing.T) { return []string(nil), errors.New("picker boom") }) - _, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + _, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.Error(t, err) assert.Contains(t, err.Error(), "picker boom") }) - t.Run("ExplicitUnavailableHostEmptySelectionPassesThrough", func(t *testing.T) { + t.Run("ExplicitUnavailableAgentEmptySelectionPassesThrough", func(t *testing.T) { // Selecting nothing leaves the original request intact so the - // installer surfaces its validation error for the bad host. + // installer surfaces its validation error for the bad agent. a := newAction( []string{"azure-skills"}, - &toolInstallFlags{hosts: []string{"gemini"}}, + &toolInstallFlags{agents: []string{"gemini"}}, []string{"copilot", "claude"}, ) mockConsole := a.console.(*mockinput.MockConsole) @@ -879,17 +987,17 @@ func TestResolveHostOptions(t *testing.T) { return true }).Respond([]string{}) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) }) - t.Run("ExplicitAvailableHostSkipsPrompt", func(t *testing.T) { - // A valid, available --host is used directly without prompting, + t.Run("ExplicitAvailableAgentSkipsPrompt", func(t *testing.T) { + // A valid, available --agent is used directly without prompting, // even in an interactive terminal. a := newAction( []string{"azure-skills"}, - &toolInstallFlags{hosts: []string{"copilot"}}, + &toolInstallFlags{agents: []string{"copilot"}}, []string{"copilot", "claude"}, ) mockConsole := a.console.(*mockinput.MockConsole) @@ -900,38 +1008,38 @@ func TestResolveHostOptions(t *testing.T) { return true }).Respond([]string{"claude"}) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) - assert.False(t, prompted, "available host must not trigger a prompt") + assert.False(t, prompted, "available agent must not trigger a prompt") }) - t.Run("ExplicitlyNamedSingleHostReturnsNil", func(t *testing.T) { + t.Run("ExplicitlyNamedSingleAgentReturnsNil", func(t *testing.T) { a := newAction([]string{"azure-skills"}, &toolInstallFlags{}, []string{"copilot"}) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Nil(t, opts) }) - t.Run("BatchInstallsAllAvailableHosts", func(t *testing.T) { + t.Run("BatchInstallsAllAvailableAgents", func(t *testing.T) { // A skill pulled in by --all / the interactive picker (not named - // in args) installs through every available host instead of + // in args) installs through every available agent instead of // aborting on ambiguity. a := newAction(nil, &toolInstallFlags{all: true}, []string{"copilot", "claude"}) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{skill}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) }) t.Run("NonSkillNoFlagReturnsNil", func(t *testing.T) { a := newAction(nil, &toolInstallFlags{}, nil) - opts, err := a.resolveHostOptions(ctx, []*tool.ToolDefinition{nonSkill}) + opts, err := a.resolveAgentOptions(ctx, []*tool.ToolDefinition{nonSkill}) require.NoError(t, err) assert.Nil(t, opts) }) } -func TestResolveHostOptions_Upgrade(t *testing.T) { +func TestResolveAgentOptions_Upgrade(t *testing.T) { skill := &tool.ToolDefinition{ Id: "azure-skills", Name: "Azure Skills", @@ -944,8 +1052,8 @@ func TestResolveHostOptions_Upgrade(t *testing.T) { newAction := func(flags *toolUpgradeFlags, present []string) *toolUpgradeAction { installer := &cmdMockInstaller{ - availableSkillHosts: func(_ context.Context, _ *tool.ToolDefinition) []string { - return present + availableSkillAgents: func(_ context.Context, td *tool.ToolDefinition) ([]string, []string) { + return mockAvailableSkillAgents(td, present) }, } manager := tool.NewManager(&cmdMockDetector{}, installer, nil) @@ -955,55 +1063,55 @@ func TestResolveHostOptions_Upgrade(t *testing.T) { ).(*toolUpgradeAction) } - t.Run("HostWithoutSkillTool", func(t *testing.T) { - a := newAction(&toolUpgradeFlags{hosts: []string{"copilot"}}, nil) - _, err := a.resolveHostOptions([]*tool.ToolDefinition{nonSkill}) + t.Run("AgentWithoutSkillTool", func(t *testing.T) { + a := newAction(&toolUpgradeFlags{agents: []string{"copilot"}}, nil) + _, err := a.resolveAgentOptions([]*tool.ToolDefinition{nonSkill}) require.Error(t, err) assert.Contains(t, err.Error(), "only applies to skill tools") }) - t.Run("HostAllCannotMixWithSpecificHosts", func(t *testing.T) { - a := newAction(&toolUpgradeFlags{hosts: []string{"all", "copilot"}}, []string{"copilot", "claude"}) - _, err := a.resolveHostOptions([]*tool.ToolDefinition{skill}) + t.Run("AgentAllCannotMixWithSpecificAgents", func(t *testing.T) { + a := newAction(&toolUpgradeFlags{agents: []string{"all", "copilot"}}, []string{"copilot", "claude"}) + _, err := a.resolveAgentOptions([]*tool.ToolDefinition{skill}) require.Error(t, err) - assert.Contains(t, err.Error(), "cannot be combined with specific hosts") + assert.Contains(t, err.Error(), "cannot be combined with specific agents") }) - t.Run("ExplicitHostsReturnsOptions", func(t *testing.T) { - a := newAction(&toolUpgradeFlags{hosts: []string{"claude"}}, []string{"copilot", "claude"}) - opts, err := a.resolveHostOptions([]*tool.ToolDefinition{skill}) + t.Run("ExplicitAgentsReturnsOptions", func(t *testing.T) { + a := newAction(&toolUpgradeFlags{agents: []string{"claude"}}, []string{"copilot", "claude"}) + opts, err := a.resolveAgentOptions([]*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) }) - t.Run("HostAllIteratesDetectedHosts", func(t *testing.T) { - a := newAction(&toolUpgradeFlags{hosts: []string{"all"}}, []string{"copilot", "claude"}) - opts, err := a.resolveHostOptions([]*tool.ToolDefinition{skill}) + t.Run("AgentAllIteratesDetectedAgents", func(t *testing.T) { + a := newAction(&toolUpgradeFlags{agents: []string{"all"}}, []string{"copilot", "claude"}) + opts, err := a.resolveAgentOptions([]*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) }) - t.Run("HostAllDefersEvenWhenNoneDetected", func(t *testing.T) { - // --host all resolves at install time, so it returns an option - // even when no host is on PATH yet. - a := newAction(&toolUpgradeFlags{hosts: []string{"all"}}, nil) - opts, err := a.resolveHostOptions([]*tool.ToolDefinition{skill}) + t.Run("AgentAllDefersEvenWhenNoneDetected", func(t *testing.T) { + // --agent all resolves at install time, so it returns an option + // even when no agent is on PATH yet. + a := newAction(&toolUpgradeFlags{agents: []string{"all"}}, nil) + opts, err := a.resolveAgentOptions([]*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) }) - // Unlike install, upgrade with no --host never errors on multiple - // hosts: the installer upgrades the host the skill is installed + // Unlike install, upgrade with no --agent never errors on multiple + // agents: the installer upgrades the agent the skill is installed // through, so no explicit choice is required. - t.Run("MultipleHostsNoFlagReturnsNil", func(t *testing.T) { + t.Run("MultipleAgentsNoFlagReturnsNil", func(t *testing.T) { a := newAction(&toolUpgradeFlags{}, []string{"copilot", "claude"}) - opts, err := a.resolveHostOptions([]*tool.ToolDefinition{skill}) + opts, err := a.resolveAgentOptions([]*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Nil(t, opts) }) } -func TestResolveHostOptions_Uninstall(t *testing.T) { +func TestResolveAgentOptions_Uninstall(t *testing.T) { skill := &tool.ToolDefinition{ Id: "azure-skills", Name: "Azure Skills", @@ -1024,37 +1132,37 @@ func TestResolveHostOptions_Uninstall(t *testing.T) { t.Run("NoFlagReturnsNil", func(t *testing.T) { a := newAction(&toolUninstallFlags{}) - opts, err := a.resolveHostOptions([]*tool.ToolDefinition{skill}) + opts, err := a.resolveAgentOptions([]*tool.ToolDefinition{skill}) require.NoError(t, err) - assert.Nil(t, opts, "no --host removes from every installed host") + assert.Nil(t, opts, "no --agent removes from every installed agent") }) - t.Run("HostWithoutSkillTool", func(t *testing.T) { - a := newAction(&toolUninstallFlags{hosts: []string{"copilot"}}) - _, err := a.resolveHostOptions([]*tool.ToolDefinition{nonSkill}) + t.Run("AgentWithoutSkillTool", func(t *testing.T) { + a := newAction(&toolUninstallFlags{agents: []string{"copilot"}}) + _, err := a.resolveAgentOptions([]*tool.ToolDefinition{nonSkill}) require.Error(t, err) assert.Contains(t, err.Error(), "only applies to skill tools") }) - t.Run("ExplicitHostReturnsOptions", func(t *testing.T) { - a := newAction(&toolUninstallFlags{hosts: []string{"claude"}}) - opts, err := a.resolveHostOptions([]*tool.ToolDefinition{skill}) + t.Run("ExplicitAgentReturnsOptions", func(t *testing.T) { + a := newAction(&toolUninstallFlags{agents: []string{"claude"}}) + opts, err := a.resolveAgentOptions([]*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) }) - t.Run("HostAllReturnsOptions", func(t *testing.T) { - a := newAction(&toolUninstallFlags{hosts: []string{"all"}}) - opts, err := a.resolveHostOptions([]*tool.ToolDefinition{skill}) + t.Run("AgentAllReturnsOptions", func(t *testing.T) { + a := newAction(&toolUninstallFlags{agents: []string{"all"}}) + opts, err := a.resolveAgentOptions([]*tool.ToolDefinition{skill}) require.NoError(t, err) assert.Len(t, opts, 1) }) - t.Run("HostAllCannotMixWithSpecificHosts", func(t *testing.T) { - a := newAction(&toolUninstallFlags{hosts: []string{"all", "copilot"}}) - _, err := a.resolveHostOptions([]*tool.ToolDefinition{skill}) + t.Run("AgentAllCannotMixWithSpecificAgents", func(t *testing.T) { + a := newAction(&toolUninstallFlags{agents: []string{"all", "copilot"}}) + _, err := a.resolveAgentOptions([]*tool.ToolDefinition{skill}) require.Error(t, err) - assert.Contains(t, err.Error(), "cannot be combined with specific hosts") + assert.Contains(t, err.Error(), "cannot be combined with specific agents") }) } @@ -1134,3 +1242,863 @@ func TestToolUninstallAction_DryRun_DoesNotDelegate(t *testing.T) { require.True(t, ok, "tool.dry_run must be emitted on dry-run uninstall") assert.True(t, gotDry) } + +// TestToolUpgradeAction_All_UpgradesInstalledTools verifies that +// `azd tool upgrade --all` upgrades every installed tool (and only those), +// without an interactive selection prompt. +func TestToolUpgradeAction_All_UpgradesInstalledTools(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + var installedIDs []string + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, tools []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + statuses := make([]*tool.ToolStatus, len(tools)) + for i, td := range tools { + // Mark the first two manifest tools installed; the rest not. + installed := i < 2 + statuses[i] = &tool.ToolStatus{Tool: td, Installed: installed} + if installed { + statuses[i].InstalledVersion = "1.0.0" + installedIDs = append(installedIDs, td.Id) + } + } + return statuses, nil + }, + } + + var upgradedIDs []string + installer := &cmdMockInstaller{ + upgrade: func(_ context.Context, td *tool.ToolDefinition, _ ...tool.InstallOption) (*tool.InstallResult, error) { + upgradedIDs = append(upgradedIDs, td.Id) + return &tool.InstallResult{Tool: td, Success: true, InstalledVersion: "2.0.0"}, nil + }, + } + manager := tool.NewManager(detector, installer, nil) + + action := newToolUpgradeAction( + nil, + &toolUpgradeFlags{all: true}, + manager, + mockinput.NewMockConsole(), + &output.NoneFormatter{}, + io.Discard, + ) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + + require.Len(t, installedIDs, 2) + assert.ElementsMatch(t, installedIDs, upgradedIDs, + "--all must upgrade exactly the installed tools") +} + +// TestToolUpgradeAction_All_JsonFormat_EmitsCleanJson exercises the reviewer's +// exact trigger — `azd tool upgrade --all --output json` — and verifies the +// writer receives valid JSON. In JSON mode the detection spinner is +// bypassed (detectAllTools) so no control bytes can corrupt the stream. +func TestToolUpgradeAction_All_JsonFormat_EmitsCleanJson(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, tools []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + statuses := make([]*tool.ToolStatus, len(tools)) + for i, td := range tools { + statuses[i] = &tool.ToolStatus{Tool: td, Installed: i < 1, InstalledVersion: "1.0.0"} + } + return statuses, nil + }, + } + installer := &cmdMockInstaller{ + upgrade: func(_ context.Context, td *tool.ToolDefinition, _ ...tool.InstallOption) (*tool.InstallResult, error) { + return &tool.InstallResult{Tool: td, Success: true, InstalledVersion: "2.0.0"}, nil + }, + } + manager := tool.NewManager(detector, installer, nil) + + var buf bytes.Buffer + action := newToolUpgradeAction( + nil, + &toolUpgradeFlags{all: true}, + manager, + mockinput.NewMockConsole(), + &output.JsonFormatter{}, + &buf, + ) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + + var items []toolInstallResultItem + require.NoError(t, json.Unmarshal(buf.Bytes(), &items), + "upgrade --all --output json must emit valid JSON") + require.NotEmpty(t, items, "at least one installed tool must be reported") +} + +// TestToolUpgradeAction_All_JsonFormat_EmptyEmitsArray verifies that when there +// is nothing to upgrade, `azd tool upgrade --all --output json` still emits an +// empty result array ([]) rather than a consoleMessage object, so automation +// sees one stable shape. +func TestToolUpgradeAction_All_JsonFormat_EmptyEmitsArray(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, tools []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + // Nothing installed → nothing to upgrade. + statuses := make([]*tool.ToolStatus, len(tools)) + for i, td := range tools { + statuses[i] = &tool.ToolStatus{Tool: td, Installed: false} + } + return statuses, nil + }, + } + manager := tool.NewManager(detector, &cmdMockInstaller{}, nil) + + var buf bytes.Buffer + action := newToolUpgradeAction( + nil, + &toolUpgradeFlags{all: true}, + manager, + mockinput.NewMockConsole(), + &output.JsonFormatter{}, + &buf, + ) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + + assert.Equal(t, "[]", strings.TrimSpace(buf.String()), + "empty upgrade must emit an empty JSON array, not a consoleMessage object") + + var items []toolInstallResultItem + require.NoError(t, json.Unmarshal(buf.Bytes(), &items)) + assert.Empty(t, items) +} + +// TestToolUpgradeAction_IDsWithAll_Errors verifies that `azd tool upgrade foo +// --all` is rejected rather than silently ignoring foo and upgrading everything. +func TestToolUpgradeAction_IDsWithAll_Errors(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + var upgraded bool + installer := &cmdMockInstaller{ + upgrade: func(_ context.Context, td *tool.ToolDefinition, _ ...tool.InstallOption) (*tool.InstallResult, error) { + upgraded = true + return &tool.InstallResult{Tool: td, Success: true}, nil + }, + } + manager := tool.NewManager(&cmdMockDetector{}, installer, nil) + + action := newToolUpgradeAction( + []string{"foo"}, + &toolUpgradeFlags{all: true}, + manager, + mockinput.NewMockConsole(), + &output.NoneFormatter{}, + io.Discard, + ) + + _, err := action.Run(t.Context()) + require.Error(t, err) + assert.ErrorIs(t, err, internal.ErrInvalidFlagCombination) + assert.False(t, upgraded, "no tool must be upgraded when the flag combination is invalid") +} + +// TestToolUpgradeAction_NoPrompt_WithoutTarget_Errors verifies that +// `azd tool upgrade` with --no-prompt (or a non-interactive terminal) and no +// tool IDs and no --all fails with guidance instead of implicitly upgrading +// every installed tool — consistent with install/uninstall and azd's +// --no-prompt contract. +func TestToolUpgradeAction_NoPrompt_WithoutTarget_Errors(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, tools []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + statuses := make([]*tool.ToolStatus, len(tools)) + for i, td := range tools { + statuses[i] = &tool.ToolStatus{Tool: td, Installed: i < 2} + } + return statuses, nil + }, + } + installer := &cmdMockInstaller{ + upgrade: func(_ context.Context, td *tool.ToolDefinition, _ ...tool.InstallOption) (*tool.InstallResult, error) { + t.Errorf("upgrade must not run without an explicit target; got %s", td.Id) + return &tool.InstallResult{Tool: td, Success: true}, nil + }, + } + manager := tool.NewManager(detector, installer, nil) + + console := mockinput.NewMockConsole() + console.SetTerminal(true) // a TTY ... + console.SetNoPromptMode(true) // ... but --no-prompt + + action := newToolUpgradeAction( + nil, &toolUpgradeFlags{}, manager, console, &output.NoneFormatter{}, io.Discard, + ) + + _, err := action.Run(t.Context()) + require.Error(t, err) + var ews *internal.ErrorWithSuggestion + require.ErrorAs(t, err, &ews) + assert.Contains(t, ews.Suggestion, "--all", + "the guidance must tell the user to pass tool IDs or --all") +} + +// TestToolUpgradeAction_JsonOnTTY_WithoutTarget_Errors verifies that +// `azd tool upgrade --output json` on an interactive terminal (no --no-prompt) +// requires an explicit target rather than opening the no-argument picker, whose +// output would corrupt the JSON result written to the same stdout. +func TestToolUpgradeAction_JsonOnTTY_WithoutTarget_Errors(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, tools []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + statuses := make([]*tool.ToolStatus, len(tools)) + for i, td := range tools { + statuses[i] = &tool.ToolStatus{Tool: td, Installed: i < 2} + } + return statuses, nil + }, + } + installer := &cmdMockInstaller{ + upgrade: func(_ context.Context, td *tool.ToolDefinition, _ ...tool.InstallOption) (*tool.InstallResult, error) { + t.Errorf("upgrade must not run without an explicit target; got %s", td.Id) + return &tool.InstallResult{Tool: td, Success: true}, nil + }, + } + manager := tool.NewManager(detector, installer, nil) + + console := mockinput.NewMockConsole() + console.SetTerminal(true) // interactive TTY, but NOT --no-prompt + + action := newToolUpgradeAction( + nil, &toolUpgradeFlags{}, manager, console, &output.JsonFormatter{}, io.Discard, + ) + + _, err := action.Run(t.Context()) + require.Error(t, err, "JSON mode must require an explicit target, not open a picker") + var ews *internal.ErrorWithSuggestion + require.ErrorAs(t, err, &ews) +} +func TestToolUpgradeAction_AllFlag_NoPrompt(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + var installedIDs []string + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, tools []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + statuses := make([]*tool.ToolStatus, len(tools)) + for i, td := range tools { + installed := i < 2 + statuses[i] = &tool.ToolStatus{Tool: td, Installed: installed} + if installed { + statuses[i].InstalledVersion = "1.0.0" + installedIDs = append(installedIDs, td.Id) + } + } + return statuses, nil + }, + } + + var upgradedIDs []string + installer := &cmdMockInstaller{ + upgrade: func(_ context.Context, td *tool.ToolDefinition, _ ...tool.InstallOption) (*tool.InstallResult, error) { + upgradedIDs = append(upgradedIDs, td.Id) + return &tool.InstallResult{Tool: td, Success: true, InstalledVersion: "2.0.0"}, nil + }, + } + manager := tool.NewManager(detector, installer, nil) + + console := mockinput.NewMockConsole() + console.SetTerminal(true) + console.SetNoPromptMode(true) + + action := newToolUpgradeAction( + nil, + &toolUpgradeFlags{all: true}, + manager, + console, + &output.NoneFormatter{}, + io.Discard, + ) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + + require.Len(t, installedIDs, 2) + assert.ElementsMatch(t, installedIDs, upgradedIDs, + "--all must upgrade every installed tool without prompting") +} + +// TestToolUpgradeAction_UnchangedVersion_ReportsUpToDate verifies that a +// non-skill tool whose detected version is identical before and after the +// upgrade reports "already up to date" — the version-comparison path that +// makes the message work for every tool, not just skills. +func TestToolUpgradeAction_UnchangedVersion_ReportsUpToDate(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + detector := &cmdMockDetector{ + detectTool: func(_ context.Context, td *tool.ToolDefinition) (*tool.ToolStatus, error) { + return &tool.ToolStatus{Tool: td, Installed: true, InstalledVersion: "1.0.0"}, nil + }, + } + installer := &cmdMockInstaller{ + upgrade: func(_ context.Context, td *tool.ToolDefinition, _ ...tool.InstallOption) (*tool.InstallResult, error) { + // Same version as detected before: nothing changed. + return &tool.InstallResult{Tool: td, Success: true, InstalledVersion: "1.0.0"}, nil + }, + } + manager := tool.NewManager(detector, installer, nil) + + action := newToolUpgradeAction( + []string{"az-cli"}, + &toolUpgradeFlags{}, + manager, + mockinput.NewMockConsole(), + &output.NoneFormatter{}, + io.Discard, + ) + + result, err := action.Run(t.Context()) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Message) + assert.Equal(t, "Tool is already up to date (v1.0.0).", result.Message.Header) +} + +// TestToolUpgradeAction_ChangedVersion_ReportsUpgraded verifies that when the +// detected version differs after the upgrade, the header reads "upgraded". +func TestToolUpgradeAction_ChangedVersion_ReportsUpgraded(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + detector := &cmdMockDetector{ + detectTool: func(_ context.Context, td *tool.ToolDefinition) (*tool.ToolStatus, error) { + return &tool.ToolStatus{Tool: td, Installed: true, InstalledVersion: "1.0.0"}, nil + }, + } + installer := &cmdMockInstaller{ + upgrade: func(_ context.Context, td *tool.ToolDefinition, _ ...tool.InstallOption) (*tool.InstallResult, error) { + return &tool.InstallResult{Tool: td, Success: true, InstalledVersion: "2.0.0"}, nil + }, + } + manager := tool.NewManager(detector, installer, nil) + + action := newToolUpgradeAction( + []string{"az-cli"}, + &toolUpgradeFlags{}, + manager, + mockinput.NewMockConsole(), + &output.NoneFormatter{}, + io.Discard, + ) + + result, err := action.Run(t.Context()) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Message) + assert.Equal(t, "Tool is upgraded to v2.0.0.", result.Message.Header) +} + +// TestSkillAgentDisplayName verifies an installed agent's command identity is +// mapped to the agent's display name from the tool manifest (e.g. "copilot" +// -> "GitHub Copilot CLI"), falling back to the command when unmatched. +func TestSkillAgentDisplayName(t *testing.T) { + td := &tool.ToolDefinition{ + SkillAgents: []tool.SkillAgent{ + {DisplayName: "GitHub Copilot CLI", Command: "copilot"}, + {DisplayName: "Claude Code CLI", Command: "claude"}, + }, + } + assert.Equal(t, "GitHub Copilot CLI", skillAgentDisplayName(td, "copilot")) + assert.Equal(t, "Claude Code CLI", skillAgentDisplayName(td, "claude")) + // An unknown command falls back to itself. + assert.Equal(t, "gemini", skillAgentDisplayName(td, "gemini")) +} + +// TestToolInstallAction_resolveUnavailableAgentPrompt_CaseInsensitive verifies +// that an explicit --agent value is matched against the available agents +// case-insensitively (like findSkillAgent). "--agent Copilot" must match the +// available "copilot" command and NOT be reported unavailable or open a +// prompt. +func TestToolInstallAction_resolveUnavailableAgentPrompt_CaseInsensitive(t *testing.T) { + installer := &cmdMockInstaller{ + availableSkillAgents: func(_ context.Context, _ *tool.ToolDefinition) ([]string, []string) { + return []string{"copilot"}, []string{"GitHub Copilot CLI"} + }, + } + manager := tool.NewManager(&cmdMockDetector{}, installer, nil) + + console := mockinput.NewMockConsole() + console.SetTerminal(true) // interactive, so the unavailable-agent path is reachable + + action := newToolInstallAction( + nil, + &toolInstallFlags{agents: []string{"Copilot"}}, + manager, console, &output.NoneFormatter{}, io.Discard, + ).(*toolInstallAction) + + skill := &tool.ToolDefinition{Id: "azure-skills", Category: tool.ToolCategorySkill} + opts, handled, err := action.resolveUnavailableAgentPrompt(t.Context(), skill) + require.NoError(t, err) + assert.False(t, handled, + "--agent Copilot must match available 'copilot' case-insensitively, not prompt") + assert.Nil(t, opts) +} + +// TestToolInstallAction_resolveToolIds_NoPromptWithoutTarget_Errors verifies +// that --no-prompt (or a non-interactive terminal) with no tool IDs and no +// --all fails with guidance instead of implicitly installing the recommended +// set — consistent with upgrade/uninstall and azd's --no-prompt contract. +func TestToolInstallAction_resolveToolIds_NoPromptWithoutTarget_Errors(t *testing.T) { + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, _ []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + return []*tool.ToolStatus{ + {Tool: &tool.ToolDefinition{Id: "rec", Priority: tool.ToolPriorityRecommended}}, + {Tool: &tool.ToolDefinition{Id: "opt", Priority: tool.ToolPriorityOptional}}, + }, nil + }, + } + manager := tool.NewManager(detector, &cmdMockInstaller{}, nil) + + console := mockinput.NewMockConsole() + console.SetTerminal(true) // a TTY ... + console.SetNoPromptMode(true) // ... but --no-prompt + + action := newToolInstallAction( + nil, &toolInstallFlags{}, manager, console, &output.NoneFormatter{}, io.Discard, + ).(*toolInstallAction) + + ids, err := action.resolveToolIds(t.Context()) + require.Error(t, err) + assert.Nil(t, ids) + var ews *internal.ErrorWithSuggestion + require.ErrorAs(t, err, &ews) + assert.Contains(t, ews.Suggestion, "--all", + "the guidance must tell the user to pass tool IDs or --all") +} + +// TestToolInstallAction_resolveToolIds_JsonOnTTY_Errors verifies that +// `--output json` on an interactive terminal (without --no-prompt) still +// requires an explicit target rather than opening a multi-select, whose prompt +// bytes would corrupt the JSON array written to the same stdout. +func TestToolInstallAction_resolveToolIds_JsonOnTTY_Errors(t *testing.T) { + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, _ []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + return []*tool.ToolStatus{ + {Tool: &tool.ToolDefinition{Id: "rec", Priority: tool.ToolPriorityRecommended}}, + }, nil + }, + } + manager := tool.NewManager(detector, &cmdMockInstaller{}, nil) + + console := mockinput.NewMockConsole() + console.SetTerminal(true) // interactive TTY, but NOT --no-prompt + + action := newToolInstallAction( + nil, &toolInstallFlags{}, manager, console, &output.JsonFormatter{}, io.Discard, + ).(*toolInstallAction) + + ids, err := action.resolveToolIds(t.Context()) + require.Error(t, err, "JSON mode must require an explicit target, not open a picker") + assert.Nil(t, ids) + var ews *internal.ErrorWithSuggestion + require.ErrorAs(t, err, &ews) +} + +// not-yet-installed tools when --all is given, without prompting. +func TestToolInstallAction_resolveToolIds_AllFlag(t *testing.T) { + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, _ []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + return []*tool.ToolStatus{ + {Tool: &tool.ToolDefinition{Id: "rec", Priority: tool.ToolPriorityRecommended}}, + {Tool: &tool.ToolDefinition{Id: "opt", Priority: tool.ToolPriorityOptional}}, + {Tool: &tool.ToolDefinition{Id: "already", Priority: tool.ToolPriorityRecommended}, + Installed: true}, + }, nil + }, + } + manager := tool.NewManager(detector, &cmdMockInstaller{}, nil) + + action := newToolInstallAction( + nil, &toolInstallFlags{all: true}, manager, mockinput.NewMockConsole(), + &output.NoneFormatter{}, io.Discard, + ).(*toolInstallAction) + + ids, err := action.resolveToolIds(t.Context()) + require.NoError(t, err) + assert.Equal(t, []string{"rec"}, ids, + "--all installs recommended, not-yet-installed tools") +} + +// TestToolInstallAction_resolveToolIds_IDsWithAll_Errors verifies that passing +// both tool IDs and --all is rejected rather than silently ignoring the IDs. +func TestToolInstallAction_resolveToolIds_IDsWithAll_Errors(t *testing.T) { + manager := tool.NewManager(&cmdMockDetector{}, &cmdMockInstaller{}, nil) + + action := newToolInstallAction( + []string{"foo"}, &toolInstallFlags{all: true}, manager, mockinput.NewMockConsole(), + &output.NoneFormatter{}, io.Discard, + ).(*toolInstallAction) + + ids, err := action.resolveToolIds(t.Context()) + require.Error(t, err) + assert.Nil(t, ids) + assert.ErrorIs(t, err, internal.ErrInvalidFlagCombination) +} + +// TestToolUninstallAction_resolveToolIds_NoPromptWithoutTarget_Errors verifies +// that uninstall — unlike install/upgrade, which only add — never treats "no +// target" as "all". With --no-prompt (or a non-interactive terminal) and +// neither tool IDs nor --all, it fails with guidance instead of silently +// removing every installed tool. +func TestToolUninstallAction_resolveToolIds_NoPromptWithoutTarget_Errors(t *testing.T) { + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, _ []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + return []*tool.ToolStatus{ + {Tool: &tool.ToolDefinition{Id: "a"}, Installed: true}, + {Tool: &tool.ToolDefinition{Id: "b"}, Installed: true}, + {Tool: &tool.ToolDefinition{Id: "c"}}, + }, nil + }, + } + manager := tool.NewManager(detector, &cmdMockInstaller{}, nil) + + console := mockinput.NewMockConsole() + console.SetTerminal(true) + console.SetNoPromptMode(true) + + action := newToolUninstallAction( + nil, &toolUninstallFlags{}, manager, console, &output.NoneFormatter{}, io.Discard, + ).(*toolUninstallAction) + + ids, err := action.resolveToolIds(t.Context()) + require.Error(t, err, "no target in non-interactive mode must not default to all") + assert.Nil(t, ids) + + var ews *internal.ErrorWithSuggestion + require.ErrorAs(t, err, &ews) + assert.Contains(t, ews.Suggestion, "--all", + "the guidance must tell the user to pass tool IDs or --all") +} + +// TestToolUninstallAction_resolveToolIds_JsonOnTTY_Errors verifies that +// `--output json` on an interactive terminal still requires an explicit target +// rather than opening the uninstall picker (whose output would corrupt JSON). +func TestToolUninstallAction_resolveToolIds_JsonOnTTY_Errors(t *testing.T) { + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, _ []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + return []*tool.ToolStatus{ + {Tool: &tool.ToolDefinition{Id: "a"}, Installed: true}, + {Tool: &tool.ToolDefinition{Id: "b"}, Installed: true}, + }, nil + }, + } + manager := tool.NewManager(detector, &cmdMockInstaller{}, nil) + + console := mockinput.NewMockConsole() + console.SetTerminal(true) // interactive TTY, but NOT --no-prompt + + action := newToolUninstallAction( + nil, &toolUninstallFlags{}, manager, console, &output.JsonFormatter{}, io.Discard, + ).(*toolUninstallAction) + + ids, err := action.resolveToolIds(t.Context()) + require.Error(t, err, "JSON mode must require an explicit target, not open a picker") + assert.Nil(t, ids) + var ews *internal.ErrorWithSuggestion + require.ErrorAs(t, err, &ews) +} + +// TestToolUninstallAction_resolveToolIds_AllFlag_NoPrompt verifies the explicit +// destructive path still works: --all (even with --no-prompt) selects every +// installed tool. +func TestToolUninstallAction_resolveToolIds_AllFlag_NoPrompt(t *testing.T) { + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, _ []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + return []*tool.ToolStatus{ + {Tool: &tool.ToolDefinition{Id: "a"}, Installed: true}, + {Tool: &tool.ToolDefinition{Id: "b"}, Installed: true}, + {Tool: &tool.ToolDefinition{Id: "c"}}, + }, nil + }, + } + manager := tool.NewManager(detector, &cmdMockInstaller{}, nil) + + console := mockinput.NewMockConsole() + console.SetTerminal(true) + console.SetNoPromptMode(true) + + action := newToolUninstallAction( + nil, &toolUninstallFlags{all: true}, manager, console, &output.NoneFormatter{}, io.Discard, + ).(*toolUninstallAction) + + ids, err := action.resolveToolIds(t.Context()) + require.NoError(t, err) + assert.ElementsMatch(t, []string{"a", "b"}, ids, + "--all must select every installed tool") +} + +// TestToolUninstallAction_resolveToolIds_IDsWithAll_Errors verifies that passing +// both tool IDs and --all is rejected rather than silently ignoring one of them. +func TestToolUninstallAction_resolveToolIds_IDsWithAll_Errors(t *testing.T) { + manager := tool.NewManager(&cmdMockDetector{}, &cmdMockInstaller{}, nil) + + action := newToolUninstallAction( + []string{"a"}, &toolUninstallFlags{all: true}, manager, mockinput.NewMockConsole(), + &output.NoneFormatter{}, io.Discard, + ).(*toolUninstallAction) + + ids, err := action.resolveToolIds(t.Context()) + require.Error(t, err) + assert.Nil(t, ids) + assert.ErrorIs(t, err, internal.ErrInvalidFlagCombination) +} + +// TestToolUpgradeAction_MultiAgentSkill_UpgradedNotUpToDate reproduces the +// multi-agent skill case: the aggregate InstalledVersion (first agent) is +// unchanged, but the installer set AlreadyUpToDate=false because another agent +// WAS upgraded. The header must read "upgraded", not "already up to date" — +// version comparison must not run for skills. +func TestToolUpgradeAction_MultiAgentSkill_UpgradedNotUpToDate(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + detector := &cmdMockDetector{ + detectTool: func(_ context.Context, td *tool.ToolDefinition) (*tool.ToolStatus, error) { + // First agent is current before the upgrade. + return &tool.ToolStatus{Tool: td, Installed: true, InstalledVersion: "1.1.87"}, nil + }, + } + installer := &cmdMockInstaller{ + upgrade: func(_ context.Context, td *tool.ToolDefinition, _ ...tool.InstallOption) (*tool.InstallResult, error) { + // Aggregate version unchanged (first agent current), but a different + // agent was upgraded, so AlreadyUpToDate is false. + return &tool.InstallResult{ + Tool: td, + Success: true, + AlreadyUpToDate: false, + InstalledVersion: "1.1.87", + }, nil + }, + } + manager := tool.NewManager(detector, installer, nil) + + action := newToolUpgradeAction( + []string{"azure-skills"}, // a manifest skill tool + &toolUpgradeFlags{}, + manager, + mockinput.NewMockConsole(), + &output.NoneFormatter{}, + io.Discard, + ) + + result, err := action.Run(t.Context()) + require.NoError(t, err) + require.NotNil(t, result) + require.NotNil(t, result.Message) + assert.Equal(t, "Tool is upgraded to v1.1.87.", result.Message.Header, + "a multi-agent skill with an upgraded agent must not read as already up to date") +} + +// TestColorAgentPrefix verifies the NAME-column ColorFunc colors the "[agent]" +// label — including when the table wraps it across lines — while leaving plain +// tool names untouched. Expected values are built with the same formatter the +// implementation uses, so the assertions hold whether or not color is enabled. +func TestColorAgentPrefix(t *testing.T) { + // Plain names (no brackets) are returned untouched. + assert.Equal(t, "Azure CLI", colorAgentPrefix("Azure CLI")) + assert.Equal(t, "", colorAgentPrefix("")) + + // A full label on one line: only the "[...]" label is formatted. + assert.Equal(t, + output.WithWarningFormat("[Claude Code CLI]")+" Azure Skills", + colorAgentPrefix("[Claude Code CLI] Azure Skills")) + + // A label the table wrapped across lines: BOTH the opening line ("[..." + // with no "]") and the tail line ("...]" with no "[") are formatted — the + // bug was that neither was. + assert.Equal(t, + output.WithWarningFormat("[GitHub Copilot"), + colorAgentPrefix("[GitHub Copilot")) + assert.Equal(t, + output.WithWarningFormat("CLI]")+" Azure Skills", + colorAgentPrefix("CLI] Azure Skills")) +} + +// TestToolNameColumn_PlainValueWrapsUnlikeAnsiValue guards the narrow-terminal +// wrapping fix: the NAME cell value must be plain text (with color applied via +// the ColorFunc), because the pretty table refuses to wrap a cell whose value +// embeds ANSI escapes. It renders the same long skill name two ways at a narrow +// width — plain (the fix) vs. ANSI embedded in the value (the old bug) — and +// asserts the plain value wraps onto more lines. +func TestToolNameColumn_PlainValueWrapsUnlikeAnsiValue(t *testing.T) { + render := func(displayName string, colorFn func(string) string) string { + var buf bytes.Buffer + formatter := &output.PrettyTableFormatter{ConsoleWidthFn: func() int { return 70 }} + err := formatter.Format( + []struct { + DisplayName string `json:"displayName"` + }{{DisplayName: displayName}}, + &buf, + output.PrettyTableFormatterOptions{ + Columns: []output.PrettyColumn{ + { + Column: output.Column{Heading: "NAME", ValueTemplate: "{{.DisplayName}}"}, + Priority: 2, + Wrappable: true, + ColorFunc: colorFn, + }, + {Column: output.Column{Heading: "STATUS", ValueTemplate: "Installed"}, Priority: 1}, + {Column: output.Column{Heading: "INSTALLED", ValueTemplate: "1.1.87"}, Priority: 1}, + }, + }, + ) + require.NoError(t, err) + return buf.String() + } + + const tail = " Azure Skills Extended Preview Bundle" + // The fix: a plain NAME value, with color applied via the ColorFunc. + plain := render("[GitHub Copilot CLI]"+tail, colorAgentPrefix) + // The old bug: ANSI escapes embedded directly in the cell value. A literal + // SGR sequence keeps this deterministic regardless of the environment's + // color settings. The pretty table refuses to wrap such a value, so it + // stays on one line. + embedded := render("\x1b[33m[GitHub Copilot CLI]\x1b[0m"+tail, nil) + + assert.Greater(t, strings.Count(plain, "\n"), strings.Count(embedded, "\n"), + "a plain NAME value must wrap at narrow width, unlike an ANSI-embedded value") +} + +// TestToolListAction_JsonFormat_SkillPerAgentRows locks the machine-readable +// contract for `azd tool list --output json`: a skill installed on multiple +// agents expands into one row per agent, each carrying the original tool name, +// the command-valued agent, and that agent's installed version. +func TestToolListAction_JsonFormat_SkillPerAgentRows(t *testing.T) { + t.Parallel() + + skill := &tool.ToolDefinition{ + Id: "azure-skills", + Name: "Azure Skills", + Category: tool.ToolCategorySkill, + Priority: tool.ToolPriorityRecommended, + SkillAgents: []tool.SkillAgent{ + {DisplayName: "GitHub Copilot CLI", Command: "copilot"}, + {DisplayName: "Claude Code CLI", Command: "claude"}, + }, + } + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, _ []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + return []*tool.ToolStatus{{ + Tool: skill, + Installed: true, + SkillAgents: []tool.InstalledSkillAgent{ + {Agent: "copilot", Version: "1.0.0"}, + {Agent: "claude", Version: "2.0.0"}, + }, + }}, nil + }, + } + manager := tool.NewManager(detector, &cmdMockInstaller{}, nil) + + var buf bytes.Buffer + action := newToolListAction(manager, mockinput.NewMockConsole(), &output.JsonFormatter{}, &buf) + _, err := action.Run(t.Context()) + require.NoError(t, err) + + var rows []toolListItem + require.NoError(t, json.Unmarshal(buf.Bytes(), &rows)) + + byAgent := make(map[string]toolListItem) + for _, r := range rows { + if r.Agent != "" { + byAgent[r.Agent] = r + } + } + require.Len(t, byAgent, 2, "a two-agent skill must produce two agent rows") + assert.Equal(t, "Azure Skills", byAgent["copilot"].Name) + assert.Equal(t, "1.0.0", byAgent["copilot"].Version) + assert.Equal(t, "Azure Skills", byAgent["claude"].Name) + assert.Equal(t, "2.0.0", byAgent["claude"].Version) +} + +// TestToolCheckAction_JsonFormat_SkillPerAgentRows locks the machine-readable +// contract for `azd tool check --output json`: each skill agent is a row with +// the command-valued agent, its installed version, the tool's latest version, +// and a per-agent update flag (so a stale agent reports an update while a current +// one does not). +func TestToolCheckAction_JsonFormat_SkillPerAgentRows(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, _ []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + return []*tool.ToolStatus{{ + Tool: &tool.ToolDefinition{Id: "azure-skills"}, + Installed: true, + SkillAgents: []tool.InstalledSkillAgent{ + {Agent: "copilot", Version: "1.0.0"}, // behind latest + {Agent: "claude", Version: "2.0.0"}, // current + }, + }}, nil + }, + } + + cacheDir := t.TempDir() + updateChecker := tool.NewUpdateChecker( + &memUserConfigManager{}, + detector, + func() (string, error) { return cacheDir, nil }, + map[string]tool.LatestVersionProvider{ + "azure-skills": stubVersionProvider{version: "2.0.0"}, + }, + ) + manager := tool.NewManager(detector, &cmdMockInstaller{}, updateChecker) + + var buf bytes.Buffer + action := newToolCheckAction(manager, mockinput.NewMockConsole(), &output.JsonFormatter{}, &buf) + _, err := action.Run(t.Context()) + require.NoError(t, err) + + var rows []toolCheckItem + require.NoError(t, json.Unmarshal(buf.Bytes(), &rows)) + + byAgent := make(map[string]toolCheckItem) + for _, r := range rows { + if r.Agent != "" { + byAgent[r.Agent] = r + } + } + require.Len(t, byAgent, 2, "a two-agent skill must produce two agent rows") + + assert.Equal(t, "1.0.0", byAgent["copilot"].InstalledVersion) + assert.Equal(t, "2.0.0", byAgent["copilot"].LatestVersion) + assert.True(t, byAgent["copilot"].UpdateAvailable, "a stale agent must report an update") + + assert.Equal(t, "2.0.0", byAgent["claude"].InstalledVersion) + assert.Equal(t, "2.0.0", byAgent["claude"].LatestVersion) + assert.False(t, byAgent["claude"].UpdateAvailable, "a current agent must not report an update") +} + +// memUserConfigManager is an in-memory config.UserConfigManager for tests that +// drive a real UpdateChecker without touching the user's config on disk. +type memUserConfigManager struct{ cfg config.Config } + +func (m *memUserConfigManager) Load() (config.Config, error) { + if m.cfg == nil { + m.cfg = config.NewEmptyConfig() + } + return m.cfg, nil +} + +func (m *memUserConfigManager) Save(c config.Config) error { + m.cfg = c + return nil +} + +// stubVersionProvider is a tool.LatestVersionProvider that returns a fixed +// version, so update checks are deterministic and offline. +type stubVersionProvider struct{ version string } + +func (s stubVersionProvider) GetLatestVersion( + context.Context, *tool.ToolDefinition, +) (string, error) { + return s.version, nil +} diff --git a/cli/azd/pkg/tool/detector.go b/cli/azd/pkg/tool/detector.go index 47209d58970..f389437f51d 100644 --- a/cli/azd/pkg/tool/detector.go +++ b/cli/azd/pkg/tool/detector.go @@ -26,17 +26,21 @@ type ToolStatus struct { // output. It is empty when the tool is not installed or when // version parsing fails. InstalledVersion string + // SkillAgents lists every agent CLI the skill is installed + // through, with the version installed via each, in manifest order. + // Populated only for skill tools (see detectSkill); nil otherwise. + SkillAgents []InstalledSkillAgent // Error records any unexpected failure during detection (e.g. a // timeout). A tool that is simply not installed has Error == nil. Error error } -// InstalledSkillHost pairs an agentic CLI host with the skill version +// InstalledSkillAgent pairs an agent CLI with the skill version // installed through it. -type InstalledSkillHost struct { - // Host is the agentic CLI host binary name (e.g. "copilot"). - Host string - // Version is the skill version installed through that host. +type InstalledSkillAgent struct { + // Agent is the agent CLI binary name (e.g. "copilot"). + Agent string + // Version is the skill version installed through that agent. Version string } @@ -58,15 +62,15 @@ type Detector interface { tools []*ToolDefinition, ) ([]*ToolStatus, error) - // DetectSkillHosts returns every configured SkillHost the skill is + // DetectSkillAgents returns every configured SkillAgent the skill is // currently installed through (with the version installed via each), // in manifest order. It returns nil for non-skill tools. The returned // error is non-nil only for context cancellation/timeout while // listing plugins. - DetectSkillHosts( + DetectSkillAgents( ctx context.Context, tool *ToolDefinition, - ) ([]InstalledSkillHost, error) + ) ([]InstalledSkillAgent, error) } type detector struct { @@ -461,51 +465,61 @@ func (d *detector) detectAzdExtension( // Skill detection // --------------------------------------------------------------------------- -// detectSkill checks whether a skill is installed by running each -// SkillHost's PluginListCommand. A host is reported as having the skill +// detectSkill checks whether a skill is installed by probing every +// SkillAgent's PluginListCommand. An agent is reported as having the skill // installed only when its PluginName appears in the listing AND its -// (required) VersionRegex captures a version. Because a host's list +// (required) VersionRegex captures a version. Because an agent's list // command reports every installed plugin, the regex anchors on this // skill's identity so another plugin's version is never mistaken for -// it. The captured group becomes InstalledVersion. Hosts whose binary -// is not on PATH are skipped silently — a missing host is not an error, -// it just means the skill cannot be installed through that host. +// it. Every agent the skill is installed through is recorded in +// SkillAgents (in manifest order); Installed and InstalledVersion reflect +// the first such agent, so a skill installed anywhere reads as installed. +// Agents whose binary is not on PATH are skipped silently — a missing +// agent is not an error, it just means the skill cannot be installed +// through that agent. func (d *detector) detectSkill( ctx context.Context, tool *ToolDefinition, ) *ToolStatus { status := &ToolStatus{Tool: tool} - if len(tool.SkillHosts) == 0 { + if len(tool.SkillAgents) == 0 { return status } - for _, host := range tool.SkillHosts { - version, err := d.skillHostVersion(ctx, host) - if err != nil { - status.Error = err - return status - } - if version != "" { - status.Installed = true - status.InstalledVersion = version - return status - } + agents, err := d.DetectSkillAgents(ctx, tool) + status.SkillAgents = agents + if len(agents) > 0 { + // The skill was found on at least one agent; report it installed even if + // a later agent's probe errored, mirroring the first-match behavior from + // before multi-agent detection. Installed and InstalledVersion reflect + // the first such agent. + status.Installed = true + status.InstalledVersion = agents[0].Version + } + + // Record any detection failure (e.g. a later agent's timeout/cancellation) + // even when an earlier agent was found: SkillAgents may be incomplete, so per + // ToolStatus.Error's contract we surface the failure rather than present a + // truncated result as a complete detection. + if err != nil { + status.Error = err } return status } -// DetectSkillHosts returns every configured SkillHost the skill is +// DetectSkillAgents returns every configured SkillAgent the skill is // currently installed through (with the version installed via each), in -// manifest order. Unlike detectSkill (which stops at the first match) it -// probes all hosts, so callers can act on every install — e.g. -// `azd tool upgrade` refreshing the skill on each host it was installed -// to, or per-host install verification. -func (d *detector) DetectSkillHosts( +// manifest order. It probes every agent so callers can act on every +// install — e.g. `azd tool upgrade` refreshing the skill on each agent it +// was installed to, or per-agent install verification. detectSkill +// delegates to it and reports the first matched agent as the aggregate +// Installed/InstalledVersion. +func (d *detector) DetectSkillAgents( ctx context.Context, tool *ToolDefinition, -) ([]InstalledSkillHost, error) { +) ([]InstalledSkillAgent, error) { if tool == nil { return nil, errors.New("tool definition must not be nil") } @@ -513,24 +527,27 @@ func (d *detector) DetectSkillHosts( return nil, nil } - var hosts []InstalledSkillHost - for _, host := range tool.SkillHosts { - version, err := d.skillHostVersion(ctx, host) + var agents []InstalledSkillAgent + for _, agent := range tool.SkillAgents { + version, err := d.skillAgentVersion(ctx, agent) if err != nil { - return nil, err + // Return the agents discovered before the error (e.g. a later agent's + // probe was cancelled) so a caller — detectSkill in particular — can + // still act on an earlier match instead of losing it. + return agents, err } if version != "" { - hosts = append(hosts, InstalledSkillHost{ - Host: host.Host, + agents = append(agents, InstalledSkillAgent{ + Agent: agent.Command, Version: version, }) } } - return hosts, nil + return agents, nil } -// skillHostVersion reports the version of the skill as installed through -// a single host, or "" when the host is not on PATH, its list command +// skillAgentVersion reports the version of the skill as installed through +// a single agent, or "" when the agent is not on PATH, its list command // fails, or the skill is not present. The error is non-nil only for // context cancellation/timeout. // @@ -539,44 +556,48 @@ func (d *detector) DetectSkillHosts( // VersionRegex must capture a version. The regex anchors on this // skill's identity (the azure@azure-skills entry in claude's `plugin // list --json` output, or the plugin name in copilot's `plugin list`), -// so a host that lists other plugins but not this skill is reported as +// so an agent that lists other plugins but not this skill is reported as // not installed. -func (d *detector) skillHostVersion( +func (d *detector) skillAgentVersion( ctx context.Context, - host SkillHost, + agent SkillAgent, ) (string, error) { - if len(host.PluginListCommand) == 0 || host.PluginName == "" { + if len(agent.PluginListCommand) == 0 || agent.PluginName == "" { return "", nil } - // A host that is not on PATH cannot have the skill installed through - // it; skip silently. - if err := d.commandRunner.ToolInPath(host.Host); err != nil { + // An agent that is not on PATH cannot have the skill installed through + // it; skip silently. Probe the exec binary (Command), not the display + // Agent, so this PATH check matches the command actually run below — + // otherwise a manifest whose Agent differs from its binary (e.g. Agent + // "Claude Code CLI" / Command "claude") is never detected and a + // just-completed install fails verification. + if err := d.commandRunner.ToolInPath(agent.Command); err != nil { return "", nil } // Run the list command. If it fails for any reason other than a // context error we cannot reliably tell whether the skill is - // installed via this host — fail closed and report not-installed + // installed via this agent — fail closed and report not-installed // rather than guessing from the error output (which often echoes the // queried name). result, err := d.commandRunner.Run(ctx, exec.RunArgs{ - Cmd: host.Host, - Args: host.PluginListCommand, + Cmd: agent.Command, + Args: agent.PluginListCommand, }) if err != nil { if isContextErr(err) { - return "", fmt.Errorf("listing %s plugins: %w", host.Host, err) + return "", fmt.Errorf("listing %s plugins: %w", agent.DisplayName, err) } return "", nil } // Match only against stdout: stderr is usually diagnostics, not the // canonical listing. - if !strings.Contains(result.Stdout, host.PluginName) { + if !strings.Contains(result.Stdout, agent.PluginName) { return "", nil } - return matchVersion(result.Stdout, host.VersionRegex), nil + return matchVersion(result.Stdout, agent.VersionRegex), nil } // --------------------------------------------------------------------------- diff --git a/cli/azd/pkg/tool/detector_test.go b/cli/azd/pkg/tool/detector_test.go index 518ec19d305..933258bf1bd 100644 --- a/cli/azd/pkg/tool/detector_test.go +++ b/cli/azd/pkg/tool/detector_test.go @@ -435,11 +435,11 @@ func TestDetectTool_Extension(t *testing.T) { } // --------------------------------------------------------------------------- -// DetectTool — Skills (host plugin listing) +// DetectTool — Skills (agent plugin listing) // --------------------------------------------------------------------------- // TestDetectTool_Skill_Copilot exercises detectSkill for the copilot -// host, whose `plugin list` output is a bullet list. The skill is +// agent, whose `plugin list` output is a bullet list. The skill is // reported installed only when PluginName appears AND the VersionRegex // captures a version. func TestDetectTool_Skill_Copilot(t *testing.T) { @@ -450,9 +450,10 @@ func TestDetectTool_Skill_Copilot(t *testing.T) { Id: "azure-skills", Name: "Azure Skills", Category: ToolCategorySkill, - SkillHosts: []SkillHost{ + SkillAgents: []SkillAgent{ { - Host: "copilot", + DisplayName: "GitHub Copilot CLI", + Command: "copilot", PluginListCommand: []string{"plugin", "list"}, PluginName: "azure@azure-skills", VersionRegex: `azure@azure-skills[^\n]*?(\d+\.\d+\.\d+)`, @@ -504,7 +505,7 @@ func TestDetectTool_Skill_Copilot(t *testing.T) { } } -// TestDetectTool_Skill_Claude exercises detectSkill for the claude host. +// TestDetectTool_Skill_Claude exercises detectSkill for the claude agent. // `claude plugin list` ignores a plugin-name argument, so detection lists // every plugin via `--json` and anchors the VersionRegex on the // azure@azure-skills entry; a listing without that entry is NOT installed. @@ -516,9 +517,10 @@ func TestDetectTool_Skill_Claude(t *testing.T) { Id: "azure-skills", Name: "Azure Skills", Category: ToolCategorySkill, - SkillHosts: []SkillHost{ + SkillAgents: []SkillAgent{ { - Host: "claude", + DisplayName: "Claude Code CLI", + Command: "claude", PluginListCommand: []string{"plugin", "list", "--json"}, PluginName: "azure@azure-skills", VersionRegex: `"id":\s*"azure@azure-skills"[^}]*?"version":\s*"v?(\d+\.\d+\.\d+)"`, @@ -581,25 +583,27 @@ func TestDetectTool_Skill_Claude(t *testing.T) { } } -// TestDetectSkillHosts verifies that DetectSkillHosts returns EVERY host +// TestDetectSkillAgents verifies that DetectSkillAgents returns EVERY agent // the skill is installed through (not just the first), in manifest order. -func TestDetectSkillHosts(t *testing.T) { +func TestDetectSkillAgents(t *testing.T) { t.Parallel() - twoHostSkill := func() *ToolDefinition { + twoAgentSkill := func() *ToolDefinition { return &ToolDefinition{ Id: "azure-skills", Name: "Azure Skills", Category: ToolCategorySkill, - SkillHosts: []SkillHost{ + SkillAgents: []SkillAgent{ { - Host: "copilot", + DisplayName: "GitHub Copilot CLI", + Command: "copilot", PluginListCommand: []string{"plugin", "list"}, PluginName: "azure@azure-skills", VersionRegex: `azure@azure-skills[^\n]*?(\d+\.\d+\.\d+)`, }, { - Host: "claude", + DisplayName: "Claude Code CLI", + Command: "claude", PluginListCommand: []string{"plugin", "list", "--json"}, PluginName: "azure@azure-skills", VersionRegex: `"id":\s*"azure@azure-skills"[^}]*?"version":\s*"v?(\d+\.\d+\.\d+)"`, @@ -608,43 +612,43 @@ func TestDetectSkillHosts(t *testing.T) { } } - // installedOutput renders host stdout that reports the skill present. + // installedOutput renders agent stdout that reports the skill present. copilotInstalled := " • azure@azure-skills (v1.1.71)\n" claudeInstalled := `[{"id":"azure@azure-skills","version":"1.1.71"}]` notInstalled := "" // empty stdout => not installed tests := []struct { - name string - copilot string - claude string - wantHosts []InstalledSkillHost + name string + copilot string + claude string + wantAgents []InstalledSkillAgent }{ { name: "BothInstalled", copilot: copilotInstalled, claude: claudeInstalled, - wantHosts: []InstalledSkillHost{ - {Host: "copilot", Version: "1.1.71"}, - {Host: "claude", Version: "1.1.71"}, + wantAgents: []InstalledSkillAgent{ + {Agent: "copilot", Version: "1.1.71"}, + {Agent: "claude", Version: "1.1.71"}, }, }, { - name: "OnlyClaude", - copilot: notInstalled, - claude: claudeInstalled, - wantHosts: []InstalledSkillHost{{Host: "claude", Version: "1.1.71"}}, + name: "OnlyClaude", + copilot: notInstalled, + claude: claudeInstalled, + wantAgents: []InstalledSkillAgent{{Agent: "claude", Version: "1.1.71"}}, }, { - name: "OnlyCopilot", - copilot: copilotInstalled, - claude: notInstalled, - wantHosts: []InstalledSkillHost{{Host: "copilot", Version: "1.1.71"}}, + name: "OnlyCopilot", + copilot: copilotInstalled, + claude: notInstalled, + wantAgents: []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.71"}}, }, { - name: "NoneInstalled", - copilot: notInstalled, - claude: notInstalled, - wantHosts: nil, + name: "NoneInstalled", + copilot: notInstalled, + claude: notInstalled, + wantAgents: nil, }, } @@ -663,25 +667,86 @@ func TestDetectSkillHosts(t *testing.T) { }).Respond(exec.RunResult{ExitCode: 0, Stdout: tt.claude}) d := NewDetector(runner) - hosts, err := d.DetectSkillHosts(t.Context(), twoHostSkill()) + agents, err := d.DetectSkillAgents(t.Context(), twoAgentSkill()) require.NoError(t, err) - assert.Equal(t, tt.wantHosts, hosts) + assert.Equal(t, tt.wantAgents, agents) }) } t.Run("NonSkillReturnsNil", func(t *testing.T) { t.Parallel() d := NewDetector(mockexec.NewMockCommandRunner()) - hosts, err := d.DetectSkillHosts(t.Context(), &ToolDefinition{ + agents, err := d.DetectSkillAgents(t.Context(), &ToolDefinition{ Id: "az-cli", Category: ToolCategoryCLI, }) require.NoError(t, err) - assert.Nil(t, hosts) + assert.Nil(t, agents) }) } +// TestDetectSkill_EarlierAgentFound_LaterAgentContextError verifies that when a +// skill is installed on an earlier agent but a later agent's probe hits a context +// error (e.g. cancellation/timeout mid-detection), the skill is still reported +// as installed from the agent already found — the context error must not discard +// an earlier positive match. The error is still recorded on the status +// (detection was incomplete). Regression test: multi-agent detection previously +// returned "not installed" with the error, dropping the skill from tool and +// update-check results. +func TestDetectSkill_EarlierAgentFound_LaterAgentContextError(t *testing.T) { + t.Parallel() + + skill := &ToolDefinition{ + Id: "azure-skills", + Name: "Azure Skills", + Category: ToolCategorySkill, + SkillAgents: []SkillAgent{ + { + DisplayName: "GitHub Copilot CLI", + Command: "copilot", + PluginListCommand: []string{"plugin", "list"}, + PluginName: "azure@azure-skills", + VersionRegex: `azure@azure-skills[^\n]*?(\d+\.\d+\.\d+)`, + }, + { + DisplayName: "Claude Code CLI", + Command: "claude", + PluginListCommand: []string{"plugin", "list", "--json"}, + PluginName: "azure@azure-skills", + VersionRegex: `"id":\s*"azure@azure-skills"[^}]*?"version":\s*"v?(\d+\.\d+\.\d+)"`, + }, + }, + } + + runner := mockexec.NewMockCommandRunner() + runner.MockToolInPath("copilot", nil) + runner.MockToolInPath("claude", nil) + // copilot (first agent) has the skill installed ... + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" + }).Respond(exec.RunResult{ExitCode: 0, Stdout: " • azure@azure-skills (v1.1.71)\n"}) + // ... but claude (later agent) is probed with a cancelled/expired context. + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "claude" + }).RespondFn(func(_ exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{}, context.DeadlineExceeded + }) + + d := NewDetector(runner) + status, err := d.DetectTool(t.Context(), skill) + require.NoError(t, err) + require.NotNil(t, status) + + assert.True(t, status.Installed, + "a skill found on an earlier agent must stay installed when a later agent's probe is cancelled") + assert.Equal(t, "1.1.71", status.InstalledVersion) + assert.Equal(t, []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.71"}}, status.SkillAgents) + // The later agent's context error is still recorded — detection was + // incomplete, so SkillAgents may be missing an agent. + require.ErrorIs(t, status.Error, context.DeadlineExceeded) +} + // --------------------------------------------------------------------------- // DetectTool — Server / Library (commandBased) // --------------------------------------------------------------------------- diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 1fade7c1792..14d7826c6a8 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -4,6 +4,7 @@ package tool import ( + "bytes" "context" "crypto/sha256" "crypto/sha512" @@ -17,6 +18,7 @@ import ( "os" osexec "os/exec" "path/filepath" + "regexp" "runtime" "slices" "strings" @@ -28,6 +30,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/output" ) @@ -51,6 +54,11 @@ type InstallResult struct { Success bool // InstalledVersion is the version detected after installation. InstalledVersion string + // AlreadyUpToDate is set for an upgrade when every targeted agent + // reported the skill was already at the latest version, so nothing was + // changed. The command layer uses it to show an "already up to date" + // result instead of an "upgraded" one. + AlreadyUpToDate bool // Strategy describes what was used to install the tool // (e.g. "winget", "brew", "manual"). Strategy string @@ -107,8 +115,8 @@ func AggregateInstallResults( type Installer interface { // Install attempts to install the given tool using the best // strategy available for the current platform. For skill tools the - // optional [WithHosts] option restricts installation to the named - // agentic CLI hosts. + // optional [WithAgents] option restricts installation to the named + // agent CLIs. Install( ctx context.Context, tool *ToolDefinition, @@ -124,17 +132,18 @@ type Installer interface { opts ...InstallOption, ) (*InstallResult, error) - // AvailableSkillHosts returns the names of the tool's configured - // SkillHosts that are currently usable (a functional CLI on PATH, per - // hostUsable), in manifest order (preferred host first). It returns nil - // for non-skill tools or when none of the hosts are usable. It probes - // the host binaries, so it takes a context. - AvailableSkillHosts(ctx context.Context, tool *ToolDefinition) []string + // AvailableSkillAgents returns the tool's configured SkillAgents that are + // currently usable (a functional CLI on PATH, per agentUsable), in + // manifest order (preferred agent first), as two index-aligned slices: the + // command identities (used for matching) and their display names (shown + // to the user). Both are nil for non-skill tools or when none of the + // agents are usable. It probes the agent binaries, so it takes a context. + AvailableSkillAgents(ctx context.Context, tool *ToolDefinition) (commands []string, names []string) // Uninstall removes the given tool from the current platform. For - // skill tools the optional [WithHosts] option restricts removal to - // the named agentic CLI hosts; with neither [WithHosts] nor - // [WithAllAvailableHosts] the skill is removed from every host it is + // skill tools the optional [WithAgents] option restricts removal to + // the named agent CLIs; with neither [WithAgents] nor + // [WithAllAvailableAgents] the skill is removed from every agent it is // installed through. Uninstall( ctx context.Context, @@ -146,36 +155,277 @@ type Installer interface { // installConfig holds the resolved options for an install or upgrade // operation. type installConfig struct { - // hosts, when non-empty, restricts a skill install/upgrade to the - // named agentic CLI hosts (e.g. "copilot", "claude"). An empty slice - // selects the single preferred host (the first configured host on + // agents, when non-empty, restricts a skill install/upgrade to the + // named agent CLIs (e.g. "copilot", "claude"). An empty slice + // selects the single preferred agent (the first configured agent on // PATH). Ignored for non-skill tools. - hosts []string - // allAvailableHosts, when true, installs a skill through every - // configured host that is on PATH, resolved at install time. It is - // used by batch flows (e.g. `azd tool install --all`) where the host + agents []string + // allAvailableAgents, when true, installs a skill through every + // configured agent that is on PATH, resolved at install time. It is + // used by batch flows (e.g. `azd tool install --all`) where the agent // CLIs may themselves be installed earlier in the same batch, so the - // set of available hosts is not known until the skill's turn. Ignored - // when hosts is non-empty or for non-skill tools. - allAvailableHosts bool + // set of available agents is not known until the skill's turn. Ignored + // when agents is non-empty or for non-skill tools. + allAvailableAgents bool + // renderer, when set, renders a live step spinner around each + // install/upgrade/uninstall step (each agent for a skill tool, or the + // tool itself otherwise). When nil the + // installer prints a plain per-agent header instead. + renderer StepRenderer + // stdin, when set, is the input reader a skill agent command reads from + // while a step spinner is showing (so prompts are answered on the same + // stream the console owns, e.g. Cobra's redirected input). When nil the + // agent command falls back to the process terminal. Ignored when no + // spinner is active (the command then runs against the runner's streams). + stdin io.Reader + // quiet, when true, suppresses interactive/terminal execution of skill + // agent commands: their stdout/stderr are captured (discarded) instead of + // connected to the process terminal. Set for `--output json` so agent CLI + // output never leaks onto stdout ahead of the structured result. Ignored + // when a step spinner is active (that path already captures output). + quiet bool } // InstallOption customizes an install or upgrade operation. type InstallOption func(*installConfig) -// WithHosts restricts a skill install/upgrade to the named agentic CLI -// hosts. It is ignored for non-skill tools. Passing no hosts (or not -// using this option) selects the single preferred host. -func WithHosts(hosts ...string) InstallOption { - return func(c *installConfig) { c.hosts = hosts } +// WithAgents restricts a skill install/upgrade to the named agentic CLI +// agents. It is ignored for non-skill tools. Passing no agents (or not +// using this option) selects the single preferred agent. +func WithAgents(agents ...string) InstallOption { + return func(c *installConfig) { c.agents = agents } } -// WithAllAvailableHosts installs a skill through every configured host +// WithAllAvailableAgents installs a skill through every configured agent // on PATH, resolved at install time. Use it for batch flows where the -// host set is not known up front. It is ignored for non-skill tools and -// is overridden by WithHosts. -func WithAllAvailableHosts() InstallOption { - return func(c *installConfig) { c.allAvailableHosts = true } +// agent set is not known up front. It is ignored for non-skill tools and +// is overridden by WithAgents. +func WithAllAvailableAgents() InstallOption { + return func(c *installConfig) { c.allAvailableAgents = true } +} + +// WithQuiet suppresses interactive/terminal execution of skill agent commands, +// capturing (discarding) their stdout/stderr instead of connecting them to the +// process terminal. Use it for `--output json`, where any agent CLI output on +// stdout would corrupt the structured result. It is independent of +// WithStepProgress (a step spinner already captures output). +func WithQuiet() InstallOption { + return func(c *installConfig) { c.quiet = true } +} + +// StepRenderer renders live per-step progress. It is the subset of +// input.Console the installer uses to show a step spinner per agent, +// matching azd provision/down. input.Console satisfies it. +type StepRenderer interface { + ShowSpinner(ctx context.Context, title string, format input.SpinnerUxType) + StopSpinner(ctx context.Context, lastMessage string, format input.SpinnerUxType) + Message(ctx context.Context, message string) +} + +// WithStepProgress renders a live step spinner around each +// install/upgrade/uninstall step via the given renderer (typically the +// console), like azd provision/down. It is opt-in so shared callers that +// manage their own progress (e.g. the first-run middleware) are unaffected. +func WithStepProgress(renderer StepRenderer) InstallOption { + return func(c *installConfig) { c.renderer = renderer } +} + +// WithInput supplies the reader a skill agent command should read stdin from +// while a step spinner is showing — typically the console's input +// (console.Handles().Stdin), so an agent prompt is answered on the same stream +// azd owns rather than the process-global os.Stdin. It is opt-in; without it +// the skill command falls back to the process terminal. +func WithInput(stdin io.Reader) InstallOption { + return func(c *installConfig) { c.stdin = stdin } +} + +// stepError returns the effective error for a step: an operation error, or +// the result's recorded error, or nil. +func stepError(result *InstallResult, err error) error { + if err != nil { + return err + } + if result != nil { + return result.Error + } + return nil +} + +// renderSkillStep frames one skill step (install, upgrade or uninstall) with a +// live spinner, then reveals the agent CLI's output beneath the resolved step +// line. +// +// It shows a step spinner (like azd provision) with title and passes work an +// output writer. Skill operations route the agent CLI's stdout/stderr through +// that writer (see skillCommandRunArgs), with stdin still connected. +// +// The output is captured line by line and, once the step resolves, printed as +// an indented gray sub-section BELOW the "(✓) Done:" / "(x) Failed:" result +// line — so it reads as a detail of the step rather than scrolling above the +// spinner. showOutput controls this: when true (interactive operations such as +// install, upgrade and uninstall) the captured output is always shown; when +// false it is shown only if the step failed, so a successful step stays quiet. +// When the CLI produced no output the step line stands alone. +// +// Because a step spinner routes the agent CLI's stdout/stderr through a pipe +// rather than a TTY, the supported agent CLIs (copilot, claude) run +// non-interactively on this path and do not emit interactive prompts here; the +// fully interactive path is the renderer==nil branch below, which runs the +// command directly against the terminal. +// +// work returns the message to show on the result line; when empty the spinner +// title is reused. This lets a step whose outcome is only known after running +// (e.g. an upgrade that reports the version or "already up to date") show a +// result line that differs from the in-progress title. When renderer is nil +// (e.g. the first-run middleware manages its own progress) it prints a stderr +// header and runs work with a nil writer, so the command runs fully +// interactively — unless quiet is set (e.g. `--output json`), in which case the +// child streams are captured so nothing leaks onto stdout. +func renderSkillStep( + ctx context.Context, + renderer StepRenderer, + title string, + showOutput bool, + quiet bool, + work func(out io.Writer) (doneTitle string, err error), +) error { + if renderer == nil { + if quiet { + // JSON / non-interactive output: capture (discard) the agent CLI's + // streams so nothing leaks onto stdout ahead of the structured + // result. A non-nil writer makes skillCommandRunArgs route + // stdout/stderr through it instead of connecting the child to the + // terminal via WithInteractive. + _, err := work(io.Discard) + return err + } + fmt.Fprintf(os.Stderr, "\n%s\n", title) + _, err := work(nil) + return err + } + + renderer.ShowSpinner(ctx, title, input.Step) + + // Capture the agent CLI's output so it can be revealed as an indented gray + // section below the step result line rather than scrolling above the spinner. + var buffered []string + out := &lineWriter{emit: func(line string) { buffered = append(buffered, line) }} + + doneTitle, err := work(out) + out.Flush() + if doneTitle == "" { + doneTitle = title + } + + // Resolve the spinner to its result line first, then print the captured + // output beneath it, indented two spaces to align under the step line. + renderer.StopSpinner(ctx, doneTitle, input.GetStepResultFormat(err)) + if showOutput || err != nil { + for _, line := range buffered { + renderer.Message(ctx, output.WithGrayFormat(" %s", line)) + } + } + return err +} + +// lineWriter buffers agent CLI output and hands each complete line to emit. +// Skill agent commands use it to surface the agent CLI's output through the +// console so it prints above the step spinner (which the console re-renders +// around each line), keeping the spinner visible. +// +// os/exec may deliver a single logical line across several Write calls (and +// several lines in one call), so Write accumulates bytes and emits only on a +// newline — never per-write fragments — while preserving empty lines. Bytes +// after the final newline are retained until a later Write completes the line +// or Flush is called. Flush must be called once the command has finished +// writing so a trailing line with no newline (e.g. the CLI's last output line) +// is not lost. +// +// CommandRunner wires a command's stdout and stderr to distinct io.MultiWriter +// values, so os/exec may call Write from two goroutines at once. The mutex +// serializes those calls (and Flush) so emit and the shared buffer are never +// accessed concurrently — avoiding a data race and interleaved/lost agent +// output. +type lineWriter struct { + mu sync.Mutex + buf []byte + emit func(string) +} + +// Write accumulates p and emits every complete, newline-terminated line via +// emit (without the trailing newline), preserving empty lines. Any bytes after +// the final newline are retained for the next Write or Flush. +func (l *lineWriter) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + l.buf = append(l.buf, p...) + for { + i := bytes.IndexByte(l.buf, '\n') + if i < 0 { + break + } + l.emit(string(l.buf[:i])) + l.buf = l.buf[i+1:] + } + return len(p), nil +} + +// Flush emits any buffered content not terminated by a newline. It is called +// once the command has finished writing so a final line without a trailing +// newline is surfaced rather than dropped. +func (l *lineWriter) Flush() { + l.mu.Lock() + defer l.mu.Unlock() + if len(l.buf) > 0 { + l.emit(string(l.buf)) + l.buf = nil + } +} + +// skillCommandRunArgs configures how a skill agent command (install, upgrade +// or uninstall) connects to the terminal. When out is non-nil (a step +// spinner is showing) the command's stdout/stderr are routed through it so +// the CLI's output prints above the spinner, and stdin is connected to the +// supplied reader (the console's input, threaded from the step-progress +// caller). Because stdout/stderr are piped (not a TTY) on this path, the +// supported agent CLIs run non-interactively and do not prompt. When out is +// nil (no spinner) the command runs fully interactively against the runner's +// configured streams. azd never pipes canned answers on the user's behalf. +func skillCommandRunArgs(base exec.RunArgs, out io.Writer, stdin io.Reader) exec.RunArgs { + if out == nil { + return base.WithInteractive(true) + } + if stdin == nil { + // No input reader supplied (e.g. a non-cmd caller); fall back to the + // process terminal so prompts remain answerable. + stdin = os.Stdin + } + return base.WithStdIn(stdin).WithStdOut(out).WithStdErr(out) +} + +// semverRegex matches a bare semantic version (no leading "v") anywhere in a +// string. Used to pull the version an agent CLI prints after an update. +var semverRegex = regexp.MustCompile(`\d+\.\d+\.\d+`) + +// parseUpgradeOutput extracts, from an agent CLI's plugin-update output, the +// version it reports and whether it said the plugin was already at the latest +// version. Best-effort across agent CLIs, e.g.: +// +// copilot: `... updated successfully (v1.1.86, already at latest). Updated 27 skills.` +// claude: `... updated from 1.1.73 to 1.1.86 for scope user. ...` +// claude: `... azure is already at the latest version (1.1.86).` +// +// The version is the last semver in the output, so an "updated from A to B" +// line yields the new version B. alreadyLatest is true when the output says +// the plugin was already current (nothing changed). +func parseUpgradeOutput(out string) (version string, alreadyLatest bool) { + lower := strings.ToLower(out) + alreadyLatest = strings.Contains(lower, "already") && + (strings.Contains(lower, "latest") || strings.Contains(lower, "up to date")) + if m := semverRegex.FindAllString(out, -1); len(m) > 0 { + version = m[len(m)-1] + } + return version, alreadyLatest } // installer is the default, unexported implementation of [Installer]. @@ -186,13 +436,13 @@ type installer struct { httpClient httpDoer platformMu sync.Mutex platform *Platform // lazily populated by ensurePlatform - // hostProbe memoizes hostUsable's version-probe result per host for the + // agentProbe memoizes agentUsable's version-probe result per agent for the // installer's lifetime (one process == one command), so the several - // host-resolution call sites do not re-spawn `--version` for the same - // host. Only on-PATH results are stored (see hostUsable). Guarded by - // hostProbeMu. - hostProbeMu sync.Mutex - hostProbe map[string]bool + // agent-resolution call sites do not re-spawn `--version` for the same + // agent. Only on-PATH results are stored (see agentUsable). Guarded by + // agentProbeMu. + agentProbeMu sync.Mutex + agentProbe map[string]bool // retryBackoff is the initial wait between post-install detection // retries (doubled each attempt). Defaults to 1s; tests may shorten // it to keep the suite fast. @@ -265,28 +515,33 @@ func (i *installer) Upgrade( return i.run(ctx, tool, true, opts) } -// AvailableSkillHosts returns the names of the tool's configured -// SkillHosts that are currently usable, in manifest order (preferred host -// first). A host counts only if [installer.hostUsable] confirms it is a -// functional CLI — not merely a same-named launcher stub on PATH — so the -// interactive host picker never offers a host the install path would later -// reject. It returns nil for non-skill tools or when none of the hosts are -// usable. -func (i *installer) AvailableSkillHosts(ctx context.Context, tool *ToolDefinition) []string { +// AvailableSkillAgents returns the tool's configured SkillAgents that are +// currently usable, in manifest order (preferred agent first), as two +// index-aligned slices: the command identities (e.g. "copilot", used for +// matching via --agent/findSkillAgent) and their friendly display names (e.g. +// "GitHub Copilot CLI", shown to the user). An agent counts only if +// [installer.agentUsable] confirms it is a functional CLI — not merely a +// same-named launcher stub on PATH — so the interactive agent picker never +// offers an agent the install path would later reject. Both are nil for +// non-skill tools or when none of the agents are usable. +func (i *installer) AvailableSkillAgents( + ctx context.Context, + tool *ToolDefinition, +) (commands []string, names []string) { if tool.Category != ToolCategorySkill { - return nil + return nil, nil } - var present []string - for _, host := range tool.SkillHosts { - if i.hostUsable(ctx, host) { - present = append(present, host.Host) + for _, agent := range tool.SkillAgents { + if i.agentUsable(ctx, agent) { + commands = append(commands, agent.Command) + names = append(names, agent.DisplayName) } } - return present + return commands, names } // Uninstall removes a tool from the current platform. Skills are -// removed through their agent host plugin command(s); other tools are +// removed through their agent plugin command(s); other tools are // removed via the platform package manager (or by deleting a // directly-downloaded artifact). func (i *installer) Uninstall( @@ -300,16 +555,26 @@ func (i *installer) Uninstall( } if tool.Category == ToolCategorySkill { - // For uninstall, both an explicit `--host all` - // (cfg.allAvailableHosts) and an omitted --host mean "remove the - // skill from every host it is installed through" — azd cannot - // remove a plugin from a host that never installed it. So only - // the explicit host names need to be threaded through; the - // empty-hosts branch of resolveSkillUninstallTargets handles both - // the default and `--host all`. - return i.runSkillUninstall(ctx, tool, cfg.hosts) + // For uninstall, both an explicit `--agent all` + // (cfg.allAvailableAgents) and an omitted --agent mean "remove the + // skill from every agent it is installed through" — azd cannot + // remove a plugin from an agent that never installed it. So only + // the explicit agent names need to be threaded through; the + // empty-agents branch of resolveSkillUninstallTargets handles both + // the default and `--agent all`. + return i.runSkillUninstall(ctx, tool, cfg.agents, cfg.renderer, cfg.stdin, cfg.quiet) + } + + // Non-skill tools uninstall as a single step; render one spinner for + // the tool (no agent) when a renderer is supplied. + if cfg.renderer == nil { + return i.runUninstall(ctx, tool) } - return i.runUninstall(ctx, tool) + title := fmt.Sprintf("Uninstalling %s", tool.Name) + cfg.renderer.ShowSpinner(ctx, title, input.Step) + result, err := i.runUninstall(ctx, tool) + cfg.renderer.StopSpinner(ctx, title, input.GetStepResultFormat(stepError(result, err))) + return result, err } // runUninstall removes a non-skill tool using the platform's package @@ -667,25 +932,28 @@ func isSystemBinaryPath(binaryPath string) bool { return false } -// runSkillUninstall removes a skill from the resolved target host(s) and -// verifies, per host, that the skill is no longer present. It mirrors -// runSkill but uses each host's PluginUninstallCommand and inverts the +// runSkillUninstall removes a skill from the resolved target agents and +// verifies, per agent, that the skill is no longer present. It mirrors +// runSkill but uses each agent's PluginUninstallCommand and inverts the // verification (success means the plugin is gone). func (i *installer) runSkillUninstall( ctx context.Context, tool *ToolDefinition, - hosts []string, + agents []string, + renderer StepRenderer, + stdin io.Reader, + quiet bool, ) (*InstallResult, error) { start := time.Now() result := &InstallResult{Tool: tool} - if len(tool.SkillHosts) == 0 { - result.Error = fmt.Errorf("%s has no SkillHosts configured", tool.Name) + if len(tool.SkillAgents) == 0 { + result.Error = fmt.Errorf("%s has no SkillAgents configured", tool.Name) result.Duration = time.Since(start) return result, nil } - targets, err := i.resolveSkillUninstallTargets(ctx, tool, hosts) + targets, err := i.resolveSkillUninstallTargets(ctx, tool, agents) if err != nil { result.Error = err result.Duration = time.Since(start) @@ -696,20 +964,23 @@ func (i *installer) runSkillUninstall( succeeded []string failures []error ) - for _, host := range targets { - fmt.Fprintf(os.Stderr, "\nUninstalling %s from %s\n", tool.Name, host.Host) - if hostErr := i.uninstallSkillForHost(ctx, tool, host); hostErr != nil { - failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) + for _, agent := range targets { + title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, agent.DisplayName) + agentErr := renderSkillStep(ctx, renderer, title, true, quiet, func(out io.Writer) (string, error) { + return "", i.uninstallSkillForAgent(ctx, tool, agent, out, stdin) + }) + if agentErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", agent.DisplayName, agentErr)) continue } - succeeded = append(succeeded, host.Host) + succeeded = append(succeeded, agent.Command) } result.Strategy = strings.Join(succeeded, ", ") result.Duration = time.Since(start) - // On failure, preserve the wrapped error for a single host so callers - // can match it with errors.Is/As; summarize when several hosts fail. + // On failure, preserve the wrapped error for a single agent so callers + // can match it with errors.Is/As; summarize when several agents fail. if len(failures) > 0 { if len(failures) == 1 { result.Error = failures[0] @@ -719,7 +990,7 @@ func (i *installer) runSkillUninstall( msgs[j] = f.Error() } result.Error = fmt.Errorf( - "%s could not be uninstalled for %d host(s): %s", + "%s could not be uninstalled for %d agent(s): %s", tool.Name, len(failures), strings.Join(msgs, "; "), ) } @@ -730,39 +1001,39 @@ func (i *installer) runSkillUninstall( return result, nil } -// resolveSkillUninstallTargets resolves the host(s) a skill should be -// removed from. With explicit host names, each must be a configured -// SkillHost that is a usable (functional) CLI on PATH. Without explicit -// hosts (the default, and also `--host all`), it targets every host the +// resolveSkillUninstallTargets resolves the agents a skill should be +// removed from. With explicit agent names, each must be a configured +// SkillAgent that is a usable (functional) CLI on PATH. Without explicit +// agents (the default, and also `--agent all`), it targets every agent the // skill is currently installed through; an error is returned when the -// skill is not installed on any host. +// skill is not installed on any agent. func (i *installer) resolveSkillUninstallTargets( ctx context.Context, tool *ToolDefinition, - hosts []string, -) ([]SkillHost, error) { - if len(hosts) > 0 { - return i.explicitSkillHostTargets(ctx, tool, hosts) + agents []string, +) ([]SkillAgent, error) { + if len(agents) > 0 { + return i.explicitSkillAgentTargets(ctx, tool, agents) } - // Default / --host all: remove from every host the skill is + // Default / --agent all: remove from every agent the skill is // currently installed through. - installed, err := i.detector.DetectSkillHosts(ctx, tool) + installed, err := i.detector.DetectSkillAgents(ctx, tool) if err != nil { return nil, err } - targets := configuredSkillHostsFor(tool, installed) + targets := configuredSkillAgentsFor(tool, installed) if len(targets) == 0 { // Nothing to do. No Links: the suggestion is self-contained. return nil, &errorhandler.ErrorWithSuggestion{ Err: fmt.Errorf( - "%s is not installed on any available host", tool.Name, + "%s is not installed on any available agent", tool.Name, ), Message: "Cannot uninstall " + tool.Name, Suggestion: fmt.Sprintf( - "%s is not installed through any agent host, so there is "+ + "%s is not installed through any agent, so there is "+ "nothing to uninstall.", tool.Name, ), } @@ -770,39 +1041,47 @@ func (i *installer) resolveSkillUninstallTargets( return targets, nil } -// uninstallSkillForHost removes the skill through a single host and -// verifies it is no longer present on that host. -func (i *installer) uninstallSkillForHost( +// uninstallSkillForAgent removes the skill through a single agent and +// verifies it is no longer present on that agent. out, when non-nil, receives +// the agent CLI's output line-by-line for display above the step spinner. +func (i *installer) uninstallSkillForAgent( ctx context.Context, tool *ToolDefinition, - host SkillHost, + agent SkillAgent, + out io.Writer, + stdin io.Reader, ) error { - if err := i.runSkillHostUninstallCommand(ctx, host); err != nil { + if err := i.runSkillAgentUninstallCommand(ctx, agent, out, stdin); err != nil { return err } - return i.verifySkillUninstalled(ctx, tool, host) + return i.verifySkillUninstalled(ctx, tool, agent) } -// runSkillHostUninstallCommand executes the host's plugin-uninstall -// command interactively (so any host prompts are answered by the user -// directly). A non-zero exit is returned as an error; the caller verifies -// via the detector and decides whether the error is fatal. -func (i *installer) runSkillHostUninstallCommand( +// runSkillAgentUninstallCommand executes the agent's plugin-uninstall command. +// When a step spinner is showing (out is non-nil) the agent CLI's output is +// routed through out so any prompt is printed above the spinner and +// answerable via the connected stdin; otherwise the command runs fully +// interactively (see skillCommandRunArgs). A non-zero exit is returned as an +// error; the caller verifies via the detector and decides whether the error +// is fatal. +func (i *installer) runSkillAgentUninstallCommand( ctx context.Context, - host SkillHost, + agent SkillAgent, + out io.Writer, + stdin io.Reader, ) error { - cmd := host.PluginUninstallCommand + cmd := agent.PluginUninstallCommand if len(cmd) == 0 { return fmt.Errorf( - "host %q has no uninstall command configured", host.Host, + "agent %q has no uninstall command configured", agent.DisplayName, ) } - runArgs := exec.NewRunArgs(host.Host, cmd...).WithInteractive(true) + runArgs := skillCommandRunArgs(exec.NewRunArgs(agent.Command, cmd...), out, stdin) if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { return fmt.Errorf( "running `%s %s`: %w", - host.Host, strings.Join(cmd, " "), err, + agent.Command, strings.Join(cmd, " "), err, ) } @@ -810,27 +1089,27 @@ func (i *installer) runSkillHostUninstallCommand( } // verifySkillUninstalled confirms the skill is no longer detectable -// through the specific host it was removed via. Like verifySkillInstalled -// it is host-scoped and retries with backoff because plugin-list output +// through the specific agent it was removed via. Like verifySkillInstalled +// it is agent-scoped and retries with backoff because plugin-list output // can lag the uninstall action. func (i *installer) verifySkillUninstalled( ctx context.Context, tool *ToolDefinition, - host SkillHost, + agent SkillAgent, ) error { const maxAttempts = 4 // 1 initial + 3 retries gone, err := i.retryDetect(ctx, maxAttempts, tool.Name, func() (bool, error) { - installed, detectErr := i.detector.DetectSkillHosts(ctx, tool) + installed, detectErr := i.detector.DetectSkillAgents(ctx, tool) if detectErr != nil { return false, detectErr } for _, h := range installed { - if h.Host == host.Host { - return false, nil // still installed on this host + if h.Agent == agent.Command { + return false, nil // still installed on this agent } } - return true, nil // no longer installed via this host + return true, nil // no longer installed via this agent }) if err != nil { return fmt.Errorf("verifying removal of %s: %w", tool.Name, err) @@ -838,7 +1117,7 @@ func (i *installer) verifySkillUninstalled( if !gone { return fmt.Errorf( "%s was uninstalled via %s but verification failed", - tool.Name, host.Host, + tool.Name, agent.DisplayName, ) } return nil @@ -856,13 +1135,43 @@ func (i *installer) run( opt(&cfg) } - // Skills follow a different flow: they install through the host - // agentic CLI native plugin command rather than the platform's + // Skills follow a different flow: they install through the agent CLI + // native plugin command rather than the platform's // package manager, so we short-circuit before platform detection. if tool.Category == ToolCategorySkill { - return i.runSkill(ctx, tool, upgrade, cfg.hosts, cfg.allAvailableHosts) + return i.runSkill(ctx, tool, upgrade, cfg.agents, cfg.allAvailableAgents, cfg.renderer, cfg.stdin, cfg.quiet) } + // Non-skill tools install as a single step through the platform + // package manager; render one spinner for the tool (no agent) when a + // renderer is supplied. + if cfg.renderer == nil { + return i.runToolInstall(ctx, tool, upgrade) + } + verb := "Installing" + if upgrade { + verb = "Upgrading" + } + title := fmt.Sprintf("%s %s", verb, tool.Name) + cfg.renderer.ShowSpinner(ctx, title, input.Step) + result, err := i.runToolInstall(ctx, tool, upgrade) + // On a successful upgrade, append the resulting version to the result + // line, mirroring skills — e.g. "Upgrading Azure CLI (v2.0.0)". + doneTitle := title + if upgrade && err == nil && result != nil && result.Success && result.InstalledVersion != "" { + doneTitle = fmt.Sprintf("%s (v%s)", title, result.InstalledVersion) + } + cfg.renderer.StopSpinner(ctx, doneTitle, input.GetStepResultFormat(stepError(result, err))) + return result, err +} + +// runToolInstall installs or upgrades a non-skill tool through the platform +// package manager (or a direct download) and verifies the result. +func (i *installer) runToolInstall( + ctx context.Context, + tool *ToolDefinition, + upgrade bool, +) (*InstallResult, error) { start := time.Now() result := &InstallResult{Tool: tool} @@ -1028,42 +1337,45 @@ func (i *installer) retryDetect( // --------------------------------------------------------------------------- // runSkill installs (or upgrades) a skill across one or more agentic CLI -// hosts. +// agents. // // Prerequisite rules: -// 1. HARD — at least one supported agentic CLI host (copilot or claude) +// 1. HARD — at least one supported agent CLI (copilot or claude) // must be on PATH. We do NOT install one ourselves; if none is // present resolveSkillTargets fails with an // [errorhandler.ErrorWithSuggestion] pointing at the install docs. // 2. SOFT — Node.js (`node`) on PATH. The Azure MCP server is started // via `npx`, so its absence breaks MCP-backed scenarios but does NOT // prevent installing the skill files. We warn and continue. -// 3. Git is NOT pre-checked. The host CLI fetches the skill repo itself +// 3. Git is NOT pre-checked. The agent CLI fetches the skill repo itself // and surfaces its own diagnostic when git is missing. // -// The hosts argument, when non-empty, restricts the operation to the -// named hosts. When allAvailable is true (and hosts is empty) the skill -// is installed through every configured host on PATH. Otherwise the -// single preferred host (first on PATH) is used (or, for an upgrade, -// every host the skill is already installed through). +// The agents argument, when non-empty, restricts the operation to the +// named agents. When allAvailable is true (and agents is empty) the skill +// is installed through every configured agent on PATH. Otherwise the +// single preferred agent (first on PATH) is used (or, for an upgrade, +// every agent the skill is already installed through). func (i *installer) runSkill( ctx context.Context, tool *ToolDefinition, upgrade bool, - hosts []string, + agents []string, allAvailable bool, + renderer StepRenderer, + stdin io.Reader, + quiet bool, ) (*InstallResult, error) { start := time.Now() result := &InstallResult{Tool: tool} - if len(tool.SkillHosts) == 0 { - result.Error = fmt.Errorf("%s has no SkillHosts configured", tool.Name) + if len(tool.SkillAgents) == 0 { + result.Error = fmt.Errorf("%s has no SkillAgents configured", tool.Name) result.Duration = time.Since(start) return result, nil } - // 1. HARD prerequisite: resolve the target host(s). - targets, err := i.resolveSkillTargets(ctx, tool, hosts, allAvailable, upgrade) + // 1. HARD prerequisite: resolve the target agent(s). + targets, err := i.resolveSkillTargets(ctx, tool, agents, allAvailable, upgrade) if err != nil { result.Error = err result.Duration = time.Since(start) @@ -1084,38 +1396,71 @@ func (i *installer) runSkill( )+output.WithLinkFormat("https://nodejs.org/")) } - // 3. Install / upgrade for each target host, collecting outcomes. - // Print a header before each host so the host CLI's streamed - // output (it runs interactively) is clearly attributed to the - // host it belongs to. + // 3. Install / upgrade for each target agent, collecting outcomes. + // renderSkillStep shows a step spinner per agent. For an install the + // agent CLI's output streams above the spinner (so prompts are + // answerable); for an upgrade the upgrade command's output is captured + // and parsed for the version and whether the skill was already at the + // latest, which the result line reports. verb := "Installing" if upgrade { verb = "Upgrading" } var ( - succeeded []string - failures []error - version string + succeeded []string + failures []error + version string + anyUpgraded bool // an upgrade actually changed at least one agent ) - for _, host := range targets { - fmt.Fprintf(os.Stderr, "\n%s %s in %s\n", verb, tool.Name, host.Host) - hostVersion, hostErr := i.installSkillForHost(ctx, tool, host, upgrade) - if hostErr != nil { - failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) + for _, agent := range targets { + title := fmt.Sprintf("%s %s in %s", verb, tool.Name, agent.DisplayName) + var ( + agentVersion string + agentUpToDate bool + ) + agentErr := renderSkillStep(ctx, renderer, title, true, quiet, func(out io.Writer) (string, error) { + v, upToDate, e := i.installSkillForAgent(ctx, tool, agent, upgrade, out, stdin) + agentVersion = v + agentUpToDate = upToDate + if e != nil { + return "", e + } + // Result line: for an upgrade, report the version and whether the + // skill was already current; otherwise reuse the step title. + done := title + switch { + case upgrade && upToDate && v != "": + done = fmt.Sprintf("%s in %s is already up to date (v%s).", tool.Name, agent.DisplayName, v) + case upgrade && upToDate: + done = fmt.Sprintf("%s in %s is already up to date.", tool.Name, agent.DisplayName) + case upgrade && v != "": + done = fmt.Sprintf("%s (v%s)", title, v) + } + return done, nil + }) + if agentErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", agent.DisplayName, agentErr)) continue } - succeeded = append(succeeded, host.Host) + succeeded = append(succeeded, agent.Command) + if !agentUpToDate { + anyUpgraded = true + } if version == "" { - version = hostVersion + version = agentVersion } } + // An upgrade that changed nothing (every targeted agent was already at the + // latest version) is reported as "already up to date" by the caller. + result.AlreadyUpToDate = upgrade && len(succeeded) > 0 && !anyUpgraded + result.Strategy = strings.Join(succeeded, ", ") result.InstalledVersion = version result.Duration = time.Since(start) - // On failure, preserve the wrapped error for a single host so callers - // can match it with errors.Is/As; summarize when several hosts fail. + // On failure, preserve the wrapped error for a single agent so callers + // can match it with errors.Is/As; summarize when several agents fail. if len(failures) > 0 { if len(failures) == 1 { result.Error = failures[0] @@ -1125,7 +1470,7 @@ func (i *installer) runSkill( msgs[j] = f.Error() } result.Error = fmt.Errorf( - "%s could not be installed for %d host(s): %s", + "%s could not be installed for %d agent(s): %s", tool.Name, len(failures), strings.Join(msgs, "; "), ) } @@ -1136,78 +1481,78 @@ func (i *installer) runSkill( return result, nil } -// resolveSkillTargets resolves the host(s) a skill should be installed -// to. With an explicit selection (hosts) every named host must be a -// configured SkillHost that is on PATH; otherwise an error naming the -// available hosts is returned. With allAvailable it acts on every -// configured host on PATH: for install, all of them; for upgrade, only +// resolveSkillTargets resolves the agents a skill should be installed +// to. With an explicit selection (agents) every named agent must be a +// configured SkillAgent that is on PATH; otherwise an error naming the +// available agents is returned. With allAvailable it acts on every +// configured agent on PATH: for install, all of them; for upgrade, only // the ones that already have the skill installed (the rest are skipped // with a warning, and an error is returned when none have it). With -// neither it returns a single host for install (the preferred host on -// PATH) or, for an upgrade, every host the skill is already installed +// neither it returns a single agent for install (the preferred agent on +// PATH) or, for an upgrade, every agent the skill is already installed // through. func (i *installer) resolveSkillTargets( ctx context.Context, tool *ToolDefinition, - hosts []string, + agents []string, allAvailable bool, upgrade bool, -) ([]SkillHost, error) { - if len(hosts) == 0 { - // Batch / --host all: act on every configured host on PATH, - // resolved here (at run time) so host CLIs installed earlier in +) ([]SkillAgent, error) { + if len(agents) == 0 { + // Batch / --agent all: act on every configured agent on PATH, + // resolved here (at run time) so agent CLIs installed earlier in // the same batch are picked up. if allAvailable { - var onPath []SkillHost - for _, host := range tool.SkillHosts { - if i.hostUsable(ctx, host) { - onPath = append(onPath, host) + var onPath []SkillAgent + for _, agent := range tool.SkillAgents { + if i.agentUsable(ctx, agent) { + onPath = append(onPath, agent) } } - // No usable host CLI present at all — surface the install + // No usable agent CLI present at all — surface the install // guidance. if len(onPath) == 0 { - host, err := i.pickSkillHost(ctx, tool) + agent, err := i.pickSkillAgent(ctx, tool) if err != nil { return nil, err } - return []SkillHost{host}, nil + return []SkillAgent{agent}, nil } - // For install, target every host on PATH. + // For install, target every agent on PATH. if !upgrade { return onPath, nil } - // For upgrade, target only hosts that actually have the skill - // installed; warn-and-skip the rest, since a host CLI cannot + // For upgrade, target only agents that actually have the skill + // installed; warn-and-skip the rest, since an agent CLI cannot // upgrade a plugin it never installed. - installed, err := i.detector.DetectSkillHosts(ctx, tool) + installed, err := i.detector.DetectSkillAgents(ctx, tool) if err != nil { return nil, err } installedSet := map[string]bool{} for _, h := range installed { - installedSet[h.Host] = true + installedSet[h.Agent] = true } - var targets []SkillHost - for _, host := range onPath { - if installedSet[host.Host] { - targets = append(targets, host) + var targets []SkillAgent + for _, agent := range onPath { + if installedSet[agent.Command] { + targets = append(targets, agent) continue } fmt.Fprintln(os.Stderr, output.WithWarningFormat( "Skipping upgrade for %s: %s is not installed on it.", - host.Host, tool.Name, + agent.DisplayName, tool.Name, )) } if len(targets) == 0 { onPathNames := make([]string, len(onPath)) for j, h := range onPath { - onPathNames[j] = h.Host + onPathNames[j] = h.DisplayName } return nil, fmt.Errorf( - "%s is not installed on any available host (%s); "+ + "%s is not installed on any available agent (%s); "+ "nothing to upgrade", tool.Name, strings.Join(onPathNames, ", "), ) @@ -1215,32 +1560,32 @@ func (i *installer) resolveSkillTargets( return targets, nil } - // For an upgrade with no explicit host, refresh every host the + // For an upgrade with no explicit agent, refresh every agent the // skill is currently installed through — not just the first — - // so a multi-host install (e.g. copilot AND claude) is kept - // fully up to date. We also avoid running an update against a - // host that never installed it. + // so a multi-agent install (e.g. copilot AND claude) is kept + // fully up to date. We also avoid running an upgrade against a + // agent that never installed it. if upgrade { - installed, err := i.detector.DetectSkillHosts(ctx, tool) + installed, err := i.detector.DetectSkillAgents(ctx, tool) if err != nil { return nil, err } if len(installed) > 0 { - if targets := configuredSkillHostsFor(tool, installed); len(targets) > 0 { + if targets := configuredSkillAgentsFor(tool, installed); len(targets) > 0 { return targets, nil } } - // The skill is not installed on any available host. Don't - // fall through to pickSkillHost — updating a plugin that was + // The skill is not installed on any available agent. Don't + // fall through to pickSkillAgent — updating a plugin that was // never installed only produces a confusing "verification // failed" error. Point the user at install instead. No Links: // the suggestion is a self-contained azd command, so there is // nothing to link (cf. managerUnavailableError). return nil, &errorhandler.ErrorWithSuggestion{ Err: fmt.Errorf( - "%s is not installed on any available host", + "%s is not installed on any available agent", tool.Name, ), Message: "Cannot upgrade " + tool.Name, @@ -1251,76 +1596,79 @@ func (i *installer) resolveSkillTargets( ), } } - host, err := i.pickSkillHost(ctx, tool) + agent, err := i.pickSkillAgent(ctx, tool) if err != nil { return nil, err } - return []SkillHost{host}, nil + return []SkillAgent{agent}, nil } - return i.explicitSkillHostTargets(ctx, tool, hosts) + return i.explicitSkillAgentTargets(ctx, tool, agents) } -// explicitSkillHostTargets resolves an explicit list of requested host -// names to their [SkillHost] definitions. A requested host is usable only -// when it is a configured SkillHost that is also a functional CLI on PATH -// (see [installer.hostUsable]); an unknown name, a host not on PATH, and a -// host present only as a launcher stub all fail with an error naming the -// supported hosts. Shared by the install/upgrade (resolveSkillTargets) and -// uninstall (resolveSkillUninstallTargets) paths so the host-availability +// explicitSkillAgentTargets resolves an explicit list of requested agent +// names to their [SkillAgent] definitions. A requested agent is usable only +// when it is a configured SkillAgent that is also a functional CLI on PATH +// (see [installer.agentUsable]); an unknown name, an agent not on PATH, and a +// agent present only as a launcher stub all fail with an error naming the +// supported agents. Shared by the install/upgrade (resolveSkillTargets) and +// uninstall (resolveSkillUninstallTargets) paths so the agent-availability // rule lives in one place. -func (i *installer) explicitSkillHostTargets( +func (i *installer) explicitSkillAgentTargets( ctx context.Context, tool *ToolDefinition, - hosts []string, -) ([]SkillHost, error) { - targets := make([]SkillHost, 0, len(hosts)) - for _, name := range hosts { - host, ok := findSkillHost(tool, name) - if !ok || !i.hostUsable(ctx, host) { - supported := make([]string, len(tool.SkillHosts)) - for j, h := range tool.SkillHosts { - supported[j] = h.Host + agents []string, +) ([]SkillAgent, error) { + targets := make([]SkillAgent, 0, len(agents)) + for _, name := range agents { + agent, ok := findSkillAgent(tool, name) + if !ok || !i.agentUsable(ctx, agent) { + supported := make([]string, len(tool.SkillAgents)) + for j, h := range tool.SkillAgents { + supported[j] = h.Command } return nil, fmt.Errorf( - "host %q is not available for %s; supported hosts: %s", + "agent %q is not available for %s; supported agents: %s", name, tool.Name, strings.Join(supported, ", "), ) } - targets = append(targets, host) + targets = append(targets, agent) } return targets, nil } -// findSkillHost returns the configured SkillHost with the given host name and -// whether one was found. It centralizes the SkillHosts lookup shared by the -// skill install/upgrade and uninstall paths. -func findSkillHost(tool *ToolDefinition, name string) (SkillHost, bool) { - idx := slices.IndexFunc(tool.SkillHosts, func(h SkillHost) bool { - return h.Host == name +// findSkillAgent returns the configured SkillAgent whose command identity +// matches name (case-insensitively) and whether one was found. Matching is by +// Command only (e.g. "copilot"), never the display Agent: --agent values are +// command names, and the interactive picker maps its display selection back +// to the command before resolving here. It centralizes the SkillAgents lookup +// shared by the skill install/upgrade and uninstall paths. +func findSkillAgent(tool *ToolDefinition, name string) (SkillAgent, bool) { + idx := slices.IndexFunc(tool.SkillAgents, func(h SkillAgent) bool { + return strings.EqualFold(h.Command, name) }) if idx < 0 { - return SkillHost{}, false + return SkillAgent{}, false } - return tool.SkillHosts[idx], true + return tool.SkillAgents[idx], true } -// configuredSkillHostsFor maps a set of installed hosts back to their -// configured SkillHost definitions, in installed order, skipping any host that -// is no longer a configured SkillHost. Shared by the upgrade +// configuredSkillAgentsFor maps a set of installed agents back to their +// configured SkillAgent definitions, in installed order, skipping any agent that +// is no longer a configured SkillAgent. Shared by the upgrade // (resolveSkillTargets) and uninstall (resolveSkillUninstallTargets) paths, -// which both act on "the hosts the skill is currently installed through". -func configuredSkillHostsFor(tool *ToolDefinition, installed []InstalledSkillHost) []SkillHost { - targets := make([]SkillHost, 0, len(installed)) +// which both act on "the agents the skill is currently installed through". +func configuredSkillAgentsFor(tool *ToolDefinition, installed []InstalledSkillAgent) []SkillAgent { + targets := make([]SkillAgent, 0, len(installed)) for _, inst := range installed { - if host, ok := findSkillHost(tool, inst.Host); ok { - targets = append(targets, host) + if agent, ok := findSkillAgent(tool, inst.Agent); ok { + targets = append(targets, agent) } } return targets } -// hostUsable reports whether an agentic CLI host on PATH is a functional +// agentUsable reports whether an agent CLI on PATH is a functional // CLI rather than a same-named launcher stub. // // Some environments put a stub on PATH — notably the VS Code Copilot Chat @@ -1328,76 +1676,76 @@ func configuredSkillHostsFor(tool *ToolDefinition, installed []InstalledSkillHos // and exits 0. It passes a bare existence check but cannot install the skill, // which used to surface as a misleading "verification failed". // -// To tell them apart, hostUsable runs the host's version command (with empty -// stdin so a prompting stub reads EOF and exits) and accepts the host only -// when the output matches its BinaryVersionRegex, anchored to the host's -// `--version` banner. Hosts without a version probe fall back to the +// To tell them apart, agentUsable runs the agent's version command (with empty +// stdin so a prompting stub reads EOF and exits) and accepts the agent only +// when the output matches its BinaryVersionRegex, anchored to the agent's +// `--version` banner. Agents without a version probe fall back to the // existence check. // -// Results are memoized per host (hostProbe); only on-PATH hosts are cached, -// so a host installed earlier in the same batch is still picked up. The cache -// assumes an on-PATH host binary is not swapped mid-command, which azd never +// Results are memoized per agent (agentProbe); only on-PATH agents are cached, +// so an agent installed earlier in the same batch is still picked up. The cache +// assumes an on-PATH agent binary is not swapped mid-command, which azd never // does. -func (i *installer) hostUsable(ctx context.Context, host SkillHost) bool { - if i.commandRunner.ToolInPath(host.Host) != nil { +func (i *installer) agentUsable(ctx context.Context, agent SkillAgent) bool { + if i.commandRunner.ToolInPath(agent.Command) != nil { return false } - i.hostProbeMu.Lock() - if i.hostProbe == nil { - i.hostProbe = map[string]bool{} + i.agentProbeMu.Lock() + if i.agentProbe == nil { + i.agentProbe = map[string]bool{} } - usable, ok := i.hostProbe[host.Host] - i.hostProbeMu.Unlock() + usable, ok := i.agentProbe[agent.Command] + i.agentProbeMu.Unlock() if ok { return usable } - usable = i.probeOnPathHost(ctx, host) + usable = i.probeOnPathAgent(ctx, agent) - i.hostProbeMu.Lock() - i.hostProbe[host.Host] = usable - i.hostProbeMu.Unlock() + i.agentProbeMu.Lock() + i.agentProbe[agent.Command] = usable + i.agentProbeMu.Unlock() return usable } -// probeOnPathHost runs the version probe for a host already confirmed to be on +// probeOnPathAgent runs the version probe for an agent already confirmed to be on // PATH and reports whether it is a functional CLI. It is the uncached half of -// hostUsable; see that method for the rationale behind the matching. -func (i *installer) probeOnPathHost(ctx context.Context, host SkillHost) bool { - if len(host.BinaryVersionArgs) == 0 || host.BinaryVersionRegex == "" { +// agentUsable; see that method for the rationale behind the matching. +func (i *installer) probeOnPathAgent(ctx context.Context, agent SkillAgent) bool { + if len(agent.BinaryVersionArgs) == 0 || agent.BinaryVersionRegex == "" { return true } result, err := i.commandRunner.Run( ctx, - exec.NewRunArgs(host.Host, host.BinaryVersionArgs...). + exec.NewRunArgs(agent.Command, agent.BinaryVersionArgs...). WithStdIn(strings.NewReader("")), ) - // A cancelled/timed-out probe is not evidence the host is a stub; do + // A cancelled/timed-out probe is not evidence the agent is a stub; do // not penalize it here (context handling is the caller's concern). if isContextErr(err) { return true } - return matchVersion(result.Stdout+"\n"+result.Stderr, host.BinaryVersionRegex) != "" + return matchVersion(result.Stdout+"\n"+result.Stderr, agent.BinaryVersionRegex) != "" } -// pickSkillHost returns the first SkillHost that is a usable (functional) -// CLI — see [installer.hostUsable], which rejects launcher stubs that merely -// share the host's name on PATH. When none of the configured hosts is usable +// pickSkillAgent returns the first SkillAgent that is a usable (functional) +// CLI — see [installer.agentUsable], which rejects launcher stubs that merely +// share the agent's name on PATH. When none of the configured agents is usable // it returns an [errorhandler.ErrorWithSuggestion] (all four fields populated // per the AGENTS.md completeness rule) that recommends installing GitHub // Copilot CLI via `azd tool install github-copilot-cli` — a single command // the user can copy-paste without leaving azd. -func (i *installer) pickSkillHost( +func (i *installer) pickSkillAgent( ctx context.Context, tool *ToolDefinition, -) (SkillHost, error) { +) (SkillAgent, error) { var checked []string - for _, host := range tool.SkillHosts { - if i.hostUsable(ctx, host) { - return host, nil + for _, agent := range tool.SkillAgents { + if i.agentUsable(ctx, agent) { + return agent, nil } - checked = append(checked, host.Host) + checked = append(checked, agent.Command) } suggestion := fmt.Sprintf( @@ -1409,9 +1757,9 @@ func (i *installer) pickSkillHost( tool.Name, tool.Id, strings.Join(checked, ", "), ) - return SkillHost{}, &errorhandler.ErrorWithSuggestion{ + return SkillAgent{}, &errorhandler.ErrorWithSuggestion{ Err: fmt.Errorf( - "no supported agentic CLI host found on PATH for %s", + "no supported agent CLI found on PATH for %s", tool.Name, ), Message: "Cannot install " + tool.Name, @@ -1425,49 +1773,116 @@ func (i *installer) pickSkillHost( } } -// installSkillForHost installs (or upgrades) the skill through a single -// host and verifies the result, returning the detected version. -func (i *installer) installSkillForHost( +// installSkillForAgent installs (or upgrades) the skill through a single agent +// and verifies the result. It returns the version and, for an upgrade, +// whether the agent reported the skill was already at the latest version. For +// an upgrade the version comes from the upgrade command's output (falling back +// to the detected version); for an install it comes from post-install +// detection. out, when non-nil, receives an install's streamed agent output +// for display above the step spinner. +func (i *installer) installSkillForAgent( ctx context.Context, tool *ToolDefinition, - host SkillHost, + agent SkillAgent, upgrade bool, -) (string, error) { - if err := i.runSkillHostCommand(ctx, host, upgrade); err != nil { - return "", err + out io.Writer, + stdin io.Reader, +) (version string, alreadyLatest bool, err error) { + // For an upgrade, record the agent's installed version before updating so + // "already up to date" is decided by comparing the actual version before + // and after — not by parsing the agent CLI's prose, which varies by agent + // and misfires when the wording is not recognized. + var beforeVersion string + if upgrade { + if installed, detectErr := i.detector.DetectSkillAgents(ctx, tool); detectErr == nil { + beforeVersion, _ = installedAgentVersion(installed, agent.Command) + } + } + + cmdOutput, err := i.runSkillAgentCommand(ctx, agent, upgrade, out, stdin) + if err != nil { + return "", false, err + } + var ( + reportedVersion string + proseLatest bool + ) + if upgrade { + reportedVersion, proseLatest = parseUpgradeOutput(cmdOutput) + } + + detectedVersion, err := i.verifySkillInstalled(ctx, tool, agent) + if err != nil { + return "", false, err + } + // Prefer the version the upgrade command reported; fall back to the + // version detected via the plugin list. + version = reportedVersion + if version == "" { + version = detectedVersion } - return i.verifySkillInstalled(ctx, tool, host) + if upgrade { + // "Already up to date" means the version did not change. Prefer the + // version the upgrade command reported: it is authoritative and immune + // to plugin-list detection lag (the listing can briefly still report the + // pre-upgrade version, which would otherwise equal beforeVersion and be + // misread as "already up to date"). Fall back to the detected version + // only when the command reported none, and to the agent CLI's prose only + // when no version is available on either side. + switch { + case beforeVersion != "" && reportedVersion != "": + alreadyLatest = beforeVersion == reportedVersion + case beforeVersion != "" && detectedVersion != "": + alreadyLatest = beforeVersion == detectedVersion + default: + alreadyLatest = proseLatest + } + } + + return version, alreadyLatest, nil +} + +// installedAgentVersion returns the installed version of the skill for the given +// agent command from a DetectSkillAgents result, and whether that agent was +// found. InstalledSkillAgent.Agent carries the executable identity, so the match +// is against agent.Command. (Distinct from detector.skillAgentVersion, which +// probes the agent CLI; this only looks up an already-detected list.) +func installedAgentVersion(installed []InstalledSkillAgent, command string) (string, bool) { + for _, h := range installed { + if h.Agent == command { + return h.Version, true + } + } + return "", false } // verifySkillInstalled confirms the skill is detectable **through the -// specific host** it was just installed via, and returns that host's -// version. This is host-scoped on purpose: verifying via the generic -// DetectTool would report success whenever ANY host has the skill, so a -// silent no-op install on a secondary host (e.g. `--host claude` while +// specific agent** it was just installed via, and returns that agent's +// version. This is agent-scoped on purpose: verifying via the generic +// DetectTool would report success whenever ANY agent has the skill, so a +// silent no-op install on a secondary agent (e.g. `--agent claude` while // copilot already has it) would be falsely reported as success with the -// wrong host's version. Plugin-list output sometimes lags the install +// wrong agent's version. Plugin-list output sometimes lags the install // action (the pre-existing copilot CLI integration documents the same // race — see internal/agent/copilot/cli.go), so it retries a few times // with exponential backoff. func (i *installer) verifySkillInstalled( ctx context.Context, tool *ToolDefinition, - host SkillHost, + agent SkillAgent, ) (string, error) { const maxAttempts = 4 // 1 initial + 3 retries var version string found, err := i.retryDetect(ctx, maxAttempts, tool.Name, func() (bool, error) { - installed, detectErr := i.detector.DetectSkillHosts(ctx, tool) + installed, detectErr := i.detector.DetectSkillAgents(ctx, tool) if detectErr != nil { return false, detectErr } - for _, h := range installed { - if h.Host == host.Host { - version = h.Version - return true, nil - } + if v, ok := installedAgentVersion(installed, agent.Command); ok { + version = v + return true, nil } return false, nil }) @@ -1479,69 +1894,100 @@ func (i *installer) verifySkillInstalled( if !found { return "", fmt.Errorf( "%s was installed via %s but verification failed", - tool.Name, host.Host, + tool.Name, agent.DisplayName, ) } return version, nil } -// runSkillHostCommand executes the host's install (or update) command -// with stdin/stdout/stderr connected to the user (WithInteractive=true) -// so any prompts the host CLI surfaces are answered by the user -// directly. azd never pipes canned answers on the user's behalf. +// runSkillAgentCommand executes the agent's install or upgrade command and +// returns the command's stdout (empty for a streamed install). // -// For fresh installs it first runs MarketplaceAddCommand when the host -// declares one. Hosts that declare no MarketplaceAddCommand skip this -// step entirely. +// Install and upgrade connect to the terminal differently: +// - Install streams the agent CLI's output through out (when a spinner is +// showing) or runs fully interactively (out nil), with stdin connected so +// the user answers any prompt (marketplace trust, install confirmation). +// azd never pipes canned answers. Nothing is captured, so "" is returned. +// For a fresh install it first runs MarketplaceAddCommand when the agent +// declares one. +// - Upgrade captures the output (no streaming) and returns it so the caller +// can parse the version and whether the skill was already at the latest. +// The plugin is already installed (marketplace trusted), so the upgrade is +// non-interactive. // // A non-zero exit is returned to the caller as an error; the caller is // expected to verify via the detector and decide whether to treat the -// error as fatal (some hosts return non-zero on idempotent re-install). -func (i *installer) runSkillHostCommand( +// error as fatal (some agents return non-zero on idempotent re-install). +func (i *installer) runSkillAgentCommand( ctx context.Context, - host SkillHost, + agent SkillAgent, upgrade bool, -) error { - cmd := host.PluginInstallCommand + out io.Writer, + stdin io.Reader, +) (string, error) { + cmd := agent.PluginInstallCommand verb := "install" if upgrade { - cmd = host.PluginUpdateCommand - verb = "update" + cmd = agent.PluginUpdateCommand + verb = "upgrade" } if len(cmd) == 0 { - return fmt.Errorf( - "host %q has no %s command configured", host.Host, verb, + return "", fmt.Errorf( + "agent %q has no %s command configured", agent.DisplayName, verb, ) } - if !upgrade && len(host.MarketplaceAddCommand) > 0 { - if err := i.runMarketplaceAdd(ctx, host); err != nil { - return err + // Upgrade: capture the output so the caller can parse the version and the + // "already at latest" state; do not stream it above the spinner. + if upgrade { + res, err := i.commandRunner.Run(ctx, exec.NewRunArgs(agent.Command, cmd...)) + if err != nil { + return "", fmt.Errorf( + "running `%s %s`: %w", + agent.Command, strings.Join(cmd, " "), err, + ) + } + return res.Stdout, nil + } + + if len(agent.MarketplaceAddCommand) > 0 { + if err := i.runMarketplaceAdd(ctx, agent, out, stdin); err != nil { + return "", err } } - runArgs := exec.NewRunArgs(host.Host, cmd...).WithInteractive(true) + runArgs := skillCommandRunArgs(exec.NewRunArgs(agent.Command, cmd...), out, stdin) if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { - return fmt.Errorf( + return "", fmt.Errorf( "running `%s %s`: %w", - host.Host, strings.Join(cmd, " "), err, + agent.Command, strings.Join(cmd, " "), err, ) } - return nil + return "", nil } -// runMarketplaceAdd registers the skill marketplace with the host CLI. -// Some hosts (e.g. copilot) return a non-zero exit when the marketplace +// runMarketplaceAdd registers the skill marketplace with the agent CLI. +// out and stdin thread the step spinner's writer and the console's input +// through skillCommandRunArgs so the agent CLI's output prints above the +// spinner and any marketplace trust prompt stays visible and answerable +// while the spinner runs (matching the install phase). When out routes the +// output through a writer, CommandRunner still captures it (io.MultiWriter), +// so the captured stdout/stderr remains available for the already-added +// check below. +// +// Some agents (e.g. copilot) return a non-zero exit when the marketplace // is already registered; we recognize that case from the captured -// output and treat it as success so the install can proceed. Hosts that +// output and treat it as success so the install can proceed. Agents that // already exit 0 in the "already added" case (e.g. claude) flow // through naturally. Any other failure is returned to the caller. func (i *installer) runMarketplaceAdd( ctx context.Context, - host SkillHost, + agent SkillAgent, + out io.Writer, + stdin io.Reader, ) error { - args := exec.NewRunArgs(host.Host, host.MarketplaceAddCommand...) + args := skillCommandRunArgs(exec.NewRunArgs(agent.Command, agent.MarketplaceAddCommand...), out, stdin) result, err := i.commandRunner.Run(ctx, args) if err == nil { return nil @@ -1551,12 +1997,12 @@ func (i *installer) runMarketplaceAdd( } return fmt.Errorf( "running `%s %s`: %w", - host.Host, strings.Join(host.MarketplaceAddCommand, " "), err, + agent.Command, strings.Join(agent.MarketplaceAddCommand, " "), err, ) } -// isMarketplaceAlreadyAdded reports whether the host CLI output indicates -// the marketplace is already registered. Observed wording per host: +// isMarketplaceAlreadyAdded reports whether the agent CLI output indicates +// the marketplace is already registered. Observed wording per agent: // - copilot: "Failed to add marketplace: ... already registered" // - claude: "Marketplace ... already on disk" func isMarketplaceAlreadyAdded(output string) bool { diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index a1793e8f926..f0a9068ec03 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -9,6 +9,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "io" "log" "net/http" "net/http/httptest" @@ -17,17 +18,39 @@ import ( "runtime" "slices" "strings" + "sync" "sync/atomic" "testing" "time" "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// fakeStepRenderer records the step spinner calls the installer makes, so +// tests can assert the per-step titles and any skill-count sub-line. +type fakeStepRenderer struct { + starts []string + stops []string + messages []string +} + +func (f *fakeStepRenderer) ShowSpinner(_ context.Context, title string, _ input.SpinnerUxType) { + f.starts = append(f.starts, title) +} + +func (f *fakeStepRenderer) StopSpinner(_ context.Context, lastMessage string, _ input.SpinnerUxType) { + f.stops = append(f.stops, lastMessage) +} + +func (f *fakeStepRenderer) Message(_ context.Context, message string) { + f.messages = append(f.messages, message) +} + // --------------------------------------------------------------------------- // mockDetector — simple in-package mock for the Detector interface // --------------------------------------------------------------------------- @@ -41,10 +64,10 @@ type mockDetector struct { ctx context.Context, tools []*ToolDefinition, ) ([]*ToolStatus, error) - detectSkillHostsFn func( + detectSkillAgentsFn func( ctx context.Context, tool *ToolDefinition, - ) ([]InstalledSkillHost, error) + ) ([]InstalledSkillAgent, error) } func (m *mockDetector) DetectTool( @@ -71,32 +94,32 @@ func (m *mockDetector) DetectAll( return results, nil } -func (m *mockDetector) DetectSkillHosts( +func (m *mockDetector) DetectSkillAgents( ctx context.Context, tool *ToolDefinition, -) ([]InstalledSkillHost, error) { - if m.detectSkillHostsFn != nil { - return m.detectSkillHostsFn(ctx, tool) - } - // Default (host-agnostic): when detectToolFn reports the skill - // installed, treat every configured host as having that version. This - // keeps host-selection tests passing without per-host wiring; - // host-scoped verification is covered by tests that set - // detectSkillHostsFn explicitly. +) ([]InstalledSkillAgent, error) { + if m.detectSkillAgentsFn != nil { + return m.detectSkillAgentsFn(ctx, tool) + } + // Default (agent-agnostic): when detectToolFn reports the skill + // installed, treat every configured agent as having that version. This + // keeps agent-selection tests passing without per-agent wiring; + // agent-scoped verification is covered by tests that set + // detectSkillAgentsFn explicitly. if m.detectToolFn != nil && tool != nil { status, err := m.detectToolFn(ctx, tool) if err != nil { return nil, err } if status != nil && status.Installed { - hosts := make([]InstalledSkillHost, 0, len(tool.SkillHosts)) - for _, h := range tool.SkillHosts { - hosts = append(hosts, InstalledSkillHost{ - Host: h.Host, + agents := make([]InstalledSkillAgent, 0, len(tool.SkillAgents)) + for _, h := range tool.SkillAgents { + agents = append(agents, InstalledSkillAgent{ + Agent: h.Command, Version: status.InstalledVersion, }) } - return hosts, nil + return agents, nil } } return nil, nil @@ -174,6 +197,104 @@ func TestInstall_WithPackageManager(t *testing.T) { assert.Contains(t, capturedArgs, "@test/tool") } +// TestRunToolInstall_StepProgress verifies that a non-skill tool install +// renders a single step spinner for the tool (no per-agent title) and no +// skill-count sub-line. +func TestRunToolInstall_StepProgress(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + for _, managers := range platformManagers { + for _, mgr := range managers { + runner.MockToolInPath(mgr, errors.New("not found")) + } + } + runner.MockToolInPath("npm", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "npm" && slices.Contains(args.Args, "--version") + }).Respond(exec.RunResult{ExitCode: 0, Stdout: "10.2.0"}) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "npm" && slices.Contains(args.Args, "install") + }).Respond(exec.RunResult{ExitCode: 0}) + + det := &mockDetector{ + detectToolFn: func( + _ context.Context, tool *ToolDefinition, + ) (*ToolStatus, error) { + return &ToolStatus{Tool: tool, Installed: true, InstalledVersion: "2.64.0"}, nil + }, + } + inst := NewInstaller(runner, NewPlatformDetector(runner), det) + + nonSkill := &ToolDefinition{ + Id: "test-tool", + Name: "Test Tool", + Category: ToolCategoryCLI, + InstallStrategies: allPlatforms(InstallStrategy{ + PackageManager: "npm", + PackageId: "@test/tool", + }), + } + + r := &fakeStepRenderer{} + result, err := inst.Install(t.Context(), nonSkill, WithStepProgress(r)) + require.NoError(t, err) + require.True(t, result.Success, "install must succeed; err=%v", result.Error) + + assert.Equal(t, []string{"Installing Test Tool"}, r.starts) + assert.Equal(t, []string{"Installing Test Tool"}, r.stops) + assert.Empty(t, r.messages, "non-skill install reports no skill count") +} + +// TestRunToolUpgrade_StepProgress_ShowsVersion verifies that a non-skill +// upgrade appends the resulting version to the step result line — the same +// treatment skills get — e.g. "Upgrading Test Tool (v2.64.0)". +func TestRunToolUpgrade_StepProgress_ShowsVersion(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + for _, managers := range platformManagers { + for _, mgr := range managers { + runner.MockToolInPath(mgr, errors.New("not found")) + } + } + runner.MockToolInPath("npm", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "npm" && slices.Contains(args.Args, "--version") + }).Respond(exec.RunResult{ExitCode: 0, Stdout: "10.2.0"}) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "npm" && slices.Contains(args.Args, "update") + }).Respond(exec.RunResult{ExitCode: 0}) + + det := &mockDetector{ + detectToolFn: func( + _ context.Context, tool *ToolDefinition, + ) (*ToolStatus, error) { + return &ToolStatus{Tool: tool, Installed: true, InstalledVersion: "2.64.0"}, nil + }, + } + inst := NewInstaller(runner, NewPlatformDetector(runner), det) + + nonSkill := &ToolDefinition{ + Id: "test-tool", + Name: "Test Tool", + Category: ToolCategoryCLI, + InstallStrategies: allPlatforms(InstallStrategy{ + PackageManager: "npm", + PackageId: "@test/tool", + }), + } + + r := &fakeStepRenderer{} + result, err := inst.Upgrade(t.Context(), nonSkill, WithStepProgress(r)) + require.NoError(t, err) + require.True(t, result.Success, "upgrade must succeed; err=%v", result.Error) + + assert.Equal(t, []string{"Upgrading Test Tool"}, r.starts) + assert.Equal(t, []string{"Upgrading Test Tool (v2.64.0)"}, r.stops, + "a non-skill upgrade must report the resulting version, like skills") +} + func TestInstall_WithInstallCommand(t *testing.T) { t.Parallel() @@ -1146,13 +1267,62 @@ func TestAggregateInstallResults_EmptyInputs(t *testing.T) { // Helpers // --------------------------------------------------------------------------- -// allSkillHostNames lists the host binary names checked by the prereq +// allSkillAgentNames lists the agent binary names checked by the prereq // logic; tests must explicitly mock every entry so ToolInPath does not // fall back to the real PATH on the developer's machine. -var allSkillHostNames = []string{"copilot", "claude"} +var allSkillAgentNames = []string{"copilot", "claude"} + +// TestRunSkill_Install_UsesConfiguredStdin verifies that when a step spinner is +// showing, the skill agent command reads stdin from the reader supplied via +// WithInput (the console's input) rather than the process-global os.Stdin — so +// an agent prompt is answered on the stream azd owns (e.g. Cobra's redirected +// input), not the wrong one. +func TestRunSkill_Install_UsesConfiguredStdin(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockAgentPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + + myInput := strings.NewReader("y\n") + var pluginStdin io.Reader + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" && slices.Contains(args.Args, "marketplace") + }).Respond(exec.RunResult{ExitCode: 0}) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" && slices.Contains(args.Args, "install") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + pluginStdin = args.StdIn + return exec.RunResult{ExitCode: 0}, nil + }) + + inst := NewInstaller( + runner, NewPlatformDetector(runner), + &mockDetector{ + detectSkillAgentsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.70"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Install( + t.Context(), newSkillTool(), + WithAgents("copilot"), + WithStepProgress(&r), + WithInput(myInput), + ) + require.NoError(t, err) + require.True(t, result.Success, "install must succeed; err=%v", result.Error) + + assert.Same(t, myInput, pluginStdin, + "the plugin install command must read stdin from the WithInput reader, not os.Stdin") +} // newSkillTool returns a minimal ToolDefinition exercising the -// codepaths covered by these tests. The host commands are simplified +// codepaths covered by these tests. The agent commands are simplified // but preserve the structural distinctions (copilot/claude each with a // MarketplaceAddCommand). func newSkillTool() *ToolDefinition { @@ -1161,9 +1331,10 @@ func newSkillTool() *ToolDefinition { Name: "Test Azure Skills", Category: ToolCategorySkill, Priority: ToolPriorityRecommended, - SkillHosts: []SkillHost{ + SkillAgents: []SkillAgent{ { - Host: "copilot", + DisplayName: "copilot", + Command: "copilot", MarketplaceAddCommand: []string{"plugin", "marketplace", "add", "microsoft/azure-skills"}, PluginInstallCommand: []string{"plugin", "install", "azure@azure-skills"}, PluginUpdateCommand: []string{"plugin", "update", "azure@azure-skills"}, @@ -1174,7 +1345,8 @@ func newSkillTool() *ToolDefinition { BinaryVersionRegex: `(?m)^GitHub Copilot CLI\s+v?(\d+\.\d+\.\d+)`, }, { - Host: "claude", + DisplayName: "claude", + Command: "claude", MarketplaceAddCommand: []string{ "plugin", "marketplace", "add", "https://github.com/microsoft/azure-skills", }, @@ -1190,30 +1362,423 @@ func newSkillTool() *ToolDefinition { } } -// mockHostPresence wires ToolInPath responses so only the named hosts -// resolve successfully. Pass an empty slice to mock every host as -// missing. Present hosts also get a version-probe response whose banner -// matches the host's anchored BinaryVersionRegex (see newSkillTool), so -// installer.hostUsable treats them as genuine, functional CLIs rather than +// TestRunSkill_StepProgress verifies that a silent skill install (the agent +// CLI writes nothing to the terminal) shows the step spinner for the whole +// step and stops it with the result, using the display-cased agent name. No +// skill count is emitted. +func TestRunSkill_StepProgress(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockAgentPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + + // The version-probe ("--version") is handled by mockAgentPresence; the + // marketplace-add and plugin-install commands succeed. + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" && slices.Contains(args.Args, "plugin") + }).Respond(exec.RunResult{ExitCode: 0}) + + inst := NewInstaller( + runner, NewPlatformDetector(runner), + &mockDetector{ + detectSkillAgentsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.70"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Install( + t.Context(), newSkillTool(), + WithAgents("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "install must succeed; err=%v", result.Error) + + // A silent install (the mock agent CLI writes nothing) keeps the spinner + // for the whole step, then stops it with the result — no early teardown, + // no streamed-output result line, and no skill count. + assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.starts) + assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.stops) + assert.Empty(t, r.messages) +} + +// TestParseUpgradeOutput covers extracting the version and the "already at +// latest" state from each agent CLI's plugin-update output. +func TestParseUpgradeOutput(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + wantVersion string + wantAlreadyLast bool + }{ + { + name: "CopilotAlreadyLatest", + in: `updated successfully (v1.1.86, already at latest). 27 skills.`, + wantVersion: "1.1.86", + wantAlreadyLast: true, + }, + { + name: "ClaudeUpdatedFromTo", + in: `✔ Plugin "azure" updated from 1.1.73 to 1.1.86 for scope user. Restart to apply changes.`, + wantVersion: "1.1.86", + wantAlreadyLast: false, + }, + { + name: "ClaudeAlreadyLatest", + in: `✔ azure is already at the latest version (1.1.86).`, + wantVersion: "1.1.86", + wantAlreadyLast: true, + }, + { + name: "NoVersion", + in: "Plugin updated.", + wantVersion: "", + wantAlreadyLast: false, + }, + { + name: "Empty", + in: "", + wantVersion: "", + wantAlreadyLast: false, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + gotVersion, gotAlreadyLast := parseUpgradeOutput(tc.in) + assert.Equal(t, tc.wantVersion, gotVersion) + assert.Equal(t, tc.wantAlreadyLast, gotAlreadyLast) + }) + } +} + +// TestRunSkill_Upgrade_StepResultShowsVersion verifies that an upgrade shows a +// plain in-progress spinner title and reports the version — parsed from the +// update command's output — on the result line only. +func TestRunSkill_Upgrade_StepResultShowsVersion(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockAgentPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + // The update command reports the new version (claude-style "from A to B"). + var upgraded bool + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" && slices.Contains(args.Args, "update") + }).RespondFn(func(_ exec.RunArgs) (exec.RunResult, error) { + upgraded = true + return exec.RunResult{ + ExitCode: 0, + Stdout: `Plugin "azure" updated from 1.1.73 to 1.1.86 for scope user.`, + }, nil + }) + + inst := NewInstaller( + runner, NewPlatformDetector(runner), + &mockDetector{ + detectSkillAgentsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillAgent, error) { + // An actual upgrade: the old version before the update runs, + // the new version afterwards. + version := "1.1.73" + if upgraded { + version = "1.1.86" + } + return []InstalledSkillAgent{{Agent: "copilot", Version: version}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Upgrade( + t.Context(), newSkillTool(), + WithAgents("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "upgrade must succeed; err=%v", result.Error) + assert.False(t, result.AlreadyUpToDate, "an actual upgrade is not up-to-date") + + // Spinner title has no version; the result line appends the new version. + assert.Equal(t, []string{"Upgrading Test Azure Skills in copilot"}, r.starts) + assert.Equal(t, []string{"Upgrading Test Azure Skills in copilot (v1.1.86)"}, r.stops) +} + +// TestRunSkill_Upgrade_AlreadyUpToDate verifies that when the agent reports the +// skill is already at the latest version, the result line says so (with the +// version) and the result is flagged AlreadyUpToDate. +func TestRunSkill_Upgrade_AlreadyUpToDate(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockAgentPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" && slices.Contains(args.Args, "update") + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: `updated successfully (v1.1.86, already at latest). 27 skills.`, + }) + + inst := NewInstaller( + runner, NewPlatformDetector(runner), + &mockDetector{ + detectSkillAgentsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.86"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Upgrade( + t.Context(), newSkillTool(), + WithAgents("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "upgrade must succeed; err=%v", result.Error) + assert.True(t, result.AlreadyUpToDate, "nothing changed, so already up to date") + + assert.Equal(t, []string{"Upgrading Test Azure Skills in copilot"}, r.starts) + assert.Equal(t, []string{"Test Azure Skills in copilot is already up to date (v1.1.86)."}, r.stops) +} + +// TestRunSkill_Upgrade_DetectionLag_NotUpToDate verifies that when the plugin +// listing still reports the pre-upgrade version (detection lag) but the upgrade +// command reports a new version, the operation is NOT misreported as "already +// up to date": the command-reported version is authoritative over the (laggy) +// detected version for the equality check. +func TestRunSkill_Upgrade_DetectionLag_NotUpToDate(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockAgentPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" && slices.Contains(args.Args, "update") + }).Respond(exec.RunResult{ + ExitCode: 0, + Stdout: `Plugin "azure" updated from 1.0.0 to 2.0.0 for scope user.`, + }) + + inst := NewInstaller( + runner, NewPlatformDetector(runner), + &mockDetector{ + detectSkillAgentsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillAgent, error) { + // Detection lags: the plugin listing keeps reporting the + // pre-upgrade version even after the update command succeeds. + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.0.0"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Upgrade( + t.Context(), newSkillTool(), + WithAgents("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "upgrade must succeed; err=%v", result.Error) + assert.False(t, result.AlreadyUpToDate, + "a reported upgrade must not be marked up-to-date even when detection lags") + assert.Equal(t, []string{"Upgrading Test Azure Skills in copilot (v2.0.0)"}, r.stops) +} + +// TestRunSkill_OutputPrintedBelowStep verifies that when the agent CLI writes +// to the terminal, each line is captured and surfaced via Message below the +// resolved step line (indented gray sub-section), and the spinner is stopped +// with the step result before that output is printed. +func TestRunSkill_OutputPrintedBelowStep(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockAgentPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + + // The plugin command writes to the terminal (StdOut), simulating the + // agent CLI surfacing progress. Both marketplace-add and install route + // their output through the step writer. + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" && slices.Contains(args.Args, "plugin") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + if args.StdOut != nil { + _, _ = args.StdOut.Write([]byte("Installing plugin...\n")) + } + return exec.RunResult{ExitCode: 0}, nil + }) + + inst := NewInstaller( + runner, NewPlatformDetector(runner), + &mockDetector{ + detectSkillAgentsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.70"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Install( + t.Context(), newSkillTool(), + WithAgents("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "install must succeed; err=%v", result.Error) + + // The spinner is shown and stopped with the step result, and the agent + // CLI's line is surfaced (indented) below the step line via Message. + assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.starts) + assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.stops) + assert.Contains(t, strings.Join(r.messages, "\n"), "Installing plugin...") +} + +// TestRunSkill_Quiet_CapturesOutput verifies that WithQuiet (used for +// `--output json`) runs the agent CLI command with captured streams instead of +// connecting it interactively to the process stdout — so agent output cannot +// leak onto stdout ahead of the JSON result. +func TestRunSkill_Quiet_CapturesOutput(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockAgentPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + + var ( + pluginInteractive bool + pluginHadStdOut bool + ) + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" && slices.Contains(args.Args, "plugin") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + if slices.Contains(args.Args, "install") { + pluginInteractive = args.Interactive + pluginHadStdOut = args.StdOut != nil + } + return exec.RunResult{ExitCode: 0}, nil + }) + + inst := NewInstaller( + runner, NewPlatformDetector(runner), + &mockDetector{ + detectSkillAgentsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.70"}}, nil + }, + }, + ) + + // No renderer (as in JSON mode) + WithQuiet. + result, err := inst.Install(t.Context(), newSkillTool(), WithAgents("copilot"), WithQuiet()) + require.NoError(t, err) + require.True(t, result.Success, "install must succeed; err=%v", result.Error) + + assert.False(t, pluginInteractive, + "quiet mode must not run the agent command interactively (would write to stdout)") + assert.True(t, pluginHadStdOut, + "quiet mode must route the agent command's stdout to a captured writer") +} + +// TestSkillAgent_DisplayVsCommand verifies the split between the display Agent +// and the lowercase Command: the CLI is exec'd by Command, the step spinner +// uses the display Agent, and --agent resolves via Command even when it +// differs from the Agent. +func TestSkillAgent_DisplayVsCommand(t *testing.T) { + t.Parallel() + + skill := &ToolDefinition{ + Id: "test-azure-skills", + Name: "Test Azure Skills", + Category: ToolCategorySkill, + SkillAgents: []SkillAgent{{ + DisplayName: "GitHub Copilot CLI", // display name (differs from Command) + Command: "copilot", // exec binary + MarketplaceAddCommand: []string{"plugin", "marketplace", "add", "microsoft/azure-skills"}, + PluginInstallCommand: []string{"plugin", "install", "azure@azure-skills"}, + PluginListCommand: []string{"plugin", "list"}, + PluginName: "azure@azure-skills", + BinaryVersionArgs: []string{"--version"}, + BinaryVersionRegex: `(?m)^GitHub Copilot CLI\s+v?(\d+\.\d+\.\d+)`, + }}, + } + + runner := mockexec.NewMockCommandRunner() + runner.MockToolInPath("copilot", nil) // looked up by the lowercase Command + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" && slices.Contains(args.Args, "--version") + }).Respond(exec.RunResult{ExitCode: 0, Stdout: "GitHub Copilot CLI 1.1.70"}) + var execCmds []string + runner.When(func(args exec.RunArgs, _ string) bool { + return args.Cmd == "copilot" && slices.Contains(args.Args, "plugin") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + execCmds = append(execCmds, args.Cmd) + return exec.RunResult{ExitCode: 0}, nil + }) + + det := &mockDetector{ + detectSkillAgentsFn: func(_ context.Context, _ *ToolDefinition) ([]InstalledSkillAgent, error) { + // DetectSkillAgents reports the command identity (see + // InstalledSkillAgent.Agent), so verification matches by command. + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.70"}}, nil + }, + } + inst := NewInstaller(runner, NewPlatformDetector(runner), det) + + // --agent "copilot" (the Command) must resolve even though the Agent is the + // full display name "GitHub Copilot CLI". + var r fakeStepRenderer + result, err := inst.Install(t.Context(), skill, WithAgents("copilot"), WithStepProgress(&r)) + require.NoError(t, err) + require.True(t, result.Success, "install must succeed; err=%v", result.Error) + + require.NotEmpty(t, execCmds, "the agent CLI must be exec'd") + for _, c := range execCmds { + assert.Equal(t, "copilot", c, "exec must use the lowercase Command, not display Agent") + } + require.NotEmpty(t, r.starts, "the step spinner must be shown") + assert.Equal(t, []string{"Installing Test Azure Skills in GitHub Copilot CLI"}, r.starts, + "the step spinner must use the display-cased Agent") +} + +// mockAgentPresence wires ToolInPath responses so only the named agents +// resolve successfully. Pass an empty slice to mock every agent as +// missing. Present agents also get a version-probe response whose banner +// matches the agent's anchored BinaryVersionRegex (see newSkillTool), so +// installer.agentUsable treats them as genuine, functional CLIs rather than // launcher stubs. Tests that want to simulate a stub register their own // later "--version" expectation, which wins (last match). -func mockHostPresence( +func mockAgentPresence( runner *mockexec.MockCommandRunner, present ...string, ) { - // Per-host `--version` banner that satisfies the host's anchored + // Per-agent `--version` banner that satisfies the agent's anchored // BinaryVersionRegex. versionBanner := map[string]string{ "copilot": "GitHub Copilot CLI 1.1.70", "claude": "1.1.70 (Claude Code)", } - for _, h := range allSkillHostNames { + for _, h := range allSkillAgentNames { if slices.Contains(present, h) { runner.MockToolInPath(h, nil) - host := h + agent := h banner := versionBanner[h] runner.When(func(args exec.RunArgs, _ string) bool { - return args.Cmd == host && slices.Contains(args.Args, "--version") + return args.Cmd == agent && slices.Contains(args.Args, "--version") }).Respond(exec.RunResult{Stdout: banner, ExitCode: 0}) } else { runner.MockToolInPath(h, errors.New("not found")) @@ -1269,37 +1834,37 @@ func installedDetector(version string) *mockDetector { } // --------------------------------------------------------------------------- -// runSkill — host selection +// runSkill — agent selection // --------------------------------------------------------------------------- -func TestRunSkill_PicksFirstAvailableHost(t *testing.T) { +func TestRunSkill_PicksFirstAvailableAgent(t *testing.T) { t.Parallel() cases := []struct { name string present []string - wantHost string + wantAgent string wantCmd string wantPlugin string }{ { name: "CopilotOnly", present: []string{"copilot"}, - wantHost: "copilot", + wantAgent: "copilot", wantCmd: "copilot", wantPlugin: "azure@azure-skills", }, { name: "ClaudeOnly", present: []string{"claude"}, - wantHost: "claude", + wantAgent: "claude", wantCmd: "claude", wantPlugin: "azure@azure-skills", }, { name: "AllPresent_PrefersCopilot", - present: allSkillHostNames, - wantHost: "copilot", + present: allSkillAgentNames, + wantAgent: "copilot", wantCmd: "copilot", wantPlugin: "azure@azure-skills", }, @@ -1310,7 +1875,7 @@ func TestRunSkill_PicksFirstAvailableHost(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, tc.present...) + mockAgentPresence(runner, tc.present...) runner.MockToolInPath("node", nil) var ranInstall bool @@ -1329,13 +1894,13 @@ func TestRunSkill_PicksFirstAvailableHost(t *testing.T) { } return exec.RunResult{ExitCode: 0}, nil }) - // Marketplace-add fallback for hosts that have one. + // Marketplace-add fallback for agents that have one. runner.When(func(args exec.RunArgs, _ string) bool { return slices.Contains(args.Args, "marketplace") }).Respond(exec.RunResult{ExitCode: 0}) // Detector reflects reality: the skill becomes "installed" - // only after the host's install command runs, so on a fresh + // only after the agent's install command runs, so on a fresh // install the pre-verify detect reports not-installed and the // install command still runs. det := &mockDetector{ @@ -1364,7 +1929,7 @@ func TestRunSkill_PicksFirstAvailableHost(t *testing.T) { result.Success, "expected success; result.Error=%v", result.Error, ) - assert.Equal(t, tc.wantHost, result.Strategy) + assert.Equal(t, tc.wantAgent, result.Strategy) assert.Equal(t, "1.1.70", result.InstalledVersion) assert.True(t, ranInstall, "expected plugin install command to run; args=%v", @@ -1374,11 +1939,11 @@ func TestRunSkill_PicksFirstAvailableHost(t *testing.T) { } } -func TestRunSkill_NoHost_FailsWithSuggestion(t *testing.T) { +func TestRunSkill_NoAgent_FailsWithSuggestion(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner /* none present */) + mockAgentPresence(runner /* none present */) runner.MockToolInPath("node", nil) inst := NewInstaller( @@ -1411,15 +1976,15 @@ func TestRunSkill_NoHost_FailsWithSuggestion(t *testing.T) { assert.Contains(t, ews.Links[0].Title, "GitHub Copilot CLI") } -// TestRunSkill_Install_LauncherStubHost_ReturnsInstallGuidance verifies -// that a host binary present on PATH but non-functional — e.g. the VS Code +// TestRunSkill_Install_LauncherStubAgent_ReturnsInstallGuidance verifies +// that an agent binary present on PATH but non-functional — e.g. the VS Code // Copilot Chat extension's `copilot` launcher stub, which only prompts to -// install the real CLI and exits 0 — is not mistaken for a usable host. +// install the real CLI and exits 0 — is not mistaken for a usable agent. // The install must fail with the same clean "install GitHub Copilot CLI" -// guidance as a host that is entirely absent (matching Windows), never the +// guidance as an agent that is entirely absent (matching Windows), never the // misleading "was installed via copilot but verification failed", and must // not attempt any plugin command through the stub. -func TestRunSkill_Install_LauncherStubHost_ReturnsInstallGuidance(t *testing.T) { +func TestRunSkill_Install_LauncherStubAgent_ReturnsInstallGuidance(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() @@ -1483,14 +2048,14 @@ func TestRunSkill_Install_LauncherStubHost_ReturnsInstallGuidance(t *testing.T) // stub's response (guards against a silent mock-precedence flip that would // otherwise let a stub be treated as usable without failing this test). assert.True(t, stubVersionProbed, - "hostUsable must probe `copilot --version` and observe the stub response") + "agentUsable must probe `copilot --version` and observe the stub response") } // TestRunSkill_Install_StubWithIncidentalVersion_Rejected verifies that a // launcher stub is still rejected when its output contains a version-shaped // token that is not a genuine version report — e.g. a bundled node version, a // path build number, or even a line that starts with the real CLI's banner -// prefix but carries no version. BinaryVersionRegex is anchored to the host's +// prefix but carries no version. BinaryVersionRegex is anchored to the agent's // `--version` banner, so only a real version line counts. func TestRunSkill_Install_StubWithIncidentalVersion_Rejected(t *testing.T) { t.Parallel() @@ -1553,7 +2118,7 @@ func TestRunSkill_NodeMissing_WarnsButProceeds(t *testing.T) { // tests in this package. runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") // node intentionally NOT on PATH — must produce only a warning. runner.MockToolInPath("node", errors.New("not found")) @@ -1592,7 +2157,7 @@ func TestRunSkill_Upgrade_RunsUpdateCommand_NotMarketplaceAdd(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) var sawMarketplaceAdd bool @@ -1617,10 +2182,10 @@ func TestRunSkill_Upgrade_RunsUpdateCommand_NotMarketplaceAdd(t *testing.T) { inst := NewInstaller( runner, NewPlatformDetector(runner), &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "copilot", Version: "1.1.71"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.71"}}, nil }, }, ) @@ -1636,32 +2201,32 @@ func TestRunSkill_Upgrade_RunsUpdateCommand_NotMarketplaceAdd(t *testing.T) { "copilot upgrade must not also run plugin install") } -// TestRunSkill_Upgrade_PrefersInstalledHost verifies that, with no -// explicit --host, an upgrade targets the host the detector reports the -// skill is installed through — not simply the first host on PATH. -func TestRunSkill_Upgrade_PrefersInstalledHost(t *testing.T) { +// TestRunSkill_Upgrade_PrefersInstalledAgent verifies that, with no +// explicit --agent, an upgrade targets the agent the detector reports the +// skill is installed through — not simply the first agent on PATH. +func TestRunSkill_Upgrade_PrefersInstalledAgent(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") // both on PATH + mockAgentPresence(runner, "copilot", "claude") // both on PATH runner.MockToolInPath("node", nil) - var updatedHost string + var updatedAgent string runner.When(func(args exec.RunArgs, _ string) bool { return (args.Cmd == "copilot" || args.Cmd == "claude") && slices.Contains(args.Args, "update") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - updatedHost = args.Cmd + updatedAgent = args.Cmd return exec.RunResult{ExitCode: 0}, nil }) // The detector reports the skill is installed via claude, even though // copilot is listed first; the upgrade must run against claude. det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "claude", Version: "1.1.71"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "claude", Version: "1.1.71"}}, nil }, } @@ -1671,39 +2236,39 @@ func TestRunSkill_Upgrade_PrefersInstalledHost(t *testing.T) { require.NoError(t, err) require.True(t, result.Success, "result.Error=%v", result.Error) assert.Equal(t, "claude", result.Strategy) - assert.Equal(t, "claude", updatedHost, - "upgrade must target the host that has the skill (claude), "+ + assert.Equal(t, "claude", updatedAgent, + "upgrade must target the agent that has the skill (claude), "+ "not the first on PATH (copilot)", ) } -// TestRunSkill_Upgrade_AllInstalledHosts verifies that, with no explicit -// --host, an upgrade refreshes EVERY host the skill is installed through -// (not just the first), so a multi-host install is kept fully current. -func TestRunSkill_Upgrade_AllInstalledHosts(t *testing.T) { +// TestRunSkill_Upgrade_AllInstalledAgents verifies that, with no explicit +// --agent, an upgrade refreshes EVERY agent the skill is installed through +// (not just the first), so a multi-agent install is kept fully current. +func TestRunSkill_Upgrade_AllInstalledAgents(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") runner.MockToolInPath("node", nil) - var updatedHosts []string + var updatedAgents []string runner.When(func(args exec.RunArgs, _ string) bool { return (args.Cmd == "copilot" || args.Cmd == "claude") && slices.Contains(args.Args, "update") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - updatedHosts = append(updatedHosts, args.Cmd) + updatedAgents = append(updatedAgents, args.Cmd) return exec.RunResult{ExitCode: 0}, nil }) // The skill is installed through BOTH copilot and claude. det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{ - {Host: "copilot", Version: "1.1.71"}, - {Host: "claude", Version: "1.1.71"}, + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{ + {Agent: "copilot", Version: "1.1.71"}, + {Agent: "claude", Version: "1.1.71"}, }, nil }, } @@ -1713,20 +2278,20 @@ func TestRunSkill_Upgrade_AllInstalledHosts(t *testing.T) { result, err := inst.Upgrade(t.Context(), newSkillTool()) require.NoError(t, err) require.True(t, result.Success, "result.Error=%v", result.Error) - assert.ElementsMatch(t, []string{"copilot", "claude"}, updatedHosts, - "upgrade with no --host must refresh every installed host") + assert.ElementsMatch(t, []string{"copilot", "claude"}, updatedAgents, + "upgrade with no --agent must refresh every installed agent") assert.Contains(t, result.Strategy, "copilot") assert.Contains(t, result.Strategy, "claude") } -// TestRunSkill_Upgrade_PrintsPerHostHeader verifies a header line is -// printed before each host's (interactive) command output so the user -// can tell which host the streamed output belongs to. -func TestRunSkill_Upgrade_PrintsPerHostHeader(t *testing.T) { +// TestRunSkill_Upgrade_PrintsPerAgentHeader verifies a header line is +// printed before each agent's (interactive) command output so the user +// can tell which agent the streamed output belongs to. +func TestRunSkill_Upgrade_PrintsPerAgentHeader(t *testing.T) { // NOT parallel: captureStderr mutates the global os.Stderr. runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") runner.MockToolInPath("node", nil) runner.When(func(args exec.RunArgs, _ string) bool { @@ -1735,12 +2300,12 @@ func TestRunSkill_Upgrade_PrintsPerHostHeader(t *testing.T) { }).Respond(exec.RunResult{ExitCode: 0}) det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{ - {Host: "copilot", Version: "1.1.71"}, - {Host: "claude", Version: "1.1.71"}, + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{ + {Agent: "copilot", Version: "1.1.71"}, + {Agent: "claude", Version: "1.1.71"}, }, nil }, } @@ -1759,17 +2324,17 @@ func TestRunSkill_Upgrade_PrintsPerHostHeader(t *testing.T) { assert.Contains(t, stderr, "Upgrading Test Azure Skills in claude") } -// TestRunSkill_Upgrade_NoHost_NotInstalled_ReturnsInstallGuidance -// verifies that `azd tool upgrade ` with no --host, when the -// skill is installed on no available host, returns a clear "install -// first" guidance error instead of falling through to a host and +// TestRunSkill_Upgrade_NoAgent_NotInstalled_ReturnsInstallGuidance +// verifies that `azd tool upgrade ` with no --agent, when the +// skill is installed on no available agent, returns a clear "install +// first" guidance error instead of falling through to an agent and // attempting to update a plugin that was never installed (which used to // produce a confusing "verification failed" error). -func TestRunSkill_Upgrade_NoHost_NotInstalled_ReturnsInstallGuidance(t *testing.T) { +func TestRunSkill_Upgrade_NoAgent_NotInstalled_ReturnsInstallGuidance(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") // both on PATH + mockAgentPresence(runner, "copilot", "claude") // both on PATH runner.MockToolInPath("node", nil) updateRan := false @@ -1781,11 +2346,11 @@ func TestRunSkill_Upgrade_NoHost_NotInstalled_ReturnsInstallGuidance(t *testing. return exec.RunResult{ExitCode: 0}, nil }) - // The skill is installed on no host. + // The skill is installed on no agent. det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]InstalledSkillAgent, error) { return nil, nil }, } @@ -1806,59 +2371,59 @@ func TestRunSkill_Upgrade_NoHost_NotInstalled_ReturnsInstallGuidance(t *testing. assert.NotEmpty(t, ews.Err) assert.NotEmpty(t, ews.Message) assert.NotEmpty(t, ews.Suggestion) - assert.Contains(t, result.Error.Error(), "not installed on any available host") + assert.Contains(t, result.Error.Error(), "not installed on any available agent") assert.Contains(t, ews.Suggestion, "azd tool install test-azure-skills") } // TestRunSkill_Upgrade_AllAvailable_SkipsNotInstalled verifies that -// `upgrade --host all` upgrades only the hosts that actually have the -// skill installed and skips on-PATH-but-not-installed hosts (with a -// warning to stderr) rather than erroring — a host CLI cannot upgrade a +// `upgrade --agent all` upgrades only the agents that actually have the +// skill installed and skips on-PATH-but-not-installed agents (with a +// warning to stderr) rather than erroring — an agent CLI cannot upgrade a // plugin it never installed. func TestRunSkill_Upgrade_AllAvailable_SkipsNotInstalled(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") // both on PATH + mockAgentPresence(runner, "copilot", "claude") // both on PATH runner.MockToolInPath("node", nil) - var updatedHosts []string + var updatedAgents []string runner.When(func(args exec.RunArgs, _ string) bool { return (args.Cmd == "copilot" || args.Cmd == "claude") && slices.Contains(args.Args, "update") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - updatedHosts = append(updatedHosts, args.Cmd) + updatedAgents = append(updatedAgents, args.Cmd) return exec.RunResult{ExitCode: 0}, nil }) // Only copilot has the skill installed. det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "copilot", Version: "1.1.71"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.71"}}, nil }, } inst := NewInstaller(runner, NewPlatformDetector(runner), det) - result, err := inst.Upgrade(t.Context(), newSkillTool(), WithAllAvailableHosts()) + result, err := inst.Upgrade(t.Context(), newSkillTool(), WithAllAvailableAgents()) require.NoError(t, err) require.True(t, result.Success, - "upgrade must succeed by skipping the not-installed host; err=%v", + "upgrade must succeed by skipping the not-installed agent; err=%v", result.Error) - assert.Equal(t, []string{"copilot"}, updatedHosts, - "only the installed host (copilot) must be upgraded; claude skipped") + assert.Equal(t, []string{"copilot"}, updatedAgents, + "only the installed agent (copilot) must be upgraded; claude skipped") } // TestRunSkill_Upgrade_AllAvailable_NoneInstalled verifies that -// `upgrade --host all` errors with a clear "not installed on any host" -// message when the skill is present on no host (and runs no update). +// `upgrade --agent all` errors with a clear "not installed on any agent" +// message when the skill is present on no agent (and runs no update). func TestRunSkill_Upgrade_AllAvailable_NoneInstalled(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") runner.MockToolInPath("node", nil) var updateRan bool @@ -1870,87 +2435,87 @@ func TestRunSkill_Upgrade_AllAvailable_NoneInstalled(t *testing.T) { }) det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]InstalledSkillAgent, error) { return nil, nil // not installed anywhere }, } inst := NewInstaller(runner, NewPlatformDetector(runner), det) - result, err := inst.Upgrade(t.Context(), newSkillTool(), WithAllAvailableHosts()) + result, err := inst.Upgrade(t.Context(), newSkillTool(), WithAllAvailableAgents()) require.NoError(t, err) require.False(t, result.Success) require.NotNil(t, result.Error) - assert.Contains(t, result.Error.Error(), "not installed on any available host") - assert.False(t, updateRan, "no host update must run when nothing is installed") + assert.Contains(t, result.Error.Error(), "not installed on any available agent") + assert.False(t, updateRan, "no agent update must run when nothing is installed") } -// TestRunSkill_Install_AllAvailable_TargetsEveryHostOnPath verifies that -// `install --host all` installs through every host on PATH regardless of +// TestRunSkill_Install_AllAvailable_TargetsEveryAgentOnPath verifies that +// `install --agent all` installs through every agent on PATH regardless of // prior install state (unlike upgrade, install does not skip). -func TestRunSkill_Install_AllAvailable_TargetsEveryHostOnPath(t *testing.T) { +func TestRunSkill_Install_AllAvailable_TargetsEveryAgentOnPath(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") runner.MockToolInPath("node", nil) - var installedHosts []string + var installedAgents []string runner.When(func(args exec.RunArgs, _ string) bool { return (args.Cmd == "copilot" || args.Cmd == "claude") && (slices.Contains(args.Args, "install") || slices.Contains(args.Args, "marketplace")) }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { if slices.Contains(args.Args, "install") { - installedHosts = append(installedHosts, args.Cmd) + installedAgents = append(installedAgents, args.Cmd) } return exec.RunResult{ExitCode: 0}, nil }) det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]InstalledSkillAgent, error) { // Reflects post-install state for verification. - return []InstalledSkillHost{ - {Host: "copilot", Version: "1.1.71"}, - {Host: "claude", Version: "1.1.71"}, + return []InstalledSkillAgent{ + {Agent: "copilot", Version: "1.1.71"}, + {Agent: "claude", Version: "1.1.71"}, }, nil }, } inst := NewInstaller(runner, NewPlatformDetector(runner), det) - result, err := inst.Install(t.Context(), newSkillTool(), WithAllAvailableHosts()) + result, err := inst.Install(t.Context(), newSkillTool(), WithAllAvailableAgents()) require.NoError(t, err) require.True(t, result.Success, "result.Error=%v", result.Error) - assert.ElementsMatch(t, []string{"copilot", "claude"}, installedHosts, - "install --host all must install through every host on PATH") + assert.ElementsMatch(t, []string{"copilot", "claude"}, installedAgents, + "install --agent all must install through every agent on PATH") } -// TestRunSkill_Install_HostScopedVerification verifies that install -// verification is scoped to the host just installed: if claude's install +// TestRunSkill_Install_AgentScopedVerification verifies that install +// verification is scoped to the agent just installed: if claude's install // silently no-ops while copilot already has the skill, verification must // FAIL — it must not falsely succeed on copilot's presence (the prior -// DetectTool-based check did, reporting the wrong host's version). -func TestRunSkill_Install_HostScopedVerification(t *testing.T) { +// DetectTool-based check did, reporting the wrong agent's version). +func TestRunSkill_Install_AgentScopedVerification(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") runner.MockToolInPath("node", nil) - // Both hosts' commands exit 0 (claude's install silently no-ops). + // Both agents' commands exit 0 (claude's install silently no-ops). runner.When(func(args exec.RunArgs, _ string) bool { return args.Cmd == "copilot" || args.Cmd == "claude" }).Respond(exec.RunResult{ExitCode: 0}) // Detector reports the skill installed ONLY via copilot — never claude. det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "copilot", Version: "1.1.71"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.71"}}, nil }, } @@ -1959,7 +2524,7 @@ func TestRunSkill_Install_HostScopedVerification(t *testing.T) { // Install via claude: claude is not actually registered, so // verification must fail rather than pass on copilot's presence. - result, err := inst.Install(t.Context(), newSkillTool(), WithHosts("claude")) + result, err := inst.Install(t.Context(), newSkillTool(), WithAgents("claude")) require.NoError(t, err) require.False(t, result.Success, "claude install must NOT be verified by copilot's presence") @@ -1971,7 +2536,7 @@ func TestRunSkill_MarketplaceAlreadyRegistered_StillInstalls(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) var installRan bool @@ -2016,7 +2581,7 @@ func TestRunSkill_MarketplaceRealError_FailsBeforeInstall(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) var installRan bool @@ -2068,7 +2633,7 @@ func TestRunSkill_InstallCommandFails_SurfacesError(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "claude") + mockAgentPresence(runner, "claude") runner.MockToolInPath("node", nil) wantErr := errors.New("exit status 2") @@ -2099,10 +2664,10 @@ func TestRunSkill_InstallCommandFails_SurfacesError(t *testing.T) { } // --------------------------------------------------------------------------- -// runSkill — no SkillHosts configured +// runSkill — no SkillAgents configured // --------------------------------------------------------------------------- -func TestRunSkill_NoSkillHosts_Errors(t *testing.T) { +func TestRunSkill_NoSkillAgents_Errors(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() @@ -2112,14 +2677,14 @@ func TestRunSkill_NoSkillHosts_Errors(t *testing.T) { Id: "empty-skill", Name: "Empty Skill", Category: ToolCategorySkill, - // No SkillHosts configured. + // No SkillAgents configured. } result, err := inst.Install(t.Context(), tool) require.NoError(t, err) require.False(t, result.Success) require.NotNil(t, result.Error) - assert.Contains(t, result.Error.Error(), "no SkillHosts configured") + assert.Contains(t, result.Error.Error(), "no SkillAgents configured") } // --------------------------------------------------------------------------- @@ -2130,7 +2695,7 @@ func TestRunSkill_VerifyDetectorError_Surfaces(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) runner.When(func(args exec.RunArgs, _ string) bool { return args.Cmd == "copilot" && @@ -2164,7 +2729,7 @@ func TestRunSkill_VerificationFails_AfterRetries(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) runner.When(func(args exec.RunArgs, _ string) bool { return args.Cmd == "copilot" && @@ -2204,7 +2769,7 @@ func TestRunSkill_ContextCanceledDuringVerify(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) runner.When(func(args exec.RunArgs, _ string) bool { return args.Cmd == "copilot" && @@ -2237,14 +2802,14 @@ func TestRunSkill_ContextCanceledDuringVerify(t *testing.T) { } // --------------------------------------------------------------------------- -// runSkill — explicit host selection (WithHosts) +// runSkill — explicit agent selection (WithAgents) // --------------------------------------------------------------------------- -func TestRunSkill_ExplicitHosts_InstallsEachHost(t *testing.T) { +func TestRunSkill_ExplicitAgents_InstallsEachAgent(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") runner.MockToolInPath("node", nil) installed := map[string]bool{} @@ -2256,7 +2821,7 @@ func TestRunSkill_ExplicitHosts_InstallsEachHost(t *testing.T) { installed[args.Cmd] = true return exec.RunResult{ExitCode: 0}, nil }) - // Marketplace-add fallback for hosts that have one. + // Marketplace-add fallback for agents that have one. runner.When(func(args exec.RunArgs, _ string) bool { return slices.Contains(args.Args, "marketplace") }).Respond(exec.RunResult{ExitCode: 0}) @@ -2266,7 +2831,7 @@ func TestRunSkill_ExplicitHosts_InstallsEachHost(t *testing.T) { ) result, err := inst.Install( - t.Context(), newSkillTool(), WithHosts("copilot", "claude"), + t.Context(), newSkillTool(), WithAgents("copilot", "claude"), ) require.NoError(t, err) require.True(t, result.Success, "result.Error=%v", result.Error) @@ -2276,11 +2841,11 @@ func TestRunSkill_ExplicitHosts_InstallsEachHost(t *testing.T) { assert.True(t, installed["claude"], "claude install must run") } -func TestRunSkill_ExplicitUnknownHost_Errors(t *testing.T) { +func TestRunSkill_ExplicitUnknownAgent_Errors(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) inst := NewInstaller( @@ -2288,21 +2853,21 @@ func TestRunSkill_ExplicitUnknownHost_Errors(t *testing.T) { ) result, err := inst.Install( - t.Context(), newSkillTool(), WithHosts("bogus"), + t.Context(), newSkillTool(), WithAgents("bogus"), ) require.NoError(t, err) require.False(t, result.Success) require.NotNil(t, result.Error) assert.Contains(t, result.Error.Error(), "not available") - // The error names the requested host so a typo is obvious. + // The error names the requested agent so a typo is obvious. assert.Contains(t, result.Error.Error(), "bogus") } -func TestRunSkill_ExplicitHostNotPresent_Errors(t *testing.T) { +func TestRunSkill_ExplicitAgentNotPresent_Errors(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") // claude deliberately NOT on PATH + mockAgentPresence(runner, "copilot") // claude deliberately NOT on PATH runner.MockToolInPath("node", nil) inst := NewInstaller( @@ -2310,24 +2875,24 @@ func TestRunSkill_ExplicitHostNotPresent_Errors(t *testing.T) { ) result, err := inst.Install( - t.Context(), newSkillTool(), WithHosts("claude"), + t.Context(), newSkillTool(), WithAgents("claude"), ) require.NoError(t, err) require.False(t, result.Success) require.NotNil(t, result.Error) assert.Contains(t, result.Error.Error(), "not available") - // The error names the requested host so the user knows which one failed. + // The error names the requested agent so the user knows which one failed. assert.Contains(t, result.Error.Error(), "claude") } -// TestRunSkill_Install_ExplicitStubHost_Rejected is a regression guard for -// the explicit `--host` path: a host requested by name that is present on +// TestRunSkill_Install_ExplicitStubAgent_Rejected is a regression guard for +// the explicit `--agent` path: an agent requested by name that is present on // PATH only as a launcher stub (not a functional CLI) must be rejected the -// same way the default / `--host all` path rejects it. explicitSkillHostTargets -// uses [installer.hostUsable] (a version probe), not a bare PATH-existence +// same way the default / `--agent all` path rejects it. explicitSkillAgentTargets +// uses [installer.agentUsable] (a version probe), not a bare PATH-existence // check, so the stub fails with "not available" instead of being driven // through an install flow that would later surface "verification failed". -func TestRunSkill_Install_ExplicitStubHost_Rejected(t *testing.T) { +func TestRunSkill_Install_ExplicitStubAgent_Rejected(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() @@ -2369,13 +2934,13 @@ func TestRunSkill_Install_ExplicitStubHost_Rejected(t *testing.T) { ) result, err := inst.Install( - t.Context(), newSkillTool(), WithHosts("copilot"), + t.Context(), newSkillTool(), WithAgents("copilot"), ) require.NoError(t, err) require.False(t, result.Success) require.NotNil(t, result.Error) - // The explicit host is rejected up front, naming the requested host — + // The explicit agent is rejected up front, naming the requested agent — // not driven through an install that fails verification. assert.Contains(t, result.Error.Error(), "not available") assert.Contains(t, result.Error.Error(), "copilot") @@ -2383,23 +2948,23 @@ func TestRunSkill_Install_ExplicitStubHost_Rejected(t *testing.T) { assert.False(t, attemptedInstall, "must not attempt to install through a non-functional launcher stub") assert.True(t, stubVersionProbed, - "explicitSkillHostTargets must probe `copilot --version` via hostUsable") + "explicitSkillAgentTargets must probe `copilot --version` vian agentUsable") } // TestRunSkill_Upgrade_DetectError_Propagated verifies that a context -// cancellation/timeout from DetectSkillHosts on the default upgrade path -// is treated as fatal rather than silently falling back to a host. +// cancellation/timeout from DetectSkillAgents on the default upgrade path +// is treated as fatal rather than silently falling back to an agent. func TestRunSkill_Upgrade_DetectError_Propagated(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]InstalledSkillAgent, error) { return nil, context.Canceled }, } @@ -2412,19 +2977,19 @@ func TestRunSkill_Upgrade_DetectError_Propagated(t *testing.T) { require.ErrorIs(t, result.Error, context.Canceled) } -// TestRunSkill_UpgradeAllHosts_DetectError_Propagated verifies the same -// for the --host all upgrade path. -func TestRunSkill_UpgradeAllHosts_DetectError_Propagated(t *testing.T) { +// TestRunSkill_UpgradeAllAgents_DetectError_Propagated verifies the same +// for the --agent all upgrade path. +func TestRunSkill_UpgradeAllAgents_DetectError_Propagated(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") runner.MockToolInPath("node", nil) det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]InstalledSkillAgent, error) { return nil, context.Canceled }, } @@ -2432,7 +2997,7 @@ func TestRunSkill_UpgradeAllHosts_DetectError_Propagated(t *testing.T) { inst := NewInstaller(runner, NewPlatformDetector(runner), det) result, err := inst.Upgrade( - t.Context(), newSkillTool(), WithAllAvailableHosts(), + t.Context(), newSkillTool(), WithAllAvailableAgents(), ) require.NoError(t, err) require.False(t, result.Success) @@ -2440,35 +3005,37 @@ func TestRunSkill_UpgradeAllHosts_DetectError_Propagated(t *testing.T) { } // --------------------------------------------------------------------------- -// AvailableSkillHosts +// AvailableSkillAgents // --------------------------------------------------------------------------- -func TestAvailableSkillHosts_ReturnsPresentInManifestOrder(t *testing.T) { +func TestAvailableSkillAgents_ReturnsPresentInManifestOrder(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() // Present out of manifest order; result must follow manifest order // (copilot, claude). - mockHostPresence(runner, "claude", "copilot") + mockAgentPresence(runner, "claude", "copilot") inst := NewInstaller(runner, NewPlatformDetector(runner), &mockDetector{}) - assert.Equal(t, - []string{"copilot", "claude"}, - inst.AvailableSkillHosts(t.Context(), newSkillTool()), - ) + commands, names := inst.AvailableSkillAgents(t.Context(), newSkillTool()) + // newSkillTool uses Agent == Command, so both slices match. + assert.Equal(t, []string{"copilot", "claude"}, commands) + assert.Equal(t, []string{"copilot", "claude"}, names) } -func TestAvailableSkillHosts_NonSkillToolReturnsNil(t *testing.T) { +func TestAvailableSkillAgents_NonSkillToolReturnsNil(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() inst := NewInstaller(runner, NewPlatformDetector(runner), &mockDetector{}) - assert.Nil(t, inst.AvailableSkillHosts(t.Context(), &ToolDefinition{ + commands, names := inst.AvailableSkillAgents(t.Context(), &ToolDefinition{ Id: "not-a-skill", Category: ToolCategoryServer, - })) + }) + assert.Nil(t, commands) + assert.Nil(t, names) } // --------------------------------------------------------------------------- @@ -3471,37 +4038,37 @@ func TestBuildUninstallCommand(t *testing.T) { } // --------------------------------------------------------------------------- -// Uninstall — skill (per-host) path +// Uninstall — skill (per-agent) path // --------------------------------------------------------------------------- -func TestUninstallSkill_RemovesFromInstalledHosts(t *testing.T) { +func TestUninstallSkill_RemovesFromInstalledAgents(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") - // Capture which hosts ran an uninstall command. - var uninstalledHosts []string + // Capture which agents ran an uninstall command. + var uninstalledAgents []string runner.When(func(args exec.RunArgs, _ string) bool { return slices.Contains(args.Args, "uninstall") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - uninstalledHosts = append(uninstalledHosts, args.Cmd) + uninstalledAgents = append(uninstalledAgents, args.Cmd) return exec.RunResult{ExitCode: 0}, nil }) - // Installed on both hosts before uninstall; gone after. + // Installed on both agents before uninstall; gone after. var uninstallStarted bool det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, tool *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]InstalledSkillAgent, error) { if uninstallStarted { return nil, nil } uninstallStarted = true - return []InstalledSkillHost{ - {Host: "copilot", Version: "1.0.0"}, - {Host: "claude", Version: "1.0.0"}, + return []InstalledSkillAgent{ + {Agent: "copilot", Version: "1.0.0"}, + {Agent: "claude", Version: "1.0.0"}, }, nil }, } @@ -3513,53 +4080,174 @@ func TestUninstallSkill_RemovesFromInstalledHosts(t *testing.T) { require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.Success) - assert.ElementsMatch(t, []string{"copilot", "claude"}, uninstalledHosts) + assert.ElementsMatch(t, []string{"copilot", "claude"}, uninstalledAgents) +} + +// TestRunSkillUninstall_StepProgress verifies the step spinner is rendered +// per agent (shown then stopped, capitalized) for a skill uninstall, with no +// skill-count sub-line. +func TestRunSkillUninstall_StepProgress(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockAgentPresence(runner, "copilot") + runner.When(func(args exec.RunArgs, _ string) bool { + return slices.Contains(args.Args, "uninstall") + }).Respond(exec.RunResult{ExitCode: 0}) + + // After uninstall, copilot no longer reports the skill so verification + // passes. + det := &mockDetector{ + detectSkillAgentsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillAgent, error) { + return nil, nil + }, + } + + inst := NewInstaller(runner, NewPlatformDetector(runner), det) + + var r fakeStepRenderer + result, err := inst.Uninstall( + t.Context(), newSkillTool(), + WithAgents("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "uninstall must succeed; err=%v", result.Error) + + // A silent uninstall keeps the spinner for the whole step, then stops it + // with the result. + assert.Equal(t, []string{"Uninstalling Test Azure Skills from copilot"}, r.starts) + assert.Equal(t, []string{"Uninstalling Test Azure Skills from copilot"}, r.stops) + assert.Empty(t, r.messages) +} + +// TestRunSkillUninstall_StepProgress_SuccessStreamsOutput verifies that the +// agent CLI's output is surfaced above the spinner as it arrives — even when the +// uninstall completes without error — so an interactive uninstall confirmation +// prompt stays visible and answerable rather than being buffered until failure. +func TestRunSkillUninstall_StepProgress_SuccessStreamsOutput(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockAgentPresence(runner, "copilot") + runner.When(func(args exec.RunArgs, _ string) bool { + return slices.Contains(args.Args, "uninstall") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + if args.StdOut != nil { + _, _ = args.StdOut.Write([]byte("Plugin \"azure@azure-skills\" uninstalled successfully.\n")) + } + return exec.RunResult{ExitCode: 0}, nil + }) + + // After uninstall the skill is gone, so verification passes. + det := &mockDetector{ + detectSkillAgentsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillAgent, error) { + return nil, nil + }, + } + inst := NewInstaller(runner, NewPlatformDetector(runner), det) + + var r fakeStepRenderer + result, err := inst.Uninstall( + t.Context(), newSkillTool(), + WithAgents("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "uninstall must succeed; err=%v", result.Error) + + assert.Contains(t, strings.Join(r.messages, "\n"), "Plugin \"azure@azure-skills\" uninstalled successfully.", + "agent CLI output must be shown below the step result line") + assert.Equal(t, []string{"Uninstalling Test Azure Skills from copilot"}, r.stops) +} + +// TestRunSkillUninstall_StepProgress_FailureShowsOutput verifies the converse: +// when the uninstall fails, the buffered agent CLI output is replayed so the +// user can see what went wrong. +func TestRunSkillUninstall_StepProgress_FailureShowsOutput(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockAgentPresence(runner, "copilot") + runner.When(func(args exec.RunArgs, _ string) bool { + return slices.Contains(args.Args, "uninstall") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + if args.StdOut != nil { + _, _ = args.StdOut.Write([]byte("could not remove plugin\n")) + } + return exec.RunResult{ExitCode: 1}, errors.New("exit code 1") + }) + + det := &mockDetector{ + detectSkillAgentsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.0.0"}}, nil + }, + } + inst := NewInstaller(runner, NewPlatformDetector(runner), det) + + var r fakeStepRenderer + result, _ := inst.Uninstall( + t.Context(), newSkillTool(), + WithAgents("copilot"), + WithStepProgress(&r), + ) + require.False(t, result.Success) + require.Error(t, result.Error) + + assert.Contains(t, strings.Join(r.messages, "\n"), "could not remove plugin", + "agent CLI output must be shown when uninstall fails") } -func TestUninstallSkill_ExplicitHost_RemovesOnlyThatHost(t *testing.T) { +func TestUninstallSkill_ExplicitAgent_RemovesOnlyThatAgent(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") - var uninstalledHosts []string + var uninstalledAgents []string runner.When(func(args exec.RunArgs, _ string) bool { return slices.Contains(args.Args, "uninstall") }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { - uninstalledHosts = append(uninstalledHosts, args.Cmd) + uninstalledAgents = append(uninstalledAgents, args.Cmd) return exec.RunResult{ExitCode: 0}, nil }) - // After uninstall, the skill is gone from copilot (the explicit host). + // After uninstall, the skill is gone from copilot (the explicit agent). det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, tool *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "claude", Version: "1.0.0"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "claude", Version: "1.0.0"}}, nil }, } inst := NewInstaller(runner, NewPlatformDetector(runner), det) - result, err := inst.Uninstall(t.Context(), newSkillTool(), WithHosts("copilot")) + result, err := inst.Uninstall(t.Context(), newSkillTool(), WithAgents("copilot")) require.NoError(t, err) require.NotNil(t, result) assert.True(t, result.Success) - assert.Equal(t, []string{"copilot"}, uninstalledHosts) + assert.Equal(t, []string{"copilot"}, uninstalledAgents) } func TestUninstallSkill_NotInstalledAnywhere_Errors(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") - // Skill is not installed on any host. + // Skill is not installed on any agent. det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]InstalledSkillAgent, error) { return nil, nil }, } @@ -3578,15 +4266,15 @@ func TestUninstallSkill_NotInstalledAnywhere_Errors(t *testing.T) { assert.Contains(t, suggestion.Suggestion, "nothing to uninstall") } -func TestUninstallSkill_ExplicitUnknownHost_Errors(t *testing.T) { +func TestUninstallSkill_ExplicitUnknownAgent_Errors(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") inst := NewInstaller(runner, NewPlatformDetector(runner), &mockDetector{}) - result, err := inst.Uninstall(t.Context(), newSkillTool(), WithHosts("bogus")) + result, err := inst.Uninstall(t.Context(), newSkillTool(), WithAgents("bogus")) require.NoError(t, err) require.NotNil(t, result) @@ -3823,17 +4511,17 @@ func TestUninstall_NonSkill_VerifyDetectError(t *testing.T) { // Uninstall — additional skill branch coverage // --------------------------------------------------------------------------- -func TestUninstallSkill_NoSkillHosts_Errors(t *testing.T) { +func TestUninstallSkill_NoSkillAgents_Errors(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() inst := NewInstaller(runner, NewPlatformDetector(runner), &mockDetector{}) tool := &ToolDefinition{ - Id: "skill-no-hosts", - Name: "Skill No Hosts", + Id: "skill-no-agents", + Name: "Skill No Agents", Category: ToolCategorySkill, - // No SkillHosts configured. + // No SkillAgents configured. } result, err := inst.Uninstall(t.Context(), tool) @@ -3842,14 +4530,14 @@ func TestUninstallSkill_NoSkillHosts_Errors(t *testing.T) { require.NotNil(t, result) assert.False(t, result.Success) require.Error(t, result.Error) - assert.Contains(t, result.Error.Error(), "no SkillHosts configured") + assert.Contains(t, result.Error.Error(), "no SkillAgents configured") } -func TestUninstallSkill_MultipleHostFailures_Summarized(t *testing.T) { +func TestUninstallSkill_MultipleAgentFailures_Summarized(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot", "claude") + mockAgentPresence(runner, "copilot", "claude") runner.When(func(args exec.RunArgs, _ string) bool { return slices.Contains(args.Args, "uninstall") }).RespondFn(func(_ exec.RunArgs) (exec.RunResult, error) { @@ -3857,12 +4545,12 @@ func TestUninstallSkill_MultipleHostFailures_Summarized(t *testing.T) { }) det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{ - {Host: "copilot", Version: "1.0.0"}, - {Host: "claude", Version: "1.0.0"}, + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{ + {Agent: "copilot", Version: "1.0.0"}, + {Agent: "claude", Version: "1.0.0"}, }, nil }, } @@ -3874,31 +4562,31 @@ func TestUninstallSkill_MultipleHostFailures_Summarized(t *testing.T) { require.NotNil(t, result) assert.False(t, result.Success) require.Error(t, result.Error) - assert.Contains(t, result.Error.Error(), "could not be uninstalled for 2 host(s)") + assert.Contains(t, result.Error.Error(), "could not be uninstalled for 2 agent(s)") } func TestUninstallSkill_VerificationFails(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.When(func(args exec.RunArgs, _ string) bool { return slices.Contains(args.Args, "uninstall") }).Respond(exec.RunResult{ExitCode: 0}) // The skill remains installed on copilot after the command runs, so - // host-scoped verification never confirms removal. + // agent-scoped verification never confirms removal. det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "copilot", Version: "1.0.0"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.0.0"}}, nil }, } inst := NewInstaller(runner, NewPlatformDetector(runner), det) inst.(*installer).retryBackoff = time.Millisecond // keep the test fast - result, err := inst.Uninstall(t.Context(), newSkillTool(), WithHosts("copilot")) + result, err := inst.Uninstall(t.Context(), newSkillTool(), WithAgents("copilot")) require.NoError(t, err) require.NotNil(t, result) @@ -3911,24 +4599,24 @@ func TestUninstallSkill_VerifyDetectError(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.When(func(args exec.RunArgs, _ string) bool { return slices.Contains(args.Args, "uninstall") }).Respond(exec.RunResult{ExitCode: 0}) - // Explicit --host copilot skips DetectSkillHosts during target + // Explicit --agent copilot skips DetectSkillAgents during target // resolution, so the only call is from verification, which errors. wantErr := errors.New("plugin list failed") det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]InstalledSkillAgent, error) { return nil, wantErr }, } inst := NewInstaller(runner, NewPlatformDetector(runner), det) - result, err := inst.Uninstall(t.Context(), newSkillTool(), WithHosts("copilot")) + result, err := inst.Uninstall(t.Context(), newSkillTool(), WithAgents("copilot")) require.NoError(t, err) require.NotNil(t, result) @@ -3944,22 +4632,23 @@ func TestUninstallSkill_NoUninstallCommandConfigured(t *testing.T) { runner.MockToolInPath("copilot", nil) det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "copilot", Version: "1.0.0"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.0.0"}}, nil }, } inst := NewInstaller(runner, NewPlatformDetector(runner), det) - // A skill host with no PluginUninstallCommand configured. + // A skill agent with no PluginUninstallCommand configured. tool := &ToolDefinition{ Id: "skill-no-uninstall-cmd", Name: "Skill No Uninstall Cmd", Category: ToolCategorySkill, - SkillHosts: []SkillHost{ + SkillAgents: []SkillAgent{ { - Host: "copilot", + DisplayName: "copilot", + Command: "copilot", PluginListCommand: []string{"plugin", "list"}, PluginName: "azure@azure-skills", VersionRegex: `(\d+\.\d+\.\d+)`, @@ -3968,7 +4657,7 @@ func TestUninstallSkill_NoUninstallCommandConfigured(t *testing.T) { }, } - result, err := inst.Uninstall(t.Context(), tool, WithHosts("copilot")) + result, err := inst.Uninstall(t.Context(), tool, WithAgents("copilot")) require.NoError(t, err) require.NotNil(t, result) @@ -3977,11 +4666,11 @@ func TestUninstallSkill_NoUninstallCommandConfigured(t *testing.T) { assert.Contains(t, result.Error.Error(), "no uninstall command configured") } -// TestHostUsable_OnPathProbeMemoizedAcrossPasses verifies that an on-PATH -// host's `--version` is spawned at most once per command even though host +// TestAgentUsable_OnPathProbeMemoizedAcrossPasses verifies that an on-PATH +// agent's `--version` is spawned at most once per command even though agent // resolution probes it from several call sites: the result is memoized on the // installer for its lifetime (one process == one command). -func TestHostUsable_OnPathProbeMemoizedAcrossPasses(t *testing.T) { +func TestAgentUsable_OnPathProbeMemoizedAcrossPasses(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() @@ -3998,21 +4687,21 @@ func TestHostUsable_OnPathProbeMemoizedAcrossPasses(t *testing.T) { inst := NewInstaller(runner, NewPlatformDetector(runner), &mockDetector{}) - // Two separate host-resolution passes on the same installer. - require.Equal(t, []string{"copilot"}, - inst.AvailableSkillHosts(t.Context(), newSkillTool())) - require.Equal(t, []string{"copilot"}, - inst.AvailableSkillHosts(t.Context(), newSkillTool())) + // Two separate agent-resolution passes on the same installer. + commands1, _ := inst.AvailableSkillAgents(t.Context(), newSkillTool()) + require.Equal(t, []string{"copilot"}, commands1) + commands2, _ := inst.AvailableSkillAgents(t.Context(), newSkillTool()) + require.Equal(t, []string{"copilot"}, commands2) assert.Equal(t, 1, copilotVersionCalls, - "on-PATH host `--version` must be probed at most once per command") + "on-PATH agent `--version` must be probed at most once per command") } -// TestHostUsable_NotOnPathResultNotMemoized verifies that a not-on-PATH result -// is never cached, so a host installed earlier in the same command (e.g. +// TestAgentUsable_NotOnPathResultNotMemoized verifies that a not-on-PATH result +// is never cached, so an agent installed earlier in the same command (e.g. // `azd tool install github-copilot-cli azure-skills`) is still picked up by a // later resolution pass. -func TestHostUsable_NotOnPathResultNotMemoized(t *testing.T) { +func TestAgentUsable_NotOnPathResultNotMemoized(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() @@ -4022,7 +4711,8 @@ func TestHostUsable_NotOnPathResultNotMemoized(t *testing.T) { inst := NewInstaller(runner, NewPlatformDetector(runner), &mockDetector{}) // First pass: copilot absent. - require.Empty(t, inst.AvailableSkillHosts(t.Context(), newSkillTool())) + commandsAbsent, _ := inst.AvailableSkillAgents(t.Context(), newSkillTool()) + require.Empty(t, commandsAbsent) // copilot becomes available and functional mid-command. runner.MockToolInPath("copilot", nil) @@ -4031,6 +4721,52 @@ func TestHostUsable_NotOnPathResultNotMemoized(t *testing.T) { }).Respond(exec.RunResult{Stdout: "GitHub Copilot CLI 1.1.70", ExitCode: 0}) // Second pass picks it up: the earlier "not on PATH" result was not cached. - assert.Equal(t, []string{"copilot"}, - inst.AvailableSkillHosts(t.Context(), newSkillTool())) + commandsPresent, _ := inst.AvailableSkillAgents(t.Context(), newSkillTool()) + assert.Equal(t, []string{"copilot"}, commandsPresent) +} + +// TestLineWriter_Write_SerializesEmit drives lineWriter.Write from many +// goroutines at once — as os/exec does when a command's stdout and stderr are +// written concurrently — with an unsynchronized emit (mirroring the buffered +// slice in renderSkillStep). The mutex must serialize emit so no line is lost +// or corrupted. Run under -race to catch a regression. +func TestLineWriter_Write_SerializesEmit(t *testing.T) { + var got []string // deliberately unsynchronized, like renderSkillStep's buffer + lw := &lineWriter{emit: func(s string) { got = append(got, s) }} + + const writers = 100 + var wg sync.WaitGroup + for range writers { + wg.Go(func() { + _, _ = lw.Write([]byte("a\nb\nc\n")) // 3 lines per write + }) + } + wg.Wait() + + assert.Len(t, got, writers*3, + "every emitted line must be recorded without loss under concurrent writes") +} + +// TestLineWriter_BuffersAcrossWritesAndPreservesEmptyLines verifies the +// line-splitting contract: a logical line split across multiple Write calls is +// emitted once (os/exec chunks arbitrarily), empty lines are preserved, and a +// trailing line with no newline is held until Flush rather than dropped. +func TestLineWriter_BuffersAcrossWritesAndPreservesEmptyLines(t *testing.T) { + var got []string + lw := &lineWriter{emit: func(s string) { got = append(got, s) }} + + // A single logical line split across two writes must emit once, joined. + _, _ = lw.Write([]byte("hel")) + _, _ = lw.Write([]byte("lo\n")) + // Consecutive newlines yield preserved empty lines. + _, _ = lw.Write([]byte("a\n\nb\n")) + // Trailing content with no newline is buffered, not emitted yet. + _, _ = lw.Write([]byte("no newline")) + + assert.Equal(t, []string{"hello", "a", "", "b"}, got, + "complete lines emit once, empty lines preserved, partial line held") + + lw.Flush() + assert.Equal(t, []string{"hello", "a", "", "b", "no newline"}, got, + "Flush emits the trailing line that had no terminating newline") } diff --git a/cli/azd/pkg/tool/manager.go b/cli/azd/pkg/tool/manager.go index c1b0bfd1f99..5e15484cac6 100644 --- a/cli/azd/pkg/tool/manager.go +++ b/cli/azd/pkg/tool/manager.go @@ -68,12 +68,16 @@ func (m *Manager) FindTool(id string) (*ToolDefinition, error) { return nil, fmt.Errorf("finding tool %q: not found", id) } -// AvailableSkillHosts returns the names of the given skill tool's -// configured agentic CLI hosts that are currently usable (a functional CLI -// on PATH), in manifest order (preferred host first). It returns nil for -// non-skill tools or when none of the hosts are usable. -func (m *Manager) AvailableSkillHosts(ctx context.Context, tool *ToolDefinition) []string { - return m.installer.AvailableSkillHosts(ctx, tool) +// AvailableSkillAgents returns the given skill tool's configured agentic CLI +// agents that are currently usable (a functional CLI on PATH), in manifest +// order (preferred agent first), as two index-aligned slices: the command +// identities and their display names. Both are nil for non-skill tools or +// when none of the agents are usable. +func (m *Manager) AvailableSkillAgents( + ctx context.Context, + tool *ToolDefinition, +) (commands []string, names []string) { + return m.installer.AvailableSkillAgents(ctx, tool) } // DetectAll probes every tool in the manifest and returns a status @@ -221,17 +225,17 @@ func (m *Manager) UpgradeAll( // is resolved against the manifest and then passed to the installer's // Uninstall method. Failures are recorded per tool so that one failure // does not abort the batch. For skill tools the optional install options -// (e.g. [WithHosts]) select which agent host(s) to remove the skill +// (e.g. [WithAgents]) select which agents to remove the skill // from. Dependencies are intentionally left in place — azd does not // auto-remove tools that other tools may rely on. // // Skills are uninstalled before any other tool. A skill is installed as a -// plugin inside an agent host CLI (e.g. azure-skills inside copilot), so -// that host CLI must still be on PATH to remove the skill cleanly. The -// built-in manifest lists skills AFTER their host CLIs (see -// TestManifest_SkillsListedAfterHostCLIs) so installs add the host first; +// plugin inside an agent CLI (e.g. azure-skills inside copilot), so +// that agent CLI must still be on PATH to remove the skill cleanly. The +// built-in manifest lists skills AFTER their agent CLIs (see +// TestManifest_SkillsListedAfterAgentCLIs) so installs add the agent first; // uninstall needs the reverse. Without this ordering a batch such as -// `azd tool uninstall --all` would remove the host CLI first and orphan +// `azd tool uninstall --all` would remove the agent CLI first and orphan // the skill, leaving it undetectable and impossible to clean up via azd. func (m *Manager) UninstallTools( ctx context.Context, diff --git a/cli/azd/pkg/tool/manager_test.go b/cli/azd/pkg/tool/manager_test.go index e8b6200a5db..ada53d81cff 100644 --- a/cli/azd/pkg/tool/manager_test.go +++ b/cli/azd/pkg/tool/manager_test.go @@ -33,7 +33,7 @@ type mockInstaller struct { tool *ToolDefinition, opts ...InstallOption, ) (*InstallResult, error) - availableSkillHostsFn func(ctx context.Context, tool *ToolDefinition) []string + availableSkillAgentsFn func(ctx context.Context, tool *ToolDefinition) (commands []string, names []string) } func (m *mockInstaller) Install( @@ -64,11 +64,14 @@ func (m *mockInstaller) Upgrade( }, nil } -func (m *mockInstaller) AvailableSkillHosts(ctx context.Context, tool *ToolDefinition) []string { - if m.availableSkillHostsFn != nil { - return m.availableSkillHostsFn(ctx, tool) +func (m *mockInstaller) AvailableSkillAgents( + ctx context.Context, + tool *ToolDefinition, +) (commands []string, names []string) { + if m.availableSkillAgentsFn != nil { + return m.availableSkillAgentsFn(ctx, tool) } - return nil + return nil, nil } func (m *mockInstaller) Uninstall( @@ -429,13 +432,13 @@ func TestManager_InstallToolsDependencyResolution(t *testing.T) { }) } -// TestManifest_SkillsListedAfterHostCLIs verifies the ordering invariant +// TestManifest_SkillsListedAfterAgentCLIs verifies the ordering invariant // the install flow relies on: every skill tool appears AFTER any agent -// host CLI it could install through (e.g. github-copilot-cli) in the +// agent CLI it could install through (e.g. github-copilot-cli) in the // built-in manifest. Batch installs (--all, interactive picker) derive // their order from the manifest, so this ordering is what guarantees the -// host CLI is installed before the skill — no runtime re-sorting needed. -func TestManifest_SkillsListedAfterHostCLIs(t *testing.T) { +// agent CLI is installed before the skill — no runtime re-sorting needed. +func TestManifest_SkillsListedAfterAgentCLIs(t *testing.T) { t.Parallel() tools := BuiltInTools() @@ -445,8 +448,9 @@ func TestManifest_SkillsListedAfterHostCLIs(t *testing.T) { }) } - // Maps a host binary name to the manifest tool id that provides it. - hostToolID := map[string]string{ + // Maps an agent binary name (SkillAgent.Command) to the manifest tool id + // that provides it. + agentToolID := map[string]string{ "copilot": "github-copilot-cli", } @@ -455,18 +459,18 @@ func TestManifest_SkillsListedAfterHostCLIs(t *testing.T) { continue } skillIdx := indexOf(td.Id) - for _, host := range td.SkillHosts { - cliID, ok := hostToolID[host.Host] + for _, agent := range td.SkillAgents { + cliID, ok := agentToolID[agent.Command] if !ok { - continue // host has no installable CLI in the manifest + continue // agent has no installable CLI in the manifest } cliIdx := indexOf(cliID) if cliIdx < 0 { continue } assert.Greater(t, skillIdx, cliIdx, - "skill %q must be listed after its host CLI %q in the "+ - "manifest so batch installs install the host CLI first", + "skill %q must be listed after its agent CLI %q in the "+ + "manifest so batch installs install the agent CLI first", td.Id, cliID) } } @@ -589,7 +593,7 @@ func TestManager_UninstallTools(t *testing.T) { _, err := mgr.UninstallTools( t.Context(), []string{"az-cli"}, - WithHosts("copilot"), + WithAgents("copilot"), ) require.NoError(t, err) @@ -625,7 +629,7 @@ func TestManager_UninstallTools(t *testing.T) { assert.True(t, results[1].Success) }) - t.Run("UninstallsSkillsBeforeHostCLIs", func(t *testing.T) { + t.Run("UninstallsSkillsBeforeAgentCLIs", func(t *testing.T) { t.Parallel() var order []string @@ -642,10 +646,10 @@ func TestManager_UninstallTools(t *testing.T) { mgr := NewManager(&mockDetector{}, inst, nil) - // IDs supplied in manifest order (host CLI before skill), which is + // IDs supplied in manifest order (agent CLI before skill), which is // what `--all` and the interactive picker produce. The skill must - // still be uninstalled first so its host CLI is on PATH to remove - // it; otherwise removing the host first would orphan the skill. + // still be uninstalled first so its agent CLI is on PATH to remove + // it; otherwise removing the agent first would orphan the skill. _, err := mgr.UninstallTools( t.Context(), []string{"github-copilot-cli", "azure-skills"}, @@ -799,20 +803,21 @@ func TestManager_UpgradeAll(t *testing.T) { } // --------------------------------------------------------------------------- -// AvailableSkillHosts +// AvailableSkillAgents // --------------------------------------------------------------------------- -func TestManager_AvailableSkillHosts(t *testing.T) { +func TestManager_AvailableSkillAgents(t *testing.T) { installer := &mockInstaller{ - availableSkillHostsFn: func(_ context.Context, _ *ToolDefinition) []string { - return []string{"copilot", "claude"} + availableSkillAgentsFn: func(_ context.Context, _ *ToolDefinition) (commands []string, names []string) { + return []string{"copilot", "claude"}, []string{"GitHub Copilot CLI", "Claude Code CLI"} }, } m := NewManager(&mockDetector{}, installer, nil) - got := m.AvailableSkillHosts(t.Context(), &ToolDefinition{ + commands, names := m.AvailableSkillAgents(t.Context(), &ToolDefinition{ Id: "azure-skills", Category: ToolCategorySkill, }) - assert.Equal(t, []string{"copilot", "claude"}, got) + assert.Equal(t, []string{"copilot", "claude"}, commands) + assert.Equal(t, []string{"GitHub Copilot CLI", "Claude Code CLI"}, names) } diff --git a/cli/azd/pkg/tool/manifest.go b/cli/azd/pkg/tool/manifest.go index 354c5a46678..a8498e4d6c0 100644 --- a/cli/azd/pkg/tool/manifest.go +++ b/cli/azd/pkg/tool/manifest.go @@ -25,8 +25,8 @@ const ( ToolCategoryServer ToolCategory = "server" // ToolCategoryAzdExtension is an azd extension installed via `azd extension install`. ToolCategoryAzdExtension ToolCategory = "azd-extension" - // ToolCategorySkill is a skill hosted by an agent CLI that azd installs through the - // host's native plugin commands. + // ToolCategorySkill is a skill installed through an agent CLI's native + // plugin commands. ToolCategorySkill ToolCategory = "skill" ) @@ -48,44 +48,54 @@ type Checksum struct { Value string } -// SkillHost describes how a single agent CLI host (e.g. GitHub Copilot CLI, +// SkillAgent describes how a single agent CLI (e.g. GitHub Copilot CLI, // Claude Code) installs and updates a skill. Skill tools carry one or more -// SkillHost entries; by default the installer targets the preferred host +// SkillAgent entries; by default the installer targets the preferred agent // (the first on PATH), but install/upgrade can target specific or all -// detected hosts when the caller selects them (e.g. via `--host`). -type SkillHost struct { - // Host is the binary name of the agent CLI (e.g. "copilot", "claude"). - Host string +// detected agents when the caller selects them (e.g. via `--agent`). +type SkillAgent struct { + // DisplayName is the agent's display name, shown to the user (e.g. "Copilot", + // "Claude"). Display-only: it is NOT the value + // --agent matches against — see Command. + DisplayName string + // Command is the agent CLI's executable name, used to run plugin + // commands and version probes (e.g. "copilot", "claude"), and the value + // --agent is matched against (case-insensitively, by findSkillAgent). + // Required and must be non-empty: it is the real, case-correct binary + // name run directly by the installer and detector paths, so exec works on + // case-sensitive filesystems (Linux). TestBuiltInTools_SkillAgentsHaveCommand + // enforces that every configured agent sets it. + Command string // MarketplaceAddCommand is the optional one-time command that registers - // the plugin marketplace with the host (e.g. ["plugin", "marketplace", + // the plugin marketplace with the agent (e.g. ["plugin", "marketplace", // "add", "microsoft/azure-skills"]). Empty when not required. MarketplaceAddCommand []string - // PluginInstallCommand installs the plugin via the host + // PluginInstallCommand installs the plugin via the agent // (e.g. ["plugin", "install", "azure@azure-skills"]). PluginInstallCommand []string // PluginUpdateCommand updates the plugin to its latest version. PluginUpdateCommand []string - // PluginUninstallCommand removes the plugin from the host + // PluginUninstallCommand removes the plugin from the agent // (e.g. ["plugin", "uninstall", "azure@azure-skills"]). PluginUninstallCommand []string - // PluginListCommand lists installed plugins on the host + // PluginListCommand lists installed plugins on the agent // (e.g. ["plugin", "list"]). The detector runs this command and // searches the output for PluginName to decide whether the skill // is installed. PluginListCommand []string - // PluginName is the plugin's short name as reported by the host's + // PluginName is the plugin's short name as reported by the agent's // plugin listing (e.g. "azure"). Used by the detector. PluginName string // VersionRegex is a Go regular expression with a capture group for // the semver portion of the version output of PluginListCommand. // Required: the detector treats a VersionRegex match as the // authoritative signal that the skill is installed (and uses the - // captured group as InstalledVersion). A host with an empty + // captured group as InstalledVersion). An agent with an empty // VersionRegex is never reported as installed. VersionRegex string - // BinaryVersionArgs are the CLI arguments that make the host binary + // BinaryVersionArgs are the CLI arguments that make the agent binary // print its own version (e.g. ["--version"]). Together with - // BinaryVersionRegex these let the installer confirm the host is a + // BinaryVersionRegex these let the installer confirm the agent is a // genuine, functional CLI before installing through it — not merely a // file of the same name on PATH. Some environments place a launcher // stub on PATH (e.g. the VS Code GitHub Copilot Chat extension drops a @@ -96,13 +106,13 @@ type SkillHost struct { // an existence-only check. BinaryVersionArgs []string // BinaryVersionRegex is a Go regular expression whose first capture - // group matches the host binary's own version. To avoid mistaking a - // launcher stub for a real CLI, anchor it to the host's `--version` + // group matches the agent binary's own version. To avoid mistaking a + // launcher stub for a real CLI, anchor it to the agent's `--version` // banner with `(?m)^` (e.g. `(?m)^GitHub Copilot CLI\s+v?(\d+\.\d+\.\d+)`) // rather than matching a bare semver: a stub's output may contain an // incidental version-shaped token (a bundled runtime version, a path // build number, a URL) that must not count. The installer treats a match - // against the probe output as proof the host CLI is genuinely installed. + // against the probe output as proof the agent CLI is genuinely installed. // When empty, the functional probe is skipped. BinaryVersionRegex string } @@ -169,15 +179,18 @@ type ToolDefinition struct { // several official install methods (e.g. the GitHub Copilot CLI) list them // all. InstallStrategies map[string][]InstallStrategy - // SkillHosts describes the agent CLI hosts that can install this tool when - // Category == ToolCategorySkill. Hosts are listed in preference order: by - // default the first host on PATH is used, but install/upgrade can target - // specific or all detected hosts (e.g. `--host all`). Platform-agnostic - // because the host CLI's plugin command syntax does not vary between + // SkillAgents describes the agent CLIs that can install this tool when + // Category == ToolCategorySkill. Agents are listed in preference order: by + // default the first agent on PATH is used, but install/upgrade can target + // specific or all detected agents (e.g. `--agent all`). Platform-agnostic + // because the agent CLI's plugin command syntax does not vary between // operating systems. Ignored for other categories. - SkillHosts []SkillHost + SkillAgents []SkillAgent // Dependencies lists the IDs of tools that must be installed before this one. Dependencies []string + // SpinnerNote is an optional follow-up message shown after a successful + // install. Empty for tools with no note. + SpinnerNote string } // BuiltInTools returns the full set of tools that ship with the azd tool registry. @@ -390,9 +403,12 @@ func azureSkills() *ToolDefinition { Category: ToolCategorySkill, Priority: ToolPriorityRecommended, Website: "https://github.com/microsoft/azure-skills", - SkillHosts: []SkillHost{ + SpinnerNote: "Azure Skills are now available in your AI agents. " + + "Ask your agent to create and manage Azure resources.", + SkillAgents: []SkillAgent{ { - Host: "copilot", + DisplayName: "Copilot", + Command: "copilot", MarketplaceAddCommand: []string{"plugin", "marketplace", "add", "microsoft/azure-skills"}, PluginInstallCommand: []string{"plugin", "install", "azure@azure-skills"}, PluginUpdateCommand: []string{"plugin", "update", "azure@azure-skills"}, @@ -401,15 +417,16 @@ func azureSkills() *ToolDefinition { PluginName: "azure@azure-skills", // Sample: " • azure@azure-skills (v1.1.70)" VersionRegex: `azure@azure-skills[^\n]*?(\d+\.\d+\.\d+)`, - // Probe the host binary itself so a launcher stub that only - // prompts to install the real CLI is not mistaken for a host. - // Anchored to copilot's `--version` banner ("GitHub Copilot + // Probe the agent binary itself so a launcher stub that only + // prompts to install the real CLI is not mistaken for an agent. + // Anchored to GitHub Copilot CLI's `--version` banner ("GitHub Copilot // CLI 1.0.64-3") so an incidental semver cannot pass. BinaryVersionArgs: []string{"--version"}, BinaryVersionRegex: `(?m)^GitHub Copilot CLI\s+v?(\d+\.\d+\.\d+)`, }, { - Host: "claude", + DisplayName: "Claude", + Command: "claude", MarketplaceAddCommand: []string{ "plugin", "marketplace", "add", "https://github.com/microsoft/azure-skills", }, @@ -431,8 +448,8 @@ func azureSkills() *ToolDefinition { // "version" follows "id" within the same object, so [^}] // keeps the capture scoped to the azure@azure-skills entry. VersionRegex: `"id":\s*"azure@azure-skills"[^}]*?"version":\s*"v?(\d+\.\d+\.\d+)"`, - // Probe the host binary itself so a launcher stub that only - // prompts to install the real CLI is not mistaken for a host. + // Probe the agent binary itself so a launcher stub that only + // prompts to install the real CLI is not mistaken for an agent. // Anchored to claude's `--version` banner ("2.1.178 (Claude // Code)") so an incidental semver cannot pass. BinaryVersionArgs: []string{"--version"}, diff --git a/cli/azd/pkg/tool/manifest_test.go b/cli/azd/pkg/tool/manifest_test.go index 39050bf906f..30302f24774 100644 --- a/cli/azd/pkg/tool/manifest_test.go +++ b/cli/azd/pkg/tool/manifest_test.go @@ -121,9 +121,9 @@ func TestBuiltInTools(t *testing.T) { tools := BuiltInTools() for _, tool := range tools { if tool.Category == ToolCategorySkill { - // Skill tools install via SkillHosts, not InstallStrategies. - assert.NotEmpty(t, tool.SkillHosts, - "skill tool %q must have SkillHosts", + // Skill tools install via SkillAgents, not InstallStrategies. + assert.NotEmpty(t, tool.SkillAgents, + "skill tool %q must have SkillAgents", tool.Id) continue } @@ -435,52 +435,55 @@ func TestGithubCopilotCLI_InstallStrategies(t *testing.T) { assert.Equal(t, []string{"uninstall", "-g", "@github/copilot"}, args) } -// TestAzureSkillsHostVersionProbeRegex locks the per-host BinaryVersionRegex -// against real `--version` banners: each host's regex must capture the version -// from that host's genuine output and reject non-version output (a launcher +// TestAzureSkillsAgentVersionProbeRegex locks the per-agent BinaryVersionRegex +// against real `--version` banners: each agent's regex must capture the version +// from that agent's genuine output and reject non-version output (a launcher // stub prompt, the banner prefix without a version, or an incidental semver // elsewhere in the stream). -func TestAzureSkillsHostVersionProbeRegex(t *testing.T) { +func TestAzureSkillsAgentVersionProbeRegex(t *testing.T) { t.Parallel() rx := map[string]string{} - for _, h := range azureSkills().SkillHosts { - rx[h.Host] = h.BinaryVersionRegex + for _, h := range azureSkills().SkillAgents { + // Key by the exec binary (Command, e.g. "copilot"/"claude") so the + // cases below are independent of the manifest's display Agent (e.g. + // "GitHub Copilot CLI"). + rx[h.Command] = h.BinaryVersionRegex } cases := []struct { name string - host string + agent string output string - wantVer string // "" => must not match (host treated as unusable) + wantVer string // "" => must not match (agent treated as unusable) }{ { name: "copilot real banner", - host: "copilot", + agent: "copilot", output: "GitHub Copilot CLI 1.0.64-3.\nRun 'copilot update' to check for updates.", wantVer: "1.0.64", }, { name: "claude real banner", - host: "claude", + agent: "claude", output: "2.1.178 (Claude Code)", wantVer: "2.1.178", }, { name: "copilot stub prompt", - host: "copilot", + agent: "copilot", output: "Cannot find GitHub Copilot CLI (https://docs.github.com/copilot)\nInstall GitHub Copilot CLI? ['y/N']", wantVer: "", }, { name: "copilot banner prefix without version", - host: "copilot", + agent: "copilot", output: "GitHub Copilot CLI is not installed\nnode v20.11.1", wantVer: "", }, { name: "claude version not at line start", - host: "claude", + agent: "claude", output: "see https://example.com/1.2.3 (Claude Code plugin)", wantVer: "", }, @@ -489,7 +492,33 @@ func TestAzureSkillsHostVersionProbeRegex(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tc.wantVer, matchVersion(tc.output, rx[tc.host])) + assert.Equal(t, tc.wantVer, matchVersion(tc.output, rx[tc.agent])) }) } } + +// TestAzureSkillsPostInstallNote locks the follow-up guidance shown after a +// successful `azd tool install azure-skills`, which the install action surfaces +// as the ActionResult FollowUp. +func TestAzureSkillsPostInstallNote(t *testing.T) { + t.Parallel() + + note := azureSkills().SpinnerNote + assert.NotEmpty(t, note, "azure-skills must set a post-install follow-up note") + assert.Contains(t, note, "Azure Skills are now available") +} + +// TestBuiltInTools_SkillAgentsHaveCommand guarantees that every configured skill +// agent sets a non-empty Command. Installer and detector paths run agent.Command +// directly (no fallback), so a missing Command would try to exec an empty +// string — this test fails fast if a new manifest entry omits it. +func TestBuiltInTools_SkillAgentsHaveCommand(t *testing.T) { + t.Parallel() + + for _, td := range BuiltInTools() { + for _, agent := range td.SkillAgents { + assert.NotEmpty(t, agent.Command, + "tool %q agent %q must set a non-empty Command", td.Id, agent.DisplayName) + } + } +} diff --git a/cli/azd/pkg/tool/update_checker.go b/cli/azd/pkg/tool/update_checker.go index 4a08397ed60..f27914ca9dd 100644 --- a/cli/azd/pkg/tool/update_checker.go +++ b/cli/azd/pkg/tool/update_checker.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "sync" "time" @@ -70,13 +71,50 @@ type UpdateCheckResult struct { // Tool is the registry definition that was checked. Tool *ToolDefinition // CurrentVersion is the version currently installed on the local - // machine (empty when the tool is not installed). + // machine (empty when the tool is not installed). For a skill installed + // on multiple agents this is only the first agent's version (an + // aggregate); see SkillAgents for the per-agent versions. CurrentVersion string // LatestVersion is the newest version known to the update checker. LatestVersion string - // UpdateAvailable is true when LatestVersion is non-empty and - // differs from CurrentVersion. + // UpdateAvailable is true when LatestVersion is non-empty and differs from + // CurrentVersion — except for a skill installed on multiple agents, where + // CurrentVersion/LatestVersion are aggregates and UpdateAvailable can be + // true even when CurrentVersion == LatestVersion (it is set whenever ANY + // agent is behind the latest version). Callers must not infer a skill's + // update state from the two aggregate version fields alone; use SkillAgents + // for the authoritative per-agent state. UpdateAvailable bool + // SkillAgents lists, for a skill tool, the per-agent installed version + // and whether an update is available for it (LatestVersion is the same + // for every agent). Populated only for skills; nil otherwise. + SkillAgents []SkillAgentUpdate +} + +// SkillAgentUpdate pairs an agent CLI with the skill version installed +// through it and whether a newer version is available. +type SkillAgentUpdate struct { + // Agent is the agent CLI binary name (e.g. "copilot"). + Agent string + // CurrentVersion is the skill version installed through this agent. + CurrentVersion string + // UpdateAvailable is true when a newer version than CurrentVersion is + // available for this agent. + UpdateAvailable bool +} + +// anyAgentUpdatable reports whether any of a skill's installed agents is behind +// the latest version. A skill installed on multiple agents exposes only the +// first agent's version as the tool's aggregate InstalledVersion, so tool-level +// update checks must consider every agent — otherwise a stale agent is missed +// when the first is current. Returns false for non-skills (no SkillAgents). +func anyAgentUpdatable(latest string, agents []InstalledSkillAgent) bool { + for _, h := range agents { + if isNewerVersion(latest, h.Version) { + return true + } + } + return false } // UpdateChecker performs periodic update checks for registered tools, @@ -208,24 +246,47 @@ func (uc *UpdateChecker) Check( for _, t := range tools { status := statusByID[t.Id] + latestVer := uc.resolveLatestVersion( + ctx, t, existing, cacheValid, + ) + // Normalize a leading "v" (release tags often carry one, e.g. "v1.0.69") + // so the displayed latest version is consistent with the installed + // version, which is captured without a prefix. Version comparison is + // already prefix-agnostic (see isNewerVersion), so this only affects + // display. + latestVer = strings.TrimPrefix(latestVer, "v") + var currentVer string + var skillAgents []SkillAgentUpdate if status != nil { currentVer = status.InstalledVersion + for _, h := range status.SkillAgents { + skillAgents = append(skillAgents, SkillAgentUpdate{ + Agent: h.Agent, + CurrentVersion: h.Version, + UpdateAvailable: isNewerVersion(latestVer, h.Version), + }) + } } - latestVer := uc.resolveLatestVersion( - ctx, t, existing, cacheValid, - ) - cache.Tools[t.Id] = CachedToolVersion{ LatestVersion: latestVer, } + // For a skill installed on multiple agents, currentVer reflects only + // the first agent, so treat the tool as updatable when ANY agent is + // behind the latest version. + updateAvailable := isNewerVersion(latestVer, currentVer) + if status != nil && anyAgentUpdatable(latestVer, status.SkillAgents) { + updateAvailable = true + } + results = append(results, &UpdateCheckResult{ Tool: t, CurrentVersion: currentVer, LatestVersion: latestVer, - UpdateAvailable: isNewerVersion(latestVer, currentVer), + UpdateAvailable: updateAvailable, + SkillAgents: skillAgents, }) } @@ -430,7 +491,13 @@ func (uc *UpdateChecker) HasUpdatesAvailable( count := 0 for _, s := range statuses { latest, ok := candidates[s.Tool.Id] - if ok && s.Installed && isNewerVersion(latest, s.InstalledVersion) { + if !ok || !s.Installed { + continue + } + // Count a skill whose first agent is current but another agent is stale + // (anyAgentUpdatable), as well as the plain single-version case. + if isNewerVersion(latest, s.InstalledVersion) || + anyAgentUpdatable(latest, s.SkillAgents) { count++ } } diff --git a/cli/azd/pkg/tool/update_checker_test.go b/cli/azd/pkg/tool/update_checker_test.go index c7867586986..237ca291698 100644 --- a/cli/azd/pkg/tool/update_checker_test.go +++ b/cli/azd/pkg/tool/update_checker_test.go @@ -471,6 +471,57 @@ func TestCheck(t *testing.T) { assert.Equal(t, "2.0.0", results[0].LatestVersion) assert.True(t, results[0].UpdateAvailable) }) + + t.Run("SkillAggregatesAnyAgentUpdate", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr := newMockUserConfigManager() + + // The aggregate InstalledVersion is the first agent (current), but a + // second agent is behind — the tool must still report an update. + det := &mockDetector{ + detectAllFn: func( + _ context.Context, tools []*ToolDefinition, + ) ([]*ToolStatus, error) { + return []*ToolStatus{{ + Tool: tools[0], + Installed: true, + InstalledVersion: "2.0.0", + SkillAgents: []InstalledSkillAgent{ + {Agent: "copilot", Version: "2.0.0"}, + {Agent: "claude", Version: "1.0.0"}, + }, + }}, nil + }, + } + + uc := NewUpdateChecker(mgr, det, staticDir(tmpDir), nil) + + seedCache := &UpdateCheckCache{ + CheckedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + Tools: map[string]CachedToolVersion{ + "azure-skills": {LatestVersion: "2.0.0"}, + }, + } + require.NoError(t, uc.SaveCache(seedCache)) + + tools := []*ToolDefinition{ + {Id: "azure-skills", Name: "Azure Skills", Category: ToolCategorySkill}, + } + + results, err := uc.Check(t.Context(), tools) + require.NoError(t, err) + require.Len(t, results, 1) + + assert.Equal(t, "2.0.0", results[0].CurrentVersion) + assert.True(t, results[0].UpdateAvailable, + "a stale second agent must mark the skill as updatable") + require.Len(t, results[0].SkillAgents, 2) + assert.False(t, results[0].SkillAgents[0].UpdateAvailable) + assert.True(t, results[0].SkillAgents[1].UpdateAvailable) + }) } // --------------------------------------------------------------------------- @@ -514,6 +565,50 @@ func TestHasUpdatesAvailable(t *testing.T) { assert.False(t, hasUpdates) assert.Equal(t, 0, count) }) + + t.Run("SkillWithStaleSecondaryAgentReportsUpdate", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr := newMockUserConfigManager() + + skill := &ToolDefinition{Id: "azure-skills", Name: "Azure Skills", Category: ToolCategorySkill} + det := &mockDetector{ + detectAllFn: func(_ context.Context, tools []*ToolDefinition) ([]*ToolStatus, error) { + statuses := make([]*ToolStatus, len(tools)) + for i, tl := range tools { + statuses[i] = &ToolStatus{ + Tool: tl, + Installed: true, + // Aggregate version == the first agent, which is current; + // only the second agent is stale. + InstalledVersion: "2.0.0", + SkillAgents: []InstalledSkillAgent{ + {Agent: "copilot", Version: "2.0.0"}, // current + {Agent: "claude", Version: "1.0.0"}, // stale + }, + } + } + return statuses, nil + }, + } + uc := NewUpdateChecker(mgr, det, staticDir(tmpDir), nil) + + cache := &UpdateCheckCache{ + CheckedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Hour), + Tools: map[string]CachedToolVersion{ + "azure-skills": {LatestVersion: "2.0.0"}, + }, + } + require.NoError(t, uc.SaveCache(cache)) + + hasUpdates, count, err := uc.HasUpdatesAvailable(t.Context(), []*ToolDefinition{skill}) + require.NoError(t, err) + assert.True(t, hasUpdates, + "a skill with a stale secondary agent must report an update even when the aggregate version is current") + assert.Equal(t, 1, count) + }) } // --------------------------------------------------------------------------- diff --git a/cli/azd/pkg/ux/multi_select.go b/cli/azd/pkg/ux/multi_select.go index 3a9f1006e9e..f8dc20043be 100644 --- a/cli/azd/pkg/ux/multi_select.go +++ b/cli/azd/pkg/ux/multi_select.go @@ -50,6 +50,11 @@ var DefaultMultiSelectOptions MultiSelectOptions = MultiSelectOptions{ AllowEmptySelection: new(false), } +// multiSelectFilterThreshold is the minimum number of choices at which the +// interactive text filter and the None/All shortcut are offered. Shorter lists +// are easy to scan and toggle directly, so both are hidden to reduce clutter. +const multiSelectFilterThreshold = 6 + type MultiSelectChoice struct { Value string Label string @@ -141,7 +146,7 @@ func (p *MultiSelect) Ask(ctx context.Context) ([]*MultiSelectChoice, error) { p.canvas.Close() }() - if !*p.options.EnableFiltering { + if !p.filteringEnabled() { p.cursor.HideCursor() } @@ -169,7 +174,7 @@ func (p *MultiSelect) Ask(ctx context.Context) ([]*MultiSelectChoice, error) { p.showHelp = args.Hint - if *p.options.EnableFiltering { + if p.filteringEnabled() { p.filter = strings.TrimSpace(args.Value) } @@ -251,6 +256,13 @@ func (p *MultiSelect) sortSelectedChoices() []*MultiSelectChoice { return finalSelected } +// filteringEnabled reports whether interactive text filtering is offered: the +// caller must allow it and the list must be long enough to be worth filtering +// (see multiSelectFilterThreshold). +func (p *MultiSelect) filteringEnabled() bool { + return *p.options.EnableFiltering && len(p.choices) >= multiSelectFilterThreshold +} + func (p *MultiSelect) applyFilter() { // Filter options if p.filter == "" { @@ -337,7 +349,7 @@ func (p *MultiSelect) renderOptions(printer Printer, indent string) { // Show checkbox checkbox := " " if option.Selected { - checkbox = output.WithSuccessFormat("✔") + checkbox = output.WithSuccessFormat("✓") } // Show item digit prefixes @@ -435,7 +447,7 @@ func (p *MultiSelect) renderMessage(printer Printer) { printer.Fprintln() // Filter - if !p.cancelled && !p.complete && *p.options.EnableFiltering { + if !p.cancelled && !p.complete && p.filteringEnabled() { printer.Fprintln() printer.Fprintf(" Filter: ") @@ -486,11 +498,18 @@ func (p *MultiSelect) renderFooter(printer Printer) { printer.Fprintln() printer.Fprintln(output.WithGrayFormat("───────────────────────────────────────")) - printer.Fprintln(output.WithGrayFormat("Use arrows to move, use space to select")) - printer.Fprintln(output.WithGrayFormat("Use left/right to select none/all")) + + hint := output.WithHighLightFormat("↑↓") + output.WithGrayFormat(" Move") + if len(p.choices) >= multiSelectFilterThreshold { + hint += output.WithGrayFormat(" • ") + output.WithHighLightFormat("←→") + + output.WithGrayFormat(" None/All") + } + hint += output.WithGrayFormat(" • ") + output.WithHighLightFormat("Space") + + output.WithGrayFormat(" Select") + output.WithGrayFormat(" • ") + + output.WithHighLightFormat("Enter") + output.WithGrayFormat(" Confirm") if p.options.HelpMessage != "" { - printer.Fprintln(output.WithGrayFormat("Use enter to submit, type ? for help")) - } else { - printer.Fprintln(output.WithGrayFormat("Use enter to submit")) + hint += output.WithGrayFormat(" • ") + output.WithHighLightFormat("?") + + output.WithGrayFormat(" Help") } + printer.Fprintln(hint) } diff --git a/cli/azd/pkg/ux/select_test.go b/cli/azd/pkg/ux/select_test.go index a53851fe92f..ea4df2af296 100644 --- a/cli/azd/pkg/ux/select_test.go +++ b/cli/azd/pkg/ux/select_test.go @@ -5,6 +5,7 @@ package ux import ( "bytes" + "fmt" "io" "testing" @@ -442,3 +443,136 @@ func TestMultiSelect_WithCanvas(t *testing.T) { result := ms.WithCanvas(c) assert.Equal(t, ms, result) } + +// buildMultiSelectChoices returns n placeholder choices for exercising the +// length-based behavior of MultiSelect (the filter/footer thresholds). +func buildMultiSelectChoices(n int) []*MultiSelectChoice { + choices := make([]*MultiSelectChoice, n) + for i := range choices { + choices[i] = &MultiSelectChoice{ + Value: fmt.Sprintf("v%d", i), + Label: fmt.Sprintf("Option %d", i), + } + } + return choices +} + +func TestMultiSelect_filteringEnabled(t *testing.T) { + // Requested by the caller but the list is shorter than the threshold. + short := NewMultiSelect(&MultiSelectOptions{ + Writer: io.Discard, + Choices: buildMultiSelectChoices(multiSelectFilterThreshold - 1), + EnableFiltering: new(true), + }) + assert.False(t, short.filteringEnabled(), + "filtering should be off below the threshold") + + // Requested and the list is long enough. + long := NewMultiSelect(&MultiSelectOptions{ + Writer: io.Discard, + Choices: buildMultiSelectChoices(multiSelectFilterThreshold), + EnableFiltering: new(true), + }) + assert.True(t, long.filteringEnabled(), + "filtering should be on at or above the threshold") + + // Explicitly disabled: off regardless of length. + disabled := NewMultiSelect(&MultiSelectOptions{ + Writer: io.Discard, + Choices: buildMultiSelectChoices(multiSelectFilterThreshold + 2), + EnableFiltering: new(false), + }) + assert.False(t, disabled.filteringEnabled(), + "filtering should be off when the caller disables it") +} + +func TestMultiSelect_renderFooter_shortList_hidesNoneAll(t *testing.T) { + var buf bytes.Buffer + ms := NewMultiSelect(&MultiSelectOptions{ + Writer: io.Discard, + Message: "Pick", + Choices: buildMultiSelectChoices(multiSelectFilterThreshold - 1), + }) + + ms.renderFooter(NewPrinter(&buf)) + + out := buf.String() + assert.Contains(t, out, "Move") + assert.Contains(t, out, "Select") + assert.Contains(t, out, "Confirm") + // The None/All shortcut is hidden for short, easy-to-scan lists. + assert.NotContains(t, out, "None/All") +} + +func TestMultiSelect_renderFooter_longList_showsNoneAll(t *testing.T) { + var buf bytes.Buffer + ms := NewMultiSelect(&MultiSelectOptions{ + Writer: io.Discard, + Message: "Pick", + Choices: buildMultiSelectChoices(multiSelectFilterThreshold), + }) + + ms.renderFooter(NewPrinter(&buf)) + + out := buf.String() + assert.Contains(t, out, "None/All") + assert.Contains(t, out, "Move") +} + +func TestMultiSelect_renderFooter_help(t *testing.T) { + // A help message adds a "? Help" shortcut to the footer. + var withHelp bytes.Buffer + NewMultiSelect(&MultiSelectOptions{ + Writer: io.Discard, + Message: "Pick", + Choices: buildMultiSelectChoices(2), + HelpMessage: "some help", + }).renderFooter(NewPrinter(&withHelp)) + assert.Contains(t, withHelp.String(), "Help") + + // Without a help message the shortcut is omitted. + var noHelp bytes.Buffer + NewMultiSelect(&MultiSelectOptions{ + Writer: io.Discard, + Message: "Pick", + Choices: buildMultiSelectChoices(2), + }).renderFooter(NewPrinter(&noHelp)) + assert.NotContains(t, noHelp.String(), "Help") +} + +func TestMultiSelect_renderFooter_skippedWhenComplete(t *testing.T) { + var buf bytes.Buffer + ms := NewMultiSelect(&MultiSelectOptions{ + Writer: io.Discard, + Message: "Pick", + Choices: buildMultiSelectChoices(multiSelectFilterThreshold), + }) + ms.complete = true + + ms.renderFooter(NewPrinter(&buf)) + + assert.Empty(t, buf.String(), "footer must not render once complete") +} + +func TestMultiSelect_renderMessage_filter(t *testing.T) { + // Long list with filtering enabled shows the filter prompt. + var long bytes.Buffer + NewMultiSelect(&MultiSelectOptions{ + Writer: io.Discard, + Message: "Pick", + Choices: buildMultiSelectChoices(multiSelectFilterThreshold), + EnableFiltering: new(true), + }).renderMessage(NewPrinter(&long)) + assert.Contains(t, long.String(), "Filter:") + assert.Contains(t, long.String(), "Type to filter list") + + // Short list hides the filter prompt even when filtering is requested. + var short bytes.Buffer + NewMultiSelect(&MultiSelectOptions{ + Writer: io.Discard, + Message: "Pick", + Choices: buildMultiSelectChoices(multiSelectFilterThreshold - 1), + EnableFiltering: new(true), + }).renderMessage(NewPrinter(&short)) + assert.NotContains(t, short.String(), "Filter:") +} diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index 3ce70fc5f4c..634aa4cb322 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -502,7 +502,7 @@ Built-in tool IDs come from azd's curated tool manifest (run `azd tool list` to | `tool.install.duration_ms` | measurement | Total install/upgrade/uninstall duration (ms) | | `tool.upgrade.from_version` | string | Previous version (single-target upgrade) | | `tool.upgrade.to_version` | string | New version after a successful upgrade (single-target) | -| `tool.check.updates_available` | measurement | Installed tools with an available update (`azd tool check`) | +| `tool.check.updates_available` | measurement | Installed tools with an available upgrade (`azd tool check`) |
@@ -690,7 +690,7 @@ How to find telemetry for a given feature area. Start here if you know the featu | **Self-Update** | `cmd.update` | `update.installMethod`, `update.fromVersion` | Update adoption | | **Hooks** | `hooks.exec` | `hooks.name`, `hooks.type`, `hooks.kind` | Hook usage by type | | **Container Build** | `container.publish`, `container.remotebuild`, `tools.pack.build` | `pack.builder.image` | Build method usage, success rates | -| **Tool Management (`azd tool`)** | `cmd.tool.install`, `cmd.tool.upgrade`, `cmd.tool.uninstall`, `cmd.tool.check` | `tool.id`, `tool.install.strategy`, `tool.firstrun.outcome` | First-run adoption, install/upgrade/uninstall success, update availability | +| **Tool Management (`azd tool`)** | `cmd.tool.install`, `cmd.tool.upgrade`, `cmd.tool.uninstall`, `cmd.tool.check` | `tool.id`, `tool.install.strategy`, `tool.firstrun.outcome` | First-run adoption, install/upgrade/uninstall success, upgrade availability | ## See Also diff --git a/docs/specs/metrics-audit/telemetry-schema.md b/docs/specs/metrics-audit/telemetry-schema.md index 13226b0e419..3b6e04dede1 100644 --- a/docs/specs/metrics-audit/telemetry-schema.md +++ b/docs/specs/metrics-audit/telemetry-schema.md @@ -321,7 +321,7 @@ no file paths, no user-identifiable data, no raw error text. | Install duration | `tool.install.duration_ms` | SystemMetadata | FeatureInsight | **Measurement** — total install/upgrade duration in ms | | Upgrade from version | `tool.upgrade.from_version` | SystemMetadata | FeatureInsight | Prior version (single-target upgrades) | | Upgrade to version | `tool.upgrade.to_version` | SystemMetadata | FeatureInsight | New version after a successful upgrade | -| Updates available | `tool.check.updates_available` | SystemMetadata | FeatureInsight | **Measurement** — number of installed tools with an available update | +| Updates available | `tool.check.updates_available` | SystemMetadata | FeatureInsight | **Measurement** — number of installed tools with an available upgrade | ### Preflight Validation