From 9bf2e38cdc52fab5dc0e767c84d810dd4f204ea9 Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 6 Jul 2026 18:37:09 -0700 Subject: [PATCH 01/41] use agent instead of host, improve multi selector ux --- cli/azd/cmd/testdata/TestFigSpec.ts | 54 ++++++------ .../testdata/TestUsage-azd-tool-install.snap | 6 +- .../TestUsage-azd-tool-uninstall.snap | 6 +- .../testdata/TestUsage-azd-tool-upgrade.snap | 4 +- cli/azd/cmd/tool.go | 82 +++++++++---------- cli/azd/cmd/tool_test.go | 28 +++---- cli/azd/pkg/ux/multi_select.go | 13 +-- 7 files changed, 98 insertions(+), 95 deletions(-) diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 95d9a3cdc17..d56b27c88eb 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6289,6 +6289,16 @@ const completionSpec: Fig.Spec = { 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', @@ -6297,16 +6307,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...', @@ -6328,6 +6328,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', @@ -6336,16 +6346,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...', @@ -6357,19 +6357,19 @@ 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: ['--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-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..7a0fdf2ed56 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap @@ -5,8 +5,8 @@ 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) + --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/tool.go b/cli/azd/cmd/tool.go index f0dddc144a0..f981acf13fd 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -472,9 +472,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.hosts, "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, @@ -549,7 +549,7 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err 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 + // --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 { @@ -590,8 +590,8 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err }, nil } -// allHostsKeyword is the reserved --host value that selects every -// detected agent host. +// allHostsKeyword is the reserved --agent value that selects every +// detected agent. const allHostsKeyword = "all" // firstSkillTool returns the first skill tool among tools, or nil when @@ -605,18 +605,18 @@ func firstSkillTool(tools []*tool.ToolDefinition) *tool.ToolDefinition { return nil } -// resolveExplicitSkillHosts maps an explicit --host flag value to install +// resolveExplicitSkillHosts 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 // 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. + // --agent all selects every detected agent. It cannot be mixed with + // specific agent names. if slices.Contains(hosts, allHostsKeyword) { if len(hosts) > 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 @@ -627,8 +627,8 @@ func resolveExplicitSkillHosts(hosts []string) ([]tool.InstallOption, error) { } // 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 +// 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 @@ -637,8 +637,8 @@ func resolveExplicitSkillHosts(hosts []string) ([]tool.InstallOption, error) { // // 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. +// 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) resolveHostOptions( ctx context.Context, tools []*tool.ToolDefinition, @@ -647,7 +647,7 @@ func (a *toolInstallAction) resolveHostOptions( 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 @@ -666,7 +666,7 @@ func (a *toolInstallAction) resolveHostOptions( return resolveExplicitSkillHosts(a.flags.hosts) } - // No --host. A skill the user did not name explicitly (batch --all or + // No --agent. 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 // batch are picked up. This is also why --all does not abort when @@ -680,13 +680,13 @@ func (a *toolInstallAction) resolveHostOptions( present := a.manager.AvailableSkillHosts(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. + // after surfacing the --agent 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, + "Multiple AI agents detected.\nTip: Use `%s` or `%s` "+ + "to select a specific agent or all agents.", + output.WithHighLightFormat("--agent "), + output.WithHighLightFormat("--agent all"), )) opts, err := a.promptForSkillHosts(ctx, skill, present) @@ -700,14 +700,14 @@ 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(present, ", "), ), 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 agent(s) to install for:\n\n"+ + " azd tool install %s --agent \n\n"+ + " azd tool install %s --agent all", skill.Id, skill.Id, ), } @@ -717,7 +717,7 @@ func (a *toolInstallAction) resolveHostOptions( return nil, nil } -// resolveUnavailableHostPrompt handles an explicit --host whose named +// resolveUnavailableHostPrompt handles an explicit --agent 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; @@ -753,7 +753,7 @@ func (a *toolInstallAction) resolveUnavailableHostPrompt( } 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, )) @@ -780,7 +780,7 @@ func (a *toolInstallAction) promptForSkillHosts( ) ([]tool.InstallOption, error) { selected, err := a.console.MultiSelect(ctx, input.ConsoleOptions{ Message: fmt.Sprintf( - "Select the agent host(s) to install %s for", skill.Name, + "Select the agent(s) to install %s for", skill.Name, ), Options: available, DefaultValue: []string{available[0]}, @@ -953,9 +953,9 @@ func newToolUpgradeFlags(cmd *cobra.Command) *toolUpgradeFlags { "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.hosts, "agent", nil, + "Upgrade the skill for the specified agent(s): copilot, claude. "+ + "Use --agent all for every detected agent (skill tools only)", ) return flags } @@ -1111,8 +1111,8 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err } // 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 +// be upgraded for, based on the --agent flag. --agent all targets every +// detected host; specific names target those hosts. When --agent is // omitted it returns no options, letting the installer upgrade every host // the skill is already installed through. func (a *toolUpgradeAction) resolveHostOptions( @@ -1124,7 +1124,7 @@ func (a *toolUpgradeAction) resolveHostOptions( 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) @@ -1194,9 +1194,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.hosts, "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( @@ -1308,8 +1308,8 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e } // 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 +// be uninstalled from, based on the --agent flag. --agent all targets every +// detected host; specific names target those hosts. 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( @@ -1321,7 +1321,7 @@ func (a *toolUninstallAction) resolveHostOptions( 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) diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 315c669fc82..53e231179df 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -647,7 +647,7 @@ func TestToolUpgradeAction_FailureDoesNotEmitToVersion(t *testing.T) { } // --------------------------------------------------------------------------- -// resolveHostOptions — --host / --all-hosts flag handling +// resolveHostOptions — --agent / --all-agents flag handling // --------------------------------------------------------------------------- func TestResolveHostOptions(t *testing.T) { @@ -687,7 +687,7 @@ func TestResolveHostOptions(t *testing.T) { a := newAction(nil, &toolInstallFlags{hosts: []string{"all", "copilot"}}, []string{"copilot", "claude"}) _, err := a.resolveHostOptions(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) { @@ -705,7 +705,7 @@ func TestResolveHostOptions(t *testing.T) { }) t.Run("HostAllDefersEvenWhenNoneDetected", func(t *testing.T) { - // --host all resolves at install time, so it returns an option + // --agent 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) @@ -724,7 +724,7 @@ func TestResolveHostOptions(t *testing.T) { 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.Suggestion, "--agent all") }) t.Run("ExplicitlyNamedMultipleHostsInteractivePrompts", func(t *testing.T) { @@ -761,7 +761,7 @@ func TestResolveHostOptions(t *testing.T) { t.Run("ExplicitlyNamedMultipleHostsEmptySelectionFallsBackToError", 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) @@ -773,11 +773,11 @@ func TestResolveHostOptions(t *testing.T) { 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. + // `--agent gemini` names a host that isn't supported/available. // In an interactive terminal we prompt over the hosts that ARE // on PATH instead of hard-failing. a := newAction( @@ -822,7 +822,7 @@ func TestResolveHostOptions(t *testing.T) { }) t.Run("ExplicitUnavailableHostNoneOnPathDefersToGuidance", func(t *testing.T) { - // `--host gemini` with no supported host on PATH: skip the picker + // `--agent 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. a := newAction( @@ -885,7 +885,7 @@ func TestResolveHostOptions(t *testing.T) { }) t.Run("ExplicitAvailableHostSkipsPrompt", func(t *testing.T) { - // A valid, available --host is used directly without prompting, + // A valid, available --agent is used directly without prompting, // even in an interactive terminal. a := newAction( []string{"azure-skills"}, @@ -966,7 +966,7 @@ func TestResolveHostOptions_Upgrade(t *testing.T) { a := newAction(&toolUpgradeFlags{hosts: []string{"all", "copilot"}}, []string{"copilot", "claude"}) _, err := a.resolveHostOptions([]*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) { @@ -984,7 +984,7 @@ func TestResolveHostOptions_Upgrade(t *testing.T) { }) t.Run("HostAllDefersEvenWhenNoneDetected", func(t *testing.T) { - // --host all resolves at install time, so it returns an option + // --agent 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}) @@ -992,7 +992,7 @@ func TestResolveHostOptions_Upgrade(t *testing.T) { assert.Len(t, opts, 1) }) - // Unlike install, upgrade with no --host never errors on multiple + // Unlike install, upgrade with no --agent never errors on multiple // hosts: the installer upgrades the host the skill is installed // through, so no explicit choice is required. t.Run("MultipleHostsNoFlagReturnsNil", func(t *testing.T) { @@ -1026,7 +1026,7 @@ func TestResolveHostOptions_Uninstall(t *testing.T) { a := newAction(&toolUninstallFlags{}) opts, err := a.resolveHostOptions([]*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 host") }) t.Run("HostWithoutSkillTool", func(t *testing.T) { @@ -1054,7 +1054,7 @@ func TestResolveHostOptions_Uninstall(t *testing.T) { a := newAction(&toolUninstallFlags{hosts: []string{"all", "copilot"}}) _, err := a.resolveHostOptions([]*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") }) } diff --git a/cli/azd/pkg/ux/multi_select.go b/cli/azd/pkg/ux/multi_select.go index 3a9f1006e9e..a88035437db 100644 --- a/cli/azd/pkg/ux/multi_select.go +++ b/cli/azd/pkg/ux/multi_select.go @@ -486,11 +486,14 @@ 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") + + 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) } From fd075f4619489b573103fa8ade7a8fea8b94e3a0 Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 6 Jul 2026 18:55:01 -0700 Subject: [PATCH 02/41] add tip for grey and multi selector no filter for less than 6 options --- cli/azd/cmd/tool.go | 12 ++++++------ cli/azd/pkg/ux/multi_select.go | 26 +++++++++++++++++++++----- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index f981acf13fd..5e89c59a0ef 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -682,12 +682,12 @@ func (a *toolInstallAction) resolveHostOptions( // Interactive terminal: prompt the user to pick the host(s), // after surfacing the --agent hint so they learn the shortcut too. if a.console.IsSpinnerInteractive() && !a.console.IsNoPromptMode() { - a.console.Message(ctx, fmt.Sprintf( - "Multiple AI agents detected.\nTip: Use `%s` or `%s` "+ - "to select a specific agent or all agents.", - output.WithHighLightFormat("--agent "), - output.WithHighLightFormat("--agent all"), - )) + 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.promptForSkillHosts(ctx, skill, present) if err != nil { diff --git a/cli/azd/pkg/ux/multi_select.go b/cli/azd/pkg/ux/multi_select.go index a88035437db..fb0e93982dc 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 == "" { @@ -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: ") @@ -487,8 +499,12 @@ func (p *MultiSelect) renderFooter(printer Printer) { printer.Fprintln() printer.Fprintln(output.WithGrayFormat("───────────────────────────────────────")) - hint := output.WithHighLightFormat("↑↓") + output.WithGrayFormat(" Move") + - output.WithGrayFormat(" • ") + output.WithHighLightFormat("Space") + + 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 != "" { From f6476b143bfb31050a20504d132b36d52115b9c5 Mon Sep 17 00:00:00 2001 From: hemarina Date: Wed, 8 Jul 2026 16:26:18 -0700 Subject: [PATCH 03/41] add spinner --- cli/azd/cmd/tool.go | 158 +++++++++++++++---- cli/azd/pkg/tool/detector.go | 2 +- cli/azd/pkg/tool/installer.go | 191 ++++++++++++++++++----- cli/azd/pkg/tool/installer_test.go | 242 +++++++++++++++++++++++++++++ cli/azd/pkg/tool/manifest.go | 16 +- cli/azd/pkg/tool/manifest_test.go | 4 +- 6 files changed, 543 insertions(+), 70 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 5e89c59a0ef..32b91b4251f 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" @@ -556,15 +557,34 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err return nil, hostErr } - operationFn := func(ctx context.Context, allIDs []string) ([]*tool.InstallResult, error) { - return a.manager.InstallTools(ctx, allIDs, hostOpts...) + start := time.Now() + + 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(hostOpts), progress)...) + }, + ) + } else { + operationFn := func(ctx context.Context, allIDs []string) ([]*tool.InstallResult, error) { + return a.manager.InstallTools(ctx, allIDs, hostOpts...) + } + outcome := runToolOperation(ctx, tools, operationFn, "Installing", "install", a.console) + installResults = outcome.Items + rawResults = outcome.Results + opErr = outcome.Err } - start := time.Now() - outcome := runToolOperation(ctx, tools, operationFn, "Installing", "install", a.console) - installResults := outcome.Items - rawResults := outcome.Results - opErr := outcome.Err emitToolInstallTelemetry(rawResults, time.Since(start), opErr, tools) if len(rawResults) == 1 { @@ -577,8 +597,7 @@ 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 } @@ -794,6 +813,55 @@ func (a *toolInstallAction) promptForSkillHosts( return []tool.InstallOption{tool.WithHosts(selected...)}, nil } +// 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)) + 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 // displays what the install command would do without making changes. func (a *toolInstallAction) dryRun( @@ -1062,19 +1130,35 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err 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...) + hostOpts, hostErr := a.resolveHostOptions(toolsToUpgrade) + if hostErr != nil { + return nil, hostErr } 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(hostOpts), progress)...) + }, + ) + } else { + operationFn := func(ctx context.Context, allIDs []string) ([]*tool.InstallResult, error) { + return a.manager.UpgradeTools(ctx, allIDs, hostOpts...) + } + outcome := runToolOperation(ctx, toolsToUpgrade, operationFn, "Upgrading", "upgrade", a.console) + upgradeResults = outcome.Items + rawResults = outcome.Results + opErr = outcome.Err + } emitToolInstallTelemetry(rawResults, time.Since(start), opErr, toolsToUpgrade) if len(rawResults) == 1 { @@ -1269,19 +1353,35 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e 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...) + hostOpts, hostErr := a.resolveHostOptions(tools) + if hostErr != nil { + return nil, hostErr } 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(hostOpts), progress)...) + }, + ) + } else { + operationFn := func(ctx context.Context, allIDs []string) ([]*tool.InstallResult, error) { + return a.manager.UninstallTools(ctx, allIDs, hostOpts...) + } + outcome := runToolOperation(ctx, tools, operationFn, "Uninstalling", "uninstall", a.console) + uninstallResults = outcome.Items + rawResults = outcome.Results + opErr = outcome.Err + } emitToolInstallTelemetry(rawResults, time.Since(start), opErr, tools) if len(rawResults) == 1 { diff --git a/cli/azd/pkg/tool/detector.go b/cli/azd/pkg/tool/detector.go index 47209d58970..2f12c9ea2f0 100644 --- a/cli/azd/pkg/tool/detector.go +++ b/cli/azd/pkg/tool/detector.go @@ -561,7 +561,7 @@ func (d *detector) skillHostVersion( // rather than guessing from the error output (which often echoes the // queried name). result, err := d.commandRunner.Run(ctx, exec.RunArgs{ - Cmd: host.Host, + Cmd: host.Command, Args: host.PluginListCommand, }) if err != nil { diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 1fade7c1792..73f8d51f4aa 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -17,8 +17,10 @@ import ( "os" osexec "os/exec" "path/filepath" + "regexp" "runtime" "slices" + "strconv" "strings" "sync" "time" @@ -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" ) @@ -158,6 +161,11 @@ type installConfig struct { // 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 + // renderer, when set, renders a live step spinner around each + // install/upgrade/uninstall step (each host for a skill tool, or the + // tool itself otherwise). When nil the + // installer prints a plain per-host header instead. + renderer StepRenderer } // InstallOption customizes an install or upgrade operation. @@ -178,6 +186,35 @@ func WithAllAvailableHosts() InstallOption { return func(c *installConfig) { c.allAvailableHosts = true } } +// StepRenderer renders live per-step progress. It is the subset of +// input.Console the installer uses to show a step spinner per host, +// 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 } +} + +// 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 +} + // installer is the default, unexported implementation of [Installer]. type installer struct { commandRunner exec.CommandRunner @@ -307,9 +344,19 @@ func (i *installer) Uninstall( // 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) + return i.runSkillUninstall(ctx, tool, cfg.hosts, cfg.renderer) + } + + // Non-skill tools uninstall as a single step; render one spinner for + // the tool (no host) 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 @@ -675,6 +722,7 @@ func (i *installer) runSkillUninstall( ctx context.Context, tool *ToolDefinition, hosts []string, + renderer StepRenderer, ) (*InstallResult, error) { start := time.Now() result := &InstallResult{Tool: tool} @@ -697,8 +745,17 @@ func (i *installer) runSkillUninstall( 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 { + title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, host.Host) + if renderer != nil { + renderer.ShowSpinner(ctx, title, input.Step) + } else { + fmt.Fprintf(os.Stderr, "\nUninstalling %s from %s\n", tool.Name, host.Host) + } + hostErr := i.uninstallSkillForHost(ctx, tool, host) + if renderer != nil { + renderer.StopSpinner(ctx, title, input.GetStepResultFormat(hostErr)) + } + if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) continue } @@ -798,11 +855,11 @@ func (i *installer) runSkillHostUninstallCommand( ) } - runArgs := exec.NewRunArgs(host.Host, cmd...).WithInteractive(true) + runArgs := exec.NewRunArgs(host.Command, cmd...).WithInteractive(true) if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { return fmt.Errorf( "running `%s %s`: %w", - host.Host, strings.Join(cmd, " "), err, + host.Command, strings.Join(cmd, " "), err, ) } @@ -860,9 +917,33 @@ func (i *installer) run( // agentic 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.hosts, cfg.allAvailableHosts, cfg.renderer) + } + + // Non-skill tools install as a single step through the platform + // package manager; render one spinner for the tool (no host) 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) + cfg.renderer.StopSpinner(ctx, title, 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} @@ -1052,6 +1133,7 @@ func (i *installer) runSkill( upgrade bool, hosts []string, allAvailable bool, + renderer StepRenderer, ) (*InstallResult, error) { start := time.Now() result := &InstallResult{Tool: tool} @@ -1084,13 +1166,15 @@ 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 host, collecting outcomes. When + // a renderer is supplied, show a step spinner per host; otherwise + // print a header so captured host output on failure is attributable + // to the host it belongs to. verb := "Installing" + pastVerb := "Installed" if upgrade { verb = "Upgrading" + pastVerb = "Upgraded" } var ( succeeded []string @@ -1098,8 +1182,19 @@ func (i *installer) runSkill( version string ) 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) + title := fmt.Sprintf("%s %s in %s", verb, tool.Name, host.Host) + if renderer != nil { + renderer.ShowSpinner(ctx, title, input.Step) + } else { + fmt.Fprintf(os.Stderr, "\n%s %s in %s\n", verb, tool.Name, host.Host) + } + hostVersion, skillCount, hostErr := i.installSkillForHost(ctx, tool, host, upgrade) + if renderer != nil { + renderer.StopSpinner(ctx, title, input.GetStepResultFormat(hostErr)) + if hostErr == nil && skillCount > 0 { + renderer.Message(ctx, fmt.Sprintf(" %s %d skills", pastVerb, skillCount)) + } + } if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) continue @@ -1297,7 +1392,7 @@ func (i *installer) explicitSkillHostTargets( // 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 + return strings.EqualFold(h.Host, name) }) if idx < 0 { return SkillHost{}, false @@ -1339,7 +1434,7 @@ func configuredSkillHostsFor(tool *ToolDefinition, installed []InstalledSkillHos // assumes an on-PATH host 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 { + if i.commandRunner.ToolInPath(host.Command) != nil { return false } @@ -1370,7 +1465,7 @@ func (i *installer) probeOnPathHost(ctx context.Context, host SkillHost) bool { } result, err := i.commandRunner.Run( ctx, - exec.NewRunArgs(host.Host, host.BinaryVersionArgs...). + exec.NewRunArgs(host.Command, host.BinaryVersionArgs...). WithStdIn(strings.NewReader("")), ) // A cancelled/timed-out probe is not evidence the host is a stub; do @@ -1426,18 +1521,22 @@ func (i *installer) pickSkillHost( } // installSkillForHost installs (or upgrades) the skill through a single -// host and verifies the result, returning the detected version. +// host and verifies the result, returning the detected version and the +// number of skills the host CLI reported installing (0 when unknown). func (i *installer) installSkillForHost( ctx context.Context, tool *ToolDefinition, host SkillHost, upgrade bool, -) (string, error) { - if err := i.runSkillHostCommand(ctx, host, upgrade); err != nil { - return "", err +) (version string, skillCount int, err error) { + out, err := i.runSkillHostCommand(ctx, host, upgrade) + if err != nil { + return "", 0, err } + skillCount = parseSkillCount(out) - return i.verifySkillInstalled(ctx, tool, host) + version, err = i.verifySkillInstalled(ctx, tool, host) + return version, skillCount, err } // verifySkillInstalled confirms the skill is detectable **through the @@ -1485,10 +1584,13 @@ func (i *installer) verifySkillInstalled( 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. +// runSkillHostCommand executes the host's install (or update) command, +// capturing its output and returning stdout so the caller can parse a +// reported skill count. Output is captured rather than streamed so the +// command layer can render a clean progress spinner; on failure the +// captured output is carried in the returned error. The marketplace-add +// and plugin-install commands are non-interactive, so azd does not +// connect stdin. // // For fresh installs it first runs MarketplaceAddCommand when the host // declares one. Hosts that declare no MarketplaceAddCommand skip this @@ -1501,7 +1603,7 @@ func (i *installer) runSkillHostCommand( ctx context.Context, host SkillHost, upgrade bool, -) error { +) (string, error) { cmd := host.PluginInstallCommand verb := "install" if upgrade { @@ -1509,26 +1611,26 @@ func (i *installer) runSkillHostCommand( verb = "update" } if len(cmd) == 0 { - return fmt.Errorf( + return "", fmt.Errorf( "host %q has no %s command configured", host.Host, verb, ) } if !upgrade && len(host.MarketplaceAddCommand) > 0 { if err := i.runMarketplaceAdd(ctx, host); err != nil { - return err + return "", err } } - runArgs := exec.NewRunArgs(host.Host, cmd...).WithInteractive(true) - if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { - return fmt.Errorf( + res, err := i.commandRunner.Run(ctx, exec.NewRunArgs(host.Command, cmd...)) + if err != nil { + return "", fmt.Errorf( "running `%s %s`: %w", - host.Host, strings.Join(cmd, " "), err, + host.Command, strings.Join(cmd, " "), err, ) } - return nil + return res.Stdout, nil } // runMarketplaceAdd registers the skill marketplace with the host CLI. @@ -1541,7 +1643,7 @@ func (i *installer) runMarketplaceAdd( ctx context.Context, host SkillHost, ) error { - args := exec.NewRunArgs(host.Host, host.MarketplaceAddCommand...) + args := exec.NewRunArgs(host.Command, host.MarketplaceAddCommand...) result, err := i.commandRunner.Run(ctx, args) if err == nil { return nil @@ -1551,7 +1653,7 @@ func (i *installer) runMarketplaceAdd( } return fmt.Errorf( "running `%s %s`: %w", - host.Host, strings.Join(host.MarketplaceAddCommand, " "), err, + host.Command, strings.Join(host.MarketplaceAddCommand, " "), err, ) } @@ -1566,6 +1668,25 @@ func isMarketplaceAlreadyAdded(output string) bool { strings.Contains(lower, "already on disk") } +// skillCountRegex captures the number of skills a host CLI reports +// installing from its stdout, e.g. "Installed 28 skills". Best-effort: +// host CLIs are not guaranteed to print a count and the wording varies. +var skillCountRegex = regexp.MustCompile(`(?i)(\d+)\s+skills?\b`) + +// parseSkillCount returns the skill count reported in the host CLI output, +// or 0 when no count could be determined. +func parseSkillCount(out string) int { + m := skillCountRegex.FindStringSubmatch(out) + if m == nil { + return 0 + } + n, err := strconv.Atoi(m[1]) + if err != nil { + return 0 + } + return n +} + // ensurePlatform lazily detects the current platform using a mutex // to guarantee thread-safe initialization. Only successful results // are cached so that transient errors can be retried. diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index a1793e8f926..b3816d78aef 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -23,11 +23,32 @@ import ( "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 // --------------------------------------------------------------------------- @@ -174,6 +195,55 @@ 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-host 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") +} + func TestInstall_WithInstallCommand(t *testing.T) { t.Parallel() @@ -1190,6 +1260,140 @@ func newSkillTool() *ToolDefinition { } } +// TestParseSkillCount covers the best-effort extraction of the number of +// skills a host CLI reports installing from its stdout. +func TestParseSkillCount(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + in string + want int + }{ + {"Installed", "Installed 28 skills", 28}, + {"SingularSkill", "Installed 1 skill", 1}, + {"WordyOutput", "done\nSuccessfully installed 7 skills into copilot\n", 7}, + {"CaseInsensitive", "Added 12 Skills", 12}, + {"NoCount", "Plugin installed successfully", 0}, + {"Empty", "", 0}, + {"NumberButNotSkills", "downloaded 28 files", 0}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, parseSkillCount(tc.in)) + }) + } +} + +// TestRunSkill_StepProgressAndSkillCount verifies that a skill install +// renders a step spinner per host (shown then stopped, with the capitalized +// agent name) and reports the skill count parsed from the host CLI's +// captured install output. +func TestRunSkill_StepProgressAndSkillCount(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockHostPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + + // The "plugin install" call reports a count on stdout; marketplace add + // (also a "plugin ..." command) responds with an empty success. The + // version-probe ("--version") is handled by mockHostPresence. + 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 strings.HasPrefix(strings.Join(args.Args, " "), "plugin install") { + return exec.RunResult{ExitCode: 0, Stdout: "Installed 28 skills"}, nil + } + return exec.RunResult{ExitCode: 0}, nil + }) + + inst := NewInstaller( + runner, NewPlatformDetector(runner), + &mockDetector{ + detectSkillHostsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillHost, error) { + return []InstalledSkillHost{{Host: "copilot", Version: "1.1.70"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Install( + t.Context(), newSkillTool(), + WithHosts("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "install must succeed; err=%v", result.Error) + + // One step spinner for the agent, shown then stopped, using the host's + // display name, plus a skill-count sub-line from the captured install + // output. + assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.starts) + assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.stops) + require.Len(t, r.messages, 1) + assert.Contains(t, r.messages[0], "Installed 28 skills") +} + +// TestSkillHost_DisplayVsCommand verifies the split between the display-cased +// Host and the lowercase Command: the CLI is exec'd by Command, the step +// spinner uses Host, and --agent matches Host case-insensitively. +func TestSkillHost_DisplayVsCommand(t *testing.T) { + t.Parallel() + + skill := &ToolDefinition{ + Id: "test-azure-skills", + Name: "Test Azure Skills", + Category: ToolCategorySkill, + SkillHosts: []SkillHost{{ + Host: "Copilot", // display / --agent identity + 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{ + detectSkillHostsFn: func(_ context.Context, _ *ToolDefinition) ([]InstalledSkillHost, error) { + return []InstalledSkillHost{{Host: "Copilot", Version: "1.1.70"}}, nil + }, + } + inst := NewInstaller(runner, NewPlatformDetector(runner), det) + + // --agent "copilot" (lowercase) must resolve against Host "Copilot". + var r fakeStepRenderer + result, err := inst.Install(t.Context(), skill, WithHosts("copilot"), WithStepProgress(&r)) + require.NoError(t, err) + require.True(t, result.Success, "install must succeed; err=%v", result.Error) + + require.NotEmpty(t, execCmds, "the host CLI must be exec'd") + for _, c := range execCmds { + assert.Equal(t, "copilot", c, "exec must use the lowercase Command, not display Host") + } + assert.Equal(t, []string{"Installing Test Azure Skills in Copilot"}, r.starts, + "the step spinner must use the display-cased Host") +} + // 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 @@ -3516,6 +3720,44 @@ func TestUninstallSkill_RemovesFromInstalledHosts(t *testing.T) { assert.ElementsMatch(t, []string{"copilot", "claude"}, uninstalledHosts) } +// TestRunSkillUninstall_StepProgress verifies the step spinner is rendered +// per host (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() + mockHostPresence(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{ + detectSkillHostsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillHost, error) { + return nil, nil + }, + } + + inst := NewInstaller(runner, NewPlatformDetector(runner), det) + + var r fakeStepRenderer + result, err := inst.Uninstall( + t.Context(), newSkillTool(), + WithHosts("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "uninstall must succeed; err=%v", result.Error) + + 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, "uninstall reports no skill count") +} + func TestUninstallSkill_ExplicitHost_RemovesOnlyThatHost(t *testing.T) { t.Parallel() diff --git a/cli/azd/pkg/tool/manifest.go b/cli/azd/pkg/tool/manifest.go index 354c5a46678..42d631c2e9d 100644 --- a/cli/azd/pkg/tool/manifest.go +++ b/cli/azd/pkg/tool/manifest.go @@ -54,8 +54,14 @@ type Checksum struct { // (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 is the agent's display name, shown to the user and matched + // (case-insensitively) by --agent (e.g. "Copilot", "Claude"). Host string + // Command is the agent CLI's executable name, used to run plugin + // commands and version probes (e.g. "copilot", "claude"). It must be + // the real, case-correct binary name so exec works on case-sensitive + // filesystems (Linux). When empty it falls back to Host. + Command string // MarketplaceAddCommand is the optional one-time command that registers // the plugin marketplace with the host (e.g. ["plugin", "marketplace", // "add", "microsoft/azure-skills"]). Empty when not required. @@ -392,7 +398,8 @@ func azureSkills() *ToolDefinition { Website: "https://github.com/microsoft/azure-skills", SkillHosts: []SkillHost{ { - Host: "copilot", + Host: "GitHub Copilot CLI", + Command: "copilot", MarketplaceAddCommand: []string{"plugin", "marketplace", "add", "microsoft/azure-skills"}, PluginInstallCommand: []string{"plugin", "install", "azure@azure-skills"}, PluginUpdateCommand: []string{"plugin", "update", "azure@azure-skills"}, @@ -403,13 +410,14 @@ func azureSkills() *ToolDefinition { 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 + // 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", + Host: "Claude Code CLI", + Command: "claude", MarketplaceAddCommand: []string{ "plugin", "marketplace", "add", "https://github.com/microsoft/azure-skills", }, diff --git a/cli/azd/pkg/tool/manifest_test.go b/cli/azd/pkg/tool/manifest_test.go index 39050bf906f..1152980cb88 100644 --- a/cli/azd/pkg/tool/manifest_test.go +++ b/cli/azd/pkg/tool/manifest_test.go @@ -445,7 +445,9 @@ func TestAzureSkillsHostVersionProbeRegex(t *testing.T) { rx := map[string]string{} for _, h := range azureSkills().SkillHosts { - rx[h.Host] = h.BinaryVersionRegex + // Key by lowercase host id so the cases below are independent of + // the manifest's display casing (e.g. "Copilot" vs "copilot"). + rx[strings.ToLower(h.Host)] = h.BinaryVersionRegex } cases := []struct { From a81eeebe5d51a38fff2a51894b594bcff5a02eab Mon Sep 17 00:00:00 2001 From: hemarina Date: Wed, 8 Jul 2026 19:16:50 -0700 Subject: [PATCH 04/41] fix spinner bar and host detection --- cli/azd/pkg/tool/detector.go | 8 +- cli/azd/pkg/tool/detector_test.go | 20 +-- cli/azd/pkg/tool/installer.go | 203 +++++++++++++++++------------ cli/azd/pkg/tool/installer_test.go | 126 +++++++++++------- cli/azd/pkg/tool/manifest_test.go | 7 +- 5 files changed, 222 insertions(+), 142 deletions(-) diff --git a/cli/azd/pkg/tool/detector.go b/cli/azd/pkg/tool/detector.go index 2f12c9ea2f0..6361abeaded 100644 --- a/cli/azd/pkg/tool/detector.go +++ b/cli/azd/pkg/tool/detector.go @@ -550,8 +550,12 @@ func (d *detector) skillHostVersion( } // 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 { + // it; skip silently. Probe the exec binary (Command), not the display + // Host, so this PATH check matches the command actually run below — + // otherwise a manifest whose Host differs from its binary (e.g. Host + // "Claude Code CLI" / Command "claude") is never detected and a + // just-completed install fails verification. + if err := d.commandRunner.ToolInPath(host.Command); err != nil { return "", nil } diff --git a/cli/azd/pkg/tool/detector_test.go b/cli/azd/pkg/tool/detector_test.go index 518ec19d305..383eb119049 100644 --- a/cli/azd/pkg/tool/detector_test.go +++ b/cli/azd/pkg/tool/detector_test.go @@ -452,7 +452,8 @@ func TestDetectTool_Skill_Copilot(t *testing.T) { Category: ToolCategorySkill, SkillHosts: []SkillHost{ { - Host: "copilot", + Host: "GitHub Copilot CLI", + Command: "copilot", PluginListCommand: []string{"plugin", "list"}, PluginName: "azure@azure-skills", VersionRegex: `azure@azure-skills[^\n]*?(\d+\.\d+\.\d+)`, @@ -518,7 +519,8 @@ func TestDetectTool_Skill_Claude(t *testing.T) { Category: ToolCategorySkill, SkillHosts: []SkillHost{ { - Host: "claude", + Host: "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+)"`, @@ -593,13 +595,15 @@ func TestDetectSkillHosts(t *testing.T) { Category: ToolCategorySkill, SkillHosts: []SkillHost{ { - Host: "copilot", + Host: "GitHub Copilot CLI", + Command: "copilot", PluginListCommand: []string{"plugin", "list"}, PluginName: "azure@azure-skills", VersionRegex: `azure@azure-skills[^\n]*?(\d+\.\d+\.\d+)`, }, { - Host: "claude", + Host: "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+)"`, @@ -624,21 +628,21 @@ func TestDetectSkillHosts(t *testing.T) { copilot: copilotInstalled, claude: claudeInstalled, wantHosts: []InstalledSkillHost{ - {Host: "copilot", Version: "1.1.71"}, - {Host: "claude", Version: "1.1.71"}, + {Host: "GitHub Copilot CLI", Version: "1.1.71"}, + {Host: "Claude Code CLI", Version: "1.1.71"}, }, }, { name: "OnlyClaude", copilot: notInstalled, claude: claudeInstalled, - wantHosts: []InstalledSkillHost{{Host: "claude", Version: "1.1.71"}}, + wantHosts: []InstalledSkillHost{{Host: "Claude Code CLI", Version: "1.1.71"}}, }, { name: "OnlyCopilot", copilot: copilotInstalled, claude: notInstalled, - wantHosts: []InstalledSkillHost{{Host: "copilot", Version: "1.1.71"}}, + wantHosts: []InstalledSkillHost{{Host: "GitHub Copilot CLI", Version: "1.1.71"}}, }, { name: "NoneInstalled", diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 73f8d51f4aa..9da0ce28155 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -17,10 +17,8 @@ import ( "os" osexec "os/exec" "path/filepath" - "regexp" "runtime" "slices" - "strconv" "strings" "sync" "time" @@ -215,6 +213,70 @@ func stepError(result *InstallResult, err error) error { return nil } +// renderSkillStep frames one skill step (install, upgrade or uninstall) with +// a live spinner that stays visible while the host CLI talks to the user. +// +// It shows a step spinner (like azd provision) and passes work an output +// writer. Skill operations route the host CLI's stdout/stderr through that +// writer (see skillCommandRunArgs), with stdin still connected, so each line +// the CLI emits — progress or an interactive prompt — is printed above the +// spinner while the spinner stays pinned below it: the console tears the +// spinner down and re-renders it around each printed line (see +// AskerConsole.println), so the bar is kept, not lost. The user answers any +// prompt directly via the connected stdin. When the CLI stays silent the +// spinner simply runs to completion. Either way the step ends by stopping the +// spinner with the result. 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. +func renderSkillStep( + ctx context.Context, + renderer StepRenderer, + title string, + work func(out io.Writer) error, +) error { + if renderer == nil { + fmt.Fprintf(os.Stderr, "\n%s\n", title) + return work(nil) + } + + renderer.ShowSpinner(ctx, title, input.Step) + out := &lineWriter{emit: func(line string) { renderer.Message(ctx, line) }} + err := work(out) + renderer.StopSpinner(ctx, title, input.GetStepResultFormat(err)) + return err +} + +// lineWriter splits writes into lines and hands each to emit. Skill host +// commands use it to surface the host 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. Content is emitted as it arrives — +// including a trailing line with no newline, e.g. an interactive prompt — so +// nothing is withheld from the user while the CLI waits for input. +type lineWriter struct { + emit func(string) +} + +func (l *lineWriter) Write(p []byte) (int, error) { + for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") { + l.emit(line) + } + return len(p), nil +} + +// skillCommandRunArgs configures how a skill host 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, while stdin stays connected so +// the user can answer prompts. When out is nil (no spinner) the command runs +// fully interactively against the terminal. azd never pipes canned answers on +// the user's behalf. +func skillCommandRunArgs(base exec.RunArgs, out io.Writer) exec.RunArgs { + if out == nil { + return base.WithInteractive(true) + } + return base.WithStdIn(os.Stdin).WithStdOut(out).WithStdErr(out) +} + // installer is the default, unexported implementation of [Installer]. type installer struct { commandRunner exec.CommandRunner @@ -746,15 +808,9 @@ func (i *installer) runSkillUninstall( ) for _, host := range targets { title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, host.Host) - if renderer != nil { - renderer.ShowSpinner(ctx, title, input.Step) - } else { - fmt.Fprintf(os.Stderr, "\nUninstalling %s from %s\n", tool.Name, host.Host) - } - hostErr := i.uninstallSkillForHost(ctx, tool, host) - if renderer != nil { - renderer.StopSpinner(ctx, title, input.GetStepResultFormat(hostErr)) - } + hostErr := renderSkillStep(ctx, renderer, title, func(out io.Writer) error { + return i.uninstallSkillForHost(ctx, tool, host, out) + }) if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) continue @@ -828,25 +884,31 @@ func (i *installer) resolveSkillUninstallTargets( } // uninstallSkillForHost removes the skill through a single host and -// verifies it is no longer present on that host. +// verifies it is no longer present on that host. out, when non-nil, receives +// the host CLI's output line-by-line for display above the step spinner. func (i *installer) uninstallSkillForHost( ctx context.Context, tool *ToolDefinition, host SkillHost, + out io.Writer, ) error { - if err := i.runSkillHostUninstallCommand(ctx, host); err != nil { + if err := i.runSkillHostUninstallCommand(ctx, host, out); err != nil { return err } return i.verifySkillUninstalled(ctx, tool, host) } -// 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. +// runSkillHostUninstallCommand executes the host's plugin-uninstall command. +// When a step spinner is showing (out is non-nil) the host 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) runSkillHostUninstallCommand( ctx context.Context, host SkillHost, + out io.Writer, ) error { cmd := host.PluginUninstallCommand if len(cmd) == 0 { @@ -855,7 +917,7 @@ func (i *installer) runSkillHostUninstallCommand( ) } - runArgs := exec.NewRunArgs(host.Command, cmd...).WithInteractive(true) + runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out) if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { return fmt.Errorf( "running `%s %s`: %w", @@ -1166,15 +1228,13 @@ func (i *installer) runSkill( )+output.WithLinkFormat("https://nodejs.org/")) } - // 3. Install / upgrade for each target host, collecting outcomes. When - // a renderer is supplied, show a step spinner per host; otherwise - // print a header so captured host output on failure is attributable - // to the host it belongs to. + // 3. Install / upgrade for each target host, collecting outcomes. Each + // host runs interactively: renderSkillStep prints a step header and a + // result line around the host CLI's streamed output, so the user can + // answer any prompts the host surfaces. verb := "Installing" - pastVerb := "Installed" if upgrade { verb = "Upgrading" - pastVerb = "Upgraded" } var ( succeeded []string @@ -1183,18 +1243,12 @@ func (i *installer) runSkill( ) for _, host := range targets { title := fmt.Sprintf("%s %s in %s", verb, tool.Name, host.Host) - if renderer != nil { - renderer.ShowSpinner(ctx, title, input.Step) - } else { - fmt.Fprintf(os.Stderr, "\n%s %s in %s\n", verb, tool.Name, host.Host) - } - hostVersion, skillCount, hostErr := i.installSkillForHost(ctx, tool, host, upgrade) - if renderer != nil { - renderer.StopSpinner(ctx, title, input.GetStepResultFormat(hostErr)) - if hostErr == nil && skillCount > 0 { - renderer.Message(ctx, fmt.Sprintf(" %s %d skills", pastVerb, skillCount)) - } - } + var hostVersion string + hostErr := renderSkillStep(ctx, renderer, title, func(out io.Writer) error { + v, e := i.installSkillForHost(ctx, tool, host, upgrade, out) + hostVersion = v + return e + }) if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) continue @@ -1388,11 +1442,15 @@ func (i *installer) explicitSkillHostTargets( } // 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. +// whether one was found. It matches case-insensitively against both the +// display Host (e.g. "GitHub Copilot CLI", as surfaced by the interactive +// picker) and the exec Command (e.g. "copilot", the short name advertised in +// the --agent flag help), so both `--agent copilot` and a picker selection +// resolve. 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 strings.EqualFold(h.Host, name) + return strings.EqualFold(h.Command, name) }) if idx < 0 { return SkillHost{}, false @@ -1521,22 +1579,20 @@ func (i *installer) pickSkillHost( } // installSkillForHost installs (or upgrades) the skill through a single -// host and verifies the result, returning the detected version and the -// number of skills the host CLI reported installing (0 when unknown). +// host and verifies the result, returning the detected version. out, when +// non-nil, receives the host CLI's output line-by-line for display above the +// step spinner. func (i *installer) installSkillForHost( ctx context.Context, tool *ToolDefinition, host SkillHost, upgrade bool, -) (version string, skillCount int, err error) { - out, err := i.runSkillHostCommand(ctx, host, upgrade) - if err != nil { - return "", 0, err + out io.Writer, +) (version string, err error) { + if err := i.runSkillHostCommand(ctx, host, upgrade, out); err != nil { + return "", err } - skillCount = parseSkillCount(out) - - version, err = i.verifySkillInstalled(ctx, tool, host) - return version, skillCount, err + return i.verifySkillInstalled(ctx, tool, host) } // verifySkillInstalled confirms the skill is detectable **through the @@ -1584,13 +1640,14 @@ func (i *installer) verifySkillInstalled( return version, nil } -// runSkillHostCommand executes the host's install (or update) command, -// capturing its output and returning stdout so the caller can parse a -// reported skill count. Output is captured rather than streamed so the -// command layer can render a clean progress spinner; on failure the -// captured output is carried in the returned error. The marketplace-add -// and plugin-install commands are non-interactive, so azd does not -// connect stdin. +// runSkillHostCommand executes the host's install (or update) command. When +// a step spinner is showing (out is non-nil) the command's output is routed +// through out so each line the host CLI writes — including any interactive +// prompt — is printed above the spinner, which stays pinned below it, with +// stdin connected so the user answers directly; when no spinner is active the +// command runs fully interactively (see skillCommandRunArgs). azd never pipes +// canned answers on the user's behalf, and captures nothing, so there is no +// output to return. // // For fresh installs it first runs MarketplaceAddCommand when the host // declares one. Hosts that declare no MarketplaceAddCommand skip this @@ -1603,7 +1660,8 @@ func (i *installer) runSkillHostCommand( ctx context.Context, host SkillHost, upgrade bool, -) (string, error) { + out io.Writer, +) error { cmd := host.PluginInstallCommand verb := "install" if upgrade { @@ -1611,26 +1669,26 @@ func (i *installer) runSkillHostCommand( verb = "update" } if len(cmd) == 0 { - return "", fmt.Errorf( + return fmt.Errorf( "host %q has no %s command configured", host.Host, verb, ) } if !upgrade && len(host.MarketplaceAddCommand) > 0 { if err := i.runMarketplaceAdd(ctx, host); err != nil { - return "", err + return err } } - res, err := i.commandRunner.Run(ctx, exec.NewRunArgs(host.Command, cmd...)) - if err != nil { - return "", fmt.Errorf( + runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out) + if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { + return fmt.Errorf( "running `%s %s`: %w", host.Command, strings.Join(cmd, " "), err, ) } - return res.Stdout, nil + return nil } // runMarketplaceAdd registers the skill marketplace with the host CLI. @@ -1668,25 +1726,6 @@ func isMarketplaceAlreadyAdded(output string) bool { strings.Contains(lower, "already on disk") } -// skillCountRegex captures the number of skills a host CLI reports -// installing from its stdout, e.g. "Installed 28 skills". Best-effort: -// host CLIs are not guaranteed to print a count and the wording varies. -var skillCountRegex = regexp.MustCompile(`(?i)(\d+)\s+skills?\b`) - -// parseSkillCount returns the skill count reported in the host CLI output, -// or 0 when no count could be determined. -func parseSkillCount(out string) int { - m := skillCountRegex.FindStringSubmatch(out) - if m == nil { - return 0 - } - n, err := strconv.Atoi(m[1]) - if err != nil { - return 0 - } - return n -} - // ensurePlatform lazily detects the current platform using a mutex // to guarantee thread-safe initialization. Only successful results // are cached so that transient errors can be retried. diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index b3816d78aef..e5ad17314a1 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -1234,6 +1234,7 @@ func newSkillTool() *ToolDefinition { SkillHosts: []SkillHost{ { Host: "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"}, @@ -1244,7 +1245,8 @@ func newSkillTool() *ToolDefinition { BinaryVersionRegex: `(?m)^GitHub Copilot CLI\s+v?(\d+\.\d+\.\d+)`, }, { - Host: "claude", + Host: "claude", + Command: "claude", MarketplaceAddCommand: []string{ "plugin", "marketplace", "add", "https://github.com/microsoft/azure-skills", }, @@ -1260,51 +1262,76 @@ func newSkillTool() *ToolDefinition { } } -// TestParseSkillCount covers the best-effort extraction of the number of -// skills a host CLI reports installing from its stdout. -func TestParseSkillCount(t *testing.T) { +// TestRunSkill_StepProgress verifies that a silent skill install (the host +// 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() - cases := []struct { - name string - in string - want int - }{ - {"Installed", "Installed 28 skills", 28}, - {"SingularSkill", "Installed 1 skill", 1}, - {"WordyOutput", "done\nSuccessfully installed 7 skills into copilot\n", 7}, - {"CaseInsensitive", "Added 12 Skills", 12}, - {"NoCount", "Plugin installed successfully", 0}, - {"Empty", "", 0}, - {"NumberButNotSkills", "downloaded 28 files", 0}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tc.want, parseSkillCount(tc.in)) - }) - } + runner := mockexec.NewMockCommandRunner() + mockHostPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + + // The version-probe ("--version") is handled by mockHostPresence; 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{ + detectSkillHostsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillHost, error) { + return []InstalledSkillHost{{Host: "copilot", Version: "1.1.70"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Install( + t.Context(), newSkillTool(), + WithHosts("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "install must succeed; err=%v", result.Error) + + // A silent install (the mock host 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) } -// TestRunSkill_StepProgressAndSkillCount verifies that a skill install -// renders a step spinner per host (shown then stopped, with the capitalized -// agent name) and reports the skill count parsed from the host CLI's -// captured install output. -func TestRunSkill_StepProgressAndSkillCount(t *testing.T) { +// TestRunSkill_StreamedOutputStopsSpinner verifies that when the host CLI +// writes to the terminal (a progress line or an interactive prompt), the +// step spinner is torn down immediately (stopped with an empty message) and +// the step result is printed inline afterwards, so the spinner never +// overwrites the CLI's own output. +// TestRunSkill_StreamedOutputPrintedAboveSpinner verifies that when the host +// CLI writes to the terminal (a progress line or an interactive prompt), +// each line is surfaced via Message (which the console prints above the +// spinner, keeping the spinner pinned below), and the spinner is stopped with +// the step result at the end rather than torn down early. +func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() mockHostPresence(runner, "copilot") runner.MockToolInPath("node", nil) - // The "plugin install" call reports a count on stdout; marketplace add - // (also a "plugin ..." command) responds with an empty success. The - // version-probe ("--version") is handled by mockHostPresence. + // The plugin command writes to the terminal (StdOut), simulating the + // host CLI surfacing progress or a prompt. marketplace-add is captured + // (no StdOut) so it stays silent; only the install writes. 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 strings.HasPrefix(strings.Join(args.Args, " "), "plugin install") { - return exec.RunResult{ExitCode: 0, Stdout: "Installed 28 skills"}, nil + if args.StdOut != nil { + _, _ = args.StdOut.Write([]byte("Installing plugin...\n")) } return exec.RunResult{ExitCode: 0}, nil }) @@ -1329,18 +1356,18 @@ func TestRunSkill_StepProgressAndSkillCount(t *testing.T) { require.NoError(t, err) require.True(t, result.Success, "install must succeed; err=%v", result.Error) - // One step spinner for the agent, shown then stopped, using the host's - // display name, plus a skill-count sub-line from the captured install - // output. + // The spinner is shown, the host CLI's line is surfaced via Message (the + // console prints it above the spinner), and the spinner is stopped with + // the step result at the end — it is kept, not torn down early. assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.starts) assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.stops) - require.Len(t, r.messages, 1) - assert.Contains(t, r.messages[0], "Installed 28 skills") + assert.Contains(t, r.messages, "Installing plugin...") } -// TestSkillHost_DisplayVsCommand verifies the split between the display-cased -// Host and the lowercase Command: the CLI is exec'd by Command, the step -// spinner uses Host, and --agent matches Host case-insensitively. +// TestSkillHost_DisplayVsCommand verifies the split between the display Host +// and the lowercase Command: the CLI is exec'd by Command, the step spinner +// uses the display Host, and --agent resolves via Command even when it +// differs from the Host. func TestSkillHost_DisplayVsCommand(t *testing.T) { t.Parallel() @@ -1349,8 +1376,8 @@ func TestSkillHost_DisplayVsCommand(t *testing.T) { Name: "Test Azure Skills", Category: ToolCategorySkill, SkillHosts: []SkillHost{{ - Host: "Copilot", // display / --agent identity - Command: "copilot", // exec binary + Host: "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"}, @@ -1375,12 +1402,13 @@ func TestSkillHost_DisplayVsCommand(t *testing.T) { det := &mockDetector{ detectSkillHostsFn: func(_ context.Context, _ *ToolDefinition) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "Copilot", Version: "1.1.70"}}, nil + return []InstalledSkillHost{{Host: "GitHub Copilot CLI", Version: "1.1.70"}}, nil }, } inst := NewInstaller(runner, NewPlatformDetector(runner), det) - // --agent "copilot" (lowercase) must resolve against Host "Copilot". + // --agent "copilot" (the Command) must resolve even though the Host is the + // full display name "GitHub Copilot CLI". var r fakeStepRenderer result, err := inst.Install(t.Context(), skill, WithHosts("copilot"), WithStepProgress(&r)) require.NoError(t, err) @@ -1390,7 +1418,8 @@ func TestSkillHost_DisplayVsCommand(t *testing.T) { for _, c := range execCmds { assert.Equal(t, "copilot", c, "exec must use the lowercase Command, not display Host") } - assert.Equal(t, []string{"Installing Test Azure Skills in Copilot"}, r.starts, + 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 Host") } @@ -3753,9 +3782,11 @@ func TestRunSkillUninstall_StepProgress(t *testing.T) { 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, "uninstall reports no skill count") + assert.Empty(t, r.messages) } func TestUninstallSkill_ExplicitHost_RemovesOnlyThatHost(t *testing.T) { @@ -4202,6 +4233,7 @@ func TestUninstallSkill_NoUninstallCommandConfigured(t *testing.T) { SkillHosts: []SkillHost{ { Host: "copilot", + Command: "copilot", PluginListCommand: []string{"plugin", "list"}, PluginName: "azure@azure-skills", VersionRegex: `(\d+\.\d+\.\d+)`, diff --git a/cli/azd/pkg/tool/manifest_test.go b/cli/azd/pkg/tool/manifest_test.go index 1152980cb88..5a8a7455431 100644 --- a/cli/azd/pkg/tool/manifest_test.go +++ b/cli/azd/pkg/tool/manifest_test.go @@ -445,9 +445,10 @@ func TestAzureSkillsHostVersionProbeRegex(t *testing.T) { rx := map[string]string{} for _, h := range azureSkills().SkillHosts { - // Key by lowercase host id so the cases below are independent of - // the manifest's display casing (e.g. "Copilot" vs "copilot"). - rx[strings.ToLower(h.Host)] = h.BinaryVersionRegex + // Key by the exec binary (Command, e.g. "copilot"/"claude") so the + // cases below are independent of the manifest's display Host (e.g. + // "GitHub Copilot CLI"). + rx[h.Command] = h.BinaryVersionRegex } cases := []struct { From 0c454336cb50bae830302f6265d0a271eba1b867 Mon Sep 17 00:00:00 2001 From: hemarina Date: Wed, 8 Jul 2026 20:29:59 -0700 Subject: [PATCH 05/41] fix host bug --- cli/azd/cmd/tool.go | 33 +++++++++----- cli/azd/cmd/tool_test.go | 60 +++++++++++++++++++------ cli/azd/pkg/tool/detector.go | 2 +- cli/azd/pkg/tool/detector_test.go | 8 ++-- cli/azd/pkg/tool/installer.go | 71 ++++++++++++++++-------------- cli/azd/pkg/tool/installer_test.go | 33 ++++++++------ cli/azd/pkg/tool/manager.go | 14 +++--- cli/azd/pkg/tool/manager_test.go | 18 +++++--- 8 files changed, 152 insertions(+), 87 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 32b91b4251f..e4078f99e8e 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -696,7 +696,7 @@ func (a *toolInstallAction) resolveHostOptions( // Explicitly-named skill: when multiple hosts are detected we cannot // safely guess which the user wants. - present := a.manager.AvailableSkillHosts(ctx, skill) + present, presentName := a.manager.AvailableSkillHosts(ctx, skill) if len(present) > 1 { // Interactive terminal: prompt the user to pick the host(s), // after surfacing the --agent hint so they learn the shortcut too. @@ -708,7 +708,7 @@ func (a *toolInstallAction) resolveHostOptions( output.WithHighLightFormat("--agent all")+ output.WithGrayFormat("` to select a specific agent or all agents.\n")) - opts, err := a.promptForSkillHosts(ctx, skill, present) + opts, err := a.promptForSkillHosts(ctx, skill, present, presentName) if err != nil { return nil, err } @@ -721,7 +721,7 @@ func (a *toolInstallAction) resolveHostOptions( return nil, &internal.ErrorWithSuggestion{ Err: fmt.Errorf("multiple AI agents detected for %s", skill.Name), Message: fmt.Sprintf( - "Detected multiple agents: %s", strings.Join(present, ", "), + "Detected multiple agents: %s", strings.Join(presentName, ", "), ), Suggestion: fmt.Sprintf( "Specify which agent(s) to install for:\n\n"+ @@ -753,7 +753,7 @@ func (a *toolInstallAction) resolveUnavailableHostPrompt( return nil, false, nil } - available := a.manager.AvailableSkillHosts(ctx, skill) + available, availableNames := a.manager.AvailableSkillHosts(ctx, skill) var unavailable []string for _, host := range a.flags.hosts { if !slices.Contains(available, host) { @@ -776,7 +776,7 @@ func (a *toolInstallAction) resolveUnavailableHostPrompt( "on your PATH:", strings.Join(unavailable, ", "), skill.Name, )) - picked, err := a.promptForSkillHosts(ctx, skill, available) + picked, err := a.promptForSkillHosts(ctx, skill, available, availableNames) if err != nil { return nil, false, err } @@ -791,18 +791,26 @@ func (a *toolInstallAction) resolveUnavailableHostPrompt( // promptForSkillHosts shows an interactive multi-select over the given // available hosts and returns the matching install option, or (nil, nil) // when the user selects nothing so callers can fall back to their own -// guidance. +// guidance. commands and names are index-aligned (from AvailableSkillHosts): +// the picker displays the friendly name for each host and maps the selection +// back to its command so the installer resolves it by command. func (a *toolInstallAction) promptForSkillHosts( 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(s) 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) @@ -810,7 +818,12 @@ func (a *toolInstallAction) promptForSkillHosts( 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.WithHosts(picked...)}, nil } // useStepSpinner reports whether a tool operation should render live diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 53e231179df..94f13272529 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -350,7 +350,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 + availableSkillHosts func(ctx context.Context, t *tool.ToolDefinition) (commands []string, names []string) } func (m *cmdMockInstaller) Install( @@ -371,11 +371,35 @@ func (m *cmdMockInstaller) Upgrade( return &tool.InstallResult{Tool: t, Success: true}, nil } -func (m *cmdMockInstaller) AvailableSkillHosts(ctx context.Context, t *tool.ToolDefinition) []string { +func (m *cmdMockInstaller) AvailableSkillHosts( + ctx context.Context, + t *tool.ToolDefinition, +) (commands []string, names []string) { if m.availableSkillHosts != nil { return m.availableSkillHosts(ctx, t) } - return nil + return nil, nil +} + +// mockAvailableSkillHosts returns commands unchanged plus the display name for +// each, derived from the tool's SkillHosts (falling back to the command when +// no host matches). It mirrors installer.AvailableSkillHosts so the mock +// yields the same (commands, names) shape from a plain list of commands. +func mockAvailableSkillHosts(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.SkillHosts { + if h.Command == c { + names[i] = h.Host + break + } + } + } + return commands, names } func (m *cmdMockInstaller) Uninstall( @@ -655,6 +679,10 @@ func TestResolveHostOptions(t *testing.T) { Id: "azure-skills", Name: "Azure Skills", Category: tool.ToolCategorySkill, + SkillHosts: []tool.SkillHost{ + {Host: "GitHub Copilot CLI", Command: "copilot"}, + {Host: "Claude Code CLI", Command: "claude"}, + }, } nonSkill := &tool.ToolDefinition{ Id: "azure-mcp-server", @@ -663,8 +691,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 + availableSkillHosts: func(_ context.Context, td *tool.ToolDefinition) ([]string, []string) { + return mockAvailableSkillHosts(td, present) }, } manager := tool.NewManager(&cmdMockDetector{}, installer, nil) @@ -723,23 +751,28 @@ func TestResolveHostOptions(t *testing.T) { require.Error(t, err) var sug *internal.ErrorWithSuggestion require.ErrorAs(t, err, &sug) - assert.Contains(t, sug.Message, "copilot, claude") + 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) { // In an interactive terminal the user is prompted to pick the - // host(s) instead of erroring out. + // host(s) instead of erroring out. The picker shows friendly Host + // 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}) 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) { @@ -791,13 +824,14 @@ 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}) 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 hosts (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) { @@ -944,8 +978,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 + availableSkillHosts: func(_ context.Context, td *tool.ToolDefinition) ([]string, []string) { + return mockAvailableSkillHosts(td, present) }, } manager := tool.NewManager(&cmdMockDetector{}, installer, nil) diff --git a/cli/azd/pkg/tool/detector.go b/cli/azd/pkg/tool/detector.go index 6361abeaded..4d5aecbde95 100644 --- a/cli/azd/pkg/tool/detector.go +++ b/cli/azd/pkg/tool/detector.go @@ -521,7 +521,7 @@ func (d *detector) DetectSkillHosts( } if version != "" { hosts = append(hosts, InstalledSkillHost{ - Host: host.Host, + Host: host.Command, Version: version, }) } diff --git a/cli/azd/pkg/tool/detector_test.go b/cli/azd/pkg/tool/detector_test.go index 383eb119049..081b2597ec2 100644 --- a/cli/azd/pkg/tool/detector_test.go +++ b/cli/azd/pkg/tool/detector_test.go @@ -628,21 +628,21 @@ func TestDetectSkillHosts(t *testing.T) { copilot: copilotInstalled, claude: claudeInstalled, wantHosts: []InstalledSkillHost{ - {Host: "GitHub Copilot CLI", Version: "1.1.71"}, - {Host: "Claude Code CLI", Version: "1.1.71"}, + {Host: "copilot", Version: "1.1.71"}, + {Host: "claude", Version: "1.1.71"}, }, }, { name: "OnlyClaude", copilot: notInstalled, claude: claudeInstalled, - wantHosts: []InstalledSkillHost{{Host: "Claude Code CLI", Version: "1.1.71"}}, + wantHosts: []InstalledSkillHost{{Host: "claude", Version: "1.1.71"}}, }, { name: "OnlyCopilot", copilot: copilotInstalled, claude: notInstalled, - wantHosts: []InstalledSkillHost{{Host: "GitHub Copilot CLI", Version: "1.1.71"}}, + wantHosts: []InstalledSkillHost{{Host: "copilot", Version: "1.1.71"}}, }, { name: "NoneInstalled", diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 9da0ce28155..96338049cb6 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -125,12 +125,13 @@ 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 + // AvailableSkillHosts returns the tool's configured SkillHosts that are + // currently usable (a functional CLI on PATH, per hostUsable), in + // manifest order (preferred host 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 + // hosts are usable. It probes the host binaries, so it takes a context. + AvailableSkillHosts(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 @@ -364,24 +365,29 @@ 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 { +// AvailableSkillHosts returns the tool's configured SkillHosts that are +// currently usable, in manifest order (preferred host first), as two +// index-aligned slices: the command identities (e.g. "copilot", used for +// matching via --agent/findSkillHost) and their friendly display names (e.g. +// "GitHub Copilot CLI", shown to the user). 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. Both are nil for +// non-skill tools or when none of the hosts are usable. +func (i *installer) AvailableSkillHosts( + 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) + commands = append(commands, host.Command) + names = append(names, host.Host) } } - return present + return commands, names } // Uninstall removes a tool from the current platform. Skills are @@ -815,7 +821,7 @@ func (i *installer) runSkillUninstall( failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) continue } - succeeded = append(succeeded, host.Host) + succeeded = append(succeeded, host.Command) } result.Strategy = strings.Join(succeeded, ", ") @@ -945,7 +951,7 @@ func (i *installer) verifySkillUninstalled( return false, detectErr } for _, h := range installed { - if h.Host == host.Host { + if h.Host == host.Command { return false, nil // still installed on this host } } @@ -1253,7 +1259,7 @@ func (i *installer) runSkill( failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) continue } - succeeded = append(succeeded, host.Host) + succeeded = append(succeeded, host.Command) if version == "" { version = hostVersion } @@ -1341,7 +1347,7 @@ func (i *installer) resolveSkillTargets( } var targets []SkillHost for _, host := range onPath { - if installedSet[host.Host] { + if installedSet[host.Command] { targets = append(targets, host) continue } @@ -1441,13 +1447,12 @@ func (i *installer) explicitSkillHostTargets( return targets, nil } -// findSkillHost returns the configured SkillHost with the given host name and -// whether one was found. It matches case-insensitively against both the -// display Host (e.g. "GitHub Copilot CLI", as surfaced by the interactive -// picker) and the exec Command (e.g. "copilot", the short name advertised in -// the --agent flag help), so both `--agent copilot` and a picker selection -// resolve. It centralizes the SkillHosts lookup shared by the skill -// install/upgrade and uninstall paths. +// findSkillHost returns the configured SkillHost whose command identity +// matches name (case-insensitively) and whether one was found. Matching is by +// Command only (e.g. "copilot"), never the display Host: --agent values are +// command names, and the interactive picker maps its display selection back +// to the command before resolving here. 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 strings.EqualFold(h.Command, name) @@ -1500,7 +1505,7 @@ func (i *installer) hostUsable(ctx context.Context, host SkillHost) bool { if i.hostProbe == nil { i.hostProbe = map[string]bool{} } - usable, ok := i.hostProbe[host.Host] + usable, ok := i.hostProbe[host.Command] i.hostProbeMu.Unlock() if ok { return usable @@ -1509,7 +1514,7 @@ func (i *installer) hostUsable(ctx context.Context, host SkillHost) bool { usable = i.probeOnPathHost(ctx, host) i.hostProbeMu.Lock() - i.hostProbe[host.Host] = usable + i.hostProbe[host.Command] = usable i.hostProbeMu.Unlock() return usable } @@ -1550,7 +1555,7 @@ func (i *installer) pickSkillHost( if i.hostUsable(ctx, host) { return host, nil } - checked = append(checked, host.Host) + checked = append(checked, host.Command) } suggestion := fmt.Sprintf( @@ -1619,7 +1624,7 @@ func (i *installer) verifySkillInstalled( return false, detectErr } for _, h := range installed { - if h.Host == host.Host { + if h.Host == host.Command { version = h.Version return true, nil } diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index e5ad17314a1..d495952d112 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -1402,7 +1402,9 @@ func TestSkillHost_DisplayVsCommand(t *testing.T) { det := &mockDetector{ detectSkillHostsFn: func(_ context.Context, _ *ToolDefinition) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "GitHub Copilot CLI", Version: "1.1.70"}}, nil + // DetectSkillHosts reports the command identity (see + // InstalledSkillHost.Host), so verification matches by command. + return []InstalledSkillHost{{Host: "copilot", Version: "1.1.70"}}, nil }, } inst := NewInstaller(runner, NewPlatformDetector(runner), det) @@ -2686,10 +2688,10 @@ func TestAvailableSkillHosts_ReturnsPresentInManifestOrder(t *testing.T) { inst := NewInstaller(runner, NewPlatformDetector(runner), &mockDetector{}) - assert.Equal(t, - []string{"copilot", "claude"}, - inst.AvailableSkillHosts(t.Context(), newSkillTool()), - ) + commands, names := inst.AvailableSkillHosts(t.Context(), newSkillTool()) + // newSkillTool uses Host == 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) { @@ -2698,10 +2700,12 @@ func TestAvailableSkillHosts_NonSkillToolReturnsNil(t *testing.T) { runner := mockexec.NewMockCommandRunner() inst := NewInstaller(runner, NewPlatformDetector(runner), &mockDetector{}) - assert.Nil(t, inst.AvailableSkillHosts(t.Context(), &ToolDefinition{ + commands, names := inst.AvailableSkillHosts(t.Context(), &ToolDefinition{ Id: "not-a-skill", Category: ToolCategoryServer, - })) + }) + assert.Nil(t, commands) + assert.Nil(t, names) } // --------------------------------------------------------------------------- @@ -4273,10 +4277,10 @@ 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())) + commands1, _ := inst.AvailableSkillHosts(t.Context(), newSkillTool()) + require.Equal(t, []string{"copilot"}, commands1) + commands2, _ := inst.AvailableSkillHosts(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") @@ -4296,7 +4300,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.AvailableSkillHosts(t.Context(), newSkillTool()) + require.Empty(t, commandsAbsent) // copilot becomes available and functional mid-command. runner.MockToolInPath("copilot", nil) @@ -4305,6 +4310,6 @@ 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.AvailableSkillHosts(t.Context(), newSkillTool()) + assert.Equal(t, []string{"copilot"}, commandsPresent) } diff --git a/cli/azd/pkg/tool/manager.go b/cli/azd/pkg/tool/manager.go index c1b0bfd1f99..af2e8f344cc 100644 --- a/cli/azd/pkg/tool/manager.go +++ b/cli/azd/pkg/tool/manager.go @@ -68,11 +68,15 @@ 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 { +// AvailableSkillHosts returns the given skill tool's configured agentic CLI +// hosts that are currently usable (a functional CLI on PATH), in manifest +// order (preferred host 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 hosts are usable. +func (m *Manager) AvailableSkillHosts( + ctx context.Context, + tool *ToolDefinition, +) (commands []string, names []string) { return m.installer.AvailableSkillHosts(ctx, tool) } diff --git a/cli/azd/pkg/tool/manager_test.go b/cli/azd/pkg/tool/manager_test.go index e8b6200a5db..72630650fd5 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 + availableSkillHostsFn 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 { +func (m *mockInstaller) AvailableSkillHosts( + ctx context.Context, + tool *ToolDefinition, +) (commands []string, names []string) { if m.availableSkillHostsFn != nil { return m.availableSkillHostsFn(ctx, tool) } - return nil + return nil, nil } func (m *mockInstaller) Uninstall( @@ -804,15 +807,16 @@ func TestManager_UpgradeAll(t *testing.T) { func TestManager_AvailableSkillHosts(t *testing.T) { installer := &mockInstaller{ - availableSkillHostsFn: func(_ context.Context, _ *ToolDefinition) []string { - return []string{"copilot", "claude"} + availableSkillHostsFn: 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.AvailableSkillHosts(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) } From 0123ca43b9682651e57da59cb53ab0d3f6698f93 Mon Sep 17 00:00:00 2001 From: hemarina Date: Wed, 8 Jul 2026 20:35:18 -0700 Subject: [PATCH 06/41] update ux message --- cli/azd/cmd/tool.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index e4078f99e8e..ad38266722a 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -318,7 +318,7 @@ func (a *toolAction) Run(ctx context.Context) (*actions.ActionResult, error) { return &actions.ActionResult{ Message: &actions.ResultMessage{ - Header: "Tool installation complete", + Header: "Your tool is installed.", }, }, nil } @@ -604,7 +604,7 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err return &actions.ActionResult{ Message: &actions.ResultMessage{ - Header: "Tool installation complete", + Header: "Your tool is installed.", }, }, nil } @@ -1415,7 +1415,7 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e return &actions.ActionResult{ Message: &actions.ResultMessage{ - Header: "Tool uninstall complete", + Header: "Your tool is uninstalled.", }, }, nil } From 8f345dec27bbb1e528ed8a4ecd46d6c8f3eb6c4d Mon Sep 17 00:00:00 2001 From: hemarina Date: Wed, 8 Jul 2026 21:11:03 -0700 Subject: [PATCH 07/41] azd tool upgrade --- cli/azd/cmd/tool.go | 20 ++- cli/azd/pkg/tool/installer.go | 202 +++++++++++++++++++++-------- cli/azd/pkg/tool/installer_test.go | 141 +++++++++++++++++++- 3 files changed, 306 insertions(+), 57 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index ad38266722a..ead9cdd528b 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -1200,9 +1200,27 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err return nil, opErr } + // Choose the success header based on whether anything actually changed: + // when every upgraded tool reported it was already at the latest version, + // say so; otherwise report an upgrade. AlreadyUpToDate is only set for + // skills, so non-skill upgrades always read as "upgraded". + header := "Tool is upgraded." + if len(rawResults) > 0 { + allUpToDate := true + for _, r := range rawResults { + if !r.AlreadyUpToDate { + allUpToDate = false + break + } + } + if allUpToDate { + header = "Tool is already up to date." + } + } + return &actions.ActionResult{ Message: &actions.ResultMessage{ - Header: "Tool upgrade complete", + Header: header, }, }, nil } diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 96338049cb6..995970a50cc 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -17,6 +17,7 @@ import ( "os" osexec "os/exec" "path/filepath" + "regexp" "runtime" "slices" "strings" @@ -52,6 +53,11 @@ type InstallResult struct { Success bool // InstalledVersion is the version detected after installation. InstalledVersion string + // AlreadyUpToDate is set for an upgrade when every targeted host + // 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 @@ -217,33 +223,42 @@ func stepError(result *InstallResult, err error) error { // renderSkillStep frames one skill step (install, upgrade or uninstall) with // a live spinner that stays visible while the host CLI talks to the user. // -// It shows a step spinner (like azd provision) and passes work an output -// writer. Skill operations route the host CLI's stdout/stderr through that -// writer (see skillCommandRunArgs), with stdin still connected, so each line -// the CLI emits — progress or an interactive prompt — is printed above the -// spinner while the spinner stays pinned below it: the console tears the +// It shows a step spinner (like azd provision) with title and passes work an +// output writer. Skill operations route the host CLI's stdout/stderr through +// that writer (see skillCommandRunArgs), with stdin still connected, so each +// line the CLI emits — progress or an interactive prompt — is printed above +// the spinner while the spinner stays pinned below it: the console tears the // spinner down and re-renders it around each printed line (see // AskerConsole.println), so the bar is kept, not lost. The user answers any // prompt directly via the connected stdin. When the CLI stays silent the -// spinner simply runs to completion. Either way the step ends by stopping the -// spinner with the result. 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. +// spinner simply runs to completion. +// +// 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. func renderSkillStep( ctx context.Context, renderer StepRenderer, title string, - work func(out io.Writer) error, + work func(out io.Writer) (doneTitle string, err error), ) error { if renderer == nil { fmt.Fprintf(os.Stderr, "\n%s\n", title) - return work(nil) + _, err := work(nil) + return err } renderer.ShowSpinner(ctx, title, input.Step) out := &lineWriter{emit: func(line string) { renderer.Message(ctx, line) }} - err := work(out) - renderer.StopSpinner(ctx, title, input.GetStepResultFormat(err)) + doneTitle, err := work(out) + if doneTitle == "" { + doneTitle = title + } + renderer.StopSpinner(ctx, doneTitle, input.GetStepResultFormat(err)) return err } @@ -278,6 +293,31 @@ func skillCommandRunArgs(base exec.RunArgs, out io.Writer) exec.RunArgs { return base.WithStdIn(os.Stdin).WithStdOut(out).WithStdErr(out) } +// semverRegex matches a bare semantic version (no leading "v") anywhere in a +// string. Used to pull the version a host CLI prints after an update. +var semverRegex = regexp.MustCompile(`\d+\.\d+\.\d+`) + +// parseUpgradeOutput extracts, from a host CLI's plugin-update output, the +// version it reports and whether it said the plugin was already at the latest +// version. Best-effort across host 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]. type installer struct { commandRunner exec.CommandRunner @@ -814,8 +854,8 @@ func (i *installer) runSkillUninstall( ) for _, host := range targets { title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, host.Host) - hostErr := renderSkillStep(ctx, renderer, title, func(out io.Writer) error { - return i.uninstallSkillForHost(ctx, tool, host, out) + hostErr := renderSkillStep(ctx, renderer, title, func(out io.Writer) (string, error) { + return "", i.uninstallSkillForHost(ctx, tool, host, out) }) if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) @@ -1234,37 +1274,65 @@ func (i *installer) runSkill( )+output.WithLinkFormat("https://nodejs.org/")) } - // 3. Install / upgrade for each target host, collecting outcomes. Each - // host runs interactively: renderSkillStep prints a step header and a - // result line around the host CLI's streamed output, so the user can - // answer any prompts the host surfaces. + // 3. Install / upgrade for each target host, collecting outcomes. + // renderSkillStep shows a step spinner per host. For an install the + // host CLI's output streams above the spinner (so prompts are + // answerable); for an upgrade the update 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 host ) for _, host := range targets { title := fmt.Sprintf("%s %s in %s", verb, tool.Name, host.Host) - var hostVersion string - hostErr := renderSkillStep(ctx, renderer, title, func(out io.Writer) error { - v, e := i.installSkillForHost(ctx, tool, host, upgrade, out) + var ( + hostVersion string + hostUpToDate bool + ) + hostErr := renderSkillStep(ctx, renderer, title, func(out io.Writer) (string, error) { + v, upToDate, e := i.installSkillForHost(ctx, tool, host, upgrade, out) hostVersion = v - return e + hostUpToDate = 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 are already up to date (v%s).", tool.Name, v) + case upgrade && upToDate: + done = fmt.Sprintf("%s are already up to date.", tool.Name) + case upgrade && v != "": + done = fmt.Sprintf("%s (v%s)", title, v) + } + return done, nil }) if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) continue } succeeded = append(succeeded, host.Command) + if !hostUpToDate { + anyUpgraded = true + } if version == "" { version = hostVersion } } + // An upgrade that changed nothing (every targeted host 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) @@ -1583,21 +1651,38 @@ func (i *installer) pickSkillHost( } } -// installSkillForHost installs (or upgrades) the skill through a single -// host and verifies the result, returning the detected version. out, when -// non-nil, receives the host CLI's output line-by-line for display above the -// step spinner. +// installSkillForHost installs (or upgrades) the skill through a single host +// and verifies the result. It returns the version and, for an upgrade, +// whether the host reported the skill was already at the latest version. For +// an upgrade the version comes from the update 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 host output +// for display above the step spinner. func (i *installer) installSkillForHost( ctx context.Context, tool *ToolDefinition, host SkillHost, upgrade bool, out io.Writer, -) (version string, err error) { - if err := i.runSkillHostCommand(ctx, host, upgrade, out); err != nil { - return "", err +) (version string, alreadyLatest bool, err error) { + cmdOutput, err := i.runSkillHostCommand(ctx, host, upgrade, out) + if err != nil { + return "", false, err + } + if upgrade { + version, alreadyLatest = parseUpgradeOutput(cmdOutput) } - return i.verifySkillInstalled(ctx, tool, host) + + detectedVersion, err := i.verifySkillInstalled(ctx, tool, host) + if err != nil { + return "", false, err + } + // Prefer the version the update command reported; fall back to the + // version detected via the plugin list. + if version == "" { + version = detectedVersion + } + return version, alreadyLatest, nil } // verifySkillInstalled confirms the skill is detectable **through the @@ -1645,18 +1730,20 @@ func (i *installer) verifySkillInstalled( return version, nil } -// runSkillHostCommand executes the host's install (or update) command. When -// a step spinner is showing (out is non-nil) the command's output is routed -// through out so each line the host CLI writes — including any interactive -// prompt — is printed above the spinner, which stays pinned below it, with -// stdin connected so the user answers directly; when no spinner is active the -// command runs fully interactively (see skillCommandRunArgs). azd never pipes -// canned answers on the user's behalf, and captures nothing, so there is no -// output to return. +// runSkillHostCommand executes the host's install or update 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 update connect to the terminal differently: +// - Install streams the host 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 host +// declares one. +// - Update 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 update 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 @@ -1666,7 +1753,7 @@ func (i *installer) runSkillHostCommand( host SkillHost, upgrade bool, out io.Writer, -) error { +) (string, error) { cmd := host.PluginInstallCommand verb := "install" if upgrade { @@ -1674,26 +1761,39 @@ func (i *installer) runSkillHostCommand( verb = "update" } if len(cmd) == 0 { - return fmt.Errorf( + return "", fmt.Errorf( "host %q has no %s command configured", host.Host, verb, ) } - if !upgrade && len(host.MarketplaceAddCommand) > 0 { + // Update: 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(host.Command, cmd...)) + if err != nil { + return "", fmt.Errorf( + "running `%s %s`: %w", + host.Command, strings.Join(cmd, " "), err, + ) + } + return res.Stdout, nil + } + + if len(host.MarketplaceAddCommand) > 0 { if err := i.runMarketplaceAdd(ctx, host); err != nil { - return err + return "", err } } runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out) if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { - return fmt.Errorf( + return "", fmt.Errorf( "running `%s %s`: %w", host.Command, strings.Join(cmd, " "), err, ) } - return nil + return "", nil } // runMarketplaceAdd registers the skill marketplace with the host CLI. diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index d495952d112..063c3c8c395 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -1307,11 +1307,142 @@ func TestRunSkill_StepProgress(t *testing.T) { assert.Empty(t, r.messages) } -// TestRunSkill_StreamedOutputStopsSpinner verifies that when the host CLI -// writes to the terminal (a progress line or an interactive prompt), the -// step spinner is torn down immediately (stopped with an empty message) and -// the step result is printed inline afterwards, so the spinner never -// overwrites the CLI's own output. +// TestParseUpgradeOutput covers extracting the version and the "already at +// latest" state from each host 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() + mockHostPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + // The update command reports the new version (claude-style "from A to B"). + 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.1.73 to 1.1.86 for scope user.`, + }) + + inst := NewInstaller( + runner, NewPlatformDetector(runner), + &mockDetector{ + detectSkillHostsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillHost, error) { + return []InstalledSkillHost{{Host: "copilot", Version: "1.1.86"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Upgrade( + t.Context(), newSkillTool(), + WithHosts("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 host 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() + mockHostPresence(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{ + detectSkillHostsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillHost, error) { + return []InstalledSkillHost{{Host: "copilot", Version: "1.1.86"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Upgrade( + t.Context(), newSkillTool(), + WithHosts("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 are already up to date (v1.1.86)."}, r.stops) +} + // TestRunSkill_StreamedOutputPrintedAboveSpinner verifies that when the host // CLI writes to the terminal (a progress line or an interactive prompt), // each line is surfaced via Message (which the console prints above the From 862ed4ffef014504564617a823b19149cde28fca Mon Sep 17 00:00:00 2001 From: hemarina Date: Wed, 8 Jul 2026 21:56:10 -0700 Subject: [PATCH 08/41] azd tool list, check, upgrade --all --- cli/azd/cmd/testdata/TestFigSpec.ts | 4 + .../testdata/TestUsage-azd-tool-upgrade.snap | 1 + cli/azd/cmd/tool.go | 211 +++++++++++++++--- cli/azd/cmd/tool_test.go | 86 +++++++ cli/azd/pkg/tool/detector.go | 36 +-- cli/azd/pkg/tool/update_checker.go | 33 ++- 6 files changed, 316 insertions(+), 55 deletions(-) diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index d56b27c88eb..c16b2724f93 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6366,6 +6366,10 @@ const completionSpec: Fig.Spec = { }, ], }, + { + name: ['--all'], + description: 'Upgrade all installed tools', + }, { name: ['--dry-run'], description: 'Preview what would be upgraded without making changes', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap b/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap index 7a0fdf2ed56..7e96fbf113d 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-tool-upgrade.snap @@ -6,6 +6,7 @@ Usage Flags --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 diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index ead9cdd528b..5c560c654ec 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -328,12 +328,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 agentic CLI host 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 host 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 { @@ -381,6 +388,24 @@ 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 hosts expands into one row per + // host, each prefixed with the host label (e.g. "[Copilot] ..."). + if s.Tool.Category == tool.ToolCategorySkill && len(s.SkillHosts) > 0 { + for _, h := range s.SkillHosts { + rows = append(rows, toolListItem{ + Id: s.Tool.Id, + Name: s.Tool.Name, + Agent: h.Host, + DisplayName: fmt.Sprintf("[%s] %s", output.WithWarningFormat(h.Host), 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 { @@ -389,12 +414,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, }) } @@ -413,7 +439,7 @@ 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, @@ -438,11 +464,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{ @@ -1023,12 +1044,17 @@ func (a *toolInstallAction) resolveToolIds(ctx context.Context) ([]string, error // --------------------------------------------------------------------------- type toolUpgradeFlags struct { + all bool dryRun bool hosts []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", @@ -1082,7 +1108,22 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err // for upgrading. fromVersions := make(map[string]string) - if len(a.args) > 0 { + 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 { @@ -1094,25 +1135,31 @@ 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`), or upgrade every installed tool when running + // non-interactively. + 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) + } + } + chosen := installed + if a.console.IsSpinnerInteractive() && 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 } } } @@ -1225,6 +1272,68 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err }, nil } +// 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) { + 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) + } + 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 +} + // resolveHostOptions determines which agentic CLI host(s) a skill should // be upgraded for, based on the --agent flag. --agent all targets every // detected host; specific names target those hosts. When --agent is @@ -1588,14 +1697,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 agentic CLI host 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. // 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 host 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 { @@ -1647,9 +1763,28 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error if r.UpdateAvailable { updatesAvailable++ } + // A skill installed on one or more hosts expands into one row per + // host, each prefixed with the host label and carrying that host's + // installed version and update status. + if r.Tool.Category == tool.ToolCategorySkill && len(r.SkillHosts) > 0 { + for _, h := range r.SkillHosts { + rows = append(rows, toolCheckItem{ + Id: r.Tool.Id, + Name: r.Tool.Name, + Agent: h.Host, + DisplayName: fmt.Sprintf("[%s] %s", output.WithWarningFormat(h.Host), 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, @@ -1675,7 +1810,7 @@ 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, @@ -1725,8 +1860,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"), )) } } diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 94f13272529..83a69afa1ce 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -7,6 +7,7 @@ import ( "context" "errors" "io" + "strings" "testing" "time" @@ -1168,3 +1169,88 @@ 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") +} + +// TestAgentLabel verifies the host command identity is capitalized for +// display (e.g. "copilot" -> "Copilot"), with an empty command mapping to "". +func TestAgentLabel(t *testing.T) { + cases := map[string]string{ + "copilot": "Copilot", + "claude": "Claude", + "gemini": "Gemini", + "": "", + } + for in, want := range cases { + assert.Equal(t, want, agentLabel(in)) + } +} + +// TestSkillDisplayName verifies a skill name is prefixed with its bracketed +// host label. +func TestSkillDisplayName(t *testing.T) { + assert.Equal(t, "[Copilot] Azure Skills", skillDisplayName("Azure Skills", "copilot")) + assert.Equal(t, "[Claude] Azure Skills", skillDisplayName("Azure Skills", "claude")) +} + +// TestColorAgentPrefix verifies only a leading "[Agent]" token is decorated: +// names without a bracket prefix are returned unchanged, and a prefixed name +// keeps its trailing text intact regardless of whether color is enabled. +func TestColorAgentPrefix(t *testing.T) { + // No bracket prefix: returned verbatim. + assert.Equal(t, "Azure CLI", colorAgentPrefix("Azure CLI")) + assert.Equal(t, "", colorAgentPrefix("")) + + // Bracket prefix: the token and trailing name are both preserved. + got := colorAgentPrefix("[Copilot] Azure Skills") + assert.Contains(t, got, "[Copilot]") + assert.True(t, strings.HasSuffix(got, "] Azure Skills"), + "trailing tool name must be preserved uncolored") +} diff --git a/cli/azd/pkg/tool/detector.go b/cli/azd/pkg/tool/detector.go index 4d5aecbde95..34486fd4d50 100644 --- a/cli/azd/pkg/tool/detector.go +++ b/cli/azd/pkg/tool/detector.go @@ -26,6 +26,10 @@ type ToolStatus struct { // output. It is empty when the tool is not installed or when // version parsing fails. InstalledVersion string + // SkillHosts lists every agentic CLI host the skill is installed + // through, with the version installed via each, in manifest order. + // Populated only for skill tools (see detectSkill); nil otherwise. + SkillHosts []InstalledSkillHost // Error records any unexpected failure during detection (e.g. a // timeout). A tool that is simply not installed has Error == nil. Error error @@ -461,15 +465,18 @@ func (d *detector) detectAzdExtension( // Skill detection // --------------------------------------------------------------------------- -// detectSkill checks whether a skill is installed by running each +// detectSkill checks whether a skill is installed by probing every // SkillHost's PluginListCommand. A host 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 // 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 host the skill is installed through is recorded in +// SkillHosts (in manifest order); Installed and InstalledVersion reflect +// the first such host, so a skill installed anywhere reads as installed. +// 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. func (d *detector) detectSkill( ctx context.Context, tool *ToolDefinition, @@ -480,17 +487,16 @@ func (d *detector) detectSkill( 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 - } + hosts, err := d.DetectSkillHosts(ctx, tool) + if err != nil { + status.Error = err + return status + } + + status.SkillHosts = hosts + if len(hosts) > 0 { + status.Installed = true + status.InstalledVersion = hosts[0].Version } return status diff --git a/cli/azd/pkg/tool/update_checker.go b/cli/azd/pkg/tool/update_checker.go index 4a08397ed60..8e4b39c5489 100644 --- a/cli/azd/pkg/tool/update_checker.go +++ b/cli/azd/pkg/tool/update_checker.go @@ -77,6 +77,22 @@ type UpdateCheckResult struct { // UpdateAvailable is true when LatestVersion is non-empty and // differs from CurrentVersion. UpdateAvailable bool + // SkillHosts lists, for a skill tool, the per-host installed version + // and whether an update is available for it (LatestVersion is the same + // for every host). Populated only for skills; nil otherwise. + SkillHosts []SkillHostUpdate +} + +// SkillHostUpdate pairs an agentic CLI host with the skill version installed +// through it and whether a newer version is available. +type SkillHostUpdate struct { + // Host is the agentic CLI host binary name (e.g. "copilot"). + Host string + // CurrentVersion is the skill version installed through this host. + CurrentVersion string + // UpdateAvailable is true when a newer version than CurrentVersion is + // available for this host. + UpdateAvailable bool } // UpdateChecker performs periodic update checks for registered tools, @@ -208,15 +224,23 @@ func (uc *UpdateChecker) Check( for _, t := range tools { status := statusByID[t.Id] + latestVer := uc.resolveLatestVersion( + ctx, t, existing, cacheValid, + ) + var currentVer string + var skillHosts []SkillHostUpdate if status != nil { currentVer = status.InstalledVersion + for _, h := range status.SkillHosts { + skillHosts = append(skillHosts, SkillHostUpdate{ + Host: h.Host, + CurrentVersion: h.Version, + UpdateAvailable: isNewerVersion(latestVer, h.Version), + }) + } } - latestVer := uc.resolveLatestVersion( - ctx, t, existing, cacheValid, - ) - cache.Tools[t.Id] = CachedToolVersion{ LatestVersion: latestVer, } @@ -226,6 +250,7 @@ func (uc *UpdateChecker) Check( CurrentVersion: currentVer, LatestVersion: latestVer, UpdateAvailable: isNewerVersion(latestVer, currentVer), + SkillHosts: skillHosts, }) } From 7ed6598c8854ec51dbd21ecb4e9cf5c91000b05e Mon Sep 17 00:00:00 2001 From: hemarina Date: Wed, 8 Jul 2026 22:31:51 -0700 Subject: [PATCH 09/41] update azd tool upgrade ux --- cli/azd/cmd/tool.go | 45 ++++++++++++++------ cli/azd/cmd/tool_test.go | 92 +++++++++++++++++++++++++++------------- 2 files changed, 94 insertions(+), 43 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 5c560c654ec..c9e09e99047 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -1247,21 +1247,40 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err return nil, opErr } - // Choose the success header based on whether anything actually changed: - // when every upgraded tool reported it was already at the latest version, - // say so; otherwise report an upgrade. AlreadyUpToDate is only set for - // skills, so non-skill upgrades always read as "upgraded". - header := "Tool is upgraded." - if len(rawResults) > 0 { - allUpToDate := true - for _, r := range rawResults { - if !r.AlreadyUpToDate { - allUpToDate = false - break - } + // Choose the success header based on whether anything actually changed. + // A tool is "already up to date" when the installer flagged it (skills + // report this per host) or its detected version is unchanged by the + // upgrade — 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 and it reads as "upgraded". + allUpToDate := len(rawResults) > 0 + for _, r := range rawResults { + upToDate := r.AlreadyUpToDate + if !upToDate && r.Tool != nil { + 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." + } + // 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 = "Tool is already up to date." + header = fmt.Sprintf("Tool is already up to date (v%s).", version) + } else { + header = fmt.Sprintf("Tool is upgraded to v%s.", version) } } diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 83a69afa1ce..bd9ff3c8365 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -7,7 +7,6 @@ import ( "context" "errors" "io" - "strings" "testing" "time" @@ -1219,38 +1218,71 @@ func TestToolUpgradeAction_All_UpgradesInstalledTools(t *testing.T) { "--all must upgrade exactly the installed tools") } -// TestAgentLabel verifies the host command identity is capitalized for -// display (e.g. "copilot" -> "Copilot"), with an empty command mapping to "". -func TestAgentLabel(t *testing.T) { - cases := map[string]string{ - "copilot": "Copilot", - "claude": "Claude", - "gemini": "Gemini", - "": "", +// 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 + }, } - for in, want := range cases { - assert.Equal(t, want, agentLabel(in)) + 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, + ) -// TestSkillDisplayName verifies a skill name is prefixed with its bracketed -// host label. -func TestSkillDisplayName(t *testing.T) { - assert.Equal(t, "[Copilot] Azure Skills", skillDisplayName("Azure Skills", "copilot")) - assert.Equal(t, "[Claude] Azure Skills", skillDisplayName("Azure Skills", "claude")) + 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) } -// TestColorAgentPrefix verifies only a leading "[Agent]" token is decorated: -// names without a bracket prefix are returned unchanged, and a prefixed name -// keeps its trailing text intact regardless of whether color is enabled. -func TestColorAgentPrefix(t *testing.T) { - // No bracket prefix: returned verbatim. - assert.Equal(t, "Azure CLI", colorAgentPrefix("Azure CLI")) - assert.Equal(t, "", colorAgentPrefix("")) - - // Bracket prefix: the token and trailing name are both preserved. - got := colorAgentPrefix("[Copilot] Azure Skills") - assert.Contains(t, got, "[Copilot]") - assert.True(t, strings.HasSuffix(got, "] Azure Skills"), - "trailing tool name must be preserved uncolored") +// 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) } From ef279902e7f0bba4470d0bd15717d7785d1b87cb Mon Sep 17 00:00:00 2001 From: hemarina Date: Thu, 9 Jul 2026 13:19:00 -0700 Subject: [PATCH 10/41] add more test coverage --- cli/azd/pkg/ux/select_test.go | 134 ++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) 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:") +} From 9dfdeda65f22cab9d5beef219d5b875b8e4094f8 Mon Sep 17 00:00:00 2001 From: hemarina Date: Thu, 9 Jul 2026 13:25:28 -0700 Subject: [PATCH 11/41] clean up UX for install --- cli/azd/pkg/tool/installer.go | 37 ++++++++++---- cli/azd/pkg/tool/installer_test.go | 80 ++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 995970a50cc..15b8f78888b 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -225,12 +225,16 @@ func stepError(result *InstallResult, err error) error { // // It shows a step spinner (like azd provision) with title and passes work an // output writer. Skill operations route the host CLI's stdout/stderr through -// that writer (see skillCommandRunArgs), with stdin still connected, so each -// line the CLI emits — progress or an interactive prompt — is printed above -// the spinner while the spinner stays pinned below it: the console tears the -// spinner down and re-renders it around each printed line (see -// AskerConsole.println), so the bar is kept, not lost. The user answers any -// prompt directly via the connected stdin. When the CLI stays silent the +// that writer (see skillCommandRunArgs), with stdin still connected. +// +// streamOutput controls how that output is surfaced. When true, each line the +// CLI emits — progress or an interactive prompt — is printed above the spinner +// while the spinner stays pinned below it: the console tears the spinner down +// and re-renders it around each printed line (see AskerConsole.println), so +// the bar is kept, not lost, and the user can answer any prompt via the +// connected stdin. When false, the output is buffered and replayed only if the +// step fails, so a step that completes without error stays quiet (used for +// non-interactive operations such as uninstall). When the CLI stays silent the // spinner simply runs to completion. // // work returns the message to show on the result line; when empty the spinner @@ -244,6 +248,7 @@ func renderSkillStep( ctx context.Context, renderer StepRenderer, title string, + streamOutput bool, work func(out io.Writer) (doneTitle string, err error), ) error { if renderer == nil { @@ -253,11 +258,25 @@ func renderSkillStep( } renderer.ShowSpinner(ctx, title, input.Step) - out := &lineWriter{emit: func(line string) { renderer.Message(ctx, line) }} + + // Stream the host CLI's output live (so interactive prompts are visible), + // or buffer it and replay only on failure so a successful step is quiet. + var buffered []string + emit := func(line string) { renderer.Message(ctx, line) } + if !streamOutput { + emit = func(line string) { buffered = append(buffered, line) } + } + out := &lineWriter{emit: emit} + doneTitle, err := work(out) if doneTitle == "" { doneTitle = title } + if !streamOutput && err != nil { + for _, line := range buffered { + renderer.Message(ctx, line) + } + } renderer.StopSpinner(ctx, doneTitle, input.GetStepResultFormat(err)) return err } @@ -854,7 +873,7 @@ func (i *installer) runSkillUninstall( ) for _, host := range targets { title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, host.Host) - hostErr := renderSkillStep(ctx, renderer, title, func(out io.Writer) (string, error) { + hostErr := renderSkillStep(ctx, renderer, title, false, func(out io.Writer) (string, error) { return "", i.uninstallSkillForHost(ctx, tool, host, out) }) if hostErr != nil { @@ -1296,7 +1315,7 @@ func (i *installer) runSkill( hostVersion string hostUpToDate bool ) - hostErr := renderSkillStep(ctx, renderer, title, func(out io.Writer) (string, error) { + hostErr := renderSkillStep(ctx, renderer, title, true, func(out io.Writer) (string, error) { v, upToDate, e := i.installSkillForHost(ctx, tool, host, upgrade, out) hostVersion = v hostUpToDate = upToDate diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index 063c3c8c395..768ef962743 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -3924,6 +3924,86 @@ func TestRunSkillUninstall_StepProgress(t *testing.T) { assert.Empty(t, r.messages) } +// TestRunSkillUninstall_StepProgress_SuccessHidesOutput verifies that when the +// host CLI prints output and the uninstall completes without error, that +// output is NOT surfaced above the spinner — a successful step stays quiet. +func TestRunSkillUninstall_StepProgress_SuccessHidesOutput(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockHostPresence(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{ + detectSkillHostsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillHost, error) { + return nil, nil + }, + } + inst := NewInstaller(runner, NewPlatformDetector(runner), det) + + var r fakeStepRenderer + result, err := inst.Uninstall( + t.Context(), newSkillTool(), + WithHosts("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "uninstall must succeed; err=%v", result.Error) + + assert.Empty(t, r.messages, + "host CLI output must be hidden when uninstall completes without error") + assert.Equal(t, []string{"Uninstalling Test Azure Skills from copilot"}, r.stops) +} + +// TestRunSkillUninstall_StepProgress_FailureShowsOutput verifies the converse: +// when the uninstall fails, the buffered host CLI output is replayed so the +// user can see what went wrong. +func TestRunSkillUninstall_StepProgress_FailureShowsOutput(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockHostPresence(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{ + detectSkillHostsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillHost, error) { + return []InstalledSkillHost{{Host: "copilot", Version: "1.0.0"}}, nil + }, + } + inst := NewInstaller(runner, NewPlatformDetector(runner), det) + + var r fakeStepRenderer + result, _ := inst.Uninstall( + t.Context(), newSkillTool(), + WithHosts("copilot"), + WithStepProgress(&r), + ) + require.False(t, result.Success) + require.Error(t, result.Error) + + assert.Contains(t, r.messages, "could not remove plugin", + "host CLI output must be shown when uninstall fails") +} + func TestUninstallSkill_ExplicitHost_RemovesOnlyThatHost(t *testing.T) { t.Parallel() From 07baa350baa5da45abfb768a0c92d0aaa94c3c37 Mon Sep 17 00:00:00 2001 From: hemarina Date: Thu, 9 Jul 2026 13:30:38 -0700 Subject: [PATCH 12/41] go fix --- cli/azd/pkg/tool/installer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 15b8f78888b..4f612a0325e 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -292,7 +292,7 @@ type lineWriter struct { } func (l *lineWriter) Write(p []byte) (int, error) { - for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") { + for line := range strings.SplitSeq(strings.TrimRight(string(p), "\n"), "\n") { l.emit(line) } return len(p), nil From 27f519490d1f70b7fdfdf10d23380bb9c3f873f3 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 12:58:08 -0700 Subject: [PATCH 13/41] use display name --- cli/azd/cmd/tool.go | 39 +++++++++++++++++++++++++++------------ cli/azd/cmd/tool_test.go | 16 ++++++++++++++++ 2 files changed, 43 insertions(+), 12 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index c9e09e99047..801ba9ae5cb 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -393,14 +393,15 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) if s.Tool.Category == tool.ToolCategorySkill && len(s.SkillHosts) > 0 { for _, h := range s.SkillHosts { rows = append(rows, toolListItem{ - Id: s.Tool.Id, - Name: s.Tool.Name, - Agent: h.Host, - DisplayName: fmt.Sprintf("[%s] %s", output.WithWarningFormat(h.Host), s.Tool.Name), - Category: string(s.Tool.Category), - Priority: string(s.Tool.Priority), - Status: "Installed", - Version: h.Version, + Id: s.Tool.Id, + Name: s.Tool.Name, + Agent: h.Host, + DisplayName: fmt.Sprintf("[%s] %s", + output.WithWarningFormat(skillHostDisplayName(s.Tool, h.Host)), s.Tool.Name), + Category: string(s.Tool.Category), + Priority: string(s.Tool.Priority), + Status: "Installed", + Version: h.Version, }) } continue @@ -1788,10 +1789,11 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error if r.Tool.Category == tool.ToolCategorySkill && len(r.SkillHosts) > 0 { for _, h := range r.SkillHosts { rows = append(rows, toolCheckItem{ - Id: r.Tool.Id, - Name: r.Tool.Name, - Agent: h.Host, - DisplayName: fmt.Sprintf("[%s] %s", output.WithWarningFormat(h.Host), r.Tool.Name), + Id: r.Tool.Id, + Name: r.Tool.Name, + Agent: h.Host, + DisplayName: fmt.Sprintf("[%s] %s", + output.WithWarningFormat(skillHostDisplayName(r.Tool, h.Host)), r.Tool.Name), InstalledVersion: h.CurrentVersion, LatestVersion: r.LatestVersion, UpdateAvailable: h.UpdateAvailable, @@ -2406,3 +2408,16 @@ func toolCheckStatus(installed, updateAvailable bool) string { return statusUpToDate } } + +// skillHostDisplayName maps an installed skill host'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 host matches. +func skillHostDisplayName(t *tool.ToolDefinition, command string) string { + for _, host := range t.SkillHosts { + if host.Command == command { + return host.Host + } + } + return command +} diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index bd9ff3c8365..54efbdb1764 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -1286,3 +1286,19 @@ func TestToolUpgradeAction_ChangedVersion_ReportsUpgraded(t *testing.T) { require.NotNil(t, result.Message) assert.Equal(t, "Tool is upgraded to v2.0.0.", result.Message.Header) } + +// TestSkillHostDisplayName verifies an installed host'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 TestSkillHostDisplayName(t *testing.T) { + td := &tool.ToolDefinition{ + SkillHosts: []tool.SkillHost{ + {Host: "GitHub Copilot CLI", Command: "copilot"}, + {Host: "Claude Code CLI", Command: "claude"}, + }, + } + assert.Equal(t, "GitHub Copilot CLI", skillHostDisplayName(td, "copilot")) + assert.Equal(t, "Claude Code CLI", skillHostDisplayName(td, "claude")) + // An unknown command falls back to itself. + assert.Equal(t, "gemini", skillHostDisplayName(td, "gemini")) +} From bea1c049e24e68cb88d75b12ab47163cbc3a7077 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 13:09:54 -0700 Subject: [PATCH 14/41] make availability check case-insensitive --- cli/azd/cmd/tool.go | 8 +++++++- cli/azd/cmd/tool_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 801ba9ae5cb..62699df39d0 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -778,7 +778,13 @@ func (a *toolInstallAction) resolveUnavailableHostPrompt( available, availableNames := a.manager.AvailableSkillHosts(ctx, skill) var unavailable []string for _, host := range a.flags.hosts { - if !slices.Contains(available, host) { + // Match case-insensitively, mirroring findSkillHost 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, host) + }) { unavailable = append(unavailable, fmt.Sprintf("%q", host)) } } diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 54efbdb1764..539c2574f9d 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -1302,3 +1302,33 @@ func TestSkillHostDisplayName(t *testing.T) { // An unknown command falls back to itself. assert.Equal(t, "gemini", skillHostDisplayName(td, "gemini")) } + +// TestToolInstallAction_resolveUnavailableHostPrompt_CaseInsensitive verifies +// that an explicit --agent value is matched against the available hosts +// case-insensitively (like findSkillHost). "--agent Copilot" must match the +// available "copilot" command and NOT be reported unavailable or open a +// prompt. +func TestToolInstallAction_resolveUnavailableHostPrompt_CaseInsensitive(t *testing.T) { + installer := &cmdMockInstaller{ + availableSkillHosts: 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-host path is reachable + + action := newToolInstallAction( + nil, + &toolInstallFlags{hosts: []string{"Copilot"}}, + manager, console, &output.NoneFormatter{}, io.Discard, + ).(*toolInstallAction) + + skill := &tool.ToolDefinition{Id: "azure-skills", Category: tool.ToolCategorySkill} + opts, handled, err := action.resolveUnavailableHostPrompt(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) +} From 75c7ee16fd223c41b7bf271a6f114ab3a05c78ce Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 13:45:43 -0700 Subject: [PATCH 15/41] default to --all if no prompt or no tty --- cli/azd/cmd/tool.go | 23 ++++++-- cli/azd/cmd/tool_test.go | 112 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 62699df39d0..fd0ff0d4ce8 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -1016,6 +1016,19 @@ func (a *toolInstallAction) resolveToolIds(ctx context.Context) ([]string, error return nil, nil } + // Non-interactive (no TTY) or --no-prompt: skip the picker and default to + // the recommended set — the picker's own pre-selection, and the same set + // `--all` installs — so automation never blocks or errors on input. + if !a.console.IsSpinnerInteractive() || a.console.IsNoPromptMode() { + var ids []string + for _, s := range uninstalled { + if s.Tool.Priority == tool.ToolPriorityRecommended { + ids = append(ids, s.Tool.Id) + } + } + return ids, nil + } + choices := make([]*uxlib.MultiSelectChoice, len(uninstalled)) for i, s := range uninstalled { choices[i] = &uxlib.MultiSelectChoice{ @@ -1157,7 +1170,7 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err } } chosen := installed - if a.console.IsSpinnerInteractive() && len(installed) > 0 { + if a.console.IsSpinnerInteractive() && !a.console.IsNoPromptMode() && len(installed) > 0 { chosen, err = a.promptForUpgradeTools(ctx, installed) if err != nil { return nil, err @@ -1632,8 +1645,12 @@ 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 through) instead of asking the user to pick. - if a.flags.all || a.flags.dryRun { + // installed through) instead of asking the user to pick. A non-interactive + // terminal or --no-prompt likewise skips the picker and defaults to every + // installed tool (matching --all and `azd tool upgrade`), so automation + // never blocks or errors on input. + if a.flags.all || a.flags.dryRun || + !a.console.IsSpinnerInteractive() || a.console.IsNoPromptMode() { ids := make([]string, 0, len(installed)) for _, s := range installed { ids = append(ids, s.Tool.Id) diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 539c2574f9d..80ebb58c609 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -1218,6 +1218,58 @@ func TestToolUpgradeAction_All_UpgradesInstalledTools(t *testing.T) { "--all must upgrade exactly the installed tools") } +// TestToolUpgradeAction_NoPrompt_UpgradesAllInstalled verifies that +// `azd tool upgrade --no-prompt` (even in a TTY) skips the interactive picker +// and upgrades every installed tool, so automation never blocks on input. +func TestToolUpgradeAction_NoPrompt_UpgradesAllInstalled(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) // a TTY ... + console.SetNoPromptMode(true) // ... but --no-prompt + + action := newToolUpgradeAction( + nil, + &toolUpgradeFlags{}, + 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, + "--no-prompt must skip the picker and upgrade all installed tools") +} + // 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 @@ -1332,3 +1384,63 @@ func TestToolInstallAction_resolveUnavailableHostPrompt_CaseInsensitive(t *testi "--agent Copilot must match available 'copilot' case-insensitively, not prompt") assert.Nil(t, opts) } + +// TestToolInstallAction_resolveToolIds_NoPromptDefaultsToRecommended verifies +// that --no-prompt (even in a TTY) skips the interactive picker and defaults to +// the recommended, not-yet-installed tools — its --all behavior, mirroring +// upgrade — so automation never blocks or errors on input. +func TestToolInstallAction_resolveToolIds_NoPromptDefaultsToRecommended(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) + + 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.NoError(t, err) + assert.Equal(t, []string{"rec"}, ids, + "--no-prompt must skip the picker and default to recommended uninstalled tools") +} + +// TestToolUninstallAction_resolveToolIds_NoPromptDefaultsToAllInstalled verifies +// that --no-prompt (even in a TTY) skips the interactive picker and defaults to +// every installed tool — its --all behavior, mirroring upgrade. +func TestToolUninstallAction_resolveToolIds_NoPromptDefaultsToAllInstalled(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.NoError(t, err) + assert.ElementsMatch(t, []string{"a", "b"}, ids, + "--no-prompt must skip the picker and default to all installed tools") +} From 8f55d984bdff76d7e4a0f4313538a2668699e058 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 13:57:04 -0700 Subject: [PATCH 16/41] only use version comparison for non-skill tools to prevent UX bug --- cli/azd/cmd/tool.go | 13 ++++++------ cli/azd/cmd/tool_test.go | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index fd0ff0d4ce8..91adec3c32e 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -1268,16 +1268,17 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err } // Choose the success header based on whether anything actually changed. - // A tool is "already up to date" when the installer flagged it (skills - // report this per host) or its detected version is unchanged by the - // upgrade — comparing the version detected before the upgrade - // (fromVersions) with the one detected after (InstalledVersion). A + // A tool is "already up to date" when the installer flagged it. For skills + // this flag is authoritative (set per host, so an upgrade on any host + // 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 and it reads as "upgraded". + // "up to date" without evidence. allUpToDate := len(rawResults) > 0 for _, r := range rawResults { upToDate := r.AlreadyUpToDate - if !upToDate && r.Tool != nil { + if !upToDate && r.Tool != nil && r.Tool.Category != tool.ToolCategorySkill { before := fromVersions[r.Tool.Id] upToDate = before != "" && r.InstalledVersion != "" && before == r.InstalledVersion diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 80ebb58c609..ae525e14b4a 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -1444,3 +1444,48 @@ func TestToolUninstallAction_resolveToolIds_NoPromptDefaultsToAllInstalled(t *te assert.ElementsMatch(t, []string{"a", "b"}, ids, "--no-prompt must skip the picker and default to all installed tools") } + +// TestToolUpgradeAction_MultiHostSkill_UpgradedNotUpToDate reproduces the +// multi-host skill case: the aggregate InstalledVersion (first host) is +// unchanged, but the installer set AlreadyUpToDate=false because another host +// WAS upgraded. The header must read "upgraded", not "already up to date" — +// version comparison must not run for skills. +func TestToolUpgradeAction_MultiHostSkill_UpgradedNotUpToDate(t *testing.T) { + tracing.ResetUsageAttributesForTest() + + detector := &cmdMockDetector{ + detectTool: func(_ context.Context, td *tool.ToolDefinition) (*tool.ToolStatus, error) { + // First host 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 host current), but a different + // host 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-host skill with an upgraded host must not read as already up to date") +} From 8b87a5e15234d6d9862224dc7e4449c3e80cb8d7 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 14:05:17 -0700 Subject: [PATCH 17/41] add mutex --- cli/azd/pkg/tool/installer.go | 9 +++++++++ cli/azd/pkg/tool/installer_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 4f612a0325e..18bfb16e7f1 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -287,11 +287,20 @@ func renderSkillStep( // line), keeping the spinner visible. Content is emitted as it arrives — // including a trailing line with no newline, e.g. an interactive prompt — so // nothing is withheld from the user while the CLI waits for input. +// +// 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 so emit (which may append to an unsynchronized buffer +// or write to the console) is never invoked concurrently — avoiding a data +// race and interleaved/lost host output. type lineWriter struct { + mu sync.Mutex emit func(string) } func (l *lineWriter) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() for line := range strings.SplitSeq(strings.TrimRight(string(p), "\n"), "\n") { l.emit(line) } diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index 768ef962743..09fc3233be1 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -17,6 +17,7 @@ import ( "runtime" "slices" "strings" + "sync" "sync/atomic" "testing" "time" @@ -4524,3 +4525,27 @@ func TestHostUsable_NotOnPathResultNotMemoized(t *testing.T) { commandsPresent, _ := inst.AvailableSkillHosts(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 + wg.Add(writers) + for i := 0; i < writers; i++ { + go func() { + defer wg.Done() + _, _ = 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") +} From 969c79ffb80db675a31aab3a904df4c51a610ac2 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 14:31:13 -0700 Subject: [PATCH 18/41] make sure command is not empty --- cli/azd/pkg/tool/manifest.go | 8 +++++--- cli/azd/pkg/tool/manifest_test.go | 15 +++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cli/azd/pkg/tool/manifest.go b/cli/azd/pkg/tool/manifest.go index 42d631c2e9d..49e4ff1bf7b 100644 --- a/cli/azd/pkg/tool/manifest.go +++ b/cli/azd/pkg/tool/manifest.go @@ -58,9 +58,11 @@ type SkillHost struct { // (case-insensitively) by --agent (e.g. "Copilot", "Claude"). Host string // Command is the agent CLI's executable name, used to run plugin - // commands and version probes (e.g. "copilot", "claude"). It must be - // the real, case-correct binary name so exec works on case-sensitive - // filesystems (Linux). When empty it falls back to Host. + // commands and version probes (e.g. "copilot", "claude"). 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_SkillHostsHaveCommand + // enforces that every configured host sets it. Command string // MarketplaceAddCommand is the optional one-time command that registers // the plugin marketplace with the host (e.g. ["plugin", "marketplace", diff --git a/cli/azd/pkg/tool/manifest_test.go b/cli/azd/pkg/tool/manifest_test.go index 5a8a7455431..9246783dceb 100644 --- a/cli/azd/pkg/tool/manifest_test.go +++ b/cli/azd/pkg/tool/manifest_test.go @@ -496,3 +496,18 @@ func TestAzureSkillsHostVersionProbeRegex(t *testing.T) { }) } } + +// TestBuiltInTools_SkillHostsHaveCommand guarantees that every configured skill +// host sets a non-empty Command. Installer and detector paths run host.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_SkillHostsHaveCommand(t *testing.T) { + t.Parallel() + + for _, td := range BuiltInTools() { + for _, host := range td.SkillHosts { + assert.NotEmpty(t, host.Command, + "tool %q host %q must set a non-empty Command", td.Id, host.Host) + } + } +} From d5fb91363768fe40d75d10536b12a5c3e7063511 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 14:43:29 -0700 Subject: [PATCH 19/41] make UpdateAvailable to be true for any agent skill updated --- cli/azd/pkg/tool/update_checker.go | 32 +++++++++++++++- cli/azd/pkg/tool/update_checker_test.go | 51 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/tool/update_checker.go b/cli/azd/pkg/tool/update_checker.go index 8e4b39c5489..e31c034efcf 100644 --- a/cli/azd/pkg/tool/update_checker.go +++ b/cli/azd/pkg/tool/update_checker.go @@ -95,6 +95,20 @@ type SkillHostUpdate struct { UpdateAvailable bool } +// anyHostUpdatable reports whether any of a skill's installed hosts is behind +// the latest version. A skill installed on multiple hosts exposes only the +// first host's version as the tool's aggregate InstalledVersion, so tool-level +// update checks must consider every host — otherwise a stale host is missed +// when the first is current. Returns false for non-skills (no SkillHosts). +func anyHostUpdatable(latest string, hosts []InstalledSkillHost) bool { + for _, h := range hosts { + if isNewerVersion(latest, h.Version) { + return true + } + } + return false +} + // UpdateChecker performs periodic update checks for registered tools, // caching results to disk so that expensive remote lookups are amortized // across CLI invocations. @@ -245,11 +259,19 @@ func (uc *UpdateChecker) Check( LatestVersion: latestVer, } + // For a skill installed on multiple hosts, currentVer reflects only + // the first host, so treat the tool as updatable when ANY host is + // behind the latest version. + updateAvailable := isNewerVersion(latestVer, currentVer) + if status != nil && anyHostUpdatable(latestVer, status.SkillHosts) { + updateAvailable = true + } + results = append(results, &UpdateCheckResult{ Tool: t, CurrentVersion: currentVer, LatestVersion: latestVer, - UpdateAvailable: isNewerVersion(latestVer, currentVer), + UpdateAvailable: updateAvailable, SkillHosts: skillHosts, }) } @@ -455,7 +477,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 host is current but another host is stale + // (anyHostUpdatable), as well as the plain single-version case. + if isNewerVersion(latest, s.InstalledVersion) || + anyHostUpdatable(latest, s.SkillHosts) { count++ } } diff --git a/cli/azd/pkg/tool/update_checker_test.go b/cli/azd/pkg/tool/update_checker_test.go index c7867586986..06bdb694c9d 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("SkillAggregatesAnyHostUpdate", func(t *testing.T) { + t.Parallel() + + tmpDir := t.TempDir() + mgr := newMockUserConfigManager() + + // The aggregate InstalledVersion is the first host (current), but a + // second host 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", + SkillHosts: []InstalledSkillHost{ + {Host: "copilot", Version: "2.0.0"}, + {Host: "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 host must mark the skill as updatable") + require.Len(t, results[0].SkillHosts, 2) + assert.False(t, results[0].SkillHosts[0].UpdateAvailable) + assert.True(t, results[0].SkillHosts[1].UpdateAvailable) + }) } // --------------------------------------------------------------------------- From f52c5b9c838766f86567a6b2fdc8894805fd418a Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 14:57:27 -0700 Subject: [PATCH 20/41] add version on upgrade to non skill tools --- cli/azd/pkg/tool/installer.go | 8 ++++- cli/azd/pkg/tool/installer_test.go | 49 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 18bfb16e7f1..88827b7ca1a 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -1069,7 +1069,13 @@ func (i *installer) run( title := fmt.Sprintf("%s %s", verb, tool.Name) cfg.renderer.ShowSpinner(ctx, title, input.Step) result, err := i.runToolInstall(ctx, tool, upgrade) - cfg.renderer.StopSpinner(ctx, title, input.GetStepResultFormat(stepError(result, err))) + // 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 } diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index 09fc3233be1..ab0e2775e4e 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -245,6 +245,55 @@ func TestRunToolInstall_StepProgress(t *testing.T) { 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() From 8c8567753df435ac6da00643da0cc353a5c56038 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 15:00:46 -0700 Subject: [PATCH 21/41] update comment --- cli/azd/pkg/tool/manager_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/tool/manager_test.go b/cli/azd/pkg/tool/manager_test.go index 72630650fd5..6d4ee78d1ad 100644 --- a/cli/azd/pkg/tool/manager_test.go +++ b/cli/azd/pkg/tool/manager_test.go @@ -448,7 +448,8 @@ func TestManifest_SkillsListedAfterHostCLIs(t *testing.T) { }) } - // Maps a host binary name to the manifest tool id that provides it. + // Maps a host binary name (SkillHost.Command) to the manifest tool id + // that provides it. hostToolID := map[string]string{ "copilot": "github-copilot-cli", } @@ -459,7 +460,7 @@ func TestManifest_SkillsListedAfterHostCLIs(t *testing.T) { } skillIdx := indexOf(td.Id) for _, host := range td.SkillHosts { - cliID, ok := hostToolID[host.Host] + cliID, ok := hostToolID[host.Command] if !ok { continue // host has no installable CLI in the manifest } From 8dc4a4f8b317da87e07b354a0b461b3f1c595b1b Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 15:24:06 -0700 Subject: [PATCH 22/41] fix the wrapping on narrower terminal widths --- cli/azd/cmd/tool.go | 24 ++++++++++++++-- cli/azd/cmd/tool_test.go | 62 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 91adec3c32e..5ce72a7326d 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -397,7 +397,7 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) Name: s.Tool.Name, Agent: h.Host, DisplayName: fmt.Sprintf("[%s] %s", - output.WithWarningFormat(skillHostDisplayName(s.Tool, h.Host)), s.Tool.Name), + skillHostDisplayName(s.Tool, h.Host), s.Tool.Name), Category: string(s.Tool.Category), Priority: string(s.Tool.Priority), Status: "Installed", @@ -445,6 +445,7 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) CardTitle: true, Wrappable: true, Truncatable: true, + ColorFunc: colorAgentPrefix, }, { Column: output.Column{Heading: "STATUS", ValueTemplate: "{{.Status}}"}, @@ -1817,7 +1818,7 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error Name: r.Tool.Name, Agent: h.Host, DisplayName: fmt.Sprintf("[%s] %s", - output.WithWarningFormat(skillHostDisplayName(r.Tool, h.Host)), r.Tool.Name), + skillHostDisplayName(r.Tool, h.Host), r.Tool.Name), InstalledVersion: h.CurrentVersion, LatestVersion: r.LatestVersion, UpdateAvailable: h.UpdateAvailable, @@ -1860,6 +1861,7 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error CardTitle: true, Wrappable: true, Truncatable: true, + ColorFunc: colorAgentPrefix, }, { Column: output.Column{Heading: "STATUS", ValueTemplate: "{{.Status}}"}, @@ -2445,3 +2447,21 @@ func skillHostDisplayName(t *tool.ToolDefinition, command string) string { } return command } + +// colorAgentPrefix colors a leading "[agent]" token (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 prefix — 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). +func colorAgentPrefix(s string) string { + if !strings.HasPrefix(s, "[") { + return s + } + end := strings.Index(s, "]") + if end < 0 { + return s + } + return output.WithWarningFormat(s[:end+1]) + s[end+1:] +} diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index ae525e14b4a..0af8d9c49a4 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -4,9 +4,11 @@ package cmd import ( + "bytes" "context" "errors" "io" + "strings" "testing" "time" @@ -1489,3 +1491,63 @@ func TestToolUpgradeAction_MultiHostSkill_UpgradedNotUpToDate(t *testing.T) { assert.Equal(t, "Tool is upgraded to v1.1.87.", result.Message.Header, "a multi-host skill with an upgraded host must not read as already up to date") } + +// TestColorAgentPrefix verifies the NAME-column ColorFunc colors only a leading +// "[agent]" token and leaves everything else untouched. +func TestColorAgentPrefix(t *testing.T) { + // No leading bracket: returned unchanged. + assert.Equal(t, "Azure CLI", colorAgentPrefix("Azure CLI")) + assert.Equal(t, "", colorAgentPrefix("")) + // Unterminated bracket: returned unchanged. + assert.Equal(t, "[oops no close", colorAgentPrefix("[oops no close")) + // A leading "[agent]" token: the visible text is preserved (the bracketed + // token and the trailing name both remain present). + got := colorAgentPrefix("[GitHub Copilot CLI] Azure Skills") + assert.Contains(t, got, "[GitHub Copilot CLI]") + assert.Contains(t, got, "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") +} From 0bc0d0b8381d5bc90a3bae5c92491508d394fae4 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 15:47:57 -0700 Subject: [PATCH 23/41] fix ux bug --- cli/azd/cmd/tool.go | 30 ++++++++++++++++++++++-------- cli/azd/cmd/tool_test.go | 30 ++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 5ce72a7326d..9f1f1f16bff 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -2448,20 +2448,34 @@ func skillHostDisplayName(t *tool.ToolDefinition, command string) string { return command } -// colorAgentPrefix colors a leading "[agent]" token (as prepended to skill +// 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 prefix — unchanged. It is the +// 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 { - if !strings.HasPrefix(s, "[") { - return s - } - end := strings.Index(s, "]") - if end < 0 { + 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 } - return output.WithWarningFormat(s[:end+1]) + s[end+1:] } diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 0af8d9c49a4..8117c876eb1 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -1492,19 +1492,29 @@ func TestToolUpgradeAction_MultiHostSkill_UpgradedNotUpToDate(t *testing.T) { "a multi-host skill with an upgraded host must not read as already up to date") } -// TestColorAgentPrefix verifies the NAME-column ColorFunc colors only a leading -// "[agent]" token and leaves everything else untouched. +// 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) { - // No leading bracket: returned unchanged. + // Plain names (no brackets) are returned untouched. assert.Equal(t, "Azure CLI", colorAgentPrefix("Azure CLI")) assert.Equal(t, "", colorAgentPrefix("")) - // Unterminated bracket: returned unchanged. - assert.Equal(t, "[oops no close", colorAgentPrefix("[oops no close")) - // A leading "[agent]" token: the visible text is preserved (the bracketed - // token and the trailing name both remain present). - got := colorAgentPrefix("[GitHub Copilot CLI] Azure Skills") - assert.Contains(t, got, "[GitHub Copilot CLI]") - assert.Contains(t, got, "Azure Skills") + + // 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 From c63de062c06dd2496c1ad515dd485bf6b14ba2f7 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 16:31:22 -0700 Subject: [PATCH 24/41] fix ux bug --- cli/azd/cmd/tool.go | 30 ++++++++++++++++++++++-------- cli/azd/cmd/tool_test.go | 30 ++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 5ce72a7326d..9f1f1f16bff 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -2448,20 +2448,34 @@ func skillHostDisplayName(t *tool.ToolDefinition, command string) string { return command } -// colorAgentPrefix colors a leading "[agent]" token (as prepended to skill +// 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 prefix — unchanged. It is the +// 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 { - if !strings.HasPrefix(s, "[") { - return s - } - end := strings.Index(s, "]") - if end < 0 { + 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 } - return output.WithWarningFormat(s[:end+1]) + s[end+1:] } diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 0af8d9c49a4..8117c876eb1 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -1492,19 +1492,29 @@ func TestToolUpgradeAction_MultiHostSkill_UpgradedNotUpToDate(t *testing.T) { "a multi-host skill with an upgraded host must not read as already up to date") } -// TestColorAgentPrefix verifies the NAME-column ColorFunc colors only a leading -// "[agent]" token and leaves everything else untouched. +// 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) { - // No leading bracket: returned unchanged. + // Plain names (no brackets) are returned untouched. assert.Equal(t, "Azure CLI", colorAgentPrefix("Azure CLI")) assert.Equal(t, "", colorAgentPrefix("")) - // Unterminated bracket: returned unchanged. - assert.Equal(t, "[oops no close", colorAgentPrefix("[oops no close")) - // A leading "[agent]" token: the visible text is preserved (the bracketed - // token and the trailing name both remain present). - got := colorAgentPrefix("[GitHub Copilot CLI] Azure Skills") - assert.Contains(t, got, "[GitHub Copilot CLI]") - assert.Contains(t, got, "Azure Skills") + + // 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 From 144b7e718d653b0b0e6b677ef2b79bbbb6ee9a62 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 16:51:50 -0700 Subject: [PATCH 25/41] update ux to include number of skills installed --- cli/azd/pkg/tool/installer.go | 130 ++++++++++++++++++++++------- cli/azd/pkg/tool/installer_test.go | 102 ++++++++++++++++++---- 2 files changed, 189 insertions(+), 43 deletions(-) diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 88827b7ca1a..94e0b3fa264 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" @@ -20,6 +21,7 @@ import ( "regexp" "runtime" "slices" + "strconv" "strings" "sync" "time" @@ -249,11 +251,11 @@ func renderSkillStep( renderer StepRenderer, title string, streamOutput bool, - work func(out io.Writer) (doneTitle string, err error), + work func(out io.Writer) (doneTitle string, note string, err error), ) error { if renderer == nil { fmt.Fprintf(os.Stderr, "\n%s\n", title) - _, err := work(nil) + _, _, err := work(nil) return err } @@ -268,7 +270,7 @@ func renderSkillStep( } out := &lineWriter{emit: emit} - doneTitle, err := work(out) + doneTitle, note, err := work(out) if doneTitle == "" { doneTitle = title } @@ -278,6 +280,11 @@ func renderSkillStep( } } renderer.StopSpinner(ctx, doneTitle, input.GetStepResultFormat(err)) + // A successful step may attach a detail line under its result (e.g. the + // number of skills installed); print it below the Done line. + if err == nil && note != "" { + renderer.Message(ctx, note) + } return err } @@ -346,6 +353,38 @@ func parseUpgradeOutput(out string) (version string, alreadyLatest bool) { return version, alreadyLatest } +// skillCountRegex captures the number of skills a host CLI reports installing, +// e.g. copilot's `... installed successfully. Installed 27 skills.` +var skillCountRegex = regexp.MustCompile(`(\d+)\s+skills?\b`) + +// skillDetailIndent aligns a step's detail line (e.g. "Installed 27 skills") +// under the step title, past the console's "(✓) Done: " result prefix. +const skillDetailIndent = " " + +// pluralizeSkill returns "skill" for a count of one and "skills" otherwise. +func pluralizeSkill(count int) string { + if count == 1 { + return "skill" + } + return "skills" +} + +// parseSkillCount extracts the count of skills a host CLI reports from its +// install output (0 when the output does not mention a count — e.g. claude's +// install line has no number). Best-effort and used only to annotate the +// install result line. +func parseSkillCount(out string) int { + m := skillCountRegex.FindStringSubmatch(out) + if len(m) < 2 { + return 0 + } + n, err := strconv.Atoi(m[1]) + if err != nil { + return 0 + } + return n +} + // installer is the default, unexported implementation of [Installer]. type installer struct { commandRunner exec.CommandRunner @@ -882,8 +921,8 @@ func (i *installer) runSkillUninstall( ) for _, host := range targets { title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, host.Host) - hostErr := renderSkillStep(ctx, renderer, title, false, func(out io.Writer) (string, error) { - return "", i.uninstallSkillForHost(ctx, tool, host, out) + hostErr := renderSkillStep(ctx, renderer, title, false, func(out io.Writer) (string, string, error) { + return "", "", i.uninstallSkillForHost(ctx, tool, host, out) }) if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) @@ -1330,12 +1369,16 @@ func (i *installer) runSkill( hostVersion string hostUpToDate bool ) - hostErr := renderSkillStep(ctx, renderer, title, true, func(out io.Writer) (string, error) { - v, upToDate, e := i.installSkillForHost(ctx, tool, host, upgrade, out) + // Buffer the host CLI's output (do not stream it): a successful step + // stays quiet, and the output is replayed only on failure. A fresh + // install additionally parses the skill count from that output to + // annotate the result line. + hostErr := renderSkillStep(ctx, renderer, title, false, func(out io.Writer) (string, string, error) { + v, upToDate, count, e := i.installSkillForHost(ctx, tool, host, upgrade, out) hostVersion = v hostUpToDate = upToDate if e != nil { - return "", e + return "", "", e } // Result line: for an upgrade, report the version and whether the // skill was already current; otherwise reuse the step title. @@ -1348,7 +1391,13 @@ func (i *installer) runSkill( case upgrade && v != "": done = fmt.Sprintf("%s (v%s)", title, v) } - return done, nil + // For a fresh install, annotate how many skills were installed when + // the host CLI reported a count (some hosts do not). + note := "" + if !upgrade && count > 0 { + note = skillDetailIndent + fmt.Sprintf("Installed %d %s", count, pluralizeSkill(count)) + } + return done, note, nil }) if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) @@ -1687,36 +1736,39 @@ func (i *installer) pickSkillHost( // installSkillForHost installs (or upgrades) the skill through a single host // and verifies the result. It returns the version and, for an upgrade, -// whether the host reported the skill was already at the latest version. For -// an upgrade the version comes from the update 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 host output -// for display above the step spinner. +// whether the host reported the skill was already at the latest version, plus +// (for a fresh install) the number of skills the host CLI reported installing +// (0 when it reports none). For an upgrade the version comes from the update +// command's output (falling back to the detected version); for an install it +// comes from post-install detection. out, when non-nil, receives the host +// CLI's output (buffered by the caller and replayed only on failure). func (i *installer) installSkillForHost( ctx context.Context, tool *ToolDefinition, host SkillHost, upgrade bool, out io.Writer, -) (version string, alreadyLatest bool, err error) { +) (version string, alreadyLatest bool, skillCount int, err error) { cmdOutput, err := i.runSkillHostCommand(ctx, host, upgrade, out) if err != nil { - return "", false, err + return "", false, 0, err } if upgrade { version, alreadyLatest = parseUpgradeOutput(cmdOutput) + } else { + skillCount = parseSkillCount(cmdOutput) } detectedVersion, err := i.verifySkillInstalled(ctx, tool, host) if err != nil { - return "", false, err + return "", false, 0, err } // Prefer the version the update command reported; fall back to the // version detected via the plugin list. if version == "" { version = detectedVersion } - return version, alreadyLatest, nil + return version, alreadyLatest, skillCount, nil } // verifySkillInstalled confirms the skill is detectable **through the @@ -1764,16 +1816,19 @@ func (i *installer) verifySkillInstalled( return version, nil } -// runSkillHostCommand executes the host's install or update command and -// returns the command's stdout (empty for a streamed install). +// runSkillHostCommand executes the host's install or update command and returns +// the command's stdout so the caller can parse it — the skill count for an +// install, the version and "already latest" state for an update. The +// fully-interactive first-run path (out nil) captures nothing and returns "". // // Install and update connect to the terminal differently: -// - Install streams the host 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 host -// declares one. +// - Install, when a spinner is showing (out non-nil), routes the host CLI's +// output to out (which buffers it for a failure replay) while also +// capturing it so the caller can parse the skill count. With out nil it +// runs fully interactively — stdin connected so the user answers any prompt +// (marketplace trust, install confirmation), azd never pipes canned +// answers — and captures nothing. A fresh install first runs +// MarketplaceAddCommand when the host declares one. // - Update 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 update is @@ -1819,15 +1874,32 @@ func (i *installer) runSkillHostCommand( } } - runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out) + // Fully interactive first-run path (no spinner): talk to the terminal + // directly without capturing, so its output and any prompt are visible. + if out == nil { + runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), nil) + if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { + return "", fmt.Errorf( + "running `%s %s`: %w", + host.Command, strings.Join(cmd, " "), err, + ) + } + return "", nil + } + + // Install with a spinner: capture the host CLI's output (so the caller can + // parse the skill count) while still routing it to out, which buffers it + // for a failure replay. + var captured bytes.Buffer + runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), io.MultiWriter(out, &captured)) if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { - return "", fmt.Errorf( + return captured.String(), fmt.Errorf( "running `%s %s`: %w", host.Command, strings.Join(cmd, " "), err, ) } - return "", nil + return captured.String(), nil } // runMarketplaceAdd registers the skill marketplace with the host CLI. diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index ab0e2775e4e..5d650dd9679 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -1493,21 +1493,21 @@ func TestRunSkill_Upgrade_AlreadyUpToDate(t *testing.T) { assert.Equal(t, []string{"Test Azure Skills are already up to date (v1.1.86)."}, r.stops) } -// TestRunSkill_StreamedOutputPrintedAboveSpinner verifies that when the host -// CLI writes to the terminal (a progress line or an interactive prompt), -// each line is surfaced via Message (which the console prints above the -// spinner, keeping the spinner pinned below), and the spinner is stopped with -// the step result at the end rather than torn down early. -func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { +// TestRunSkill_Install_SuccessHidesHostOutput verifies that a successful skill +// install does NOT surface the host CLI's raw output above the spinner — only +// the Done line shows. (On failure the buffered output is replayed; see +// TestRunSkill_InstallCommandFails_SurfacesError and the uninstall failure +// test.) When the host reports no skill count, no detail line is added either. +func TestRunSkill_Install_SuccessHidesHostOutput(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() mockHostPresence(runner, "copilot") runner.MockToolInPath("node", nil) - // The plugin command writes to the terminal (StdOut), simulating the - // host CLI surfacing progress or a prompt. marketplace-add is captured - // (no StdOut) so it stays silent; only the install writes. + // The plugin command writes progress to the terminal (StdOut); on success + // it must be suppressed rather than surfaced. marketplace-add runs without + // a captured writer (StdOut nil) so it stays silent; only the install writes. 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) { @@ -1537,12 +1537,86 @@ func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { require.NoError(t, err) require.True(t, result.Success, "install must succeed; err=%v", result.Error) - // The spinner is shown, the host CLI's line is surfaced via Message (the - // console prints it above the spinner), and the spinner is stopped with - // the step result at the end — it is kept, not torn down early. + // The spinner is shown and stopped with the step result; the host CLI's + // output is not surfaced, and with no count reported there is no detail line. 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, r.messages, "Installing plugin...") + assert.Empty(t, r.messages, "a successful install must not surface the host CLI output") +} + +// TestRunSkill_Install_ReportsSkillCount verifies that a fresh install parses +// the number of skills the host CLI reports and shows it as an indented detail +// line under the Done line — without surfacing the raw host output. +func TestRunSkill_Install_ReportsSkillCount(t *testing.T) { + t.Parallel() + + runner := mockexec.NewMockCommandRunner() + mockHostPresence(runner, "copilot") + runner.MockToolInPath("node", nil) + + 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("Plugin azure@azure-skills installed successfully. Installed 27 skills.\n"), + ) + } + return exec.RunResult{ExitCode: 0}, nil + }) + + inst := NewInstaller( + runner, NewPlatformDetector(runner), + &mockDetector{ + detectSkillHostsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillHost, error) { + return []InstalledSkillHost{{Host: "copilot", Version: "1.1.70"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Install( + t.Context(), newSkillTool(), + WithHosts("copilot"), + WithStepProgress(&r), + ) + require.NoError(t, err) + require.True(t, result.Success, "install must succeed; err=%v", result.Error) + + assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.stops) + // The count is surfaced as a single indented detail line; the raw CLI text + // (e.g. "installed successfully") is not. + require.Len(t, r.messages, 1) + assert.Contains(t, r.messages[0], "Installed 27 skills") + assert.NotContains(t, r.messages[0], "installed successfully") +} + +// TestParseSkillCount verifies the best-effort parsing of the skill count from +// a host CLI's install output: the number before "skill"/"skills", 0 when no +// count is present, and pluralization handled by the caller. +func TestParseSkillCount(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + out string + want int + }{ + {"CopilotStyle", "Plugin installed successfully. Installed 27 skills.", 27}, + {"Singular", "Installed 1 skill.", 1}, + {"PicksNumberBeforeSkills", "Found 99 plugins. Installed 3 skills.", 3}, + {"NoCount", "Installing plugin...", 0}, + {"Empty", "", 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.want, parseSkillCount(tt.out)) + }) + } } // TestSkillHost_DisplayVsCommand verifies the split between the display Host @@ -4587,7 +4661,7 @@ func TestLineWriter_Write_SerializesEmit(t *testing.T) { const writers = 100 var wg sync.WaitGroup wg.Add(writers) - for i := 0; i < writers; i++ { + for range writers { go func() { defer wg.Done() _, _ = lw.Write([]byte("a\nb\nc\n")) // 3 lines per write From faf5d61bd37ec9f70e42ab30cf7ee3dc22ded743 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 17:24:35 -0700 Subject: [PATCH 26/41] address the feedback --- cli/azd/cmd/tool_test.go | 182 ++++++++++++++++++++++++++++- cli/azd/pkg/tool/installer.go | 44 ++++++- cli/azd/pkg/tool/installer_test.go | 24 ++-- cli/azd/pkg/tool/manifest.go | 14 ++- 4 files changed, 239 insertions(+), 25 deletions(-) diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 8117c876eb1..7014bd48e62 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -6,6 +6,7 @@ package cmd import ( "bytes" "context" + "encoding/json" "errors" "io" "strings" @@ -15,6 +16,7 @@ import ( "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" @@ -1418,10 +1420,12 @@ func TestToolInstallAction_resolveToolIds_NoPromptDefaultsToRecommended(t *testi "--no-prompt must skip the picker and default to recommended uninstalled tools") } -// TestToolUninstallAction_resolveToolIds_NoPromptDefaultsToAllInstalled verifies -// that --no-prompt (even in a TTY) skips the interactive picker and defaults to -// every installed tool — its --all behavior, mirroring upgrade. -func TestToolUninstallAction_resolveToolIds_NoPromptDefaultsToAllInstalled(t *testing.T) { +// 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{ @@ -1441,10 +1445,43 @@ func TestToolUninstallAction_resolveToolIds_NoPromptDefaultsToAllInstalled(t *te 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_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, - "--no-prompt must skip the picker and default to all installed tools") + "--all must select every installed tool") } // TestToolUpgradeAction_MultiHostSkill_UpgradedNotUpToDate reproduces the @@ -1561,3 +1598,138 @@ func TestToolNameColumn_PlainValueWrapsUnlikeAnsiValue(t *testing.T) { 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_SkillPerHostRows locks the machine-readable +// contract for `azd tool list --output json`: a skill installed on multiple +// agents expands into one row per host, each carrying the original tool name, +// the command-valued agent, and that host's installed version. +func TestToolListAction_JsonFormat_SkillPerHostRows(t *testing.T) { + t.Parallel() + + skill := &tool.ToolDefinition{ + Id: "azure-skills", + Name: "Azure Skills", + Category: tool.ToolCategorySkill, + Priority: tool.ToolPriorityRecommended, + SkillHosts: []tool.SkillHost{ + {Host: "GitHub Copilot CLI", Command: "copilot"}, + {Host: "Claude Code CLI", Command: "claude"}, + }, + } + detector := &cmdMockDetector{ + detectAll: func(_ context.Context, _ []*tool.ToolDefinition) ([]*tool.ToolStatus, error) { + return []*tool.ToolStatus{{ + Tool: skill, + Installed: true, + SkillHosts: []tool.InstalledSkillHost{ + {Host: "copilot", Version: "1.0.0"}, + {Host: "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-host 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_SkillPerHostRows locks the machine-readable +// contract for `azd tool check --output json`: each skill host is a row with +// the command-valued agent, its installed version, the tool's latest version, +// and a per-host update flag (so a stale host reports an update while a current +// one does not). +func TestToolCheckAction_JsonFormat_SkillPerHostRows(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, + SkillHosts: []tool.InstalledSkillHost{ + {Host: "copilot", Version: "1.0.0"}, // behind latest + {Host: "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-host 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 host 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 host 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/installer.go b/cli/azd/pkg/tool/installer.go index 94e0b3fa264..f610ba6afa8 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -1749,12 +1749,20 @@ func (i *installer) installSkillForHost( upgrade bool, out io.Writer, ) (version string, alreadyLatest bool, skillCount int, err error) { + var beforeVersion string + if upgrade { + if installed, detectErr := i.detector.DetectSkillHosts(ctx, tool); detectErr == nil { + beforeVersion, _ = skillHostVersion(installed, host.Command) + } + } + cmdOutput, err := i.runSkillHostCommand(ctx, host, upgrade, out) if err != nil { return "", false, 0, err } + var proseLatest bool if upgrade { - version, alreadyLatest = parseUpgradeOutput(cmdOutput) + version, proseLatest = parseUpgradeOutput(cmdOutput) } else { skillCount = parseSkillCount(cmdOutput) } @@ -1768,9 +1776,35 @@ func (i *installer) installSkillForHost( if version == "" { version = detectedVersion } + + if upgrade { + // The authoritative "already up to date" signal is an unchanged + // version. Only when a version is unavailable on either side fall back + // to the host CLI's prose, so azd neither claims up to date without + // evidence nor misreports an upgrade when the wording is unrecognized. + if beforeVersion != "" && detectedVersion != "" { + alreadyLatest = beforeVersion == detectedVersion + } else { + alreadyLatest = proseLatest + } + } + return version, alreadyLatest, skillCount, nil } +// skillHostVersion returns the installed version of the skill for the given +// host command from a DetectSkillHosts result, and whether that host was +// found. InstalledSkillHost.Host carries the executable identity, so the match +// is against host.Command. +func skillHostVersion(installed []InstalledSkillHost, command string) (string, bool) { + for _, h := range installed { + if h.Host == 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 @@ -1794,11 +1828,9 @@ func (i *installer) verifySkillInstalled( if detectErr != nil { return false, detectErr } - for _, h := range installed { - if h.Host == host.Command { - version = h.Version - return true, nil - } + if v, ok := skillHostVersion(installed, host.Command); ok { + version = v + return true, nil } return false, nil }) diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index 5d650dd9679..cd2e775a9e7 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -1419,11 +1419,15 @@ func TestRunSkill_Upgrade_StepResultShowsVersion(t *testing.T) { mockHostPresence(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") - }).Respond(exec.RunResult{ - ExitCode: 0, - Stdout: `Plugin "azure" updated from 1.1.73 to 1.1.86 for scope user.`, + }).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( @@ -1432,7 +1436,13 @@ func TestRunSkill_Upgrade_StepResultShowsVersion(t *testing.T) { detectSkillHostsFn: func( _ context.Context, _ *ToolDefinition, ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "copilot", Version: "1.1.86"}}, nil + // 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 []InstalledSkillHost{{Host: "copilot", Version: version}}, nil }, }, ) @@ -4660,12 +4670,10 @@ func TestLineWriter_Write_SerializesEmit(t *testing.T) { const writers = 100 var wg sync.WaitGroup - wg.Add(writers) for range writers { - go func() { - defer wg.Done() + wg.Go(func() { _, _ = lw.Write([]byte("a\nb\nc\n")) // 3 lines per write - }() + }) } wg.Wait() diff --git a/cli/azd/pkg/tool/manifest.go b/cli/azd/pkg/tool/manifest.go index 49e4ff1bf7b..0a39e84cbf0 100644 --- a/cli/azd/pkg/tool/manifest.go +++ b/cli/azd/pkg/tool/manifest.go @@ -52,15 +52,17 @@ type Checksum struct { // Claude Code) installs and updates a skill. Skill tools carry one or more // SkillHost entries; by default the installer targets the preferred host // (the first on PATH), but install/upgrade can target specific or all -// detected hosts when the caller selects them (e.g. via `--host`). +// detected hosts when the caller selects them (e.g. via `--agent`). type SkillHost struct { - // Host is the agent's display name, shown to the user and matched - // (case-insensitively) by --agent (e.g. "Copilot", "Claude"). + // Host is the agent's display name, shown to the user (e.g. "GitHub + // Copilot CLI", "Claude Code CLI"). Display-only: it is NOT the value + // --agent matches against — see Command. Host string // Command is the agent CLI's executable name, used to run plugin - // commands and version probes (e.g. "copilot", "claude"). 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 + // commands and version probes (e.g. "copilot", "claude"), and the value + // --agent is matched against (case-insensitively, by findSkillHost). + // 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_SkillHostsHaveCommand // enforces that every configured host sets it. Command string From 87e7131e3fae8f467778fae22739d08189916977 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 17:38:16 -0700 Subject: [PATCH 27/41] remove all behavior for uninstall/upgrade/install if not specific in no tty --- cli/azd/cmd/tool.go | 59 ++++++++++++++++-------- cli/azd/cmd/tool_test.go | 99 +++++++++++++++++++++++++++++++++------- 2 files changed, 124 insertions(+), 34 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 9f1f1f16bff..db08cbb11cb 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -961,6 +961,24 @@ 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), + Suggestion: fmt.Sprintf( + "Specify one or more tool IDs, or --all:\n\n"+ + " azd tool %s [ ...]\n\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) { // --all: install all recommended tools that are not already installed. @@ -1017,17 +1035,11 @@ func (a *toolInstallAction) resolveToolIds(ctx context.Context) ([]string, error return nil, nil } - // Non-interactive (no TTY) or --no-prompt: skip the picker and default to - // the recommended set — the picker's own pre-selection, and the same set - // `--all` installs — so automation never blocks or errors on input. + // Non-interactive (no TTY) or --no-prompt: the picker can't run, so require + // an explicit target (tool IDs or --all) rather than implicitly installing + // the recommended set. if !a.console.IsSpinnerInteractive() || a.console.IsNoPromptMode() { - var ids []string - for _, s := range uninstalled { - if s.Tool.Priority == tool.ToolPriorityRecommended { - ids = append(ids, s.Tool.Id) - } - } - return ids, nil + return nil, noToolTargetError("install") } choices := make([]*uxlib.MultiSelectChoice, len(uninstalled)) @@ -1158,8 +1170,8 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err } default: // No args: prompt the user to pick from installed tools (like - // `azd tool install`), or upgrade every installed tool when running - // non-interactively. + // `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 @@ -1170,6 +1182,12 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err installed = append(installed, s) } } + // Non-interactive (no TTY) or --no-prompt: the picker can't run, so + // require an explicit target (tool IDs or --all) rather than implicitly + // upgrading every installed tool. + if len(installed) > 0 && (!a.console.IsSpinnerInteractive() || a.console.IsNoPromptMode()) { + return nil, noToolTargetError("upgrade") + } chosen := installed if a.console.IsSpinnerInteractive() && !a.console.IsNoPromptMode() && len(installed) > 0 { chosen, err = a.promptForUpgradeTools(ctx, installed) @@ -1647,12 +1665,8 @@ 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 through) instead of asking the user to pick. A non-interactive - // terminal or --no-prompt likewise skips the picker and defaults to every - // installed tool (matching --all and `azd tool upgrade`), so automation - // never blocks or errors on input. - if a.flags.all || a.flags.dryRun || - !a.console.IsSpinnerInteractive() || a.console.IsNoPromptMode() { + // installed through) instead of asking the user to pick. + if a.flags.all || a.flags.dryRun { ids := make([]string, 0, len(installed)) for _, s := range installed { ids = append(ids, s.Tool.Id) @@ -1660,6 +1674,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 or --no-prompt) and the user gave + // neither tool IDs nor --all, fail with explicit guidance instead of + // silently removing every installed tool. + if !a.console.IsSpinnerInteractive() || a.console.IsNoPromptMode() { + 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)) diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 7014bd48e62..87b42126350 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -1222,10 +1222,50 @@ func TestToolUpgradeAction_All_UpgradesInstalledTools(t *testing.T) { "--all must upgrade exactly the installed tools") } -// TestToolUpgradeAction_NoPrompt_UpgradesAllInstalled verifies that -// `azd tool upgrade --no-prompt` (even in a TTY) skips the interactive picker -// and upgrades every installed tool, so automation never blocks on input. -func TestToolUpgradeAction_NoPrompt_UpgradesAllInstalled(t *testing.T) { +// 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_AllFlag_NoPrompt upgrades every installed tool when +// --all is given, without prompting. +func TestToolUpgradeAction_AllFlag_NoPrompt(t *testing.T) { tracing.ResetUsageAttributesForTest() var installedIDs []string @@ -1254,12 +1294,12 @@ func TestToolUpgradeAction_NoPrompt_UpgradesAllInstalled(t *testing.T) { manager := tool.NewManager(detector, installer, nil) console := mockinput.NewMockConsole() - console.SetTerminal(true) // a TTY ... - console.SetNoPromptMode(true) // ... but --no-prompt + console.SetTerminal(true) + console.SetNoPromptMode(true) action := newToolUpgradeAction( nil, - &toolUpgradeFlags{}, + &toolUpgradeFlags{all: true}, manager, console, &output.NoneFormatter{}, @@ -1271,7 +1311,7 @@ func TestToolUpgradeAction_NoPrompt_UpgradesAllInstalled(t *testing.T) { require.Len(t, installedIDs, 2) assert.ElementsMatch(t, installedIDs, upgradedIDs, - "--no-prompt must skip the picker and upgrade all installed tools") + "--all must upgrade every installed tool without prompting") } // TestToolUpgradeAction_UnchangedVersion_ReportsUpToDate verifies that a @@ -1389,18 +1429,16 @@ func TestToolInstallAction_resolveUnavailableHostPrompt_CaseInsensitive(t *testi assert.Nil(t, opts) } -// TestToolInstallAction_resolveToolIds_NoPromptDefaultsToRecommended verifies -// that --no-prompt (even in a TTY) skips the interactive picker and defaults to -// the recommended, not-yet-installed tools — its --all behavior, mirroring -// upgrade — so automation never blocks or errors on input. -func TestToolInstallAction_resolveToolIds_NoPromptDefaultsToRecommended(t *testing.T) { +// 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}}, - {Tool: &tool.ToolDefinition{Id: "already", Priority: tool.ToolPriorityRecommended}, - Installed: true}, }, nil }, } @@ -1414,10 +1452,39 @@ func TestToolInstallAction_resolveToolIds_NoPromptDefaultsToRecommended(t *testi 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_AllFlag installs the recommended, +// 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, - "--no-prompt must skip the picker and default to recommended uninstalled tools") + "--all installs recommended, not-yet-installed tools") } // TestToolUninstallAction_resolveToolIds_NoPromptWithoutTarget_Errors verifies From aa1fcbf7a69c67c5385fafa6e686679b325498f5 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 17:55:54 -0700 Subject: [PATCH 28/41] revert ux for number of skills, need more discussion with designer --- cli/azd/pkg/tool/installer.go | 134 +++++++---------------------- cli/azd/pkg/tool/installer_test.go | 100 +++------------------ 2 files changed, 46 insertions(+), 188 deletions(-) diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index f610ba6afa8..6791519d1cb 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -4,7 +4,6 @@ package tool import ( - "bytes" "context" "crypto/sha256" "crypto/sha512" @@ -21,7 +20,6 @@ import ( "regexp" "runtime" "slices" - "strconv" "strings" "sync" "time" @@ -251,11 +249,11 @@ func renderSkillStep( renderer StepRenderer, title string, streamOutput bool, - work func(out io.Writer) (doneTitle string, note string, err error), + work func(out io.Writer) (doneTitle string, err error), ) error { if renderer == nil { fmt.Fprintf(os.Stderr, "\n%s\n", title) - _, _, err := work(nil) + _, err := work(nil) return err } @@ -270,7 +268,7 @@ func renderSkillStep( } out := &lineWriter{emit: emit} - doneTitle, note, err := work(out) + doneTitle, err := work(out) if doneTitle == "" { doneTitle = title } @@ -280,11 +278,6 @@ func renderSkillStep( } } renderer.StopSpinner(ctx, doneTitle, input.GetStepResultFormat(err)) - // A successful step may attach a detail line under its result (e.g. the - // number of skills installed); print it below the Done line. - if err == nil && note != "" { - renderer.Message(ctx, note) - } return err } @@ -353,38 +346,6 @@ func parseUpgradeOutput(out string) (version string, alreadyLatest bool) { return version, alreadyLatest } -// skillCountRegex captures the number of skills a host CLI reports installing, -// e.g. copilot's `... installed successfully. Installed 27 skills.` -var skillCountRegex = regexp.MustCompile(`(\d+)\s+skills?\b`) - -// skillDetailIndent aligns a step's detail line (e.g. "Installed 27 skills") -// under the step title, past the console's "(✓) Done: " result prefix. -const skillDetailIndent = " " - -// pluralizeSkill returns "skill" for a count of one and "skills" otherwise. -func pluralizeSkill(count int) string { - if count == 1 { - return "skill" - } - return "skills" -} - -// parseSkillCount extracts the count of skills a host CLI reports from its -// install output (0 when the output does not mention a count — e.g. claude's -// install line has no number). Best-effort and used only to annotate the -// install result line. -func parseSkillCount(out string) int { - m := skillCountRegex.FindStringSubmatch(out) - if len(m) < 2 { - return 0 - } - n, err := strconv.Atoi(m[1]) - if err != nil { - return 0 - } - return n -} - // installer is the default, unexported implementation of [Installer]. type installer struct { commandRunner exec.CommandRunner @@ -921,8 +882,8 @@ func (i *installer) runSkillUninstall( ) for _, host := range targets { title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, host.Host) - hostErr := renderSkillStep(ctx, renderer, title, false, func(out io.Writer) (string, string, error) { - return "", "", i.uninstallSkillForHost(ctx, tool, host, out) + hostErr := renderSkillStep(ctx, renderer, title, false, func(out io.Writer) (string, error) { + return "", i.uninstallSkillForHost(ctx, tool, host, out) }) if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) @@ -1369,16 +1330,12 @@ func (i *installer) runSkill( hostVersion string hostUpToDate bool ) - // Buffer the host CLI's output (do not stream it): a successful step - // stays quiet, and the output is replayed only on failure. A fresh - // install additionally parses the skill count from that output to - // annotate the result line. - hostErr := renderSkillStep(ctx, renderer, title, false, func(out io.Writer) (string, string, error) { - v, upToDate, count, e := i.installSkillForHost(ctx, tool, host, upgrade, out) + hostErr := renderSkillStep(ctx, renderer, title, true, func(out io.Writer) (string, error) { + v, upToDate, e := i.installSkillForHost(ctx, tool, host, upgrade, out) hostVersion = v hostUpToDate = upToDate if e != nil { - return "", "", e + return "", e } // Result line: for an upgrade, report the version and whether the // skill was already current; otherwise reuse the step title. @@ -1391,13 +1348,7 @@ func (i *installer) runSkill( case upgrade && v != "": done = fmt.Sprintf("%s (v%s)", title, v) } - // For a fresh install, annotate how many skills were installed when - // the host CLI reported a count (some hosts do not). - note := "" - if !upgrade && count > 0 { - note = skillDetailIndent + fmt.Sprintf("Installed %d %s", count, pluralizeSkill(count)) - } - return done, note, nil + return done, nil }) if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) @@ -1736,19 +1687,22 @@ func (i *installer) pickSkillHost( // installSkillForHost installs (or upgrades) the skill through a single host // and verifies the result. It returns the version and, for an upgrade, -// whether the host reported the skill was already at the latest version, plus -// (for a fresh install) the number of skills the host CLI reported installing -// (0 when it reports none). For an upgrade the version comes from the update -// command's output (falling back to the detected version); for an install it -// comes from post-install detection. out, when non-nil, receives the host -// CLI's output (buffered by the caller and replayed only on failure). +// whether the host reported the skill was already at the latest version. For +// an upgrade the version comes from the update 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 host output +// for display above the step spinner. func (i *installer) installSkillForHost( ctx context.Context, tool *ToolDefinition, host SkillHost, upgrade bool, out io.Writer, -) (version string, alreadyLatest bool, skillCount int, err error) { +) (version string, alreadyLatest bool, err error) { + // For an upgrade, record the host's installed version before updating so + // "already up to date" is decided by comparing the actual version before + // and after — not by parsing the host CLI's prose, which varies by host + // and misfires when the wording is not recognized. var beforeVersion string if upgrade { if installed, detectErr := i.detector.DetectSkillHosts(ctx, tool); detectErr == nil { @@ -1758,18 +1712,16 @@ func (i *installer) installSkillForHost( cmdOutput, err := i.runSkillHostCommand(ctx, host, upgrade, out) if err != nil { - return "", false, 0, err + return "", false, err } var proseLatest bool if upgrade { version, proseLatest = parseUpgradeOutput(cmdOutput) - } else { - skillCount = parseSkillCount(cmdOutput) } detectedVersion, err := i.verifySkillInstalled(ctx, tool, host) if err != nil { - return "", false, 0, err + return "", false, err } // Prefer the version the update command reported; fall back to the // version detected via the plugin list. @@ -1789,7 +1741,7 @@ func (i *installer) installSkillForHost( } } - return version, alreadyLatest, skillCount, nil + return version, alreadyLatest, nil } // skillHostVersion returns the installed version of the skill for the given @@ -1848,19 +1800,16 @@ func (i *installer) verifySkillInstalled( return version, nil } -// runSkillHostCommand executes the host's install or update command and returns -// the command's stdout so the caller can parse it — the skill count for an -// install, the version and "already latest" state for an update. The -// fully-interactive first-run path (out nil) captures nothing and returns "". +// runSkillHostCommand executes the host's install or update command and +// returns the command's stdout (empty for a streamed install). // // Install and update connect to the terminal differently: -// - Install, when a spinner is showing (out non-nil), routes the host CLI's -// output to out (which buffers it for a failure replay) while also -// capturing it so the caller can parse the skill count. With out nil it -// runs fully interactively — stdin connected so the user answers any prompt -// (marketplace trust, install confirmation), azd never pipes canned -// answers — and captures nothing. A fresh install first runs -// MarketplaceAddCommand when the host declares one. +// - Install streams the host 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 host +// declares one. // - Update 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 update is @@ -1906,32 +1855,15 @@ func (i *installer) runSkillHostCommand( } } - // Fully interactive first-run path (no spinner): talk to the terminal - // directly without capturing, so its output and any prompt are visible. - if out == nil { - runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), nil) - if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { - return "", fmt.Errorf( - "running `%s %s`: %w", - host.Command, strings.Join(cmd, " "), err, - ) - } - return "", nil - } - - // Install with a spinner: capture the host CLI's output (so the caller can - // parse the skill count) while still routing it to out, which buffers it - // for a failure replay. - var captured bytes.Buffer - runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), io.MultiWriter(out, &captured)) + runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out) if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { - return captured.String(), fmt.Errorf( + return "", fmt.Errorf( "running `%s %s`: %w", host.Command, strings.Join(cmd, " "), err, ) } - return captured.String(), nil + return "", nil } // runMarketplaceAdd registers the skill marketplace with the host CLI. diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index cd2e775a9e7..05e53177b27 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -1503,21 +1503,21 @@ func TestRunSkill_Upgrade_AlreadyUpToDate(t *testing.T) { assert.Equal(t, []string{"Test Azure Skills are already up to date (v1.1.86)."}, r.stops) } -// TestRunSkill_Install_SuccessHidesHostOutput verifies that a successful skill -// install does NOT surface the host CLI's raw output above the spinner — only -// the Done line shows. (On failure the buffered output is replayed; see -// TestRunSkill_InstallCommandFails_SurfacesError and the uninstall failure -// test.) When the host reports no skill count, no detail line is added either. -func TestRunSkill_Install_SuccessHidesHostOutput(t *testing.T) { +// TestRunSkill_StreamedOutputPrintedAboveSpinner verifies that when the host +// CLI writes to the terminal (a progress line or an interactive prompt), +// each line is surfaced via Message (which the console prints above the +// spinner, keeping the spinner pinned below), and the spinner is stopped with +// the step result at the end rather than torn down early. +func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() mockHostPresence(runner, "copilot") runner.MockToolInPath("node", nil) - // The plugin command writes progress to the terminal (StdOut); on success - // it must be suppressed rather than surfaced. marketplace-add runs without - // a captured writer (StdOut nil) so it stays silent; only the install writes. + // The plugin command writes to the terminal (StdOut), simulating the + // host CLI surfacing progress or a prompt. marketplace-add is captured + // (no StdOut) so it stays silent; only the install writes. 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) { @@ -1547,86 +1547,12 @@ func TestRunSkill_Install_SuccessHidesHostOutput(t *testing.T) { 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; the host CLI's - // output is not surfaced, and with no count reported there is no detail line. + // The spinner is shown, the host CLI's line is surfaced via Message (the + // console prints it above the spinner), and the spinner is stopped with + // the step result at the end — it is kept, not torn down early. 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, "a successful install must not surface the host CLI output") -} - -// TestRunSkill_Install_ReportsSkillCount verifies that a fresh install parses -// the number of skills the host CLI reports and shows it as an indented detail -// line under the Done line — without surfacing the raw host output. -func TestRunSkill_Install_ReportsSkillCount(t *testing.T) { - t.Parallel() - - runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") - runner.MockToolInPath("node", nil) - - 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("Plugin azure@azure-skills installed successfully. Installed 27 skills.\n"), - ) - } - return exec.RunResult{ExitCode: 0}, nil - }) - - inst := NewInstaller( - runner, NewPlatformDetector(runner), - &mockDetector{ - detectSkillHostsFn: func( - _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { - return []InstalledSkillHost{{Host: "copilot", Version: "1.1.70"}}, nil - }, - }, - ) - - var r fakeStepRenderer - result, err := inst.Install( - t.Context(), newSkillTool(), - WithHosts("copilot"), - WithStepProgress(&r), - ) - require.NoError(t, err) - require.True(t, result.Success, "install must succeed; err=%v", result.Error) - - assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.stops) - // The count is surfaced as a single indented detail line; the raw CLI text - // (e.g. "installed successfully") is not. - require.Len(t, r.messages, 1) - assert.Contains(t, r.messages[0], "Installed 27 skills") - assert.NotContains(t, r.messages[0], "installed successfully") -} - -// TestParseSkillCount verifies the best-effort parsing of the skill count from -// a host CLI's install output: the number before "skill"/"skills", 0 when no -// count is present, and pluralization handled by the caller. -func TestParseSkillCount(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - out string - want int - }{ - {"CopilotStyle", "Plugin installed successfully. Installed 27 skills.", 27}, - {"Singular", "Installed 1 skill.", 1}, - {"PicksNumberBeforeSkills", "Found 99 plugins. Installed 3 skills.", 3}, - {"NoCount", "Installing plugin...", 0}, - {"Empty", "", 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - assert.Equal(t, tt.want, parseSkillCount(tt.out)) - }) - } + assert.Contains(t, r.messages, "Installing plugin...") } // TestSkillHost_DisplayVsCommand verifies the split between the display Host From f7a737b76f5332254f737375a6f4fd6d5fc9dd74 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 18:43:40 -0700 Subject: [PATCH 29/41] fix bug --- cli/azd/pkg/tool/detector.go | 21 ++++++++--- cli/azd/pkg/tool/detector_test.go | 59 ++++++++++++++++++++++++++++++ cli/azd/pkg/tool/installer.go | 11 +++--- cli/azd/pkg/tool/installer_test.go | 2 +- 4 files changed, 81 insertions(+), 12 deletions(-) diff --git a/cli/azd/pkg/tool/detector.go b/cli/azd/pkg/tool/detector.go index 34486fd4d50..9fea80ba6b7 100644 --- a/cli/azd/pkg/tool/detector.go +++ b/cli/azd/pkg/tool/detector.go @@ -488,15 +488,21 @@ func (d *detector) detectSkill( } hosts, err := d.DetectSkillHosts(ctx, tool) - if err != nil { - status.Error = err - return status - } - status.SkillHosts = hosts if len(hosts) > 0 { + // The skill was found on at least one host; report it installed even if + // a later host's probe errored (e.g. was cancelled), mirroring the + // first-match behavior from before multi-host detection. Installed and + // InstalledVersion reflect the first such host. status.Installed = true status.InstalledVersion = hosts[0].Version + return status + } + + // Nothing was found: surface a detection error (e.g. context cancellation) + // so a genuinely failed probe is not silently reported as "not installed". + if err != nil { + status.Error = err } return status @@ -523,7 +529,10 @@ func (d *detector) DetectSkillHosts( for _, host := range tool.SkillHosts { version, err := d.skillHostVersion(ctx, host) if err != nil { - return nil, err + // Return the hosts discovered before the error (e.g. a later host's + // probe was cancelled) so a caller — detectSkill in particular — can + // still act on an earlier match instead of losing it. + return hosts, err } if version != "" { hosts = append(hosts, InstalledSkillHost{ diff --git a/cli/azd/pkg/tool/detector_test.go b/cli/azd/pkg/tool/detector_test.go index 081b2597ec2..24b304a8b40 100644 --- a/cli/azd/pkg/tool/detector_test.go +++ b/cli/azd/pkg/tool/detector_test.go @@ -686,6 +686,65 @@ func TestDetectSkillHosts(t *testing.T) { }) } +// TestDetectSkill_EarlierHostFound_LaterHostContextError verifies that when a +// skill is installed on an earlier host but a later host's probe hits a context +// error (e.g. cancellation/timeout mid-detection), the skill is still reported +// as installed from the host already found — the context error must not discard +// an earlier positive match. Regression test: multi-host detection previously +// returned "not installed" with the error, dropping the skill from tool and +// update-check results. +func TestDetectSkill_EarlierHostFound_LaterHostContextError(t *testing.T) { + t.Parallel() + + skill := &ToolDefinition{ + Id: "azure-skills", + Name: "Azure Skills", + Category: ToolCategorySkill, + SkillHosts: []SkillHost{ + { + Host: "GitHub Copilot CLI", + Command: "copilot", + PluginListCommand: []string{"plugin", "list"}, + PluginName: "azure@azure-skills", + VersionRegex: `azure@azure-skills[^\n]*?(\d+\.\d+\.\d+)`, + }, + { + Host: "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 host) 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 host) 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 host must stay installed when a later host's probe is cancelled") + assert.NoError(t, status.Error, + "a positive match must not be masked by a later host's context error") + assert.Equal(t, "1.1.71", status.InstalledVersion) + assert.Equal(t, []InstalledSkillHost{{Host: "copilot", Version: "1.1.71"}}, status.SkillHosts) +} + // --------------------------------------------------------------------------- // DetectTool — Server / Library (commandBased) // --------------------------------------------------------------------------- diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 6791519d1cb..ccf32586398 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -1706,7 +1706,7 @@ func (i *installer) installSkillForHost( var beforeVersion string if upgrade { if installed, detectErr := i.detector.DetectSkillHosts(ctx, tool); detectErr == nil { - beforeVersion, _ = skillHostVersion(installed, host.Command) + beforeVersion, _ = installedHostVersion(installed, host.Command) } } @@ -1744,11 +1744,12 @@ func (i *installer) installSkillForHost( return version, alreadyLatest, nil } -// skillHostVersion returns the installed version of the skill for the given +// installedHostVersion returns the installed version of the skill for the given // host command from a DetectSkillHosts result, and whether that host was // found. InstalledSkillHost.Host carries the executable identity, so the match -// is against host.Command. -func skillHostVersion(installed []InstalledSkillHost, command string) (string, bool) { +// is against host.Command. (Distinct from detector.skillHostVersion, which +// probes the host CLI; this only looks up an already-detected list.) +func installedHostVersion(installed []InstalledSkillHost, command string) (string, bool) { for _, h := range installed { if h.Host == command { return h.Version, true @@ -1780,7 +1781,7 @@ func (i *installer) verifySkillInstalled( if detectErr != nil { return false, detectErr } - if v, ok := skillHostVersion(installed, host.Command); ok { + if v, ok := installedHostVersion(installed, host.Command); ok { version = v return true, nil } diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index 05e53177b27..7aaa2815241 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -114,7 +114,7 @@ func (m *mockDetector) DetectSkillHosts( hosts := make([]InstalledSkillHost, 0, len(tool.SkillHosts)) for _, h := range tool.SkillHosts { hosts = append(hosts, InstalledSkillHost{ - Host: h.Host, + Host: h.Command, Version: status.InstalledVersion, }) } From 5721fb9830d09f4f15ffce53d9e2a10f17e0e813 Mon Sep 17 00:00:00 2001 From: hemarina Date: Fri, 10 Jul 2026 19:23:55 -0700 Subject: [PATCH 30/41] address feedback --- cli/azd/cmd/tool.go | 16 ++++---- cli/azd/pkg/tool/detector.go | 13 ++++--- cli/azd/pkg/tool/detector_test.go | 8 ++-- cli/azd/pkg/tool/installer.go | 61 +++++++++++++++++++++--------- cli/azd/pkg/tool/installer_test.go | 52 ++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 35 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index db08cbb11cb..f5cecaf6b3a 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -594,8 +594,8 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err // 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(hostOpts), progress)...) + func(ctx context.Context, ids []string, progress ...tool.InstallOption) ([]*tool.InstallResult, error) { + return a.manager.InstallTools(ctx, ids, append(slices.Clone(hostOpts), progress...)...) }, ) } else { @@ -878,14 +878,14 @@ func runStepSpinner( ctx context.Context, console input.Console, tools []*tool.ToolDefinition, - run func(context.Context, []string, tool.InstallOption) ([]*tool.InstallResult, error), + 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)) + results, err := run(ctx, ids, tool.WithStepProgress(console), tool.WithInput(console.Handles().Stdin)) if err != nil { return results, err } @@ -1245,8 +1245,8 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err 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(hostOpts), progress)...) + func(ctx context.Context, ids []string, progress ...tool.InstallOption) ([]*tool.InstallResult, error) { + return a.manager.UpgradeTools(ctx, ids, append(slices.Clone(hostOpts), progress...)...) }, ) } else { @@ -1568,8 +1568,8 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e 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(hostOpts), progress)...) + func(ctx context.Context, ids []string, progress ...tool.InstallOption) ([]*tool.InstallResult, error) { + return a.manager.UninstallTools(ctx, ids, append(slices.Clone(hostOpts), progress...)...) }, ) } else { diff --git a/cli/azd/pkg/tool/detector.go b/cli/azd/pkg/tool/detector.go index 9fea80ba6b7..f6340894676 100644 --- a/cli/azd/pkg/tool/detector.go +++ b/cli/azd/pkg/tool/detector.go @@ -491,16 +491,17 @@ func (d *detector) detectSkill( status.SkillHosts = hosts if len(hosts) > 0 { // The skill was found on at least one host; report it installed even if - // a later host's probe errored (e.g. was cancelled), mirroring the - // first-match behavior from before multi-host detection. Installed and - // InstalledVersion reflect the first such host. + // a later host's probe errored, mirroring the first-match behavior from + // before multi-host detection. Installed and InstalledVersion reflect + // the first such host. status.Installed = true status.InstalledVersion = hosts[0].Version - return status } - // Nothing was found: surface a detection error (e.g. context cancellation) - // so a genuinely failed probe is not silently reported as "not installed". + // Record any detection failure (e.g. a later host's timeout/cancellation) + // even when an earlier host was found: SkillHosts 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 } diff --git a/cli/azd/pkg/tool/detector_test.go b/cli/azd/pkg/tool/detector_test.go index 24b304a8b40..c8452099915 100644 --- a/cli/azd/pkg/tool/detector_test.go +++ b/cli/azd/pkg/tool/detector_test.go @@ -690,7 +690,8 @@ func TestDetectSkillHosts(t *testing.T) { // skill is installed on an earlier host but a later host's probe hits a context // error (e.g. cancellation/timeout mid-detection), the skill is still reported // as installed from the host already found — the context error must not discard -// an earlier positive match. Regression test: multi-host detection previously +// an earlier positive match. The error is still recorded on the status +// (detection was incomplete). Regression test: multi-host detection previously // returned "not installed" with the error, dropping the skill from tool and // update-check results. func TestDetectSkill_EarlierHostFound_LaterHostContextError(t *testing.T) { @@ -739,10 +740,11 @@ func TestDetectSkill_EarlierHostFound_LaterHostContextError(t *testing.T) { assert.True(t, status.Installed, "a skill found on an earlier host must stay installed when a later host's probe is cancelled") - assert.NoError(t, status.Error, - "a positive match must not be masked by a later host's context error") assert.Equal(t, "1.1.71", status.InstalledVersion) assert.Equal(t, []InstalledSkillHost{{Host: "copilot", Version: "1.1.71"}}, status.SkillHosts) + // The later host's context error is still recorded — detection was + // incomplete, so SkillHosts may be missing a host. + require.ErrorIs(t, status.Error, context.DeadlineExceeded) } // --------------------------------------------------------------------------- diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index ccf32586398..8df410284a9 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -171,6 +171,12 @@ type installConfig struct { // tool itself otherwise). When nil the // installer prints a plain per-host header instead. renderer StepRenderer + // stdin, when set, is the input reader a skill host 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 + // host 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 } // InstallOption customizes an install or upgrade operation. @@ -208,6 +214,15 @@ func WithStepProgress(renderer StepRenderer) InstallOption { return func(c *installConfig) { c.renderer = renderer } } +// WithInput supplies the reader a skill host command should read stdin from +// while a step spinner is showing — typically the console's input +// (console.Handles().Stdin), so a host 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 { @@ -310,15 +325,21 @@ func (l *lineWriter) Write(p []byte) (int, error) { // skillCommandRunArgs configures how a skill host 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, while stdin stays connected so -// the user can answer prompts. When out is nil (no spinner) the command runs -// fully interactively against the terminal. azd never pipes canned answers on -// the user's behalf. -func skillCommandRunArgs(base exec.RunArgs, out io.Writer) exec.RunArgs { +// the CLI's output prints above the spinner, while stdin is connected to the +// supplied reader (the console's input, threaded from the step-progress +// caller) so the user can answer prompts. 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) } - return base.WithStdIn(os.Stdin).WithStdOut(out).WithStdErr(out) + 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 @@ -480,7 +501,7 @@ func (i *installer) Uninstall( // 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, cfg.renderer) + return i.runSkillUninstall(ctx, tool, cfg.hosts, cfg.renderer, cfg.stdin) } // Non-skill tools uninstall as a single step; render one spinner for @@ -859,6 +880,7 @@ func (i *installer) runSkillUninstall( tool *ToolDefinition, hosts []string, renderer StepRenderer, + stdin io.Reader, ) (*InstallResult, error) { start := time.Now() result := &InstallResult{Tool: tool} @@ -883,7 +905,7 @@ func (i *installer) runSkillUninstall( for _, host := range targets { title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, host.Host) hostErr := renderSkillStep(ctx, renderer, title, false, func(out io.Writer) (string, error) { - return "", i.uninstallSkillForHost(ctx, tool, host, out) + return "", i.uninstallSkillForHost(ctx, tool, host, out, stdin) }) if hostErr != nil { failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) @@ -965,8 +987,9 @@ func (i *installer) uninstallSkillForHost( tool *ToolDefinition, host SkillHost, out io.Writer, + stdin io.Reader, ) error { - if err := i.runSkillHostUninstallCommand(ctx, host, out); err != nil { + if err := i.runSkillHostUninstallCommand(ctx, host, out, stdin); err != nil { return err } return i.verifySkillUninstalled(ctx, tool, host) @@ -983,6 +1006,7 @@ func (i *installer) runSkillHostUninstallCommand( ctx context.Context, host SkillHost, out io.Writer, + stdin io.Reader, ) error { cmd := host.PluginUninstallCommand if len(cmd) == 0 { @@ -991,7 +1015,7 @@ func (i *installer) runSkillHostUninstallCommand( ) } - runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out) + runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out, stdin) if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { return fmt.Errorf( "running `%s %s`: %w", @@ -1053,7 +1077,7 @@ func (i *installer) run( // agentic 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, cfg.renderer) + return i.runSkill(ctx, tool, upgrade, cfg.hosts, cfg.allAvailableHosts, cfg.renderer, cfg.stdin) } // Non-skill tools install as a single step through the platform @@ -1276,6 +1300,7 @@ func (i *installer) runSkill( hosts []string, allAvailable bool, renderer StepRenderer, + stdin io.Reader, ) (*InstallResult, error) { start := time.Now() result := &InstallResult{Tool: tool} @@ -1331,7 +1356,7 @@ func (i *installer) runSkill( hostUpToDate bool ) hostErr := renderSkillStep(ctx, renderer, title, true, func(out io.Writer) (string, error) { - v, upToDate, e := i.installSkillForHost(ctx, tool, host, upgrade, out) + v, upToDate, e := i.installSkillForHost(ctx, tool, host, upgrade, out, stdin) hostVersion = v hostUpToDate = upToDate if e != nil { @@ -1342,9 +1367,9 @@ func (i *installer) runSkill( done := title switch { case upgrade && upToDate && v != "": - done = fmt.Sprintf("%s are already up to date (v%s).", tool.Name, v) + done = fmt.Sprintf("%s in %s are already up to date (v%s).", tool.Name, host.Host, v) case upgrade && upToDate: - done = fmt.Sprintf("%s are already up to date.", tool.Name) + done = fmt.Sprintf("%s in %s are already up to date.", tool.Name, host.Host) case upgrade && v != "": done = fmt.Sprintf("%s (v%s)", title, v) } @@ -1537,7 +1562,7 @@ func (i *installer) explicitSkillHostTargets( if !ok || !i.hostUsable(ctx, host) { supported := make([]string, len(tool.SkillHosts)) for j, h := range tool.SkillHosts { - supported[j] = h.Host + supported[j] = h.Command } return nil, fmt.Errorf( "host %q is not available for %s; supported hosts: %s", @@ -1698,6 +1723,7 @@ func (i *installer) installSkillForHost( host SkillHost, upgrade bool, out io.Writer, + stdin io.Reader, ) (version string, alreadyLatest bool, err error) { // For an upgrade, record the host's installed version before updating so // "already up to date" is decided by comparing the actual version before @@ -1710,7 +1736,7 @@ func (i *installer) installSkillForHost( } } - cmdOutput, err := i.runSkillHostCommand(ctx, host, upgrade, out) + cmdOutput, err := i.runSkillHostCommand(ctx, host, upgrade, out, stdin) if err != nil { return "", false, err } @@ -1824,6 +1850,7 @@ func (i *installer) runSkillHostCommand( host SkillHost, upgrade bool, out io.Writer, + stdin io.Reader, ) (string, error) { cmd := host.PluginInstallCommand verb := "install" @@ -1856,7 +1883,7 @@ func (i *installer) runSkillHostCommand( } } - runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out) + runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out, stdin) if _, err := i.commandRunner.Run(ctx, runArgs); err != nil { return "", fmt.Errorf( "running `%s %s`: %w", diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index 7aaa2815241..d5f9a49d8ac 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" @@ -1271,6 +1272,55 @@ func TestAggregateInstallResults_EmptyInputs(t *testing.T) { // fall back to the real PATH on the developer's machine. var allSkillHostNames = []string{"copilot", "claude"} +// TestRunSkill_Install_UsesConfiguredStdin verifies that when a step spinner is +// showing, the skill host command reads stdin from the reader supplied via +// WithInput (the console's input) rather than the process-global os.Stdin — so +// a host 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() + mockHostPresence(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{ + detectSkillHostsFn: func( + _ context.Context, _ *ToolDefinition, + ) ([]InstalledSkillHost, error) { + return []InstalledSkillHost{{Host: "copilot", Version: "1.1.70"}}, nil + }, + }, + ) + + var r fakeStepRenderer + result, err := inst.Install( + t.Context(), newSkillTool(), + WithHosts("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 // but preserve the structural distinctions (copilot/claude each with a @@ -1500,7 +1550,7 @@ func TestRunSkill_Upgrade_AlreadyUpToDate(t *testing.T) { 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 are already up to date (v1.1.86)."}, r.stops) + assert.Equal(t, []string{"Test Azure Skills in copilot are already up to date (v1.1.86)."}, r.stops) } // TestRunSkill_StreamedOutputPrintedAboveSpinner verifies that when the host From b6dcb83dc8724b470f2f07f9179806b6483aff2f Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 11:13:14 -0700 Subject: [PATCH 31/41] use short name for agent --- cli/azd/pkg/tool/manifest.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/pkg/tool/manifest.go b/cli/azd/pkg/tool/manifest.go index 0a39e84cbf0..2d13232d4cb 100644 --- a/cli/azd/pkg/tool/manifest.go +++ b/cli/azd/pkg/tool/manifest.go @@ -402,7 +402,7 @@ func azureSkills() *ToolDefinition { Website: "https://github.com/microsoft/azure-skills", SkillHosts: []SkillHost{ { - Host: "GitHub Copilot CLI", + Host: "GitHub", Command: "copilot", MarketplaceAddCommand: []string{"plugin", "marketplace", "add", "microsoft/azure-skills"}, PluginInstallCommand: []string{"plugin", "install", "azure@azure-skills"}, @@ -420,7 +420,7 @@ func azureSkills() *ToolDefinition { BinaryVersionRegex: `(?m)^GitHub Copilot CLI\s+v?(\d+\.\d+\.\d+)`, }, { - Host: "Claude Code CLI", + Host: "Claude", Command: "claude", MarketplaceAddCommand: []string{ "plugin", "marketplace", "add", "https://github.com/microsoft/azure-skills", From 090381d392c009a4c4a4cd3c71e1bd4f9dcebb9f Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 11:35:45 -0700 Subject: [PATCH 32/41] address feedback --- cli/azd/cmd/tool.go | 3 ++- cli/azd/pkg/tool/installer.go | 23 +++++++++++++++++------ cli/azd/pkg/tool/installer_test.go | 17 +++++++++-------- 3 files changed, 28 insertions(+), 15 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index f5cecaf6b3a..831bc81fb6e 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -969,7 +969,8 @@ func (a *toolInstallAction) dryRun( // 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), + 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"+ diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 8df410284a9..c19b380c230 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -247,9 +247,10 @@ func stepError(result *InstallResult, err error) error { // while the spinner stays pinned below it: the console tears the spinner down // and re-renders it around each printed line (see AskerConsole.println), so // the bar is kept, not lost, and the user can answer any prompt via the -// connected stdin. When false, the output is buffered and replayed only if the -// step fails, so a step that completes without error stays quiet (used for -// non-interactive operations such as uninstall). When the CLI stays silent the +// connected stdin (used for interactive operations such as install, upgrade +// and uninstall, whose host CLIs may prompt for confirmation). When false, the +// output is buffered and replayed only if the step fails, so a step that +// completes without error stays quiet. When the CLI stays silent the // spinner simply runs to completion. // // work returns the message to show on the result line; when empty the spinner @@ -904,7 +905,7 @@ func (i *installer) runSkillUninstall( ) for _, host := range targets { title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, host.Host) - hostErr := renderSkillStep(ctx, renderer, title, false, func(out io.Writer) (string, error) { + hostErr := renderSkillStep(ctx, renderer, title, true, func(out io.Writer) (string, error) { return "", i.uninstallSkillForHost(ctx, tool, host, out, stdin) }) if hostErr != nil { @@ -1878,7 +1879,7 @@ func (i *installer) runSkillHostCommand( } if len(host.MarketplaceAddCommand) > 0 { - if err := i.runMarketplaceAdd(ctx, host); err != nil { + if err := i.runMarketplaceAdd(ctx, host, out, stdin); err != nil { return "", err } } @@ -1895,6 +1896,14 @@ func (i *installer) runSkillHostCommand( } // runMarketplaceAdd registers the skill marketplace with the host CLI. +// out and stdin thread the step spinner's writer and the console's input +// through skillCommandRunArgs so the host 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 hosts (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 @@ -1903,8 +1912,10 @@ func (i *installer) runSkillHostCommand( func (i *installer) runMarketplaceAdd( ctx context.Context, host SkillHost, + out io.Writer, + stdin io.Reader, ) error { - args := exec.NewRunArgs(host.Command, host.MarketplaceAddCommand...) + args := skillCommandRunArgs(exec.NewRunArgs(host.Command, host.MarketplaceAddCommand...), out, stdin) result, err := i.commandRunner.Run(ctx, args) if err == nil { return nil diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index d5f9a49d8ac..a4b3933d20c 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -1566,8 +1566,8 @@ func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { runner.MockToolInPath("node", nil) // The plugin command writes to the terminal (StdOut), simulating the - // host CLI surfacing progress or a prompt. marketplace-add is captured - // (no StdOut) so it stays silent; only the install writes. + // host CLI surfacing progress or a prompt. Both marketplace-add and install + // stream their output through the step writer so prompts stay visible. 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) { @@ -4034,10 +4034,11 @@ func TestRunSkillUninstall_StepProgress(t *testing.T) { assert.Empty(t, r.messages) } -// TestRunSkillUninstall_StepProgress_SuccessHidesOutput verifies that when the -// host CLI prints output and the uninstall completes without error, that -// output is NOT surfaced above the spinner — a successful step stays quiet. -func TestRunSkillUninstall_StepProgress_SuccessHidesOutput(t *testing.T) { +// TestRunSkillUninstall_StepProgress_SuccessStreamsOutput verifies that the +// host 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() @@ -4070,8 +4071,8 @@ func TestRunSkillUninstall_StepProgress_SuccessHidesOutput(t *testing.T) { require.NoError(t, err) require.True(t, result.Success, "uninstall must succeed; err=%v", result.Error) - assert.Empty(t, r.messages, - "host CLI output must be hidden when uninstall completes without error") + assert.Contains(t, r.messages, "Plugin \"azure@azure-skills\" uninstalled successfully.", + "host CLI output must be streamed live so interactive prompts stay visible") assert.Equal(t, []string{"Uninstalling Test Azure Skills from copilot"}, r.stops) } From e4337d118a8aa2ce930d8f8ac16918cc9804fffc Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 12:06:25 -0700 Subject: [PATCH 33/41] add changelog --- cli/azd/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 01ce06ca473..66431ee449e 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 `azd tool install` skill flag `--host` has been renamed to `--agent`. Update any scripts or automation that pass `--host` to `azd tool install` to use `--agent` instead. + ### Bugs Fixed ### Other Changes From 0ce96b2d130d934a3b27e3296e6328de58ab53cb Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 13:35:34 -0700 Subject: [PATCH 34/41] rename to agent --- cli/azd/cmd/tool.go | 240 ++++----- cli/azd/cmd/tool_test.go | 324 +++++------ cli/azd/pkg/tool/detector.go | 118 ++-- cli/azd/pkg/tool/detector_test.go | 102 ++-- cli/azd/pkg/tool/installer.go | 672 +++++++++++------------ cli/azd/pkg/tool/installer_test.go | 684 ++++++++++++------------ cli/azd/pkg/tool/manager.go | 24 +- cli/azd/pkg/tool/manager_test.go | 48 +- cli/azd/pkg/tool/manifest.go | 72 +-- cli/azd/pkg/tool/manifest_test.go | 46 +- cli/azd/pkg/tool/update_checker.go | 54 +- cli/azd/pkg/tool/update_checker_test.go | 20 +- 12 files changed, 1202 insertions(+), 1202 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 831bc81fb6e..6b4e22a8ff0 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -330,7 +330,7 @@ func (a *toolAction) Run(ctx context.Context) (*actions.ActionResult, error) { type toolListItem struct { Id string `json:"id"` Name string `json:"name"` - // Agent is the agentic CLI host a skill row is installed through (e.g. + // 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"` @@ -338,7 +338,7 @@ type toolListItem struct { Status string `json:"status"` Version string `json:"version"` // DisplayName is the NAME cell shown in the table: a skill row is - // prefixed with its host label (e.g. "[Copilot] Azure Skills"), other + // 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:"-"` } @@ -388,16 +388,16 @@ 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 hosts expands into one row per - // host, each prefixed with the host label (e.g. "[Copilot] ..."). - if s.Tool.Category == tool.ToolCategorySkill && len(s.SkillHosts) > 0 { - for _, h := range s.SkillHosts { + // 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.Host, + Agent: h.Agent, DisplayName: fmt.Sprintf("[%s] %s", - skillHostDisplayName(s.Tool, h.Host), s.Tool.Name), + skillAgentDisplayName(s.Tool, h.Agent), s.Tool.Name), Category: string(s.Tool.Category), Priority: string(s.Tool.Priority), Status: "Installed", @@ -486,7 +486,7 @@ func (a *toolListAction) Run(ctx context.Context) (*actions.ActionResult, error) type toolInstallFlags struct { all bool - hosts []string + agents []string dryRun bool } @@ -496,7 +496,7 @@ func newToolInstallFlags(cmd *cobra.Command) *toolInstallFlags { &flags.all, "all", false, "Install all recommended tools", ) cmd.Flags().StringSliceVar( - &flags.hosts, "agent", nil, + &flags.agents, "agent", nil, "Install the skill for the specified agent(s): copilot, claude. "+ "Use --agent all for every detected agent (skill tools only)", ) @@ -572,12 +572,12 @@ 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 + // 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 } start := time.Now() @@ -595,12 +595,12 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err 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(hostOpts), progress...)...) + 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, hostOpts...) + return a.manager.InstallTools(ctx, allIDs, agentOpts...) } outcome := runToolOperation(ctx, tools, operationFn, "Installing", "install", a.console) installResults = outcome.Items @@ -632,9 +632,9 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err }, nil } -// allHostsKeyword is the reserved --agent value that selects every +// allAgentsKeyword is the reserved --agent value that selects every // detected agent. -const allHostsKeyword = "all" +const allAgentsKeyword = "all" // firstSkillTool returns the first skill tool among tools, or nil when // none are present. @@ -647,45 +647,45 @@ func firstSkillTool(tools []*tool.ToolDefinition) *tool.ToolDefinition { return nil } -// resolveExplicitSkillHosts maps an explicit --agent 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) { +func resolveExplicitSkillAgents(agents []string) ([]tool.InstallOption, error) { // --agent all selects every detected agent. It cannot be mixed with // specific agent names. - if slices.Contains(hosts, allHostsKeyword) { - if len(hosts) > 1 { + if slices.Contains(agents, allAgentsKeyword) { + if len(agents) > 1 { return nil, fmt.Errorf( "--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 +// 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 +// 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) resolveHostOptions( +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 { @@ -696,32 +696,32 @@ func (a *toolInstallAction) resolveHostOptions( } 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 --agent. 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 + // 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, presentName := 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), + // Interactive terminal: prompt the user to pick the agent(s), // after surfacing the --agent hint so they learn the shortcut too. if a.console.IsSpinnerInteractive() && !a.console.IsNoPromptMode() { a.console.Message(ctx, "Multiple AI agents detected.\n"+ @@ -731,7 +731,7 @@ func (a *toolInstallAction) resolveHostOptions( output.WithHighLightFormat("--agent all")+ output.WithGrayFormat("` to select a specific agent or all agents.\n")) - opts, err := a.promptForSkillHosts(ctx, skill, present, presentName) + opts, err := a.promptForSkillAgents(ctx, skill, present, presentName) if err != nil { return nil, err } @@ -747,7 +747,7 @@ func (a *toolInstallAction) resolveHostOptions( "Detected multiple agents: %s", strings.Join(presentName, ", "), ), Suggestion: fmt.Sprintf( - "Specify which agent(s) to install for:\n\n"+ + "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, @@ -755,20 +755,20 @@ func (a *toolInstallAction) resolveHostOptions( } } - // 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 --agent 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) { @@ -776,28 +776,28 @@ func (a *toolInstallAction) resolveUnavailableHostPrompt( return nil, false, nil } - available, availableNames := a.manager.AvailableSkillHosts(ctx, skill) + available, availableNames := a.manager.AvailableSkillAgents(ctx, skill) var unavailable []string - for _, host := range a.flags.hosts { - // Match case-insensitively, mirroring findSkillHost and the --agent + 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, host) + return strings.EqualFold(cmd, agent) }) { - unavailable = append(unavailable, fmt.Sprintf("%q", host)) + 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( @@ -805,25 +805,25 @@ func (a *toolInstallAction) resolveUnavailableHostPrompt( "on your PATH:", strings.Join(unavailable, ", "), skill.Name, )) - picked, err := a.promptForSkillHosts(ctx, skill, available, availableNames) + 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. commands and names are index-aligned (from AvailableSkillHosts): -// the picker displays the friendly name for each host and maps the selection +// 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) promptForSkillHosts( +func (a *toolInstallAction) promptForSkillAgents( ctx context.Context, skill *tool.ToolDefinition, commands []string, @@ -836,13 +836,13 @@ func (a *toolInstallAction) promptForSkillHosts( selected, err := a.console.MultiSelect(ctx, input.ConsoleOptions{ Message: fmt.Sprintf( - "Select the agent(s) to install %s for", skill.Name, + "Select the agents to install %s for", skill.Name, ), 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 @@ -852,7 +852,7 @@ func (a *toolInstallAction) promptForSkillHosts( for i, name := range selected { picked[i] = toCommand[name] } - return []tool.InstallOption{tool.WithHosts(picked...)}, nil + return []tool.InstallOption{tool.WithAgents(picked...)}, nil } // useStepSpinner reports whether a tool operation should render live @@ -1080,7 +1080,7 @@ 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 { @@ -1094,7 +1094,7 @@ func newToolUpgradeFlags(cmd *cobra.Command) *toolUpgradeFlags { "Preview what would be upgraded without making changes", ) cmd.Flags().StringSliceVar( - &flags.hosts, "agent", nil, + &flags.agents, "agent", nil, "Upgrade the skill for the specified agent(s): copilot, claude. "+ "Use --agent all for every detected agent (skill tools only)", ) @@ -1230,9 +1230,9 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err TitleNote: "Upgrades installed tools to their latest versions", }) - hostOpts, hostErr := a.resolveHostOptions(toolsToUpgrade) - if hostErr != nil { - return nil, hostErr + agentOpts, agentErr := a.resolveAgentOptions(toolsToUpgrade) + if agentErr != nil { + return nil, agentErr } start := time.Now() @@ -1247,12 +1247,12 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err 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(hostOpts), progress...)...) + 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, hostOpts...) + return a.manager.UpgradeTools(ctx, allIDs, agentOpts...) } outcome := runToolOperation(ctx, toolsToUpgrade, operationFn, "Upgrading", "upgrade", a.console) upgradeResults = outcome.Items @@ -1289,7 +1289,7 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err // 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 host, so an upgrade on any host + // 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 @@ -1394,15 +1394,15 @@ func (a *toolUpgradeAction) promptForUpgradeTools( return chosen, nil } -// resolveHostOptions determines which agentic CLI host(s) a skill should +// resolveAgentOptions determines which agent CLI(s) a skill should // be upgraded for, based on the --agent flag. --agent all targets every -// detected host; specific names target those hosts. When --agent is -// omitted it returns no options, letting the installer upgrade every host +// 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 } @@ -1411,7 +1411,7 @@ func (a *toolUpgradeAction) resolveHostOptions( 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 @@ -1468,7 +1468,7 @@ func (a *toolUpgradeAction) dryRun( type toolUninstallFlags struct { all bool - hosts []string + agents []string dryRun bool } @@ -1478,7 +1478,7 @@ func newToolUninstallFlags(cmd *cobra.Command) *toolUninstallFlags { &flags.all, "all", false, "Uninstall all installed tools", ) cmd.Flags().StringSliceVar( - &flags.hosts, "agent", nil, + &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)", @@ -1553,9 +1553,9 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e TitleNote: "Uninstalls specified tools from the local machine", }) - hostOpts, hostErr := a.resolveHostOptions(tools) - if hostErr != nil { - return nil, hostErr + agentOpts, agentErr := a.resolveAgentOptions(tools) + if agentErr != nil { + return nil, agentErr } start := time.Now() @@ -1570,12 +1570,12 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e 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(hostOpts), progress...)...) + 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, hostOpts...) + return a.manager.UninstallTools(ctx, allIDs, agentOpts...) } outcome := runToolOperation(ctx, tools, operationFn, "Uninstalling", "uninstall", a.console) uninstallResults = outcome.Items @@ -1607,15 +1607,15 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e }, nil } -// resolveHostOptions determines which agentic CLI host(s) a skill should +// resolveAgentOptions determines which agent CLI(s) a skill should // be uninstalled from, based on the --agent flag. --agent all targets every -// detected host; specific names target those hosts. When --agent is +// 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 } @@ -1624,7 +1624,7 @@ func (a *toolUninstallAction) resolveHostOptions( 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 @@ -1665,7 +1665,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)) @@ -1768,7 +1768,7 @@ func (a *toolUninstallAction) dryRun( type toolCheckItem struct { Id string `json:"id"` Name string `json:"name"` - // Agent is the agentic CLI host a skill row is checked through (e.g. + // 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"` @@ -1778,7 +1778,7 @@ type toolCheckItem struct { // 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 host label (e.g. "[Copilot] Azure Skills"), other + // 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:"-"` } @@ -1832,17 +1832,17 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error if r.UpdateAvailable { updatesAvailable++ } - // A skill installed on one or more hosts expands into one row per - // host, each prefixed with the host label and carrying that host's + // 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 update status. - if r.Tool.Category == tool.ToolCategorySkill && len(r.SkillHosts) > 0 { - for _, h := range r.SkillHosts { + 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.Host, + Agent: h.Agent, DisplayName: fmt.Sprintf("[%s] %s", - skillHostDisplayName(r.Tool, h.Host), r.Tool.Name), + skillAgentDisplayName(r.Tool, h.Agent), r.Tool.Name), InstalledVersion: h.CurrentVersion, LatestVersion: r.LatestVersion, UpdateAvailable: h.UpdateAvailable, @@ -2459,14 +2459,14 @@ func toolCheckStatus(installed, updateAvailable bool) string { } } -// skillHostDisplayName maps an installed skill host's command identity (e.g. +// 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 host matches. -func skillHostDisplayName(t *tool.ToolDefinition, command string) string { - for _, host := range t.SkillHosts { - if host.Command == command { - return host.Host +// 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 diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 87b42126350..95876bf43a8 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -338,9 +338,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 } @@ -354,7 +354,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) (commands []string, names []string) + availableSkillAgents func(ctx context.Context, t *tool.ToolDefinition) (commands []string, names []string) } func (m *cmdMockInstaller) Install( @@ -375,30 +375,30 @@ func (m *cmdMockInstaller) Upgrade( return &tool.InstallResult{Tool: t, Success: true}, nil } -func (m *cmdMockInstaller) AvailableSkillHosts( +func (m *cmdMockInstaller) AvailableSkillAgents( ctx context.Context, t *tool.ToolDefinition, ) (commands []string, names []string) { - if m.availableSkillHosts != nil { - return m.availableSkillHosts(ctx, t) + if m.availableSkillAgents != nil { + return m.availableSkillAgents(ctx, t) } return nil, nil } -// mockAvailableSkillHosts returns commands unchanged plus the display name for -// each, derived from the tool's SkillHosts (falling back to the command when -// no host matches). It mirrors installer.AvailableSkillHosts so the mock +// 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 mockAvailableSkillHosts(td *tool.ToolDefinition, commands []string) ([]string, []string) { +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.SkillHosts { + for _, h := range td.SkillAgents { if h.Command == c { - names[i] = h.Host + names[i] = h.DisplayName break } } @@ -675,17 +675,17 @@ func TestToolUpgradeAction_FailureDoesNotEmitToVersion(t *testing.T) { } // --------------------------------------------------------------------------- -// resolveHostOptions — --agent / --all-agents 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, - SkillHosts: []tool.SkillHost{ - {Host: "GitHub Copilot CLI", Command: "copilot"}, - {Host: "Claude Code CLI", Command: "claude"}, + SkillAgents: []tool.SkillAgent{ + {DisplayName: "GitHub Copilot CLI", Command: "copilot"}, + {DisplayName: "Claude Code CLI", Command: "claude"}, }, } nonSkill := &tool.ToolDefinition{ @@ -695,8 +695,8 @@ func TestResolveHostOptions(t *testing.T) { newAction := func(args []string, flags *toolInstallFlags, present []string) *toolInstallAction { installer := &cmdMockInstaller{ - availableSkillHosts: func(_ context.Context, td *tool.ToolDefinition) ([]string, []string) { - return mockAvailableSkillHosts(td, present) + availableSkillAgents: func(_ context.Context, td *tool.ToolDefinition) ([]string, []string) { + return mockAvailableSkillAgents(td, present) }, } manager := tool.NewManager(&cmdMockDetector{}, installer, nil) @@ -708,50 +708,50 @@ 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 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) { + t.Run("AgentAllDefersEvenWhenNoneDetected", func(t *testing.T) { // --agent 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}) + // 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) @@ -759,9 +759,9 @@ func TestResolveHostOptions(t *testing.T) { 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. The picker shows friendly Host + // 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) @@ -772,14 +772,14 @@ func TestResolveHostOptions(t *testing.T) { return true }).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"}) @@ -791,12 +791,12 @@ 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 --agent. a := newAction([]string{"azure-skills"}, &toolInstallFlags{}, []string{"copilot", "claude"}) @@ -806,20 +806,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, "--agent all") }) - t.Run("ExplicitUnavailableHostInteractivePrompts", func(t *testing.T) { - // `--agent 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) @@ -830,20 +830,20 @@ func TestResolveHostOptions(t *testing.T) { return true }).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 (friendly display names), + // 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) @@ -853,19 +853,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) { - // `--agent 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) @@ -876,18 +876,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) @@ -898,17 +898,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) @@ -917,17 +917,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) { + 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) @@ -938,38 +938,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", @@ -982,8 +982,8 @@ func TestResolveHostOptions_Upgrade(t *testing.T) { newAction := func(flags *toolUpgradeFlags, present []string) *toolUpgradeAction { installer := &cmdMockInstaller{ - availableSkillHosts: func(_ context.Context, td *tool.ToolDefinition) ([]string, []string) { - return mockAvailableSkillHosts(td, present) + availableSkillAgents: func(_ context.Context, td *tool.ToolDefinition) ([]string, []string) { + return mockAvailableSkillAgents(td, present) }, } manager := tool.NewManager(&cmdMockDetector{}, installer, nil) @@ -993,55 +993,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 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) { + t.Run("AgentAllDefersEvenWhenNoneDetected", func(t *testing.T) { // --agent 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}) + // 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 --agent never errors on multiple - // hosts: the installer upgrades the host the skill is installed + // 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", @@ -1062,35 +1062,35 @@ 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 --agent 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 agents") }) @@ -1383,46 +1383,46 @@ func TestToolUpgradeAction_ChangedVersion_ReportsUpgraded(t *testing.T) { assert.Equal(t, "Tool is upgraded to v2.0.0.", result.Message.Header) } -// TestSkillHostDisplayName verifies an installed host's command identity is +// 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 TestSkillHostDisplayName(t *testing.T) { +func TestSkillAgentDisplayName(t *testing.T) { td := &tool.ToolDefinition{ - SkillHosts: []tool.SkillHost{ - {Host: "GitHub Copilot CLI", Command: "copilot"}, - {Host: "Claude Code CLI", Command: "claude"}, + SkillAgents: []tool.SkillAgent{ + {DisplayName: "GitHub Copilot CLI", Command: "copilot"}, + {DisplayName: "Claude Code CLI", Command: "claude"}, }, } - assert.Equal(t, "GitHub Copilot CLI", skillHostDisplayName(td, "copilot")) - assert.Equal(t, "Claude Code CLI", skillHostDisplayName(td, "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", skillHostDisplayName(td, "gemini")) + assert.Equal(t, "gemini", skillAgentDisplayName(td, "gemini")) } -// TestToolInstallAction_resolveUnavailableHostPrompt_CaseInsensitive verifies -// that an explicit --agent value is matched against the available hosts -// case-insensitively (like findSkillHost). "--agent Copilot" must match the +// 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_resolveUnavailableHostPrompt_CaseInsensitive(t *testing.T) { +func TestToolInstallAction_resolveUnavailableAgentPrompt_CaseInsensitive(t *testing.T) { installer := &cmdMockInstaller{ - availableSkillHosts: func(_ context.Context, _ *tool.ToolDefinition) ([]string, []string) { + 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-host path is reachable + console.SetTerminal(true) // interactive, so the unavailable-agent path is reachable action := newToolInstallAction( nil, - &toolInstallFlags{hosts: []string{"Copilot"}}, + &toolInstallFlags{agents: []string{"Copilot"}}, manager, console, &output.NoneFormatter{}, io.Discard, ).(*toolInstallAction) skill := &tool.ToolDefinition{Id: "azure-skills", Category: tool.ToolCategorySkill} - opts, handled, err := action.resolveUnavailableHostPrompt(t.Context(), skill) + 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") @@ -1551,24 +1551,24 @@ func TestToolUninstallAction_resolveToolIds_AllFlag_NoPrompt(t *testing.T) { "--all must select every installed tool") } -// TestToolUpgradeAction_MultiHostSkill_UpgradedNotUpToDate reproduces the -// multi-host skill case: the aggregate InstalledVersion (first host) is -// unchanged, but the installer set AlreadyUpToDate=false because another host +// 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_MultiHostSkill_UpgradedNotUpToDate(t *testing.T) { +func TestToolUpgradeAction_MultiAgentSkill_UpgradedNotUpToDate(t *testing.T) { tracing.ResetUsageAttributesForTest() detector := &cmdMockDetector{ detectTool: func(_ context.Context, td *tool.ToolDefinition) (*tool.ToolStatus, error) { - // First host is current before the upgrade. + // 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 host current), but a different - // host was upgraded, so AlreadyUpToDate is false. + // Aggregate version unchanged (first agent current), but a different + // agent was upgraded, so AlreadyUpToDate is false. return &tool.InstallResult{ Tool: td, Success: true, @@ -1593,7 +1593,7 @@ func TestToolUpgradeAction_MultiHostSkill_UpgradedNotUpToDate(t *testing.T) { require.NotNil(t, result) require.NotNil(t, result.Message) assert.Equal(t, "Tool is upgraded to v1.1.87.", result.Message.Header, - "a multi-host skill with an upgraded host must not read as already up to date") + "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]" @@ -1666,11 +1666,11 @@ func TestToolNameColumn_PlainValueWrapsUnlikeAnsiValue(t *testing.T) { "a plain NAME value must wrap at narrow width, unlike an ANSI-embedded value") } -// TestToolListAction_JsonFormat_SkillPerHostRows locks the machine-readable +// 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 host, each carrying the original tool name, -// the command-valued agent, and that host's installed version. -func TestToolListAction_JsonFormat_SkillPerHostRows(t *testing.T) { +// 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{ @@ -1678,9 +1678,9 @@ func TestToolListAction_JsonFormat_SkillPerHostRows(t *testing.T) { Name: "Azure Skills", Category: tool.ToolCategorySkill, Priority: tool.ToolPriorityRecommended, - SkillHosts: []tool.SkillHost{ - {Host: "GitHub Copilot CLI", Command: "copilot"}, - {Host: "Claude Code CLI", Command: "claude"}, + SkillAgents: []tool.SkillAgent{ + {DisplayName: "GitHub Copilot CLI", Command: "copilot"}, + {DisplayName: "Claude Code CLI", Command: "claude"}, }, } detector := &cmdMockDetector{ @@ -1688,9 +1688,9 @@ func TestToolListAction_JsonFormat_SkillPerHostRows(t *testing.T) { return []*tool.ToolStatus{{ Tool: skill, Installed: true, - SkillHosts: []tool.InstalledSkillHost{ - {Host: "copilot", Version: "1.0.0"}, - {Host: "claude", Version: "2.0.0"}, + SkillAgents: []tool.InstalledSkillAgent{ + {Agent: "copilot", Version: "1.0.0"}, + {Agent: "claude", Version: "2.0.0"}, }, }}, nil }, @@ -1711,19 +1711,19 @@ func TestToolListAction_JsonFormat_SkillPerHostRows(t *testing.T) { byAgent[r.Agent] = r } } - require.Len(t, byAgent, 2, "a two-host skill must produce two agent rows") + 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_SkillPerHostRows locks the machine-readable -// contract for `azd tool check --output json`: each skill host is a row with +// 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-host update flag (so a stale host reports an update while a current +// and a per-agent update flag (so a stale agent reports an update while a current // one does not). -func TestToolCheckAction_JsonFormat_SkillPerHostRows(t *testing.T) { +func TestToolCheckAction_JsonFormat_SkillPerAgentRows(t *testing.T) { tracing.ResetUsageAttributesForTest() detector := &cmdMockDetector{ @@ -1731,9 +1731,9 @@ func TestToolCheckAction_JsonFormat_SkillPerHostRows(t *testing.T) { return []*tool.ToolStatus{{ Tool: &tool.ToolDefinition{Id: "azure-skills"}, Installed: true, - SkillHosts: []tool.InstalledSkillHost{ - {Host: "copilot", Version: "1.0.0"}, // behind latest - {Host: "claude", Version: "2.0.0"}, // current + SkillAgents: []tool.InstalledSkillAgent{ + {Agent: "copilot", Version: "1.0.0"}, // behind latest + {Agent: "claude", Version: "2.0.0"}, // current }, }}, nil }, @@ -1764,15 +1764,15 @@ func TestToolCheckAction_JsonFormat_SkillPerHostRows(t *testing.T) { byAgent[r.Agent] = r } } - require.Len(t, byAgent, 2, "a two-host skill must produce two agent rows") + 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 host must report an update") + 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 host must not report an update") + assert.False(t, byAgent["claude"].UpdateAvailable, "a current agent must not report an update") } // memUserConfigManager is an in-memory config.UserConfigManager for tests that diff --git a/cli/azd/pkg/tool/detector.go b/cli/azd/pkg/tool/detector.go index f6340894676..0586ee9b872 100644 --- a/cli/azd/pkg/tool/detector.go +++ b/cli/azd/pkg/tool/detector.go @@ -26,21 +26,21 @@ type ToolStatus struct { // output. It is empty when the tool is not installed or when // version parsing fails. InstalledVersion string - // SkillHosts lists every agentic CLI host the skill is installed + // 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. - SkillHosts []InstalledSkillHost + 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 } @@ -62,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 { @@ -466,40 +466,40 @@ func (d *detector) detectAzdExtension( // --------------------------------------------------------------------------- // detectSkill checks whether a skill is installed by probing every -// SkillHost's PluginListCommand. A host is reported as having the skill +// 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. Every host the skill is installed through is recorded in -// SkillHosts (in manifest order); Installed and InstalledVersion reflect -// the first such host, so a skill installed anywhere reads as installed. -// 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 } - hosts, err := d.DetectSkillHosts(ctx, tool) - status.SkillHosts = hosts - if len(hosts) > 0 { - // The skill was found on at least one host; report it installed even if - // a later host's probe errored, mirroring the first-match behavior from - // before multi-host detection. Installed and InstalledVersion reflect - // the first such host. + 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 = hosts[0].Version + status.InstalledVersion = agents[0].Version } - // Record any detection failure (e.g. a later host's timeout/cancellation) - // even when an earlier host was found: SkillHosts may be incomplete, so per + // 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 { @@ -509,16 +509,16 @@ func (d *detector) detectSkill( 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( +// probes all agents, 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. +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") } @@ -526,27 +526,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 the hosts discovered before the error (e.g. a later host's + // 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 hosts, err + return agents, err } if version != "" { - hosts = append(hosts, InstalledSkillHost{ - Host: host.Command, + 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. // @@ -555,48 +555,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 + // An agent that is not on PATH cannot have the skill installed through // it; skip silently. Probe the exec binary (Command), not the display - // Host, so this PATH check matches the command actually run below — - // otherwise a manifest whose Host differs from its binary (e.g. Host + // 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(host.Command); err != nil { + 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.Command, - 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 c8452099915..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,9 @@ func TestDetectTool_Skill_Copilot(t *testing.T) { Id: "azure-skills", Name: "Azure Skills", Category: ToolCategorySkill, - SkillHosts: []SkillHost{ + SkillAgents: []SkillAgent{ { - Host: "GitHub Copilot CLI", + DisplayName: "GitHub Copilot CLI", Command: "copilot", PluginListCommand: []string{"plugin", "list"}, PluginName: "azure@azure-skills", @@ -505,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. @@ -517,9 +517,9 @@ func TestDetectTool_Skill_Claude(t *testing.T) { Id: "azure-skills", Name: "Azure Skills", Category: ToolCategorySkill, - SkillHosts: []SkillHost{ + SkillAgents: []SkillAgent{ { - Host: "Claude Code CLI", + DisplayName: "Claude Code CLI", Command: "claude", PluginListCommand: []string{"plugin", "list", "--json"}, PluginName: "azure@azure-skills", @@ -583,26 +583,26 @@ 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: "GitHub Copilot CLI", + DisplayName: "GitHub Copilot CLI", Command: "copilot", PluginListCommand: []string{"plugin", "list"}, PluginName: "azure@azure-skills", VersionRegex: `azure@azure-skills[^\n]*?(\d+\.\d+\.\d+)`, }, { - Host: "Claude Code CLI", + DisplayName: "Claude Code CLI", Command: "claude", PluginListCommand: []string{"plugin", "list", "--json"}, PluginName: "azure@azure-skills", @@ -612,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, }, } @@ -667,50 +667,50 @@ 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_EarlierHostFound_LaterHostContextError verifies that when a -// skill is installed on an earlier host but a later host's probe hits a context +// 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 host already found — the context error must not discard +// 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-host detection previously +// (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_EarlierHostFound_LaterHostContextError(t *testing.T) { +func TestDetectSkill_EarlierAgentFound_LaterAgentContextError(t *testing.T) { t.Parallel() skill := &ToolDefinition{ Id: "azure-skills", Name: "Azure Skills", Category: ToolCategorySkill, - SkillHosts: []SkillHost{ + SkillAgents: []SkillAgent{ { - Host: "GitHub Copilot CLI", + DisplayName: "GitHub Copilot CLI", Command: "copilot", PluginListCommand: []string{"plugin", "list"}, PluginName: "azure@azure-skills", VersionRegex: `azure@azure-skills[^\n]*?(\d+\.\d+\.\d+)`, }, { - Host: "Claude Code CLI", + DisplayName: "Claude Code CLI", Command: "claude", PluginListCommand: []string{"plugin", "list", "--json"}, PluginName: "azure@azure-skills", @@ -722,11 +722,11 @@ func TestDetectSkill_EarlierHostFound_LaterHostContextError(t *testing.T) { runner := mockexec.NewMockCommandRunner() runner.MockToolInPath("copilot", nil) runner.MockToolInPath("claude", nil) - // copilot (first host) has the skill installed ... + // 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 host) is probed with a cancelled/expired context. + // ... 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) { @@ -739,11 +739,11 @@ func TestDetectSkill_EarlierHostFound_LaterHostContextError(t *testing.T) { require.NotNil(t, status) assert.True(t, status.Installed, - "a skill found on an earlier host must stay installed when a later host's probe is cancelled") + "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, []InstalledSkillHost{{Host: "copilot", Version: "1.1.71"}}, status.SkillHosts) - // The later host's context error is still recorded — detection was - // incomplete, so SkillHosts may be missing a host. + 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) } diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index c19b380c230..ce16e2a665e 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -53,7 +53,7 @@ type InstallResult struct { Success bool // InstalledVersion is the version detected after installation. InstalledVersion string - // AlreadyUpToDate is set for an upgrade when every targeted host + // 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. @@ -114,8 +114,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, @@ -131,18 +131,18 @@ type Installer interface { opts ...InstallOption, ) (*InstallResult, error) - // AvailableSkillHosts returns the tool's configured SkillHosts that are - // currently usable (a functional CLI on PATH, per hostUsable), in - // manifest order (preferred host first), as two index-aligned slices: the + // 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 - // hosts are usable. It probes the host binaries, so it takes a context. - AvailableSkillHosts(ctx context.Context, tool *ToolDefinition) (commands []string, names []string) + // 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, @@ -154,27 +154,27 @@ 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 host for a skill tool, or the + // install/upgrade/uninstall step (each agent for a skill tool, or the // tool itself otherwise). When nil the - // installer prints a plain per-host header instead. + // installer prints a plain per-agent header instead. renderer StepRenderer - // stdin, when set, is the input reader a skill host command reads from + // 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 - // host command falls back to the process terminal. Ignored when no + // 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 } @@ -182,23 +182,23 @@ type installConfig struct { // 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 } } // StepRenderer renders live per-step progress. It is the subset of -// input.Console the installer uses to show a step spinner per host, +// 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) @@ -214,9 +214,9 @@ func WithStepProgress(renderer StepRenderer) InstallOption { return func(c *installConfig) { c.renderer = renderer } } -// WithInput supplies the reader a skill host command should read stdin from +// 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 a host prompt is answered on the same stream +// (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 { @@ -236,10 +236,10 @@ func stepError(result *InstallResult, err error) error { } // renderSkillStep frames one skill step (install, upgrade or uninstall) with -// a live spinner that stays visible while the host CLI talks to the user. +// a live spinner that stays visible while the agent CLI talks to the user. // // It shows a step spinner (like azd provision) with title and passes work an -// output writer. Skill operations route the host CLI's stdout/stderr through +// output writer. Skill operations route the agent CLI's stdout/stderr through // that writer (see skillCommandRunArgs), with stdin still connected. // // streamOutput controls how that output is surfaced. When true, each line the @@ -248,7 +248,7 @@ func stepError(result *InstallResult, err error) error { // and re-renders it around each printed line (see AskerConsole.println), so // the bar is kept, not lost, and the user can answer any prompt via the // connected stdin (used for interactive operations such as install, upgrade -// and uninstall, whose host CLIs may prompt for confirmation). When false, the +// and uninstall, whose agent CLIs may prompt for confirmation). When false, the // output is buffered and replayed only if the step fails, so a step that // completes without error stays quiet. When the CLI stays silent the // spinner simply runs to completion. @@ -275,7 +275,7 @@ func renderSkillStep( renderer.ShowSpinner(ctx, title, input.Step) - // Stream the host CLI's output live (so interactive prompts are visible), + // Stream the agent CLI's output live (so interactive prompts are visible), // or buffer it and replay only on failure so a successful step is quiet. var buffered []string emit := func(line string) { renderer.Message(ctx, line) } @@ -297,8 +297,8 @@ func renderSkillStep( return err } -// lineWriter splits writes into lines and hands each to emit. Skill host -// commands use it to surface the host CLI's output through the console so it +// lineWriter splits writes into lines and hands each 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. Content is emitted as it arrives — // including a trailing line with no newline, e.g. an interactive prompt — so @@ -308,7 +308,7 @@ func renderSkillStep( // values, so os/exec may call Write from two goroutines at once. The mutex // serializes those calls so emit (which may append to an unsynchronized buffer // or write to the console) is never invoked concurrently — avoiding a data -// race and interleaved/lost host output. +// race and interleaved/lost agent output. type lineWriter struct { mu sync.Mutex emit func(string) @@ -323,7 +323,7 @@ func (l *lineWriter) Write(p []byte) (int, error) { return len(p), nil } -// skillCommandRunArgs configures how a skill host command (install, upgrade +// 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, while stdin is connected to the @@ -344,12 +344,12 @@ func skillCommandRunArgs(base exec.RunArgs, out io.Writer, stdin io.Reader) exec } // semverRegex matches a bare semantic version (no leading "v") anywhere in a -// string. Used to pull the version a host CLI prints after an update. +// string. Used to pull the version an agent CLI prints after an update. var semverRegex = regexp.MustCompile(`\d+\.\d+\.\d+`) -// parseUpgradeOutput extracts, from a host CLI's plugin-update output, the +// 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 host CLIs, e.g.: +// 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. ...` @@ -376,13 +376,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. @@ -455,33 +455,33 @@ func (i *installer) Upgrade( return i.run(ctx, tool, true, opts) } -// AvailableSkillHosts returns the tool's configured SkillHosts that are -// currently usable, in manifest order (preferred host first), as two +// 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/findSkillHost) and their friendly display names (e.g. -// "GitHub Copilot CLI", shown to the user). 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. Both are nil for -// non-skill tools or when none of the hosts are usable. -func (i *installer) AvailableSkillHosts( +// 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, nil } - for _, host := range tool.SkillHosts { - if i.hostUsable(ctx, host) { - commands = append(commands, host.Command) - names = append(names, host.Host) + for _, agent := range tool.SkillAgents { + if i.agentUsable(ctx, agent) { + commands = append(commands, agent.Command) + names = append(names, agent.DisplayName) } } 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( @@ -495,18 +495,18 @@ 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, cfg.renderer, cfg.stdin) + // 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) } // Non-skill tools uninstall as a single step; render one spinner for - // the tool (no host) when a renderer is supplied. + // the tool (no agent) when a renderer is supplied. if cfg.renderer == nil { return i.runUninstall(ctx, tool) } @@ -872,27 +872,27 @@ 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, ) (*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) @@ -903,23 +903,23 @@ func (i *installer) runSkillUninstall( succeeded []string failures []error ) - for _, host := range targets { - title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, host.Host) - hostErr := renderSkillStep(ctx, renderer, title, true, func(out io.Writer) (string, error) { - return "", i.uninstallSkillForHost(ctx, tool, host, out, stdin) + for _, agent := range targets { + title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, agent.DisplayName) + agentErr := renderSkillStep(ctx, renderer, title, true, func(out io.Writer) (string, error) { + return "", i.uninstallSkillForAgent(ctx, tool, agent, out, stdin) }) - if hostErr != nil { - failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) + if agentErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", agent.DisplayName, agentErr)) continue } - succeeded = append(succeeded, host.Command) + 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] @@ -929,7 +929,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, "; "), ) } @@ -940,39 +940,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, ), } @@ -980,47 +980,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. out, when non-nil, receives -// the host CLI's output line-by-line for display above the step spinner. -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, out, stdin); 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. -// When a step spinner is showing (out is non-nil) the host CLI's output is +// 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) runSkillHostUninstallCommand( +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 := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out, stdin) + 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.Command, strings.Join(cmd, " "), err, + agent.Command, strings.Join(cmd, " "), err, ) } @@ -1028,27 +1028,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.Command { - 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) @@ -1056,7 +1056,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 @@ -1074,15 +1074,15 @@ 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, cfg.renderer, cfg.stdin) + return i.runSkill(ctx, tool, upgrade, cfg.agents, cfg.allAvailableAgents, cfg.renderer, cfg.stdin) } // Non-skill tools install as a single step through the platform - // package manager; render one spinner for the tool (no host) when a + // 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) @@ -1276,29 +1276,29 @@ 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, @@ -1306,14 +1306,14 @@ func (i *installer) runSkill( 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) @@ -1334,9 +1334,9 @@ func (i *installer) runSkill( )+output.WithLinkFormat("https://nodejs.org/")) } - // 3. Install / upgrade for each target host, collecting outcomes. - // renderSkillStep shows a step spinner per host. For an install the - // host CLI's output streams above the spinner (so prompts are + // 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 update command's output is captured // and parsed for the version and whether the skill was already at the // latest, which the result line reports. @@ -1348,18 +1348,18 @@ func (i *installer) runSkill( succeeded []string failures []error version string - anyUpgraded bool // an upgrade actually changed at least one host + anyUpgraded bool // an upgrade actually changed at least one agent ) - for _, host := range targets { - title := fmt.Sprintf("%s %s in %s", verb, tool.Name, host.Host) + for _, agent := range targets { + title := fmt.Sprintf("%s %s in %s", verb, tool.Name, agent.DisplayName) var ( - hostVersion string - hostUpToDate bool + agentVersion string + agentUpToDate bool ) - hostErr := renderSkillStep(ctx, renderer, title, true, func(out io.Writer) (string, error) { - v, upToDate, e := i.installSkillForHost(ctx, tool, host, upgrade, out, stdin) - hostVersion = v - hostUpToDate = upToDate + agentErr := renderSkillStep(ctx, renderer, title, true, 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 } @@ -1368,28 +1368,28 @@ func (i *installer) runSkill( done := title switch { case upgrade && upToDate && v != "": - done = fmt.Sprintf("%s in %s are already up to date (v%s).", tool.Name, host.Host, v) + done = fmt.Sprintf("%s in %s are already up to date (v%s).", tool.Name, agent.DisplayName, v) case upgrade && upToDate: - done = fmt.Sprintf("%s in %s are already up to date.", tool.Name, host.Host) + done = fmt.Sprintf("%s in %s are already up to date.", tool.Name, agent.DisplayName) case upgrade && v != "": done = fmt.Sprintf("%s (v%s)", title, v) } return done, nil }) - if hostErr != nil { - failures = append(failures, fmt.Errorf("%s: %w", host.Host, hostErr)) + if agentErr != nil { + failures = append(failures, fmt.Errorf("%s: %w", agent.DisplayName, agentErr)) continue } - succeeded = append(succeeded, host.Command) - if !hostUpToDate { + succeeded = append(succeeded, agent.Command) + if !agentUpToDate { anyUpgraded = true } if version == "" { - version = hostVersion + version = agentVersion } } - // An upgrade that changed nothing (every targeted host was already at the + // 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 @@ -1397,8 +1397,8 @@ func (i *installer) runSkill( 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] @@ -1408,7 +1408,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, "; "), ) } @@ -1419,78 +1419,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.Command] { - 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, ", "), ) @@ -1498,32 +1498,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 + // so a multi-agent 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. + // 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, @@ -1534,79 +1534,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 { + 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 whose command identity +// 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 Host: --agent values are +// 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 SkillHosts lookup +// to the command before resolving here. It centralizes the SkillAgents 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 { +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 @@ -1614,76 +1614,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.Command) != 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.Command] - 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.Command] = 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.Command, 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.Command) + checked = append(checked, agent.Command) } suggestion := fmt.Sprintf( @@ -1695,9 +1695,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, @@ -1711,33 +1711,33 @@ func (i *installer) pickSkillHost( } } -// installSkillForHost installs (or upgrades) the skill through a single host +// installSkillForAgent installs (or upgrades) the skill through a single agent // and verifies the result. It returns the version and, for an upgrade, -// whether the host reported the skill was already at the latest version. For +// whether the agent reported the skill was already at the latest version. For // an upgrade the version comes from the update 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 host output +// detection. out, when non-nil, receives an install's streamed agent output // for display above the step spinner. -func (i *installer) installSkillForHost( +func (i *installer) installSkillForAgent( ctx context.Context, tool *ToolDefinition, - host SkillHost, + agent SkillAgent, upgrade bool, out io.Writer, stdin io.Reader, ) (version string, alreadyLatest bool, err error) { - // For an upgrade, record the host's installed version before updating so + // 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 host CLI's prose, which varies by host + // 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.DetectSkillHosts(ctx, tool); detectErr == nil { - beforeVersion, _ = installedHostVersion(installed, host.Command) + if installed, detectErr := i.detector.DetectSkillAgents(ctx, tool); detectErr == nil { + beforeVersion, _ = installedAgentVersion(installed, agent.Command) } } - cmdOutput, err := i.runSkillHostCommand(ctx, host, upgrade, out, stdin) + cmdOutput, err := i.runSkillAgentCommand(ctx, agent, upgrade, out, stdin) if err != nil { return "", false, err } @@ -1746,7 +1746,7 @@ func (i *installer) installSkillForHost( version, proseLatest = parseUpgradeOutput(cmdOutput) } - detectedVersion, err := i.verifySkillInstalled(ctx, tool, host) + detectedVersion, err := i.verifySkillInstalled(ctx, tool, agent) if err != nil { return "", false, err } @@ -1759,7 +1759,7 @@ func (i *installer) installSkillForHost( if upgrade { // The authoritative "already up to date" signal is an unchanged // version. Only when a version is unavailable on either side fall back - // to the host CLI's prose, so azd neither claims up to date without + // to the agent CLI's prose, so azd neither claims up to date without // evidence nor misreports an upgrade when the wording is unrecognized. if beforeVersion != "" && detectedVersion != "" { alreadyLatest = beforeVersion == detectedVersion @@ -1771,14 +1771,14 @@ func (i *installer) installSkillForHost( return version, alreadyLatest, nil } -// installedHostVersion returns the installed version of the skill for the given -// host command from a DetectSkillHosts result, and whether that host was -// found. InstalledSkillHost.Host carries the executable identity, so the match -// is against host.Command. (Distinct from detector.skillHostVersion, which -// probes the host CLI; this only looks up an already-detected list.) -func installedHostVersion(installed []InstalledSkillHost, command string) (string, bool) { +// 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.Host == command { + if h.Agent == command { return h.Version, true } } @@ -1786,29 +1786,29 @@ func installedHostVersion(installed []InstalledSkillHost, command string) (strin } // 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 } - if v, ok := installedHostVersion(installed, host.Command); ok { + if v, ok := installedAgentVersion(installed, agent.Command); ok { version = v return true, nil } @@ -1822,21 +1822,21 @@ 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 and +// runSkillAgentCommand executes the agent's install or update command and // returns the command's stdout (empty for a streamed install). // // Install and update connect to the terminal differently: -// - Install streams the host CLI's output through out (when a spinner is +// - 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 host +// For a fresh install it first runs MarketplaceAddCommand when the agent // declares one. // - Update captures the output (no streaming) and returns it so the caller // can parse the version and whether the skill was already at the latest. @@ -1845,77 +1845,77 @@ func (i *installer) verifySkillInstalled( // // 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, out io.Writer, stdin io.Reader, ) (string, error) { - cmd := host.PluginInstallCommand + cmd := agent.PluginInstallCommand verb := "install" if upgrade { - cmd = host.PluginUpdateCommand + cmd = agent.PluginUpdateCommand verb = "update" } if len(cmd) == 0 { return "", fmt.Errorf( - "host %q has no %s command configured", host.Host, verb, + "agent %q has no %s command configured", agent.DisplayName, verb, ) } // Update: 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(host.Command, cmd...)) + res, err := i.commandRunner.Run(ctx, exec.NewRunArgs(agent.Command, cmd...)) if err != nil { return "", fmt.Errorf( "running `%s %s`: %w", - host.Command, strings.Join(cmd, " "), err, + agent.Command, strings.Join(cmd, " "), err, ) } return res.Stdout, nil } - if len(host.MarketplaceAddCommand) > 0 { - if err := i.runMarketplaceAdd(ctx, host, out, stdin); err != nil { + if len(agent.MarketplaceAddCommand) > 0 { + if err := i.runMarketplaceAdd(ctx, agent, out, stdin); err != nil { return "", err } } - runArgs := skillCommandRunArgs(exec.NewRunArgs(host.Command, cmd...), out, stdin) + 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.Command, strings.Join(cmd, " "), err, + agent.Command, strings.Join(cmd, " "), err, ) } return "", nil } -// runMarketplaceAdd registers the skill marketplace with the host CLI. +// 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 host CLI's output prints above the +// 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 hosts (e.g. copilot) return a non-zero exit when the marketplace +// 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 := skillCommandRunArgs(exec.NewRunArgs(host.Command, host.MarketplaceAddCommand...), out, stdin) + args := skillCommandRunArgs(exec.NewRunArgs(agent.Command, agent.MarketplaceAddCommand...), out, stdin) result, err := i.commandRunner.Run(ctx, args) if err == nil { return nil @@ -1925,12 +1925,12 @@ func (i *installer) runMarketplaceAdd( } return fmt.Errorf( "running `%s %s`: %w", - host.Command, 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 a4b3933d20c..2bf483f1626 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -64,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( @@ -94,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.Command, + 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 @@ -198,7 +198,7 @@ func TestInstall_WithPackageManager(t *testing.T) { } // TestRunToolInstall_StepProgress verifies that a non-skill tool install -// renders a single step spinner for the tool (no per-host title) and no +// 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() @@ -1267,21 +1267,21 @@ 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 host command reads stdin from the reader supplied via +// 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 -// a host prompt is answered on the stream azd owns (e.g. Cobra's redirected +// 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() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) myInput := strings.NewReader("y\n") @@ -1299,10 +1299,10 @@ func TestRunSkill_Install_UsesConfiguredStdin(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.70"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.70"}}, nil }, }, ) @@ -1310,7 +1310,7 @@ func TestRunSkill_Install_UsesConfiguredStdin(t *testing.T) { var r fakeStepRenderer result, err := inst.Install( t.Context(), newSkillTool(), - WithHosts("copilot"), + WithAgents("copilot"), WithStepProgress(&r), WithInput(myInput), ) @@ -1322,7 +1322,7 @@ func TestRunSkill_Install_UsesConfiguredStdin(t *testing.T) { } // 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 { @@ -1331,9 +1331,9 @@ 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"}, @@ -1345,8 +1345,8 @@ func newSkillTool() *ToolDefinition { BinaryVersionRegex: `(?m)^GitHub Copilot CLI\s+v?(\d+\.\d+\.\d+)`, }, { - Host: "claude", - Command: "claude", + DisplayName: "claude", + Command: "claude", MarketplaceAddCommand: []string{ "plugin", "marketplace", "add", "https://github.com/microsoft/azure-skills", }, @@ -1362,7 +1362,7 @@ func newSkillTool() *ToolDefinition { } } -// TestRunSkill_StepProgress verifies that a silent skill install (the host +// 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. @@ -1370,10 +1370,10 @@ func TestRunSkill_StepProgress(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) - // The version-probe ("--version") is handled by mockHostPresence; the + // 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") @@ -1382,10 +1382,10 @@ func TestRunSkill_StepProgress(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.70"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.70"}}, nil }, }, ) @@ -1393,13 +1393,13 @@ func TestRunSkill_StepProgress(t *testing.T) { var r fakeStepRenderer result, err := inst.Install( t.Context(), newSkillTool(), - WithHosts("copilot"), + 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 host CLI writes nothing) keeps the spinner + // 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) @@ -1408,7 +1408,7 @@ func TestRunSkill_StepProgress(t *testing.T) { } // TestParseUpgradeOutput covers extracting the version and the "already at -// latest" state from each host CLI's plugin-update output. +// latest" state from each agent CLI's plugin-update output. func TestParseUpgradeOutput(t *testing.T) { t.Parallel() @@ -1466,7 +1466,7 @@ func TestRunSkill_Upgrade_StepResultShowsVersion(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) // The update command reports the new version (claude-style "from A to B"). var upgraded bool @@ -1483,16 +1483,16 @@ func TestRunSkill_Upgrade_StepResultShowsVersion(t *testing.T) { inst := NewInstaller( runner, NewPlatformDetector(runner), &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]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 []InstalledSkillHost{{Host: "copilot", Version: version}}, nil + return []InstalledSkillAgent{{Agent: "copilot", Version: version}}, nil }, }, ) @@ -1500,7 +1500,7 @@ func TestRunSkill_Upgrade_StepResultShowsVersion(t *testing.T) { var r fakeStepRenderer result, err := inst.Upgrade( t.Context(), newSkillTool(), - WithHosts("copilot"), + WithAgents("copilot"), WithStepProgress(&r), ) require.NoError(t, err) @@ -1512,14 +1512,14 @@ func TestRunSkill_Upgrade_StepResultShowsVersion(t *testing.T) { assert.Equal(t, []string{"Upgrading Test Azure Skills in copilot (v1.1.86)"}, r.stops) } -// TestRunSkill_Upgrade_AlreadyUpToDate verifies that when the host reports the +// 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() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) runner.When(func(args exec.RunArgs, _ string) bool { return args.Cmd == "copilot" && slices.Contains(args.Args, "update") @@ -1531,10 +1531,10 @@ func TestRunSkill_Upgrade_AlreadyUpToDate(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.86"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.86"}}, nil }, }, ) @@ -1542,7 +1542,7 @@ func TestRunSkill_Upgrade_AlreadyUpToDate(t *testing.T) { var r fakeStepRenderer result, err := inst.Upgrade( t.Context(), newSkillTool(), - WithHosts("copilot"), + WithAgents("copilot"), WithStepProgress(&r), ) require.NoError(t, err) @@ -1553,7 +1553,7 @@ func TestRunSkill_Upgrade_AlreadyUpToDate(t *testing.T) { assert.Equal(t, []string{"Test Azure Skills in copilot are already up to date (v1.1.86)."}, r.stops) } -// TestRunSkill_StreamedOutputPrintedAboveSpinner verifies that when the host +// TestRunSkill_StreamedOutputPrintedAboveSpinner verifies that when the agent // CLI writes to the terminal (a progress line or an interactive prompt), // each line is surfaced via Message (which the console prints above the // spinner, keeping the spinner pinned below), and the spinner is stopped with @@ -1562,11 +1562,11 @@ func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { t.Parallel() runner := mockexec.NewMockCommandRunner() - mockHostPresence(runner, "copilot") + mockAgentPresence(runner, "copilot") runner.MockToolInPath("node", nil) // The plugin command writes to the terminal (StdOut), simulating the - // host CLI surfacing progress or a prompt. Both marketplace-add and install + // agent CLI surfacing progress or a prompt. Both marketplace-add and install // stream their output through the step writer so prompts stay visible. runner.When(func(args exec.RunArgs, _ string) bool { return args.Cmd == "copilot" && slices.Contains(args.Args, "plugin") @@ -1580,10 +1580,10 @@ func TestRunSkill_StreamedOutputPrintedAboveSpinner(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.70"}}, nil + ) ([]InstalledSkillAgent, error) { + return []InstalledSkillAgent{{Agent: "copilot", Version: "1.1.70"}}, nil }, }, ) @@ -1591,13 +1591,13 @@ func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { var r fakeStepRenderer result, err := inst.Install( t.Context(), newSkillTool(), - WithHosts("copilot"), + WithAgents("copilot"), WithStepProgress(&r), ) require.NoError(t, err) require.True(t, result.Success, "install must succeed; err=%v", result.Error) - // The spinner is shown, the host CLI's line is surfaced via Message (the + // The spinner is shown, the agent CLI's line is surfaced via Message (the // console prints it above the spinner), and the spinner is stopped with // the step result at the end — it is kept, not torn down early. assert.Equal(t, []string{"Installing Test Azure Skills in copilot"}, r.starts) @@ -1605,19 +1605,19 @@ func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { assert.Contains(t, r.messages, "Installing plugin...") } -// TestSkillHost_DisplayVsCommand verifies the split between the display Host +// 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 Host, and --agent resolves via Command even when it -// differs from the Host. -func TestSkillHost_DisplayVsCommand(t *testing.T) { +// 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, - SkillHosts: []SkillHost{{ - Host: "GitHub Copilot CLI", // display name (differs from Command) + 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"}, @@ -1642,54 +1642,54 @@ func TestSkillHost_DisplayVsCommand(t *testing.T) { }) det := &mockDetector{ - detectSkillHostsFn: func(_ context.Context, _ *ToolDefinition) ([]InstalledSkillHost, error) { - // DetectSkillHosts reports the command identity (see - // InstalledSkillHost.Host), so verification matches by command. - return []InstalledSkillHost{{Host: "copilot", Version: "1.1.70"}}, nil + 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 Host is the + // --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, WithHosts("copilot"), WithStepProgress(&r)) + 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 host CLI must be exec'd") + 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 Host") + 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 Host") + "the step spinner must use the display-cased Agent") } -// 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 +// 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")) @@ -1745,37 +1745,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", }, @@ -1786,7 +1786,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 @@ -1805,13 +1805,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{ @@ -1840,7 +1840,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", @@ -1850,11 +1850,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( @@ -1887,15 +1887,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() @@ -1959,14 +1959,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() @@ -2029,7 +2029,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")) @@ -2068,7 +2068,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 @@ -2093,10 +2093,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 }, }, ) @@ -2112,32 +2112,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 }, } @@ -2147,39 +2147,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 }, } @@ -2189,20 +2189,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 { @@ -2211,12 +2211,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 }, } @@ -2235,17 +2235,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 @@ -2257,11 +2257,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 }, } @@ -2282,59 +2282,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 @@ -2346,87 +2346,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 }, } @@ -2435,7 +2435,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") @@ -2447,7 +2447,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 @@ -2492,7 +2492,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 @@ -2544,7 +2544,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") @@ -2575,10 +2575,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() @@ -2588,14 +2588,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") } // --------------------------------------------------------------------------- @@ -2606,7 +2606,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" && @@ -2640,7 +2640,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" && @@ -2680,7 +2680,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" && @@ -2713,14 +2713,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{} @@ -2732,7 +2732,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}) @@ -2742,7 +2742,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) @@ -2752,11 +2752,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( @@ -2764,21 +2764,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( @@ -2786,24 +2786,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() @@ -2845,13 +2845,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") @@ -2859,23 +2859,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 }, } @@ -2888,19 +2888,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 }, } @@ -2908,7 +2908,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) @@ -2916,32 +2916,32 @@ 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{}) - commands, names := inst.AvailableSkillHosts(t.Context(), newSkillTool()) - // newSkillTool uses Host == Command, so both slices match. + 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{}) - commands, names := inst.AvailableSkillHosts(t.Context(), &ToolDefinition{ + commands, names := inst.AvailableSkillAgents(t.Context(), &ToolDefinition{ Id: "not-a-skill", Category: ToolCategoryServer, }) @@ -3949,37 +3949,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 }, } @@ -3991,17 +3991,17 @@ 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 host (shown then stopped, capitalized) for a skill uninstall, with no +// 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() - 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}) @@ -4009,9 +4009,9 @@ func TestRunSkillUninstall_StepProgress(t *testing.T) { // After uninstall, copilot no longer reports the skill so verification // passes. det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]InstalledSkillAgent, error) { return nil, nil }, } @@ -4021,7 +4021,7 @@ func TestRunSkillUninstall_StepProgress(t *testing.T) { var r fakeStepRenderer result, err := inst.Uninstall( t.Context(), newSkillTool(), - WithHosts("copilot"), + WithAgents("copilot"), WithStepProgress(&r), ) require.NoError(t, err) @@ -4035,14 +4035,14 @@ func TestRunSkillUninstall_StepProgress(t *testing.T) { } // TestRunSkillUninstall_StepProgress_SuccessStreamsOutput verifies that the -// host CLI's output is surfaced above the spinner as it arrives — even when 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() - mockHostPresence(runner, "copilot") + 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) { @@ -4054,9 +4054,9 @@ func TestRunSkillUninstall_StepProgress_SuccessStreamsOutput(t *testing.T) { // After uninstall the skill is gone, so verification passes. det := &mockDetector{ - detectSkillHostsFn: func( + detectSkillAgentsFn: func( _ context.Context, _ *ToolDefinition, - ) ([]InstalledSkillHost, error) { + ) ([]InstalledSkillAgent, error) { return nil, nil }, } @@ -4065,25 +4065,25 @@ func TestRunSkillUninstall_StepProgress_SuccessStreamsOutput(t *testing.T) { var r fakeStepRenderer result, err := inst.Uninstall( t.Context(), newSkillTool(), - WithHosts("copilot"), + WithAgents("copilot"), WithStepProgress(&r), ) require.NoError(t, err) require.True(t, result.Success, "uninstall must succeed; err=%v", result.Error) assert.Contains(t, r.messages, "Plugin \"azure@azure-skills\" uninstalled successfully.", - "host CLI output must be streamed live so interactive prompts stay visible") + "agent CLI output must be streamed live so interactive prompts stay visible") assert.Equal(t, []string{"Uninstalling Test Azure Skills from copilot"}, r.stops) } // TestRunSkillUninstall_StepProgress_FailureShowsOutput verifies the converse: -// when the uninstall fails, the buffered host CLI output is replayed so the +// 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() - mockHostPresence(runner, "copilot") + 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) { @@ -4094,10 +4094,10 @@ func TestRunSkillUninstall_StepProgress_FailureShowsOutput(t *testing.T) { }) 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) @@ -4105,60 +4105,60 @@ func TestRunSkillUninstall_StepProgress_FailureShowsOutput(t *testing.T) { var r fakeStepRenderer result, _ := inst.Uninstall( t.Context(), newSkillTool(), - WithHosts("copilot"), + WithAgents("copilot"), WithStepProgress(&r), ) require.False(t, result.Success) require.Error(t, result.Error) assert.Contains(t, r.messages, "could not remove plugin", - "host CLI output must be shown when uninstall fails") + "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 }, } @@ -4177,15 +4177,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) @@ -4422,17 +4422,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) @@ -4441,14 +4441,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) { @@ -4456,12 +4456,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 }, } @@ -4473,31 +4473,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) @@ -4510,24 +4510,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) @@ -4543,22 +4543,22 @@ 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", @@ -4568,7 +4568,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) @@ -4577,11 +4577,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() @@ -4598,21 +4598,21 @@ func TestHostUsable_OnPathProbeMemoizedAcrossPasses(t *testing.T) { inst := NewInstaller(runner, NewPlatformDetector(runner), &mockDetector{}) - // Two separate host-resolution passes on the same installer. - commands1, _ := 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.AvailableSkillHosts(t.Context(), newSkillTool()) + 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() @@ -4622,7 +4622,7 @@ func TestHostUsable_NotOnPathResultNotMemoized(t *testing.T) { inst := NewInstaller(runner, NewPlatformDetector(runner), &mockDetector{}) // First pass: copilot absent. - commandsAbsent, _ := inst.AvailableSkillHosts(t.Context(), newSkillTool()) + commandsAbsent, _ := inst.AvailableSkillAgents(t.Context(), newSkillTool()) require.Empty(t, commandsAbsent) // copilot becomes available and functional mid-command. @@ -4632,7 +4632,7 @@ 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. - commandsPresent, _ := inst.AvailableSkillHosts(t.Context(), newSkillTool()) + commandsPresent, _ := inst.AvailableSkillAgents(t.Context(), newSkillTool()) assert.Equal(t, []string{"copilot"}, commandsPresent) } diff --git a/cli/azd/pkg/tool/manager.go b/cli/azd/pkg/tool/manager.go index af2e8f344cc..5e15484cac6 100644 --- a/cli/azd/pkg/tool/manager.go +++ b/cli/azd/pkg/tool/manager.go @@ -68,16 +68,16 @@ func (m *Manager) FindTool(id string) (*ToolDefinition, error) { return nil, fmt.Errorf("finding tool %q: not found", id) } -// AvailableSkillHosts returns the given skill tool's configured agentic CLI -// hosts that are currently usable (a functional CLI on PATH), in manifest -// order (preferred host first), as two index-aligned slices: the command +// 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 hosts are usable. -func (m *Manager) AvailableSkillHosts( +// when none of the agents are usable. +func (m *Manager) AvailableSkillAgents( ctx context.Context, tool *ToolDefinition, ) (commands []string, names []string) { - return m.installer.AvailableSkillHosts(ctx, tool) + return m.installer.AvailableSkillAgents(ctx, tool) } // DetectAll probes every tool in the manifest and returns a status @@ -225,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 6d4ee78d1ad..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) (commands []string, names []string) + availableSkillAgentsFn func(ctx context.Context, tool *ToolDefinition) (commands []string, names []string) } func (m *mockInstaller) Install( @@ -64,12 +64,12 @@ func (m *mockInstaller) Upgrade( }, nil } -func (m *mockInstaller) AvailableSkillHosts( +func (m *mockInstaller) AvailableSkillAgents( ctx context.Context, tool *ToolDefinition, ) (commands []string, names []string) { - if m.availableSkillHostsFn != nil { - return m.availableSkillHostsFn(ctx, tool) + if m.availableSkillAgentsFn != nil { + return m.availableSkillAgentsFn(ctx, tool) } return nil, nil } @@ -432,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() @@ -448,9 +448,9 @@ func TestManifest_SkillsListedAfterHostCLIs(t *testing.T) { }) } - // Maps a host binary name (SkillHost.Command) to the manifest tool id + // Maps an agent binary name (SkillAgent.Command) to the manifest tool id // that provides it. - hostToolID := map[string]string{ + agentToolID := map[string]string{ "copilot": "github-copilot-cli", } @@ -459,18 +459,18 @@ func TestManifest_SkillsListedAfterHostCLIs(t *testing.T) { continue } skillIdx := indexOf(td.Id) - for _, host := range td.SkillHosts { - cliID, ok := hostToolID[host.Command] + 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) } } @@ -593,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) @@ -629,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 @@ -646,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"}, @@ -803,18 +803,18 @@ 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) (commands []string, names []string) { + 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) - commands, names := m.AvailableSkillHosts(t.Context(), &ToolDefinition{ + commands, names := m.AvailableSkillAgents(t.Context(), &ToolDefinition{ Id: "azure-skills", Category: ToolCategorySkill, }) diff --git a/cli/azd/pkg/tool/manifest.go b/cli/azd/pkg/tool/manifest.go index 2d13232d4cb..c7d26f822a4 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,54 +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 `--agent`). -type SkillHost struct { - // Host is the agent's display name, shown to the user (e.g. "GitHub +// 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. "GitHub // Copilot CLI", "Claude Code CLI"). Display-only: it is NOT the value // --agent matches against — see Command. - Host string + 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 findSkillHost). + // --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_SkillHostsHaveCommand - // enforces that every configured host sets it. + // 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 @@ -106,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 } @@ -179,13 +179,13 @@ 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 } @@ -400,9 +400,9 @@ func azureSkills() *ToolDefinition { Category: ToolCategorySkill, Priority: ToolPriorityRecommended, Website: "https://github.com/microsoft/azure-skills", - SkillHosts: []SkillHost{ + SkillAgents: []SkillAgent{ { - Host: "GitHub", + DisplayName: "GitHub", Command: "copilot", MarketplaceAddCommand: []string{"plugin", "marketplace", "add", "microsoft/azure-skills"}, PluginInstallCommand: []string{"plugin", "install", "azure@azure-skills"}, @@ -412,16 +412,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. + // 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", - Command: "claude", + DisplayName: "Claude", + Command: "claude", MarketplaceAddCommand: []string{ "plugin", "marketplace", "add", "https://github.com/microsoft/azure-skills", }, @@ -443,8 +443,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 9246783dceb..362b4f0bae3 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,55 +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 { + 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 Host (e.g. + // 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: "", }, @@ -492,22 +492,22 @@ 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])) }) } } -// TestBuiltInTools_SkillHostsHaveCommand guarantees that every configured skill -// host sets a non-empty Command. Installer and detector paths run host.Command +// 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_SkillHostsHaveCommand(t *testing.T) { +func TestBuiltInTools_SkillAgentsHaveCommand(t *testing.T) { t.Parallel() for _, td := range BuiltInTools() { - for _, host := range td.SkillHosts { - assert.NotEmpty(t, host.Command, - "tool %q host %q must set a non-empty Command", td.Id, host.Host) + 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 e31c034efcf..afcae8df079 100644 --- a/cli/azd/pkg/tool/update_checker.go +++ b/cli/azd/pkg/tool/update_checker.go @@ -77,31 +77,31 @@ type UpdateCheckResult struct { // UpdateAvailable is true when LatestVersion is non-empty and // differs from CurrentVersion. UpdateAvailable bool - // SkillHosts lists, for a skill tool, the per-host installed version + // 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 host). Populated only for skills; nil otherwise. - SkillHosts []SkillHostUpdate + // for every agent). Populated only for skills; nil otherwise. + SkillAgents []SkillAgentUpdate } -// SkillHostUpdate pairs an agentic CLI host with the skill version installed +// SkillAgentUpdate pairs an agent CLI with the skill version installed // through it and whether a newer version is available. -type SkillHostUpdate struct { - // Host is the agentic CLI host binary name (e.g. "copilot"). - Host string - // CurrentVersion is the skill version installed through this host. +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 host. + // available for this agent. UpdateAvailable bool } -// anyHostUpdatable reports whether any of a skill's installed hosts is behind -// the latest version. A skill installed on multiple hosts exposes only the -// first host's version as the tool's aggregate InstalledVersion, so tool-level -// update checks must consider every host — otherwise a stale host is missed -// when the first is current. Returns false for non-skills (no SkillHosts). -func anyHostUpdatable(latest string, hosts []InstalledSkillHost) bool { - for _, h := range hosts { +// 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 } @@ -243,12 +243,12 @@ func (uc *UpdateChecker) Check( ) var currentVer string - var skillHosts []SkillHostUpdate + var skillAgents []SkillAgentUpdate if status != nil { currentVer = status.InstalledVersion - for _, h := range status.SkillHosts { - skillHosts = append(skillHosts, SkillHostUpdate{ - Host: h.Host, + for _, h := range status.SkillAgents { + skillAgents = append(skillAgents, SkillAgentUpdate{ + Agent: h.Agent, CurrentVersion: h.Version, UpdateAvailable: isNewerVersion(latestVer, h.Version), }) @@ -259,11 +259,11 @@ func (uc *UpdateChecker) Check( LatestVersion: latestVer, } - // For a skill installed on multiple hosts, currentVer reflects only - // the first host, so treat the tool as updatable when ANY host is + // 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 && anyHostUpdatable(latestVer, status.SkillHosts) { + if status != nil && anyAgentUpdatable(latestVer, status.SkillAgents) { updateAvailable = true } @@ -272,7 +272,7 @@ func (uc *UpdateChecker) Check( CurrentVersion: currentVer, LatestVersion: latestVer, UpdateAvailable: updateAvailable, - SkillHosts: skillHosts, + SkillAgents: skillAgents, }) } @@ -480,10 +480,10 @@ func (uc *UpdateChecker) HasUpdatesAvailable( if !ok || !s.Installed { continue } - // Count a skill whose first host is current but another host is stale - // (anyHostUpdatable), as well as the plain single-version case. + // 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) || - anyHostUpdatable(latest, s.SkillHosts) { + 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 06bdb694c9d..dfbf67dab2c 100644 --- a/cli/azd/pkg/tool/update_checker_test.go +++ b/cli/azd/pkg/tool/update_checker_test.go @@ -472,14 +472,14 @@ func TestCheck(t *testing.T) { assert.True(t, results[0].UpdateAvailable) }) - t.Run("SkillAggregatesAnyHostUpdate", func(t *testing.T) { + t.Run("SkillAggregatesAnyAgentUpdate", func(t *testing.T) { t.Parallel() tmpDir := t.TempDir() mgr := newMockUserConfigManager() - // The aggregate InstalledVersion is the first host (current), but a - // second host is behind — the tool must still report an update. + // 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, @@ -488,9 +488,9 @@ func TestCheck(t *testing.T) { Tool: tools[0], Installed: true, InstalledVersion: "2.0.0", - SkillHosts: []InstalledSkillHost{ - {Host: "copilot", Version: "2.0.0"}, - {Host: "claude", Version: "1.0.0"}, + SkillAgents: []InstalledSkillAgent{ + {Agent: "copilot", Version: "2.0.0"}, + {Agent: "claude", Version: "1.0.0"}, }, }}, nil }, @@ -517,10 +517,10 @@ func TestCheck(t *testing.T) { assert.Equal(t, "2.0.0", results[0].CurrentVersion) assert.True(t, results[0].UpdateAvailable, - "a stale second host must mark the skill as updatable") - require.Len(t, results[0].SkillHosts, 2) - assert.False(t, results[0].SkillHosts[0].UpdateAvailable) - assert.True(t, results[0].SkillHosts[1].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) }) } From ba0d906eff9f466041bfe7f4f6fbae588ade1e59 Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 14:55:58 -0700 Subject: [PATCH 35/41] address feedback --- cli/azd/CHANGELOG.md | 2 +- cli/azd/cmd/tool.go | 132 +++++++++++++++++++---------- cli/azd/cmd/tool_test.go | 105 +++++++++++++++++++++++ cli/azd/pkg/tool/installer.go | 52 +++++++++--- cli/azd/pkg/tool/installer_test.go | 24 ++++++ 5 files changed, 256 insertions(+), 59 deletions(-) diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 66431ee449e..e77f8e29c61 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -8,7 +8,7 @@ ### Breaking Changes -- [[#9045]](https://github.com/Azure/azure-dev/pull/9045) The `azd tool install` skill flag `--host` has been renamed to `--agent`. Update any scripts or automation that pass `--host` to `azd tool install` to use `--agent` instead. + - [[#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 diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 6b4e22a8ff0..333dba5e3d3 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -311,7 +311,7 @@ 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. @@ -602,7 +602,8 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err 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) + outcome := runToolOperation(ctx, tools, operationFn, "Installing", "install", a.console, + a.formatter.Kind() == output.JsonFormat) installResults = outcome.Items rawResults = outcome.Results opErr = outcome.Err @@ -855,6 +856,36 @@ func (a *toolInstallAction) promptForSkillAgents( 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 +} + // 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. @@ -980,20 +1011,33 @@ func noToolTargetError(command string) error { } } +// 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) } @@ -1012,16 +1056,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) } @@ -1142,6 +1178,10 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err // for upgrading. fromVersions := make(map[string]string) + if len(a.args) > 0 && a.flags.all { + return nil, toolIDsWithAllError("upgrade") + } + switch { case a.flags.all: // --all: upgrade every installed tool. @@ -1254,7 +1294,8 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err 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) + outcome := runToolOperation(ctx, toolsToUpgrade, operationFn, "Upgrading", "upgrade", a.console, + a.formatter.Kind() == output.JsonFormat) upgradeResults = outcome.Items rawResults = outcome.Results opErr = outcome.Err @@ -1335,16 +1376,8 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err // 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) { - 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) } return statuses, nil @@ -1577,7 +1610,8 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e 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) + outcome := runToolOperation(ctx, tools, operationFn, "Uninstalling", "uninstall", a.console, + a.formatter.Kind() == output.JsonFormat) uninstallResults = outcome.Items rawResults = outcome.Results opErr = outcome.Err @@ -1632,6 +1666,10 @@ func (a *toolUninstallAction) resolveAgentOptions( // 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 @@ -1639,16 +1677,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) } @@ -2239,6 +2269,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, @@ -2246,6 +2279,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)) @@ -2272,9 +2306,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 diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 95876bf43a8..f6aa31db3b9 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -63,6 +63,7 @@ func TestRunToolOperationUnsuccessfulResultReturnsError(t *testing.T) { "Installing", "install", console, + false, ) results, err := outcome.Items, outcome.Err @@ -97,6 +98,7 @@ func TestRunToolOperationSuccessfulResultReturnsNoError(t *testing.T) { "Installing", "install", console, + false, ) results, err := outcome.Items, outcome.Err @@ -1222,6 +1224,77 @@ func TestToolUpgradeAction_All_UpgradesInstalledTools(t *testing.T) { "--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 parseable 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 parseable JSON") + require.NotEmpty(t, items, "at least one installed tool must be reported") +} + +// 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 @@ -1487,6 +1560,22 @@ func TestToolInstallAction_resolveToolIds_AllFlag(t *testing.T) { "--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 @@ -1551,6 +1640,22 @@ func TestToolUninstallAction_resolveToolIds_AllFlag_NoPrompt(t *testing.T) { "--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 diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index ce16e2a665e..bf80a7d1972 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" @@ -285,6 +286,7 @@ func renderSkillStep( out := &lineWriter{emit: emit} doneTitle, err := work(out) + out.Flush() if doneTitle == "" { doneTitle = title } @@ -297,32 +299,60 @@ func renderSkillStep( return err } -// lineWriter splits writes into lines and hands each 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. Content is emitted as it arrives — -// including a trailing line with no newline, e.g. an interactive prompt — so -// nothing is withheld from the user while the CLI waits for input. +// 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 so emit (which may append to an unsynchronized buffer -// or write to the console) is never invoked concurrently — avoiding a data -// race and interleaved/lost agent output. +// 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() - for line := range strings.SplitSeq(strings.TrimRight(string(p), "\n"), "\n") { - l.emit(line) + 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 diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index 2bf483f1626..28c9f899d25 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -4657,3 +4657,27 @@ func TestLineWriter_Write_SerializesEmit(t *testing.T) { 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") +} From a3474e5ef3cc2163eaffe4f8a75e7cd620fe7dcd Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 15:39:55 -0700 Subject: [PATCH 36/41] address Kristen's feedback --- cli/azd/cmd/tool.go | 28 ++++++++++++++++++++++++---- cli/azd/pkg/tool/installer.go | 4 ++-- cli/azd/pkg/tool/installer_test.go | 2 +- cli/azd/pkg/tool/manifest.go | 6 +++--- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 333dba5e3d3..9048ddaa164 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -316,9 +316,13 @@ func (a *toolAction) Run(ctx context.Context) (*actions.ActionResult, error) { // 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: "Your tool is installed.", + Header: header, }, }, nil } @@ -626,9 +630,13 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err return nil, opErr } + header := "Your tool is installed." + if len(tools) > 1 { + header = "Your tools are installed." + } return &actions.ActionResult{ Message: &actions.ResultMessage{ - Header: "Your tool is installed.", + Header: header, }, }, nil } @@ -1354,6 +1362,12 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err 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).". @@ -1634,9 +1648,13 @@ 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: "Your tool is uninstalled.", + Header: header, }, }, nil } @@ -2491,7 +2509,9 @@ func toolCheckStatus(installed, updateAvailable bool) string { case !installed: return statusNotInstall case updateAvailable: - return statusUpdate + // Use "upgrade" to match the `azd tool upgrade` command and the + // check hints, rather than the shared extension "Update available". + return "Upgrade available" default: return statusUpToDate } diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index bf80a7d1972..954a3cfa9e4 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -1398,9 +1398,9 @@ func (i *installer) runSkill( done := title switch { case upgrade && upToDate && v != "": - done = fmt.Sprintf("%s in %s are already up to date (v%s).", tool.Name, agent.DisplayName, 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 are already up to date.", tool.Name, agent.DisplayName) + 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) } diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index 28c9f899d25..a82f63aa809 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -1550,7 +1550,7 @@ func TestRunSkill_Upgrade_AlreadyUpToDate(t *testing.T) { 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 are already up to date (v1.1.86)."}, r.stops) + assert.Equal(t, []string{"Test Azure Skills in copilot is already up to date (v1.1.86)."}, r.stops) } // TestRunSkill_StreamedOutputPrintedAboveSpinner verifies that when the agent diff --git a/cli/azd/pkg/tool/manifest.go b/cli/azd/pkg/tool/manifest.go index c7d26f822a4..83fab49895c 100644 --- a/cli/azd/pkg/tool/manifest.go +++ b/cli/azd/pkg/tool/manifest.go @@ -54,8 +54,8 @@ type Checksum struct { // (the first on PATH), but install/upgrade can target specific or all // 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. "GitHub - // Copilot CLI", "Claude Code CLI"). Display-only: it is NOT the value + // 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 @@ -402,7 +402,7 @@ func azureSkills() *ToolDefinition { Website: "https://github.com/microsoft/azure-skills", SkillAgents: []SkillAgent{ { - DisplayName: "GitHub", + DisplayName: "Copilot", Command: "copilot", MarketplaceAddCommand: []string{"plugin", "marketplace", "add", "microsoft/azure-skills"}, PluginInstallCommand: []string{"plugin", "install", "azure@azure-skills"}, From 49c3cf7b0a7ab68e0366ef5f3c8c71debf7a190e Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 17:47:43 -0700 Subject: [PATCH 37/41] use upgrade instead of update, address Hiyo feedback --- cli/azd/cmd/extension.go | 6 +-- cli/azd/cmd/extension_test.go | 4 +- cli/azd/cmd/testdata/TestFigSpec.ts | 2 +- .../testdata/TestUsage-azd-tool-check.snap | 2 +- cli/azd/cmd/testdata/TestUsage-azd-tool.snap | 2 +- cli/azd/cmd/tool.go | 48 +++++++++-------- cli/azd/cmd/tool_test.go | 40 +++++++++++++- cli/azd/pkg/tool/detector.go | 9 ++-- cli/azd/pkg/tool/installer.go | 53 ++++++++++--------- cli/azd/pkg/tool/update_checker.go | 7 +++ docs/reference/telemetry-data.md | 4 +- docs/specs/metrics-audit/telemetry-schema.md | 2 +- 12 files changed, 116 insertions(+), 63 deletions(-) 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 5fc6e0de8c8..45d387cfee9 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6420,7 +6420,7 @@ const completionSpec: Fig.Spec = { subcommands: [ { name: ['check'], - description: 'Check for tool updates.', + description: 'Check for tool upgrades.', }, { name: ['install'], 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.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 9048ddaa164..b67561357c3 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -155,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, @@ -555,10 +555,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)) @@ -1273,10 +1275,12 @@ 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", + }) + } agentOpts, agentErr := a.resolveAgentOptions(toolsToUpgrade) if agentErr != nil { @@ -1595,10 +1599,12 @@ 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", + }) + } agentOpts, agentErr := a.resolveAgentOptions(tools) if agentErr != nil { @@ -1822,7 +1828,7 @@ type toolCheckItem struct { 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 @@ -1856,7 +1862,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 { @@ -1864,13 +1870,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) } } @@ -1882,7 +1888,7 @@ func (a *toolCheckAction) Run(ctx context.Context) (*actions.ActionResult, error } // 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 update status. + // installed version and upgrade status. if r.Tool.Category == tool.ToolCategorySkill && len(r.SkillAgents) > 0 { for _, h := range r.SkillAgents { rows = append(rows, toolCheckItem{ @@ -2503,15 +2509,13 @@ 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: - // Use "upgrade" to match the `azd tool upgrade` command and the - // check hints, rather than the shared extension "Update available". - return "Upgrade available" + return statusUpgrade default: return statusUpToDate } diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index f6aa31db3b9..2cc83b09a52 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -538,7 +538,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 parseable 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 diff --git a/cli/azd/pkg/tool/detector.go b/cli/azd/pkg/tool/detector.go index 0586ee9b872..f389437f51d 100644 --- a/cli/azd/pkg/tool/detector.go +++ b/cli/azd/pkg/tool/detector.go @@ -511,10 +511,11 @@ func (d *detector) detectSkill( // 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 agents, 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. +// 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, diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 954a3cfa9e4..859ac0b183d 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -237,22 +237,24 @@ func stepError(result *InstallResult, err error) error { } // renderSkillStep frames one skill step (install, upgrade or uninstall) with -// a live spinner that stays visible while the agent CLI talks to the user. +// a live spinner that stays visible while the agent CLI runs. // // 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. // -// streamOutput controls how that output is surfaced. When true, each line the -// CLI emits — progress or an interactive prompt — is printed above the spinner -// while the spinner stays pinned below it: the console tears the spinner down -// and re-renders it around each printed line (see AskerConsole.println), so -// the bar is kept, not lost, and the user can answer any prompt via the -// connected stdin (used for interactive operations such as install, upgrade -// and uninstall, whose agent CLIs may prompt for confirmation). When false, the -// output is buffered and replayed only if the step fails, so a step that -// completes without error stays quiet. When the CLI stays silent the -// spinner simply runs to completion. +// streamOutput controls how that output is surfaced. When true, the agent +// CLI's output is line-buffered and each complete line is printed above the +// spinner while the spinner stays pinned below it: the console tears the +// spinner down and re-renders it around each printed line (see +// AskerConsole.println), so the bar is kept, not lost. 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. When false, the output is buffered and replayed only if the step +// fails, so a step that completes without error stays quiet. When the CLI +// stays silent the spinner simply runs to completion. // // 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 @@ -356,11 +358,12 @@ func (l *lineWriter) Flush() { // 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, while stdin is connected to the +// 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) so the user can answer prompts. 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. +// 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) @@ -1367,7 +1370,7 @@ func (i *installer) runSkill( // 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 update command's output is captured + // 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" @@ -1531,7 +1534,7 @@ func (i *installer) resolveSkillTargets( // For an upgrade with no explicit agent, refresh every agent the // skill is currently installed through — not just the first — // so a multi-agent install (e.g. copilot AND claude) is kept - // fully up to date. We also avoid running an update against a + // fully up to date. We also avoid running an upgrade against a // agent that never installed it. if upgrade { installed, err := i.detector.DetectSkillAgents(ctx, tool) @@ -1744,7 +1747,7 @@ func (i *installer) pickSkillAgent( // 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 update command's output (falling back +// 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. @@ -1780,7 +1783,7 @@ func (i *installer) installSkillForAgent( if err != nil { return "", false, err } - // Prefer the version the update command reported; fall back to the + // Prefer the version the upgrade command reported; fall back to the // version detected via the plugin list. if version == "" { version = detectedVersion @@ -1858,19 +1861,19 @@ func (i *installer) verifySkillInstalled( return version, nil } -// runSkillAgentCommand executes the agent's install or update command and +// runSkillAgentCommand executes the agent's install or upgrade command and // returns the command's stdout (empty for a streamed install). // -// Install and update connect to the terminal differently: +// 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. -// - Update captures the output (no streaming) and returns it so the caller +// - 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 update is +// 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 @@ -1887,7 +1890,7 @@ func (i *installer) runSkillAgentCommand( verb := "install" if upgrade { cmd = agent.PluginUpdateCommand - verb = "update" + verb = "upgrade" } if len(cmd) == 0 { return "", fmt.Errorf( @@ -1895,7 +1898,7 @@ func (i *installer) runSkillAgentCommand( ) } - // Update: capture the output so the caller can parse the version and the + // 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...)) diff --git a/cli/azd/pkg/tool/update_checker.go b/cli/azd/pkg/tool/update_checker.go index afcae8df079..845a24b14bc 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" @@ -241,6 +242,12 @@ func (uc *UpdateChecker) Check( 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 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 From 422a2bce9e758498db83991b60270b7a7f126613 Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 18:42:15 -0700 Subject: [PATCH 38/41] update check for multi selector, have stout under spinner grey section --- cli/azd/cmd/tool.go | 13 +++++++- cli/azd/pkg/tool/installer.go | 51 +++++++++++++++--------------- cli/azd/pkg/tool/installer_test.go | 28 ++++++++-------- cli/azd/pkg/tool/manifest.go | 5 +++ cli/azd/pkg/tool/manifest_test.go | 11 +++++++ cli/azd/pkg/ux/multi_select.go | 2 +- 6 files changed, 68 insertions(+), 42 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index b67561357c3..9ed6b93203e 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -636,9 +636,20 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err 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: header, + Header: header, + FollowUp: strings.Join(notes, "\n"), }, }, nil } diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index 859ac0b183d..c2719062df7 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -236,25 +236,27 @@ func stepError(result *InstallResult, err error) error { return nil } -// renderSkillStep frames one skill step (install, upgrade or uninstall) with -// a live spinner that stays visible while the agent CLI runs. +// 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. // -// streamOutput controls how that output is surfaced. When true, the agent -// CLI's output is line-buffered and each complete line is printed above the -// spinner while the spinner stays pinned below it: the console tears the -// spinner down and re-renders it around each printed line (see -// AskerConsole.println), so the bar is kept, not lost. 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. When false, the output is buffered and replayed only if the step -// fails, so a step that completes without error stays quiet. When the CLI -// stays silent the spinner simply runs to completion. +// 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 @@ -267,7 +269,7 @@ func renderSkillStep( ctx context.Context, renderer StepRenderer, title string, - streamOutput bool, + showOutput bool, work func(out io.Writer) (doneTitle string, err error), ) error { if renderer == nil { @@ -278,26 +280,25 @@ func renderSkillStep( renderer.ShowSpinner(ctx, title, input.Step) - // Stream the agent CLI's output live (so interactive prompts are visible), - // or buffer it and replay only on failure so a successful step is quiet. + // 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 - emit := func(line string) { renderer.Message(ctx, line) } - if !streamOutput { - emit = func(line string) { buffered = append(buffered, line) } - } - out := &lineWriter{emit: emit} + out := &lineWriter{emit: func(line string) { buffered = append(buffered, line) }} doneTitle, err := work(out) out.Flush() if doneTitle == "" { doneTitle = title } - if !streamOutput && err != nil { + + // 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, line) + renderer.Message(ctx, output.WithGrayFormat(" %s", line)) } } - renderer.StopSpinner(ctx, doneTitle, input.GetStepResultFormat(err)) return err } diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index a82f63aa809..552fdf5adf0 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -1553,12 +1553,11 @@ func TestRunSkill_Upgrade_AlreadyUpToDate(t *testing.T) { assert.Equal(t, []string{"Test Azure Skills in copilot is already up to date (v1.1.86)."}, r.stops) } -// TestRunSkill_StreamedOutputPrintedAboveSpinner verifies that when the agent -// CLI writes to the terminal (a progress line or an interactive prompt), -// each line is surfaced via Message (which the console prints above the -// spinner, keeping the spinner pinned below), and the spinner is stopped with -// the step result at the end rather than torn down early. -func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { +// 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() @@ -1566,8 +1565,8 @@ func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { runner.MockToolInPath("node", nil) // The plugin command writes to the terminal (StdOut), simulating the - // agent CLI surfacing progress or a prompt. Both marketplace-add and install - // stream their output through the step writer so prompts stay visible. + // 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) { @@ -1597,12 +1596,11 @@ func TestRunSkill_StreamedOutputPrintedAboveSpinner(t *testing.T) { require.NoError(t, err) require.True(t, result.Success, "install must succeed; err=%v", result.Error) - // The spinner is shown, the agent CLI's line is surfaced via Message (the - // console prints it above the spinner), and the spinner is stopped with - // the step result at the end — it is kept, not torn down early. + // 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, r.messages, "Installing plugin...") + assert.Contains(t, strings.Join(r.messages, "\n"), "Installing plugin...") } // TestSkillAgent_DisplayVsCommand verifies the split between the display Agent @@ -4071,8 +4069,8 @@ func TestRunSkillUninstall_StepProgress_SuccessStreamsOutput(t *testing.T) { require.NoError(t, err) require.True(t, result.Success, "uninstall must succeed; err=%v", result.Error) - assert.Contains(t, r.messages, "Plugin \"azure@azure-skills\" uninstalled successfully.", - "agent CLI output must be streamed live so interactive prompts stay visible") + 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) } @@ -4111,7 +4109,7 @@ func TestRunSkillUninstall_StepProgress_FailureShowsOutput(t *testing.T) { require.False(t, result.Success) require.Error(t, result.Error) - assert.Contains(t, r.messages, "could not remove plugin", + assert.Contains(t, strings.Join(r.messages, "\n"), "could not remove plugin", "agent CLI output must be shown when uninstall fails") } diff --git a/cli/azd/pkg/tool/manifest.go b/cli/azd/pkg/tool/manifest.go index 83fab49895c..a8498e4d6c0 100644 --- a/cli/azd/pkg/tool/manifest.go +++ b/cli/azd/pkg/tool/manifest.go @@ -188,6 +188,9 @@ type ToolDefinition struct { 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. @@ -400,6 +403,8 @@ func azureSkills() *ToolDefinition { Category: ToolCategorySkill, Priority: ToolPriorityRecommended, Website: "https://github.com/microsoft/azure-skills", + SpinnerNote: "Azure Skills are now available in your AI agents. " + + "Ask your agent to create and manage Azure resources.", SkillAgents: []SkillAgent{ { DisplayName: "Copilot", diff --git a/cli/azd/pkg/tool/manifest_test.go b/cli/azd/pkg/tool/manifest_test.go index 362b4f0bae3..30302f24774 100644 --- a/cli/azd/pkg/tool/manifest_test.go +++ b/cli/azd/pkg/tool/manifest_test.go @@ -497,6 +497,17 @@ func TestAzureSkillsAgentVersionProbeRegex(t *testing.T) { } } +// 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 diff --git a/cli/azd/pkg/ux/multi_select.go b/cli/azd/pkg/ux/multi_select.go index fb0e93982dc..f8dc20043be 100644 --- a/cli/azd/pkg/ux/multi_select.go +++ b/cli/azd/pkg/ux/multi_select.go @@ -349,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 From d5b719124ffcb40d14066ae0a5df6ee43e0a03d2 Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 20:02:51 -0700 Subject: [PATCH 39/41] address all feedback --- cli/azd/cmd/tool.go | 38 ++++++++++++- cli/azd/cmd/tool_test.go | 70 +++++++++++++++++++++++ cli/azd/pkg/tool/installer.go | 64 ++++++++++++++++----- cli/azd/pkg/tool/installer_test.go | 91 ++++++++++++++++++++++++++++++ 4 files changed, 249 insertions(+), 14 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 9ed6b93203e..0231319ad99 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -545,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 } @@ -586,6 +591,12 @@ func (a *toolInstallAction) Run(ctx context.Context) (*actions.ActionResult, err 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() var ( @@ -1266,6 +1277,11 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err } 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.", )) @@ -1298,6 +1314,12 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err 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() var ( @@ -1586,6 +1608,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 } @@ -1622,6 +1649,12 @@ func (a *toolUninstallAction) Run(ctx context.Context) (*actions.ActionResult, e 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() var ( @@ -2460,7 +2493,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". @@ -2468,6 +2501,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, )) diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 2cc83b09a52..85c282cb37f 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -74,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", @@ -1304,6 +1334,46 @@ func TestToolUpgradeAction_All_JsonFormat_EmitsCleanJson(t *testing.T) { 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) { diff --git a/cli/azd/pkg/tool/installer.go b/cli/azd/pkg/tool/installer.go index c2719062df7..14d7826c6a8 100644 --- a/cli/azd/pkg/tool/installer.go +++ b/cli/azd/pkg/tool/installer.go @@ -178,6 +178,12 @@ type installConfig struct { // 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. @@ -198,6 +204,15 @@ 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. @@ -264,15 +279,26 @@ func stepError(result *InstallResult, err error) error { // 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. +// 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 @@ -536,7 +562,7 @@ func (i *installer) Uninstall( // 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) + 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 @@ -916,6 +942,7 @@ func (i *installer) runSkillUninstall( agents []string, renderer StepRenderer, stdin io.Reader, + quiet bool, ) (*InstallResult, error) { start := time.Now() result := &InstallResult{Tool: tool} @@ -939,7 +966,7 @@ func (i *installer) runSkillUninstall( ) for _, agent := range targets { title := fmt.Sprintf("Uninstalling %s from %s", tool.Name, agent.DisplayName) - agentErr := renderSkillStep(ctx, renderer, title, true, func(out io.Writer) (string, error) { + agentErr := renderSkillStep(ctx, renderer, title, true, quiet, func(out io.Writer) (string, error) { return "", i.uninstallSkillForAgent(ctx, tool, agent, out, stdin) }) if agentErr != nil { @@ -1112,7 +1139,7 @@ func (i *installer) run( // 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.agents, cfg.allAvailableAgents, cfg.renderer, cfg.stdin) + 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 @@ -1336,6 +1363,7 @@ func (i *installer) runSkill( allAvailable bool, renderer StepRenderer, stdin io.Reader, + quiet bool, ) (*InstallResult, error) { start := time.Now() result := &InstallResult{Tool: tool} @@ -1390,7 +1418,7 @@ func (i *installer) runSkill( agentVersion string agentUpToDate bool ) - agentErr := renderSkillStep(ctx, renderer, title, true, func(out io.Writer) (string, error) { + 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 @@ -1775,9 +1803,12 @@ func (i *installer) installSkillForAgent( if err != nil { return "", false, err } - var proseLatest bool + var ( + reportedVersion string + proseLatest bool + ) if upgrade { - version, proseLatest = parseUpgradeOutput(cmdOutput) + reportedVersion, proseLatest = parseUpgradeOutput(cmdOutput) } detectedVersion, err := i.verifySkillInstalled(ctx, tool, agent) @@ -1786,18 +1817,25 @@ func (i *installer) installSkillForAgent( } // Prefer the version the upgrade command reported; fall back to the // version detected via the plugin list. + version = reportedVersion if version == "" { version = detectedVersion } if upgrade { - // The authoritative "already up to date" signal is an unchanged - // version. Only when a version is unavailable on either side fall back - // to the agent CLI's prose, so azd neither claims up to date without - // evidence nor misreports an upgrade when the wording is unrecognized. - if beforeVersion != "" && detectedVersion != "" { + // "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 - } else { + default: alreadyLatest = proseLatest } } diff --git a/cli/azd/pkg/tool/installer_test.go b/cli/azd/pkg/tool/installer_test.go index 552fdf5adf0..f0a9068ec03 100644 --- a/cli/azd/pkg/tool/installer_test.go +++ b/cli/azd/pkg/tool/installer_test.go @@ -1553,6 +1553,50 @@ func TestRunSkill_Upgrade_AlreadyUpToDate(t *testing.T) { 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 @@ -1603,6 +1647,53 @@ func TestRunSkill_OutputPrintedBelowStep(t *testing.T) { 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 From 59ce64ef45e26c949cd1eaf372cb02727d15ea2b Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 20:38:33 -0700 Subject: [PATCH 40/41] address another round of feedback --- cli/azd/cmd/tool.go | 42 +++++++++++------- cli/azd/cmd/tool_test.go | 95 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 18 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index 0231319ad99..f3b42909699 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -756,7 +756,7 @@ func (a *toolInstallAction) resolveAgentOptions( if len(present) > 1 { // Interactive terminal: prompt the user to pick the agent(s), // after surfacing the --agent hint so they learn the shortcut too. - if a.console.IsSpinnerInteractive() && !a.console.IsNoPromptMode() { + if promptAllowed(a.console, a.formatter) { a.console.Message(ctx, "Multiple AI agents detected.\n"+ output.WithGrayFormat("Tip: Use `")+ output.WithHighLightFormat("--agent ")+ @@ -805,7 +805,7 @@ 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 } @@ -918,6 +918,18 @@ func detectAllTools( 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 parseable 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. @@ -1104,10 +1116,10 @@ func (a *toolInstallAction) resolveToolIds(ctx context.Context) ([]string, error return nil, nil } - // Non-interactive (no TTY) or --no-prompt: the picker can't run, so require - // an explicit target (tool IDs or --all) rather than implicitly installing - // the recommended set. - if !a.console.IsSpinnerInteractive() || a.console.IsNoPromptMode() { + // 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") } @@ -1255,14 +1267,14 @@ func (a *toolUpgradeAction) Run(ctx context.Context) (*actions.ActionResult, err installed = append(installed, s) } } - // Non-interactive (no TTY) or --no-prompt: the picker can't run, so - // require an explicit target (tool IDs or --all) rather than implicitly - // upgrading every installed tool. - if len(installed) > 0 && (!a.console.IsSpinnerInteractive() || a.console.IsNoPromptMode()) { + // 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 a.console.IsSpinnerInteractive() && !a.console.IsNoPromptMode() && len(installed) > 0 { + if promptAllowed(a.console, a.formatter) && len(installed) > 0 { chosen, err = a.promptForUpgradeTools(ctx, installed) if err != nil { return nil, err @@ -1775,10 +1787,10 @@ func (a *toolUninstallAction) resolveToolIds(ctx context.Context) ([]string, err // 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 or --no-prompt) and the user gave - // neither tool IDs nor --all, fail with explicit guidance instead of - // silently removing every installed tool. - if !a.console.IsSpinnerInteractive() || a.console.IsNoPromptMode() { + // 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") } diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 85c282cb37f..20d3d91fb7f 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -1444,8 +1444,42 @@ func TestToolUpgradeAction_NoPrompt_WithoutTarget_Errors(t *testing.T) { "the guidance must tell the user to pass tool IDs or --all") } -// TestToolUpgradeAction_AllFlag_NoPrompt upgrades every installed tool when -// --all is given, without prompting. +// 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() @@ -1642,7 +1676,34 @@ func TestToolInstallAction_resolveToolIds_NoPromptWithoutTarget_Errors(t *testin "the guidance must tell the user to pass tool IDs or --all") } -// TestToolInstallAction_resolveToolIds_AllFlag installs the recommended, +// 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{ @@ -1719,6 +1780,34 @@ func TestToolUninstallAction_resolveToolIds_NoPromptWithoutTarget_Errors(t *test "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. From 972886fe0074e2a8bb329ce8eb1f8d6f3f5ac58e Mon Sep 17 00:00:00 2001 From: hemarina Date: Mon, 13 Jul 2026 22:53:09 -0700 Subject: [PATCH 41/41] address feedback, update comment --- cli/azd/cmd/tool.go | 2 +- cli/azd/cmd/tool_test.go | 6 ++-- cli/azd/pkg/tool/update_checker.go | 13 ++++++-- cli/azd/pkg/tool/update_checker_test.go | 44 +++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/cli/azd/cmd/tool.go b/cli/azd/cmd/tool.go index f3b42909699..4a21662a01f 100644 --- a/cli/azd/cmd/tool.go +++ b/cli/azd/cmd/tool.go @@ -922,7 +922,7 @@ func detectAllTools( // 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 parseable document. When prompting is not allowed, callers +// 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() && diff --git a/cli/azd/cmd/tool_test.go b/cli/azd/cmd/tool_test.go index 20d3d91fb7f..2b619e69b0b 100644 --- a/cli/azd/cmd/tool_test.go +++ b/cli/azd/cmd/tool_test.go @@ -604,7 +604,7 @@ func TestToolInstallAction_JsonFormat_NoBannerLeak(t *testing.T) { var items []toolInstallResultItem require.NoError(t, json.Unmarshal(buf.Bytes(), &items), - "JSON output must be a single parseable document with no banner leak") + "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, @@ -1294,7 +1294,7 @@ func TestToolUpgradeAction_All_UpgradesInstalledTools(t *testing.T) { // TestToolUpgradeAction_All_JsonFormat_EmitsCleanJson exercises the reviewer's // exact trigger — `azd tool upgrade --all --output json` — and verifies the -// writer receives parseable JSON. In JSON mode the detection spinner is +// 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() @@ -1330,7 +1330,7 @@ func TestToolUpgradeAction_All_JsonFormat_EmitsCleanJson(t *testing.T) { var items []toolInstallResultItem require.NoError(t, json.Unmarshal(buf.Bytes(), &items), - "upgrade --all --output json must emit parseable JSON") + "upgrade --all --output json must emit valid JSON") require.NotEmpty(t, items, "at least one installed tool must be reported") } diff --git a/cli/azd/pkg/tool/update_checker.go b/cli/azd/pkg/tool/update_checker.go index 845a24b14bc..f27914ca9dd 100644 --- a/cli/azd/pkg/tool/update_checker.go +++ b/cli/azd/pkg/tool/update_checker.go @@ -71,12 +71,19 @@ 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 diff --git a/cli/azd/pkg/tool/update_checker_test.go b/cli/azd/pkg/tool/update_checker_test.go index dfbf67dab2c..237ca291698 100644 --- a/cli/azd/pkg/tool/update_checker_test.go +++ b/cli/azd/pkg/tool/update_checker_test.go @@ -565,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) + }) } // ---------------------------------------------------------------------------