From 84fef65ac31e9f8bd7ce312e6fee5322a3b6f8a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:43:57 +0000 Subject: [PATCH 01/12] Initial plan From 45080e26822319396f419a2d9f0b335e3a6db580 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 17:55:03 +0000 Subject: [PATCH 02/12] Add extension metadata support for figspec generation Co-authored-by: JeffreyCA <9157833+JeffreyCA@users.noreply.github.com> --- cli/azd/cmd/completion.go | 20 +- cli/azd/cmd/figspec_test.go | 21 +- cli/azd/cmd/testdata/TestFigSpec.ts | 1493 +++++++++++++++-- .../internal/figspec/extension_metadata.go | 139 ++ .../figspec/extension_metadata_test.go | 322 ++++ cli/azd/internal/figspec/spec_builder.go | 100 +- cli/azd/pkg/extensions/manager.go | 22 + 7 files changed, 1990 insertions(+), 127 deletions(-) create mode 100644 cli/azd/internal/figspec/extension_metadata.go create mode 100644 cli/azd/internal/figspec/extension_metadata_test.go diff --git a/cli/azd/cmd/completion.go b/cli/azd/cmd/completion.go index a57c1d77a12..abea5c9a1a2 100644 --- a/cli/azd/cmd/completion.go +++ b/cli/azd/cmd/completion.go @@ -9,6 +9,7 @@ import ( "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal/figspec" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/spf13/cobra" ) @@ -140,14 +141,20 @@ func newCompletionFigFlags(cmd *cobra.Command) *completionFigFlags { } type completionFigAction struct { - flags *completionFigFlags - cmd *cobra.Command + flags *completionFigFlags + cmd *cobra.Command + extensionManager *extensions.Manager } -func newCompletionFigAction(cmd *cobra.Command, flags *completionFigFlags) actions.Action { +func newCompletionFigAction( + cmd *cobra.Command, + flags *completionFigFlags, + extensionManager *extensions.Manager, +) actions.Action { return &completionFigAction{ - flags: flags, - cmd: cmd, + flags: flags, + cmd: cmd, + extensionManager: extensionManager, } } @@ -179,7 +186,8 @@ func (a *completionFigAction) Run(ctx context.Context) (*actions.ActionResult, e rootCmd := a.cmd.Root() // Generate the Fig spec - builder := figspec.NewSpecBuilder(a.flags.includeHidden) + builder := figspec.NewSpecBuilder(a.flags.includeHidden). + WithExtensionMetadata(a.extensionManager) spec := builder.BuildSpec(rootCmd) // Convert to TypeScript diff --git a/cli/azd/cmd/figspec_test.go b/cli/azd/cmd/figspec_test.go index 127b80d7718..68889c395e0 100644 --- a/cli/azd/cmd/figspec_test.go +++ b/cli/azd/cmd/figspec_test.go @@ -9,6 +9,8 @@ import ( "time" "github.com/azure/azure-dev/cli/azd/internal/figspec" + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/test/azdcli" "github.com/azure/azure-dev/cli/azd/test/snapshot" "github.com/stretchr/testify/require" @@ -47,7 +49,11 @@ func TestFigSpec(t *testing.T) { root := NewRootCmd(false, nil, nil) - builder := figspec.NewSpecBuilder(false) + // Create a simple metadata loader using the config directory + metadataLoader := &testExtensionMetadataLoader{} + + builder := figspec.NewSpecBuilder(false). + WithExtensionMetadata(metadataLoader) spec := builder.BuildSpec(root) typescript, err := spec.ToTypeScript() @@ -56,3 +62,16 @@ func TestFigSpec(t *testing.T) { snapshotter := snapshot.NewConfig(".ts") snapshotter.SnapshotT(t, typescript) } + +// testExtensionMetadataLoader implements figspec.ExtensionMetadataProvider for testing +// by directly reading metadata from the test's config directory. +type testExtensionMetadataLoader struct{} + +func (l *testExtensionMetadataLoader) LoadMetadata(extensionId string) (*extensions.ExtensionCommandMetadata, error) { + userConfigDir, err := config.GetUserConfigDir() + if err != nil { + return nil, err + } + + return extensions.LoadMetadataFromDir(userConfigDir, extensionId) +} diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index f395422c9f0..8f487b977e4 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -193,10 +193,533 @@ const completionSpec: Fig.Spec = { { name: ['agent'], description: 'Extension for the Foundry Agent Service. (Preview)', + subcommands: [ + { + name: ['init'], + description: 'Initialize a new AI agent project. (Preview)', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--environment', '-e'], + description: 'The name of the azd environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--help', '-h'], + description: 'help for init', + }, + { + name: ['--host'], + description: '[Optional] For container based agents, can override the default host to target a container app instead. Accepted values: \'containerapp\'', + args: [ + { + name: 'host', + }, + ], + }, + { + name: ['--manifest', '-m'], + description: 'Path or URI to an agent manifest to add to your azd project', + args: [ + { + name: 'manifest', + }, + ], + }, + { + name: ['--no-prompt'], + description: 'Accepts the default value instead of prompting, or it fails if there is no default.', + }, + { + name: ['--project-id', '-p'], + description: 'Existing Microsoft Foundry Project Id to initialize your azd environment with', + args: [ + { + name: 'project-id', + }, + ], + }, + { + name: ['--src', '-s'], + description: '[Optional] Directory to download the agent definition to (defaults to \'src/\')', + args: [ + { + name: 'src', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for version', + }, + { + name: ['--no-prompt'], + description: 'Accepts the default value instead of prompting, or it fails if there is no default.', + }, + ], + }, + ], }, { name: ['finetuning'], description: 'Extension for Foundry Fine Tuning. (Preview)', + subcommands: [ + { + name: ['init'], + description: 'Initialize a new AI Fine-tuning project. (Preview)', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--environment', '-n'], + description: 'The name of the azd environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--from-job', '-j'], + description: 'Clone configuration from an existing job ID', + args: [ + { + name: 'from-job', + }, + ], + }, + { + name: ['--help', '-h'], + description: 'help for init', + }, + { + name: ['--no-prompt'], + description: 'accepts the default value instead of prompting, or fails if there is no default', + }, + { + name: ['--project-endpoint', '-e'], + description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', + args: [ + { + name: 'project-endpoint', + }, + ], + }, + { + name: ['--project-resource-id', '-p'], + description: 'ARM resource ID of the Microsoft Foundry Project (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{account}/projects/{project})', + args: [ + { + name: 'project-resource-id', + }, + ], + }, + { + name: ['--subscription', '-s'], + description: 'Azure subscription ID', + args: [ + { + name: 'subscription', + }, + ], + }, + { + name: ['--template', '-t'], + description: 'URL or path to a fine-tune job template', + args: [ + { + name: 'template', + }, + ], + }, + { + name: ['--working-directory', '-w'], + description: 'Local path for project output', + args: [ + { + name: 'working-directory', + }, + ], + }, + ], + }, + { + name: ['jobs'], + description: 'Manage fine-tuning jobs', + subcommands: [ + { + name: ['cancel'], + description: 'Cancels a running or queued fine-tuning job.', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--force'], + description: 'Skip confirmation prompt', + isDangerous: true, + }, + { + name: ['--help', '-h'], + description: 'help for cancel', + }, + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--no-prompt'], + description: 'accepts the default value instead of prompting, or fails if there is no default', + }, + ], + }, + { + name: ['deploy'], + description: 'Deploy a fine-tuned model to Azure Cognitive Services', + options: [ + { + name: ['--capacity', '-c'], + description: 'Capacity units', + args: [ + { + name: 'capacity', + }, + ], + }, + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--deployment-name', '-d'], + description: 'Deployment name (required)', + args: [ + { + name: 'deployment-name', + }, + ], + }, + { + name: ['--help', '-h'], + description: 'help for deploy', + }, + { + name: ['--job-id', '-i'], + description: 'Fine-tuning job ID (required)', + args: [ + { + name: 'job-id', + }, + ], + }, + { + name: ['--model-format', '-m'], + description: 'Model format', + args: [ + { + name: 'model-format', + }, + ], + }, + { + name: ['--no-prompt'], + description: 'accepts the default value instead of prompting, or fails if there is no default', + }, + { + name: ['--no-wait'], + description: 'Do not wait for deployment to complete', + }, + { + name: ['--sku', '-s'], + description: 'SKU for deployment', + args: [ + { + name: 'sku', + }, + ], + }, + { + name: ['--version', '-v'], + description: 'Model version', + args: [ + { + name: 'version', + }, + ], + }, + ], + }, + { + name: ['list'], + description: 'List fine-tuning jobs.', + options: [ + { + name: ['--after'], + description: 'Pagination cursor', + args: [ + { + name: 'after', + }, + ], + }, + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for list', + }, + { + name: ['--no-prompt'], + description: 'accepts the default value instead of prompting, or fails if there is no default', + }, + { + name: ['--output', '-o'], + description: 'Output format: table, json', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--top', '-t'], + description: 'Number of jobs to return', + args: [ + { + name: 'top', + }, + ], + }, + ], + }, + { + name: ['pause'], + description: 'Pauses a running fine-tuning job.', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for pause', + }, + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--no-prompt'], + description: 'accepts the default value instead of prompting, or fails if there is no default', + }, + ], + }, + { + name: ['resume'], + description: 'Resumes a paused fine-tuning job.', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for resume', + }, + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--no-prompt'], + description: 'accepts the default value instead of prompting, or fails if there is no default', + }, + ], + }, + { + name: ['show'], + description: 'Shows detailed information about a specific job.', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for show', + }, + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--logs'], + description: 'Include recent training logs', + }, + { + name: ['--no-prompt'], + description: 'accepts the default value instead of prompting, or fails if there is no default', + }, + { + name: ['--output', '-o'], + description: 'Output format: table, json, yaml', + args: [ + { + name: 'output', + }, + ], + }, + ], + }, + { + name: ['submit'], + description: 'submit fine tuning job', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--file', '-f'], + description: 'Path to the config file.', + args: [ + { + name: 'file', + }, + ], + }, + { + name: ['--help', '-h'], + description: 'help for submit', + }, + { + name: ['--model', '-m'], + description: 'Base model to fine-tune. Overrides config file. Required if --file is not provided', + args: [ + { + name: 'model', + }, + ], + }, + { + name: ['--no-prompt'], + description: 'accepts the default value instead of prompting, or fails if there is no default', + }, + { + name: ['--seed', '-r'], + description: 'Random seed for reproducibility of the job. If a seed is not specified, one will be generated for you. Overrides config file.', + args: [ + { + name: 'seed', + }, + ], + }, + { + name: ['--suffix', '-s'], + description: 'An optional string of up to 64 characters that will be added to your fine-tuned model name. Overrides config file.', + args: [ + { + name: 'suffix', + }, + ], + }, + { + name: ['--training-file', '-t'], + description: 'Training file ID or local path. Use \'local:\' prefix for local paths. Required if --file is not provided', + args: [ + { + name: 'training-file', + }, + ], + }, + { + name: ['--validation-file', '-v'], + description: 'Validation file ID or local path. Use \'local:\' prefix for local paths.', + args: [ + { + name: 'validation-file', + }, + ], + }, + ], + }, + ], + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for jobs', + }, + { + name: ['--no-prompt'], + description: 'accepts the default value instead of prompting, or fails if there is no default', + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for version', + }, + { + name: ['--no-prompt'], + description: 'accepts the default value instead of prompting, or fails if there is no default', + }, + ], + }, + ], }, ], }, @@ -290,6 +813,82 @@ const completionSpec: Fig.Spec = { { name: ['coding-agent'], description: 'This extension configures GitHub Copilot Coding Agent access to Azure', + subcommands: [ + { + name: ['config'], + description: 'Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP', + options: [ + { + name: ['--branch-name'], + description: 'The branch name to use when pushing changes to the copilot-setup-steps.yml', + args: [ + { + name: 'branch-name', + }, + ], + }, + { + name: ['--debug'], + description: 'Enables debugging and diagnostics logging.', + }, + { + name: ['--github-host-name'], + description: 'The hostname to use with GitHub commands', + args: [ + { + name: 'github-host-name', + }, + ], + }, + { + name: ['--help', '-h'], + description: 'help for config', + }, + { + name: ['--managed-identity-name'], + description: 'The name to use for the managed identity, if created.', + args: [ + { + name: 'managed-identity-name', + }, + ], + }, + { + name: ['--remote-name'], + description: 'The name of the git remote where the Copilot Coding Agent will run (ex: /)', + args: [ + { + name: 'remote-name', + }, + ], + }, + { + name: ['--roles'], + description: 'The roles to assign to the service principal or managed identity. By default, the service principal or managed identity will be granted the Reader role.', + isRepeatable: true, + args: [ + { + name: 'roles', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for version', + }, + ], + }, + ], }, { name: ['completion'], @@ -320,6 +919,36 @@ const completionSpec: Fig.Spec = { { name: ['concurx'], description: 'Concurrent execution for azd deployment', + subcommands: [ + { + name: ['up'], + description: 'Runs azd up in concurrent mode', + options: [ + { + name: ['--debug'], + description: 'Enable debug logging for azd commands', + }, + { + name: ['--help', '-h'], + description: 'help for up', + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for version', + }, + ], + }, + ], }, { name: ['config'], @@ -345,41 +974,171 @@ const completionSpec: Fig.Spec = { description: 'Resets configuration to default.', options: [ { - name: ['--force', '-f'], - description: 'Force reset without confirmation.', - isDangerous: true, + name: ['--force', '-f'], + description: 'Force reset without confirmation.', + isDangerous: true, + }, + ], + }, + { + name: ['set'], + description: 'Sets a configuration.', + args: [ + { + name: 'path', + }, + { + name: 'value', + }, + ], + }, + { + name: ['show'], + description: 'Show all the configuration values.', + }, + { + name: ['unset'], + description: 'Unsets a configuration.', + args: { + name: 'path', + }, + }, + ], + }, + { + name: ['demo'], + description: 'This extension provides examples of the AZD extension framework.', + subcommands: [ + { + name: ['colors', 'colours'], + description: 'Displays all ASCII colors with their standard and high-intensity variants.', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for colors', + }, + ], + }, + { + name: ['config'], + description: 'Setup monitoring configuration for the project and services', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for config', + }, + ], + }, + { + name: ['context'], + description: 'Get the context of the AZD project & environment.', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for context', + }, + ], + }, + { + name: ['gh-url-parse'], + description: 'Parse a GitHub URL and extract repository information.', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for gh-url-parse', + }, + ], + }, + { + name: ['listen'], + description: 'Starts the extension and listens for events.', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for listen', + }, + ], + }, + { + name: ['mcp'], + description: 'MCP server commands for demo extension', + subcommands: [ + { + name: ['start'], + description: 'Start MCP server with demo tools', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for start', + }, + ], + }, + ], + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for mcp', + }, + ], + }, + { + name: ['prompt'], + description: 'Examples of prompting the user for input.', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for prompt', }, ], }, { - name: ['set'], - description: 'Sets a configuration.', - args: [ - { - name: 'path', - }, - { - name: 'value', - }, + name: ['version'], + description: 'Prints the version of the application', + options: [ + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for version', + }, ], }, - { - name: ['show'], - description: 'Show all the configuration values.', - }, - { - name: ['unset'], - description: 'Unsets a configuration.', - args: { - name: 'path', - }, - }, ], }, - { - name: ['demo'], - description: 'This extension provides examples of the AZD extension framework.', - }, { name: ['deploy'], description: 'Deploy your project code to Azure.', @@ -1492,128 +2251,488 @@ const completionSpec: Fig.Spec = { description: 'Unmask secrets in output.', isDangerous: true, }, - ], - args: { - name: 'resource-name|resource-id', - isOptional: true, - }, - }, - { - name: ['template'], - description: 'Find and view template details.', - subcommands: [ + ], + args: { + name: 'resource-name|resource-id', + isOptional: true, + }, + }, + { + name: ['template'], + description: 'Find and view template details.', + subcommands: [ + { + name: ['list', 'ls'], + description: 'Show list of sample azd templates. (Beta)', + options: [ + { + name: ['--filter', '-f'], + description: 'The tag(s) used to filter template results. Supports comma-separated values.', + isRepeatable: true, + args: [ + { + name: 'filter', + generators: azdGenerators.listTemplateTags, + }, + ], + }, + { + name: ['--source', '-s'], + description: 'Filters templates by source.', + args: [ + { + name: 'source', + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Show details for a given template. (Beta)', + args: { + name: 'template', + generators: azdGenerators.listTemplates, + }, + }, + { + name: ['source'], + description: 'View and manage template sources. (Beta)', + subcommands: [ + { + name: ['add'], + description: 'Adds an azd template source with the specified key. (Beta)', + options: [ + { + name: ['--location', '-l'], + description: 'Location of the template source. Required when using type flag.', + args: [ + { + name: 'location', + }, + ], + }, + { + name: ['--name', '-n'], + description: 'Display name of the template source.', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--type', '-t'], + description: 'Kind of the template source. Supported types are \'file\', \'url\' and \'gh\'.', + args: [ + { + name: 'type', + }, + ], + }, + ], + args: { + name: 'key', + }, + }, + { + name: ['list', 'ls'], + description: 'Lists the configured azd template sources. (Beta)', + }, + { + name: ['remove'], + description: 'Removes the specified azd template source (Beta)', + args: { + name: 'key', + }, + }, + ], + }, + ], + }, + { + name: ['up'], + description: 'Provision and deploy your project to Azure with a single command.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Print the version number of Azure Developer CLI.', + }, + { + name: ['x'], + description: 'This extension provides a set of tools for AZD extension developers to test and debug their extensions.', + subcommands: [ + { + name: ['build'], + description: 'Build the azd extension project', + options: [ + { + name: ['--all'], + description: 'When set builds for all os/platforms. Defaults to the current os/platform only.', + }, + { + name: ['--cwd'], + description: 'Path to the azd extension project', + args: [ + { + name: 'cwd', + }, + ], + }, + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for build', + }, + { + name: ['--output', '-o'], + description: 'Path to the output directory. Defaults to ./bin folder.', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--skip-install'], + description: 'When set skips reinstalling extension after successful build.', + }, + ], + }, + { + name: ['init'], + description: 'Initialize a new AZD extension project', + options: [ + { + name: ['--capabilities'], + description: 'The list of capabilities for the extension (e.g., custom-commands,lifecycle-events,mcp-server,service-target-provider).', + isRepeatable: true, + args: [ + { + name: 'capabilities', + }, + ], + }, + { + name: ['--cwd'], + description: 'Path to the azd extension project', + args: [ + { + name: 'cwd', + }, + ], + }, + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for init', + }, + { + name: ['--id'], + description: 'The extension identifier (e.g., company.extension).', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--language'], + description: 'The programming language for the extension (go, dotnet, javascript, python).', + args: [ + { + name: 'language', + }, + ], + }, + { + name: ['--name'], + description: 'The display name for the extension.', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--namespace'], + description: 'The namespace for the extension commands.', + args: [ + { + name: 'namespace', + }, + ], + }, + { + name: ['--no-prompt'], + description: 'Skip all prompts by providing all required parameters via command-line flags.', + }, + { + name: ['--registry', '-r'], + description: 'When set will create a local extension source registry.', + }, + ], + }, + { + name: ['pack'], + description: 'Build and pack extension artifacts', + options: [ + { + name: ['--cwd'], + description: 'Path to the azd extension project', + args: [ + { + name: 'cwd', + }, + ], + }, + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for pack', + }, + { + name: ['--input', '-i'], + description: 'Path to the input directory.', + args: [ + { + name: 'input', + }, + ], + }, + { + name: ['--output', '-o'], + description: 'Path to the artifacts output directory. If not provided, will use local registry artifacts path.', + args: [ + { + name: 'output', + }, + ], + }, + { + name: ['--rebuild'], + description: 'Rebuild the extension before packaging.', + }, + ], + }, { - name: ['list', 'ls'], - description: 'Show list of sample azd templates. (Beta)', + name: ['publish'], + description: 'Publish the extension to the extension source', options: [ { - name: ['--filter', '-f'], - description: 'The tag(s) used to filter template results. Supports comma-separated values.', + name: ['--artifacts'], + description: 'Path to artifacts to process (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', isRepeatable: true, args: [ { - name: 'filter', - generators: azdGenerators.listTemplateTags, + name: 'artifacts', }, ], }, { - name: ['--source', '-s'], - description: 'Filters templates by source.', + name: ['--cwd'], + description: 'Path to the azd extension project', args: [ { - name: 'source', + name: 'cwd', + }, + ], + }, + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for publish', + }, + { + name: ['--registry', '-r'], + description: 'Path to the extension source registry', + args: [ + { + name: 'registry', + }, + ], + }, + { + name: ['--repo'], + description: 'Github repository to create the release in (e.g. owner/repo)', + args: [ + { + name: 'repo', + }, + ], + }, + { + name: ['--version', '-v'], + description: 'Version of the release', + args: [ + { + name: 'version', }, ], }, ], }, { - name: ['show'], - description: 'Show details for a given template. (Beta)', - args: { - name: 'template', - generators: azdGenerators.listTemplates, - }, - }, - { - name: ['source'], - description: 'View and manage template sources. (Beta)', - subcommands: [ + name: ['release'], + description: 'Create a new extension release from the packaged artifacts', + options: [ { - name: ['add'], - description: 'Adds an azd template source with the specified key. (Beta)', - options: [ - { - name: ['--location', '-l'], - description: 'Location of the template source. Required when using type flag.', - args: [ - { - name: 'location', - }, - ], - }, - { - name: ['--name', '-n'], - description: 'Display name of the template source.', - args: [ - { - name: 'name', - }, - ], - }, - { - name: ['--type', '-t'], - description: 'Kind of the template source. Supported types are \'file\', \'url\' and \'gh\'.', - args: [ - { - name: 'type', - }, - ], - }, + name: ['--artifacts'], + description: 'Path to artifacts to upload to the release (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', + isRepeatable: true, + args: [ + { + name: 'artifacts', + }, ], - args: { - name: 'key', + }, + { + name: ['--confirm'], + description: 'Skip confirmation prompt', + }, + { + name: ['--cwd'], + description: 'Path to the azd extension project', + args: [ + { + name: 'cwd', }, + ], }, { - name: ['list', 'ls'], - description: 'Lists the configured azd template sources. (Beta)', + name: ['--debug'], + description: 'Enable debug mode', }, { - name: ['remove'], - description: 'Removes the specified azd template source (Beta)', - args: { - name: 'key', + name: ['--draft', '-d'], + description: 'Create a draft release', + }, + { + name: ['--help', '-h'], + description: 'help for release', + }, + { + name: ['--notes', '-n'], + description: 'Release notes', + args: [ + { + name: 'notes', + }, + ], + }, + { + name: ['--notes-file', '-F'], + description: 'Read release notes from file (use "-" to read from standard input)', + args: [ + { + name: 'notes-file', + }, + ], + }, + { + name: ['--prerelease'], + description: 'Create a pre-release version', + }, + { + name: ['--repo', '-r'], + description: 'Github repository to create the release in (e.g. owner/repo)', + args: [ + { + name: 'repo', }, + ], + }, + { + name: ['--title', '-t'], + description: 'Title of the release', + args: [ + { + name: 'title', + }, + ], + }, + { + name: ['--version', '-v'], + description: 'Version of the release', + args: [ + { + name: 'version', + }, + ], }, ], }, - ], - }, - { - name: ['up'], - description: 'Provision and deploy your project to Azure with a single command.', - options: [ { - name: ['--environment', '-e'], - description: 'The name of the environment to use.', - args: [ - { - name: 'environment', - }, + name: ['version'], + description: 'Prints the version of the application', + options: [ + { + name: ['--cwd'], + description: 'Path to the azd extension project', + args: [ + { + name: 'cwd', + }, + ], + }, + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for version', + }, + ], + }, + { + name: ['watch'], + description: 'Watches the AZD extension project for file changes and rebuilds it.', + options: [ + { + name: ['--cwd'], + description: 'Path to the azd extension project', + args: [ + { + name: 'cwd', + }, + ], + }, + { + name: ['--debug'], + description: 'Enable debug mode', + }, + { + name: ['--help', '-h'], + description: 'help for watch', + }, ], }, ], }, - { - name: ['version'], - description: 'Print the version number of Azure Developer CLI.', - }, - { - name: ['x'], - description: 'This extension provides a set of tools for AZD extension developers to test and debug their extensions.', - }, { name: ['help'], description: 'Help about any command', @@ -1629,10 +2748,64 @@ const completionSpec: Fig.Spec = { { name: ['agent'], description: 'Extension for the Foundry Agent Service. (Preview)', + subcommands: [ + { + name: ['init'], + description: 'Initialize a new AI agent project. (Preview)', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, { name: ['finetuning'], description: 'Extension for Foundry Fine Tuning. (Preview)', + subcommands: [ + { + name: ['init'], + description: 'Initialize a new AI Fine-tuning project. (Preview)', + }, + { + name: ['jobs'], + description: 'Manage fine-tuning jobs', + subcommands: [ + { + name: ['cancel'], + description: 'Cancels a running or queued fine-tuning job.', + }, + { + name: ['deploy'], + description: 'Deploy a fine-tuned model to Azure Cognitive Services', + }, + { + name: ['list'], + description: 'List fine-tuning jobs.', + }, + { + name: ['pause'], + description: 'Pauses a running fine-tuning job.', + }, + { + name: ['resume'], + description: 'Resumes a paused fine-tuning job.', + }, + { + name: ['show'], + description: 'Shows detailed information about a specific job.', + }, + { + name: ['submit'], + description: 'submit fine tuning job', + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, ], }, @@ -1657,6 +2830,16 @@ const completionSpec: Fig.Spec = { { name: ['coding-agent'], description: 'This extension configures GitHub Copilot Coding Agent access to Azure', + subcommands: [ + { + name: ['config'], + description: 'Configure the GitHub Copilot coding agent to access Azure resources via the Azure MCP', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, { name: ['completion'], @@ -1687,6 +2870,16 @@ const completionSpec: Fig.Spec = { { name: ['concurx'], description: 'Concurrent execution for azd deployment', + subcommands: [ + { + name: ['up'], + description: 'Runs azd up in concurrent mode', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, { name: ['config'], @@ -1725,6 +2918,46 @@ const completionSpec: Fig.Spec = { { name: ['demo'], description: 'This extension provides examples of the AZD extension framework.', + subcommands: [ + { + name: ['colors', 'colours'], + description: 'Displays all ASCII colors with their standard and high-intensity variants.', + }, + { + name: ['config'], + description: 'Setup monitoring configuration for the project and services', + }, + { + name: ['context'], + description: 'Get the context of the AZD project & environment.', + }, + { + name: ['gh-url-parse'], + description: 'Parse a GitHub URL and extract repository information.', + }, + { + name: ['listen'], + description: 'Starts the extension and listens for events.', + }, + { + name: ['mcp'], + description: 'MCP server commands for demo extension', + subcommands: [ + { + name: ['start'], + description: 'Start MCP server with demo tools', + }, + ], + }, + { + name: ['prompt'], + description: 'Examples of prompting the user for input.', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, { name: ['deploy'], @@ -1967,6 +3200,36 @@ const completionSpec: Fig.Spec = { { name: ['x'], description: 'This extension provides a set of tools for AZD extension developers to test and debug their extensions.', + subcommands: [ + { + name: ['build'], + description: 'Build the azd extension project', + }, + { + name: ['init'], + description: 'Initialize a new AZD extension project', + }, + { + name: ['pack'], + description: 'Build and pack extension artifacts', + }, + { + name: ['publish'], + description: 'Publish the extension to the extension source', + }, + { + name: ['release'], + description: 'Create a new extension release from the packaged artifacts', + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + { + name: ['watch'], + description: 'Watches the AZD extension project for file changes and rebuilds it.', + }, + ], }, ], }, diff --git a/cli/azd/internal/figspec/extension_metadata.go b/cli/azd/internal/figspec/extension_metadata.go new file mode 100644 index 00000000000..07582d66109 --- /dev/null +++ b/cli/azd/internal/figspec/extension_metadata.go @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package figspec + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/extensions" +) + +// ExtensionMetadataProvider provides extension metadata for generating figspec completions +type ExtensionMetadataProvider interface { + // LoadMetadata loads the cached metadata for an extension by its ID + LoadMetadata(extensionId string) (*extensions.ExtensionCommandMetadata, error) +} + +// convertExtensionCommand converts an extension command to a Fig subcommand recursively +func convertExtensionCommand(cmd extensions.Command, includeHidden bool) *Subcommand { + if !includeHidden && cmd.Hidden { + return nil + } + + names := []string{cmd.Name[len(cmd.Name)-1]} + names = append(names, cmd.Aliases...) + + subcommand := &Subcommand{ + Name: names, + Description: cmd.Short, + Hidden: cmd.Hidden, + } + + // Convert flags to options + for _, flag := range cmd.Flags { + if !includeHidden && flag.Hidden { + continue + } + + option := convertExtensionFlag(flag) + subcommand.Options = append(subcommand.Options, option) + } + + // Convert args + for _, arg := range cmd.Args { + figArg := convertExtensionArg(arg) + subcommand.Args = append(subcommand.Args, figArg) + } + + // Convert subcommands recursively + for _, subcmd := range cmd.Subcommands { + figSubcmd := convertExtensionCommand(subcmd, includeHidden) + if figSubcmd != nil { + subcommand.Subcommands = append(subcommand.Subcommands, *figSubcmd) + } + } + + return subcommand +} + +// convertExtensionFlag converts an extension flag to a Fig option +func convertExtensionFlag(flag extensions.Flag) Option { + names := []string{"--" + flag.Name} + if flag.Shorthand != "" { + names = append(names, "-"+flag.Shorthand) + } + + option := Option{ + Name: names, + Description: flag.Description, + IsRequired: flag.Required, + Hidden: flag.Hidden, + } + + // Set isDangerous for common dangerous flags + isDangerous := flag.Name == "force" || + flag.Name == "purge" || + flag.Name == "show-secrets" + option.IsDangerous = isDangerous + + // Handle flag arguments (non-bool flags need args) + if flag.Type != "bool" && flag.Type != "" { + arg := Arg{ + Name: flag.Name, + } + + // Add valid values as suggestions + if len(flag.ValidValues) > 0 { + arg.Suggestions = flag.ValidValues + } + + option.Args = []Arg{arg} + } + + // Set isRepeatable for array types + isRepeatable := flag.Type == "stringArray" || flag.Type == "intArray" + option.IsRepeatable = isRepeatable + + return option +} + +// convertExtensionArg converts an extension argument to a Fig arg +func convertExtensionArg(arg extensions.Argument) Arg { + figArg := Arg{ + Name: arg.Name, + Description: arg.Description, + IsOptional: !arg.Required, + } + + // Add valid values as suggestions + if len(arg.ValidValues) > 0 { + figArg.Suggestions = arg.ValidValues + } + + return figArg +} + +// convertExtensionCommandForHelp converts an extension command to a Fig help subcommand recursively +// (only includes name, description, and nested subcommands for help tree) +func convertExtensionCommandForHelp(cmd extensions.Command, includeHidden bool) *Subcommand { + if !includeHidden && cmd.Hidden { + return nil + } + + names := []string{cmd.Name[len(cmd.Name)-1]} + names = append(names, cmd.Aliases...) + + subcommand := &Subcommand{ + Name: names, + Description: cmd.Short, + } + + // Convert subcommands recursively for help tree + for _, subcmd := range cmd.Subcommands { + figSubcmd := convertExtensionCommandForHelp(subcmd, includeHidden) + if figSubcmd != nil { + subcommand.Subcommands = append(subcommand.Subcommands, *figSubcmd) + } + } + + return subcommand +} diff --git a/cli/azd/internal/figspec/extension_metadata_test.go b/cli/azd/internal/figspec/extension_metadata_test.go new file mode 100644 index 00000000000..71d128bf86d --- /dev/null +++ b/cli/azd/internal/figspec/extension_metadata_test.go @@ -0,0 +1,322 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package figspec + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/stretchr/testify/require" +) + +func TestConvertExtensionCommand(t *testing.T) { + tests := []struct { + name string + cmd extensions.Command + includeHidden bool + wantNil bool + wantName []string + wantDesc string + wantSubcmds int + wantOptions int + wantArgs int + }{ + { + name: "basic command", + cmd: extensions.Command{ + Name: []string{"context"}, + Short: "Display context info", + }, + includeHidden: false, + wantNil: false, + wantName: []string{"context"}, + wantDesc: "Display context info", + wantSubcmds: 0, + wantOptions: 0, + wantArgs: 0, + }, + { + name: "command with aliases", + cmd: extensions.Command{ + Name: []string{"list"}, + Short: "List items", + Aliases: []string{"ls", "l"}, + }, + includeHidden: false, + wantNil: false, + wantName: []string{"list", "ls", "l"}, + wantDesc: "List items", + wantSubcmds: 0, + wantOptions: 0, + wantArgs: 0, + }, + { + name: "hidden command not included", + cmd: extensions.Command{ + Name: []string{"hidden"}, + Short: "Hidden command", + Hidden: true, + }, + includeHidden: false, + wantNil: true, + }, + { + name: "hidden command included when flag set", + cmd: extensions.Command{ + Name: []string{"hidden"}, + Short: "Hidden command", + Hidden: true, + }, + includeHidden: true, + wantNil: false, + wantName: []string{"hidden"}, + wantDesc: "Hidden command", + wantSubcmds: 0, + wantOptions: 0, + wantArgs: 0, + }, + { + name: "command with flags", + cmd: extensions.Command{ + Name: []string{"run"}, + Short: "Run something", + Flags: []extensions.Flag{ + {Name: "verbose", Shorthand: "v", Description: "Enable verbose output", Type: "bool"}, + {Name: "output", Shorthand: "o", Description: "Output format", Type: "string"}, + }, + }, + includeHidden: false, + wantNil: false, + wantName: []string{"run"}, + wantDesc: "Run something", + wantSubcmds: 0, + wantOptions: 2, + wantArgs: 0, + }, + { + name: "command with args", + cmd: extensions.Command{ + Name: []string{"get"}, + Short: "Get an item", + Args: []extensions.Argument{ + {Name: "name", Description: "Item name", Required: true}, + {Name: "version", Description: "Optional version", Required: false}, + }, + }, + includeHidden: false, + wantNil: false, + wantName: []string{"get"}, + wantDesc: "Get an item", + wantSubcmds: 0, + wantOptions: 0, + wantArgs: 2, + }, + { + name: "command with subcommands", + cmd: extensions.Command{ + Name: []string{"service"}, + Short: "Service commands", + Subcommands: []extensions.Command{ + {Name: []string{"service", "start"}, Short: "Start service"}, + {Name: []string{"service", "stop"}, Short: "Stop service"}, + }, + }, + includeHidden: false, + wantNil: false, + wantName: []string{"service"}, + wantDesc: "Service commands", + wantSubcmds: 2, + wantOptions: 0, + wantArgs: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertExtensionCommand(tt.cmd, tt.includeHidden) + + if tt.wantNil { + require.Nil(t, result) + return + } + + require.NotNil(t, result) + require.Equal(t, tt.wantName, result.Name) + require.Equal(t, tt.wantDesc, result.Description) + require.Len(t, result.Subcommands, tt.wantSubcmds) + require.Len(t, result.Options, tt.wantOptions) + require.Len(t, result.Args, tt.wantArgs) + }) + } +} + +func TestConvertExtensionFlag(t *testing.T) { + tests := []struct { + name string + flag extensions.Flag + wantName []string + wantDesc string + wantRequired bool + wantRepeatable bool + wantDangerous bool + wantArgsCount int + wantSuggestions []string + }{ + { + name: "simple bool flag", + flag: extensions.Flag{ + Name: "verbose", + Shorthand: "v", + Description: "Verbose output", + Type: "bool", + }, + wantName: []string{"--verbose", "-v"}, + wantDesc: "Verbose output", + wantRequired: false, + wantRepeatable: false, + wantDangerous: false, + wantArgsCount: 0, + wantSuggestions: nil, + }, + { + name: "string flag with valid values", + flag: extensions.Flag{ + Name: "format", + Description: "Output format", + Type: "string", + ValidValues: []string{"json", "table", "yaml"}, + }, + wantName: []string{"--format"}, + wantDesc: "Output format", + wantRequired: false, + wantRepeatable: false, + wantDangerous: false, + wantArgsCount: 1, + wantSuggestions: []string{"json", "table", "yaml"}, + }, + { + name: "required flag", + flag: extensions.Flag{ + Name: "name", + Description: "Item name", + Type: "string", + Required: true, + }, + wantName: []string{"--name"}, + wantDesc: "Item name", + wantRequired: true, + wantRepeatable: false, + wantDangerous: false, + wantArgsCount: 1, + wantSuggestions: nil, + }, + { + name: "array flag is repeatable", + flag: extensions.Flag{ + Name: "tag", + Description: "Tags to apply", + Type: "stringArray", + }, + wantName: []string{"--tag"}, + wantDesc: "Tags to apply", + wantRequired: false, + wantRepeatable: true, + wantDangerous: false, + wantArgsCount: 1, + wantSuggestions: nil, + }, + { + name: "force flag is dangerous", + flag: extensions.Flag{ + Name: "force", + Description: "Force operation", + Type: "bool", + }, + wantName: []string{"--force"}, + wantDesc: "Force operation", + wantRequired: false, + wantRepeatable: false, + wantDangerous: true, + wantArgsCount: 0, + wantSuggestions: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertExtensionFlag(tt.flag) + + require.Equal(t, tt.wantName, result.Name) + require.Equal(t, tt.wantDesc, result.Description) + require.Equal(t, tt.wantRequired, result.IsRequired) + require.Equal(t, tt.wantRepeatable, result.IsRepeatable) + require.Equal(t, tt.wantDangerous, result.IsDangerous) + require.Len(t, result.Args, tt.wantArgsCount) + + if tt.wantArgsCount > 0 && tt.wantSuggestions != nil { + require.Equal(t, tt.wantSuggestions, result.Args[0].Suggestions) + } + }) + } +} + +func TestConvertExtensionArg(t *testing.T) { + tests := []struct { + name string + arg extensions.Argument + wantName string + wantDesc string + wantOptional bool + wantSuggestions []string + }{ + { + name: "required arg", + arg: extensions.Argument{ + Name: "filename", + Description: "File to process", + Required: true, + }, + wantName: "filename", + wantDesc: "File to process", + wantOptional: false, + wantSuggestions: nil, + }, + { + name: "optional arg", + arg: extensions.Argument{ + Name: "version", + Description: "Optional version", + Required: false, + }, + wantName: "version", + wantDesc: "Optional version", + wantOptional: true, + wantSuggestions: nil, + }, + { + name: "arg with valid values", + arg: extensions.Argument{ + Name: "environment", + Description: "Target environment", + Required: true, + ValidValues: []string{"dev", "staging", "prod"}, + }, + wantName: "environment", + wantDesc: "Target environment", + wantOptional: false, + wantSuggestions: []string{"dev", "staging", "prod"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertExtensionArg(tt.arg) + + require.Equal(t, tt.wantName, result.Name) + require.Equal(t, tt.wantDesc, result.Description) + require.Equal(t, tt.wantOptional, result.IsOptional) + require.Equal(t, tt.wantSuggestions, result.Suggestions) + }) + } +} diff --git a/cli/azd/internal/figspec/spec_builder.go b/cli/azd/internal/figspec/spec_builder.go index ed13415e477..e9da42a7cb5 100644 --- a/cli/azd/internal/figspec/spec_builder.go +++ b/cli/azd/internal/figspec/spec_builder.go @@ -15,11 +15,12 @@ import ( // SpecBuilder builds Fig autocomplete specifications from Cobra commands. // Note: This is different from Fig's concept of "generators" which are dynamic completion functions. type SpecBuilder struct { - suggestionProvider CustomSuggestionProvider - generatorProvider CustomGeneratorProvider - argsProvider CustomArgsProvider - flagArgsProvider CustomFlagArgsProvider - includeHidden bool + suggestionProvider CustomSuggestionProvider + generatorProvider CustomGeneratorProvider + argsProvider CustomArgsProvider + flagArgsProvider CustomFlagArgsProvider + extensionMetadataProvider ExtensionMetadataProvider + includeHidden bool } // NewSpecBuilder creates a new Fig spec builder @@ -34,6 +35,14 @@ func NewSpecBuilder(includeHidden bool) *SpecBuilder { } } +// WithExtensionMetadata sets the extension metadata provider for the builder. +// When set, the builder will use extension metadata to generate full command trees +// for extensions that have the metadata capability. +func (sb *SpecBuilder) WithExtensionMetadata(provider ExtensionMetadataProvider) *SpecBuilder { + sb.extensionMetadataProvider = provider + return sb +} + // generateNonPersistentGlobalOptions generates options for non-persistent global flags (--help, --docs) func (sb *SpecBuilder) generateNonPersistentGlobalOptions(root *cobra.Command) []Option { // Create a flagset with only the non-persistent global flags @@ -88,6 +97,12 @@ func (sb *SpecBuilder) generateSubcommands(cmd *cobra.Command, ctx *CommandConte names := []string{sub.Name()} names = append(names, sub.Aliases...) + // Check if this is an extension command with metadata capability + if extensionSubcmd := sb.tryGenerateExtensionSubcommand(sub, names); extensionSubcmd != nil { + subcommands = append(subcommands, *extensionSubcmd) + continue + } + localOpts := sb.generateOptions(sub.LocalNonPersistentFlags(), subCtx.CommandPath, false) args := sb.generateCommandArgs(sub, subCtx) nestedSubcommands := sb.generateSubcommands(sub, subCtx) @@ -109,6 +124,43 @@ func (sb *SpecBuilder) generateSubcommands(cmd *cobra.Command, ctx *CommandConte return subcommands } +// tryGenerateExtensionSubcommand attempts to generate a subcommand from extension metadata. +// Returns nil if the command is not an extension command or has no metadata available. +func (sb *SpecBuilder) tryGenerateExtensionSubcommand(cmd *cobra.Command, names []string) *Subcommand { + if sb.extensionMetadataProvider == nil { + return nil + } + + // Check if this command has extension annotations + extensionId, hasId := cmd.Annotations["extension.id"] + if !hasId { + return nil + } + + // Try to load extension metadata + metadata, err := sb.extensionMetadataProvider.LoadMetadata(extensionId) + if err != nil || metadata == nil { + return nil + } + + // Build the subcommand from metadata + subcommand := &Subcommand{ + Name: names, + Description: cmd.Short, + Hidden: cmd.Hidden, + } + + // Add subcommands from metadata + for _, extCmd := range metadata.Commands { + figSubcmd := convertExtensionCommand(extCmd, sb.includeHidden) + if figSubcmd != nil { + subcommand.Subcommands = append(subcommand.Subcommands, *figSubcmd) + } + } + + return subcommand +} + func (sb *SpecBuilder) generateOptions(flagSet *pflag.FlagSet, commandPath string, persistent bool) []Option { var options []Option flagSet.VisitAll(func(flag *pflag.Flag) { @@ -262,6 +314,12 @@ func (sb *SpecBuilder) generateHelpSubcommands(cmd *cobra.Command) []Subcommand names := []string{sub.Name()} names = append(names, sub.Aliases...) + // Check if this is an extension command with metadata + if helpSubcmd := sb.tryGenerateExtensionHelpSubcommand(sub, names); helpSubcmd != nil { + subcommands = append(subcommands, *helpSubcmd) + continue + } + helpSub := Subcommand{ Name: names, Description: sub.Short, @@ -273,3 +331,35 @@ func (sb *SpecBuilder) generateHelpSubcommands(cmd *cobra.Command) []Subcommand return subcommands } + +// tryGenerateExtensionHelpSubcommand attempts to generate a help subcommand from extension metadata. +func (sb *SpecBuilder) tryGenerateExtensionHelpSubcommand(cmd *cobra.Command, names []string) *Subcommand { + if sb.extensionMetadataProvider == nil { + return nil + } + + extensionId, hasId := cmd.Annotations["extension.id"] + if !hasId { + return nil + } + + metadata, err := sb.extensionMetadataProvider.LoadMetadata(extensionId) + if err != nil || metadata == nil { + return nil + } + + subcommand := &Subcommand{ + Name: names, + Description: cmd.Short, + } + + // Add help subcommands from metadata + for _, extCmd := range metadata.Commands { + helpSubcmd := convertExtensionCommandForHelp(extCmd, sb.includeHidden) + if helpSubcmd != nil { + subcommand.Subcommands = append(subcommand.Subcommands, *helpSubcmd) + } + } + + return subcommand +} diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index ba059af3f32..1b2aac3e46d 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -958,3 +958,25 @@ func (m *Manager) MetadataExists(extensionId string) bool { _, err = os.Stat(metadataPath) return err == nil } + +// LoadMetadataFromDir loads extension metadata from a specific config directory. +// This is a standalone function useful for testing or when a Manager instance is not available. +func LoadMetadataFromDir(configDir, extensionId string) (*ExtensionCommandMetadata, error) { + extensionDir := filepath.Join(configDir, "extensions", extensionId) + metadataPath := filepath.Join(extensionDir, metadataFileName) + + data, err := os.ReadFile(metadataPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("metadata not found for extension '%s'", extensionId) + } + return nil, fmt.Errorf("failed to read metadata file: %w", err) + } + + var metadata ExtensionCommandMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) + } + + return &metadata, nil +} From 93f4642af463105a70d93aaba1c45e6d49bd3ae4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:00:28 +0000 Subject: [PATCH 03/12] Fix formatting issues Co-authored-by: JeffreyCA <9157833+JeffreyCA@users.noreply.github.com> --- cli/azd/extensions/azure.ai.finetune/go.mod | 2 +- .../extensions/microsoft.azd.concurx/go.mod | 4 +- .../extensions/microsoft.azd.concurx/go.sum | 8 +- .../figspec/extension_metadata_test.go | 88 +++++++++---------- 4 files changed, 51 insertions(+), 51 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/go.mod b/cli/azd/extensions/azure.ai.finetune/go.mod index 90b0dd3d22c..c056931f2b3 100644 --- a/cli/azd/extensions/azure.ai.finetune/go.mod +++ b/cli/azd/extensions/azure.ai.finetune/go.mod @@ -12,6 +12,7 @@ require ( github.com/openai/openai-go/v3 v3.2.0 github.com/sethvargo/go-retry v0.3.0 github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -69,7 +70,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/theckman/yacspin v0.13.12 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.mod b/cli/azd/extensions/microsoft.azd.concurx/go.mod index 1164ab05cfb..f6ca13b2857 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.mod +++ b/cli/azd/extensions/microsoft.azd.concurx/go.mod @@ -76,9 +76,9 @@ require ( go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.37.0 // indirect + golang.org/x/term v0.38.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect google.golang.org/grpc v1.76.0 // indirect diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.sum b/cli/azd/extensions/microsoft.azd.concurx/go.sum index f9299daa7c5..a3768ddd5b4 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.sum +++ b/cli/azd/extensions/microsoft.azd.concurx/go.sum @@ -205,8 +205,8 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -214,8 +214,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= diff --git a/cli/azd/internal/figspec/extension_metadata_test.go b/cli/azd/internal/figspec/extension_metadata_test.go index 71d128bf86d..4842a7fb432 100644 --- a/cli/azd/internal/figspec/extension_metadata_test.go +++ b/cli/azd/internal/figspec/extension_metadata_test.go @@ -153,15 +153,15 @@ func TestConvertExtensionCommand(t *testing.T) { func TestConvertExtensionFlag(t *testing.T) { tests := []struct { - name string - flag extensions.Flag - wantName []string - wantDesc string - wantRequired bool - wantRepeatable bool - wantDangerous bool - wantArgsCount int - wantSuggestions []string + name string + flag extensions.Flag + wantName []string + wantDesc string + wantRequired bool + wantRepeatable bool + wantDangerous bool + wantArgsCount int + wantSuggestions []string }{ { name: "simple bool flag", @@ -171,13 +171,13 @@ func TestConvertExtensionFlag(t *testing.T) { Description: "Verbose output", Type: "bool", }, - wantName: []string{"--verbose", "-v"}, - wantDesc: "Verbose output", - wantRequired: false, - wantRepeatable: false, - wantDangerous: false, - wantArgsCount: 0, - wantSuggestions: nil, + wantName: []string{"--verbose", "-v"}, + wantDesc: "Verbose output", + wantRequired: false, + wantRepeatable: false, + wantDangerous: false, + wantArgsCount: 0, + wantSuggestions: nil, }, { name: "string flag with valid values", @@ -187,13 +187,13 @@ func TestConvertExtensionFlag(t *testing.T) { Type: "string", ValidValues: []string{"json", "table", "yaml"}, }, - wantName: []string{"--format"}, - wantDesc: "Output format", - wantRequired: false, - wantRepeatable: false, - wantDangerous: false, - wantArgsCount: 1, - wantSuggestions: []string{"json", "table", "yaml"}, + wantName: []string{"--format"}, + wantDesc: "Output format", + wantRequired: false, + wantRepeatable: false, + wantDangerous: false, + wantArgsCount: 1, + wantSuggestions: []string{"json", "table", "yaml"}, }, { name: "required flag", @@ -203,13 +203,13 @@ func TestConvertExtensionFlag(t *testing.T) { Type: "string", Required: true, }, - wantName: []string{"--name"}, - wantDesc: "Item name", - wantRequired: true, - wantRepeatable: false, - wantDangerous: false, - wantArgsCount: 1, - wantSuggestions: nil, + wantName: []string{"--name"}, + wantDesc: "Item name", + wantRequired: true, + wantRepeatable: false, + wantDangerous: false, + wantArgsCount: 1, + wantSuggestions: nil, }, { name: "array flag is repeatable", @@ -218,13 +218,13 @@ func TestConvertExtensionFlag(t *testing.T) { Description: "Tags to apply", Type: "stringArray", }, - wantName: []string{"--tag"}, - wantDesc: "Tags to apply", - wantRequired: false, - wantRepeatable: true, - wantDangerous: false, - wantArgsCount: 1, - wantSuggestions: nil, + wantName: []string{"--tag"}, + wantDesc: "Tags to apply", + wantRequired: false, + wantRepeatable: true, + wantDangerous: false, + wantArgsCount: 1, + wantSuggestions: nil, }, { name: "force flag is dangerous", @@ -233,13 +233,13 @@ func TestConvertExtensionFlag(t *testing.T) { Description: "Force operation", Type: "bool", }, - wantName: []string{"--force"}, - wantDesc: "Force operation", - wantRequired: false, - wantRepeatable: false, - wantDangerous: true, - wantArgsCount: 0, - wantSuggestions: nil, + wantName: []string{"--force"}, + wantDesc: "Force operation", + wantRequired: false, + wantRepeatable: false, + wantDangerous: true, + wantArgsCount: 0, + wantSuggestions: nil, }, } From 48001d0933bb3110035645bf7948714b11753293 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 27 Jan 2026 19:22:21 +0000 Subject: [PATCH 04/12] Fix indentation --- cli/azd/cmd/testdata/TestFigSpec.ts | 906 +++++++++--------- .../internal/figspec/typescript_renderer.go | 2 +- 2 files changed, 454 insertions(+), 454 deletions(-) diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 8f487b977e4..55849f8f7d3 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -206,9 +206,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the azd environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -219,18 +219,18 @@ const completionSpec: Fig.Spec = { name: ['--host'], description: '[Optional] For container based agents, can override the default host to target a container app instead. Accepted values: \'containerapp\'', args: [ - { - name: 'host', - }, + { + name: 'host', + }, ], }, { name: ['--manifest', '-m'], description: 'Path or URI to an agent manifest to add to your azd project', args: [ - { - name: 'manifest', - }, + { + name: 'manifest', + }, ], }, { @@ -241,18 +241,18 @@ const completionSpec: Fig.Spec = { name: ['--project-id', '-p'], description: 'Existing Microsoft Foundry Project Id to initialize your azd environment with', args: [ - { - name: 'project-id', - }, + { + name: 'project-id', + }, ], }, { name: ['--src', '-s'], description: '[Optional] Directory to download the agent definition to (defaults to \'src/\')', args: [ - { - name: 'src', - }, + { + name: 'src', + }, ], }, ], @@ -293,18 +293,18 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-n'], description: 'The name of the azd environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { name: ['--from-job', '-j'], description: 'Clone configuration from an existing job ID', args: [ - { - name: 'from-job', - }, + { + name: 'from-job', + }, ], }, { @@ -319,45 +319,45 @@ const completionSpec: Fig.Spec = { name: ['--project-endpoint', '-e'], description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', args: [ - { - name: 'project-endpoint', - }, + { + name: 'project-endpoint', + }, ], }, { name: ['--project-resource-id', '-p'], description: 'ARM resource ID of the Microsoft Foundry Project (e.g., /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{account}/projects/{project})', args: [ - { - name: 'project-resource-id', - }, + { + name: 'project-resource-id', + }, ], }, { name: ['--subscription', '-s'], description: 'Azure subscription ID', args: [ - { - name: 'subscription', - }, + { + name: 'subscription', + }, ], }, { name: ['--template', '-t'], description: 'URL or path to a fine-tune job template', args: [ - { - name: 'template', - }, + { + name: 'template', + }, ], }, { name: ['--working-directory', '-w'], description: 'Local path for project output', args: [ - { - name: 'working-directory', - }, + { + name: 'working-directory', + }, ], }, ], @@ -387,9 +387,9 @@ const completionSpec: Fig.Spec = { name: ['--id', '-i'], description: 'Job ID (required)', args: [ - { - name: 'id', - }, + { + name: 'id', + }, ], }, { @@ -406,9 +406,9 @@ const completionSpec: Fig.Spec = { name: ['--capacity', '-c'], description: 'Capacity units', args: [ - { - name: 'capacity', - }, + { + name: 'capacity', + }, ], }, { @@ -419,9 +419,9 @@ const completionSpec: Fig.Spec = { name: ['--deployment-name', '-d'], description: 'Deployment name (required)', args: [ - { - name: 'deployment-name', - }, + { + name: 'deployment-name', + }, ], }, { @@ -432,18 +432,18 @@ const completionSpec: Fig.Spec = { name: ['--job-id', '-i'], description: 'Fine-tuning job ID (required)', args: [ - { - name: 'job-id', - }, + { + name: 'job-id', + }, ], }, { name: ['--model-format', '-m'], description: 'Model format', args: [ - { - name: 'model-format', - }, + { + name: 'model-format', + }, ], }, { @@ -458,18 +458,18 @@ const completionSpec: Fig.Spec = { name: ['--sku', '-s'], description: 'SKU for deployment', args: [ - { - name: 'sku', - }, + { + name: 'sku', + }, ], }, { name: ['--version', '-v'], description: 'Model version', args: [ - { - name: 'version', - }, + { + name: 'version', + }, ], }, ], @@ -482,9 +482,9 @@ const completionSpec: Fig.Spec = { name: ['--after'], description: 'Pagination cursor', args: [ - { - name: 'after', - }, + { + name: 'after', + }, ], }, { @@ -503,18 +503,18 @@ const completionSpec: Fig.Spec = { name: ['--output', '-o'], description: 'Output format: table, json', args: [ - { - name: 'output', - }, + { + name: 'output', + }, ], }, { name: ['--top', '-t'], description: 'Number of jobs to return', args: [ - { - name: 'top', - }, + { + name: 'top', + }, ], }, ], @@ -535,9 +535,9 @@ const completionSpec: Fig.Spec = { name: ['--id', '-i'], description: 'Job ID (required)', args: [ - { - name: 'id', - }, + { + name: 'id', + }, ], }, { @@ -562,9 +562,9 @@ const completionSpec: Fig.Spec = { name: ['--id', '-i'], description: 'Job ID (required)', args: [ - { - name: 'id', - }, + { + name: 'id', + }, ], }, { @@ -589,9 +589,9 @@ const completionSpec: Fig.Spec = { name: ['--id', '-i'], description: 'Job ID (required)', args: [ - { - name: 'id', - }, + { + name: 'id', + }, ], }, { @@ -606,9 +606,9 @@ const completionSpec: Fig.Spec = { name: ['--output', '-o'], description: 'Output format: table, json, yaml', args: [ - { - name: 'output', - }, + { + name: 'output', + }, ], }, ], @@ -625,9 +625,9 @@ const completionSpec: Fig.Spec = { name: ['--file', '-f'], description: 'Path to the config file.', args: [ - { - name: 'file', - }, + { + name: 'file', + }, ], }, { @@ -638,9 +638,9 @@ const completionSpec: Fig.Spec = { name: ['--model', '-m'], description: 'Base model to fine-tune. Overrides config file. Required if --file is not provided', args: [ - { - name: 'model', - }, + { + name: 'model', + }, ], }, { @@ -651,36 +651,36 @@ const completionSpec: Fig.Spec = { name: ['--seed', '-r'], description: 'Random seed for reproducibility of the job. If a seed is not specified, one will be generated for you. Overrides config file.', args: [ - { - name: 'seed', - }, + { + name: 'seed', + }, ], }, { name: ['--suffix', '-s'], description: 'An optional string of up to 64 characters that will be added to your fine-tuned model name. Overrides config file.', args: [ - { - name: 'suffix', - }, + { + name: 'suffix', + }, ], }, { name: ['--training-file', '-t'], description: 'Training file ID or local path. Use \'local:\' prefix for local paths. Required if --file is not provided', args: [ - { - name: 'training-file', - }, + { + name: 'training-file', + }, ], }, { name: ['--validation-file', '-v'], description: 'Validation file ID or local path. Use \'local:\' prefix for local paths.', args: [ - { - name: 'validation-file', - }, + { + name: 'validation-file', + }, ], }, ], @@ -739,37 +739,37 @@ const completionSpec: Fig.Spec = { name: ['--client-certificate'], description: 'The path to the client certificate for the service principal to authenticate with.', args: [ - { - name: 'client-certificate', - }, + { + name: 'client-certificate', + }, ], }, { name: ['--client-id'], description: 'The client id for the service principal to authenticate with.', args: [ - { - name: 'client-id', - }, + { + name: 'client-id', + }, ], }, { name: ['--client-secret'], description: 'The client secret for the service principal to authenticate with. Set to the empty string to read the value from the console.', args: [ - { - name: 'client-secret', - }, + { + name: 'client-secret', + }, ], }, { name: ['--federated-credential-provider'], description: 'The provider to use to acquire a federated token to authenticate with. Supported values: github, azure-pipelines, oidc', args: [ - { - name: 'federated-credential-provider', - suggestions: ['github', 'azure-pipelines', 'oidc'], - }, + { + name: 'federated-credential-provider', + suggestions: ['github', 'azure-pipelines', 'oidc'], + }, ], }, { @@ -780,18 +780,18 @@ const completionSpec: Fig.Spec = { name: ['--redirect-port'], description: 'Choose the port to be used as part of the redirect URI during interactive login.', args: [ - { - name: 'redirect-port', - }, + { + name: 'redirect-port', + }, ], }, { name: ['--tenant-id'], description: 'The tenant id or domain name to authenticate with.', args: [ - { - name: 'tenant-id', - }, + { + name: 'tenant-id', + }, ], }, { @@ -822,9 +822,9 @@ const completionSpec: Fig.Spec = { name: ['--branch-name'], description: 'The branch name to use when pushing changes to the copilot-setup-steps.yml', args: [ - { - name: 'branch-name', - }, + { + name: 'branch-name', + }, ], }, { @@ -835,9 +835,9 @@ const completionSpec: Fig.Spec = { name: ['--github-host-name'], description: 'The hostname to use with GitHub commands', args: [ - { - name: 'github-host-name', - }, + { + name: 'github-host-name', + }, ], }, { @@ -848,18 +848,18 @@ const completionSpec: Fig.Spec = { name: ['--managed-identity-name'], description: 'The name to use for the managed identity, if created.', args: [ - { - name: 'managed-identity-name', - }, + { + name: 'managed-identity-name', + }, ], }, { name: ['--remote-name'], description: 'The name of the git remote where the Copilot Coding Agent will run (ex: /)', args: [ - { - name: 'remote-name', - }, + { + name: 'remote-name', + }, ], }, { @@ -867,9 +867,9 @@ const completionSpec: Fig.Spec = { description: 'The roles to assign to the service principal or managed identity. By default, the service principal or managed identity will be granted the Reader role.', isRepeatable: true, args: [ - { - name: 'roles', - }, + { + name: 'roles', + }, ], }, ], @@ -1151,18 +1151,18 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { name: ['--from-package'], description: 'Deploys the packaged service located at the provided path. Supports zipped file packages (file path) or container images (image tag).', args: [ - { - name: 'file-path|image-tag', - }, + { + name: 'file-path|image-tag', + }, ], }, ], @@ -1179,9 +1179,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -1216,9 +1216,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -1234,9 +1234,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -1257,9 +1257,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -1277,9 +1277,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -1296,9 +1296,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -1315,18 +1315,18 @@ const completionSpec: Fig.Spec = { name: ['--location', '-l'], description: 'Azure location for the new environment', args: [ - { - name: 'location', - }, + { + name: 'location', + }, ], }, { name: ['--subscription'], description: 'Name or ID of an Azure subscription to use for the new environment', args: [ - { - name: 'subscription', - }, + { + name: 'subscription', + }, ], }, ], @@ -1342,27 +1342,27 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { name: ['--hint'], description: 'Hint to help identify the environment to refresh', args: [ - { - name: 'hint', - }, + { + name: 'hint', + }, ], }, { name: ['--layer'], description: 'Provisioning layer to refresh the environment from.', args: [ - { - name: 'layer', - }, + { + name: 'layer', + }, ], }, ], @@ -1378,9 +1378,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -1410,18 +1410,18 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { name: ['--file'], description: 'Path to .env formatted file to load environment values from.', args: [ - { - name: 'file', - }, + { + name: 'file', + }, ], }, ], @@ -1444,9 +1444,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -1473,18 +1473,18 @@ const completionSpec: Fig.Spec = { name: ['--source', '-s'], description: 'The extension source to use for installs', args: [ - { - name: 'source', - }, + { + name: 'source', + }, ], }, { name: ['--version', '-v'], description: 'The version of the extension to install', args: [ - { - name: 'version', - }, + { + name: 'version', + }, ], }, ], @@ -1505,9 +1505,9 @@ const completionSpec: Fig.Spec = { name: ['--source'], description: 'Filter extensions by source', args: [ - { - name: 'source', - }, + { + name: 'source', + }, ], }, { @@ -1515,9 +1515,9 @@ const completionSpec: Fig.Spec = { description: 'Filter extensions by tags', isRepeatable: true, args: [ - { - name: 'tags', - }, + { + name: 'tags', + }, ], }, ], @@ -1530,9 +1530,9 @@ const completionSpec: Fig.Spec = { name: ['--source', '-s'], description: 'The extension source to use.', args: [ - { - name: 'source', - }, + { + name: 'source', + }, ], }, ], @@ -1553,27 +1553,27 @@ const completionSpec: Fig.Spec = { name: ['--location', '-l'], description: 'The location of the extension source', args: [ - { - name: 'location', - }, + { + name: 'location', + }, ], }, { name: ['--name', '-n'], description: 'The name of the extension source', args: [ - { - name: 'name', - }, + { + name: 'name', + }, ], }, { name: ['--type', '-t'], description: 'The type of the extension source. Supported types are \'file\' and \'url\'', args: [ - { - name: 'type', - }, + { + name: 'type', + }, ], }, ], @@ -1618,18 +1618,18 @@ const completionSpec: Fig.Spec = { name: ['--source', '-s'], description: 'The extension source to use for upgrades', args: [ - { - name: 'source', - }, + { + name: 'source', + }, ], }, { name: ['--version', '-v'], description: 'The version of the extension to upgrade to', args: [ - { - name: 'version', - }, + { + name: 'version', + }, ], }, ], @@ -1653,27 +1653,27 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { name: ['--platform'], description: 'Forces hooks to run for the specified platform.', args: [ - { - name: 'platform', - }, + { + name: 'platform', + }, ], }, { name: ['--service'], description: 'Only runs hooks for the specified service.', args: [ - { - name: 'service', - }, + { + name: 'service', + }, ], }, ], @@ -1713,9 +1713,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -1735,18 +1735,18 @@ const completionSpec: Fig.Spec = { name: ['--branch', '-b'], description: 'The template branch to initialize from. Must be used with a template argument (--template or -t).', args: [ - { - name: 'branch', - }, + { + name: 'branch', + }, ], }, { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -1754,10 +1754,10 @@ const completionSpec: Fig.Spec = { description: 'The tag(s) used to filter template results. Supports comma-separated values.', isRepeatable: true, args: [ - { - name: 'filter', - generators: azdGenerators.listTemplateTags, - }, + { + name: 'filter', + generators: azdGenerators.listTemplateTags, + }, ], }, { @@ -1768,9 +1768,9 @@ const completionSpec: Fig.Spec = { name: ['--location', '-l'], description: 'Azure location for the new environment', args: [ - { - name: 'location', - }, + { + name: 'location', + }, ], }, { @@ -1781,19 +1781,19 @@ const completionSpec: Fig.Spec = { name: ['--subscription', '-s'], description: 'Name or ID of an Azure subscription to use for the new environment', args: [ - { - name: 'subscription', - }, + { + name: 'subscription', + }, ], }, { name: ['--template', '-t'], description: 'Initializes a new application from a template. You can use Full URI, /, or if it\'s part of the azure-samples organization.', args: [ - { - name: 'template', - generators: azdGenerators.listTemplatesFiltered, - }, + { + name: 'template', + generators: azdGenerators.listTemplatesFiltered, + }, ], }, { @@ -1818,10 +1818,10 @@ const completionSpec: Fig.Spec = { name: ['--action'], description: 'Action type: \'all\' or \'readonly\'', args: [ - { - name: 'action', - suggestions: ['all', 'readonly'], - }, + { + name: 'action', + suggestions: ['all', 'readonly'], + }, ], }, { @@ -1832,48 +1832,48 @@ const completionSpec: Fig.Spec = { name: ['--operation'], description: 'Operation type: \'tool\' or \'sampling\'', args: [ - { - name: 'operation', - suggestions: ['tool', 'sampling'], - }, + { + name: 'operation', + suggestions: ['tool', 'sampling'], + }, ], }, { name: ['--permission'], description: 'Permission: \'allow\', \'deny\', or \'prompt\'', args: [ - { - name: 'permission', - suggestions: ['allow', 'deny', 'prompt'], - }, + { + name: 'permission', + suggestions: ['allow', 'deny', 'prompt'], + }, ], }, { name: ['--scope'], description: 'Rule scope: \'global\', or \'project\'', args: [ - { - name: 'scope', - suggestions: ['global', 'project'], - }, + { + name: 'scope', + suggestions: ['global', 'project'], + }, ], }, { name: ['--server'], description: 'Server name', args: [ - { - name: 'server', - }, + { + name: 'server', + }, ], }, { name: ['--tool'], description: 'Specific tool name (requires --server)', args: [ - { - name: 'tool', - }, + { + name: 'tool', + }, ], }, ], @@ -1886,49 +1886,49 @@ const completionSpec: Fig.Spec = { name: ['--action'], description: 'Action type to filter by (readonly, any)', args: [ - { - name: 'action', - suggestions: ['all', 'readonly'], - }, + { + name: 'action', + suggestions: ['all', 'readonly'], + }, ], }, { name: ['--operation'], description: 'Operation to filter by (tool, sampling)', args: [ - { - name: 'operation', - suggestions: ['tool', 'sampling'], - }, + { + name: 'operation', + suggestions: ['tool', 'sampling'], + }, ], }, { name: ['--permission'], description: 'Permission to filter by (allow, deny, prompt)', args: [ - { - name: 'permission', - suggestions: ['allow', 'deny', 'prompt'], - }, + { + name: 'permission', + suggestions: ['allow', 'deny', 'prompt'], + }, ], }, { name: ['--scope'], description: 'Consent scope to filter by (global, project). If not specified, lists rules from all scopes.', args: [ - { - name: 'scope', - suggestions: ['global', 'project'], - }, + { + name: 'scope', + suggestions: ['global', 'project'], + }, ], }, { name: ['--target'], description: 'Specific target to operate on (server/tool format)', args: [ - { - name: 'target', - }, + { + name: 'target', + }, ], }, ], @@ -1941,49 +1941,49 @@ const completionSpec: Fig.Spec = { name: ['--action'], description: 'Action type to filter by (readonly, any)', args: [ - { - name: 'action', - suggestions: ['all', 'readonly'], - }, + { + name: 'action', + suggestions: ['all', 'readonly'], + }, ], }, { name: ['--operation'], description: 'Operation to filter by (tool, sampling)', args: [ - { - name: 'operation', - suggestions: ['tool', 'sampling'], - }, + { + name: 'operation', + suggestions: ['tool', 'sampling'], + }, ], }, { name: ['--permission'], description: 'Permission to filter by (allow, deny, prompt)', args: [ - { - name: 'permission', - suggestions: ['allow', 'deny', 'prompt'], - }, + { + name: 'permission', + suggestions: ['allow', 'deny', 'prompt'], + }, ], }, { name: ['--scope'], description: 'Consent scope to filter by (global, project). If not specified, revokes rules from all scopes.', args: [ - { - name: 'scope', - suggestions: ['global', 'project'], - }, + { + name: 'scope', + suggestions: ['global', 'project'], + }, ], }, { name: ['--target'], description: 'Specific target to operate on (server/tool format)', args: [ - { - name: 'target', - }, + { + name: 'target', + }, ], }, ], @@ -2004,9 +2004,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -2035,18 +2035,18 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { name: ['--output-path'], description: 'File or folder path where the generated packages will be saved.', args: [ - { - name: 'output-path', - }, + { + name: 'output-path', + }, ], }, ], @@ -2067,46 +2067,46 @@ const completionSpec: Fig.Spec = { name: ['--applicationServiceManagementReference', '-m'], description: 'Service Management Reference. References application or service contact information from a Service or Asset Management database. This value must be a Universally Unique Identifier (UUID). You can set this value globally by running azd config set pipeline.config.applicationServiceManagementReference .', args: [ - { - name: 'applicationServiceManagementReference', - }, + { + name: 'applicationServiceManagementReference', + }, ], }, { name: ['--auth-type'], description: 'The authentication type used between the pipeline provider and Azure for deployment (Only valid for GitHub provider). Valid values: federated, client-credentials.', args: [ - { - name: 'auth-type', - suggestions: ['federated', 'client-credentials'], - }, + { + name: 'auth-type', + suggestions: ['federated', 'client-credentials'], + }, ], }, { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { name: ['--principal-id'], description: 'The client id of the service principal to use to grant access to Azure resources as part of the pipeline.', args: [ - { - name: 'principal-id', - }, + { + name: 'principal-id', + }, ], }, { name: ['--principal-name'], description: 'The name of the service principal to use to grant access to Azure resources as part of the pipeline.', args: [ - { - name: 'principal-name', - }, + { + name: 'principal-name', + }, ], }, { @@ -2114,28 +2114,28 @@ const completionSpec: Fig.Spec = { description: 'The roles to assign to the service principal. By default the service principal will be granted the Contributor and User Access Administrator roles.', isRepeatable: true, args: [ - { - name: 'principal-role', - }, + { + name: 'principal-role', + }, ], }, { name: ['--provider'], description: 'The pipeline provider to use (github for Github Actions and azdo for Azure Pipelines).', args: [ - { - name: 'provider', - suggestions: ['github', 'azdo'], - }, + { + name: 'provider', + suggestions: ['github', 'azdo'], + }, ], }, { name: ['--remote-name'], description: 'The name of the git remote to configure the pipeline to run on.', args: [ - { - name: 'remote-name', - }, + { + name: 'remote-name', + }, ], }, ], @@ -2150,9 +2150,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -2181,27 +2181,27 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { name: ['--from-package'], description: 'Publishes the service from a container image (image tag).', args: [ - { - name: 'image-tag', - }, + { + name: 'image-tag', + }, ], }, { name: ['--to'], description: 'The target container image in the form \'[registry/]repository[:tag]\' to publish to.', args: [ - { - name: 'image-tag', - }, + { + name: 'image-tag', + }, ], }, ], @@ -2222,9 +2222,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -2241,9 +2241,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -2270,19 +2270,19 @@ const completionSpec: Fig.Spec = { description: 'The tag(s) used to filter template results. Supports comma-separated values.', isRepeatable: true, args: [ - { - name: 'filter', - generators: azdGenerators.listTemplateTags, - }, + { + name: 'filter', + generators: azdGenerators.listTemplateTags, + }, ], }, { name: ['--source', '-s'], description: 'Filters templates by source.', args: [ - { - name: 'source', - }, + { + name: 'source', + }, ], }, ], @@ -2307,27 +2307,27 @@ const completionSpec: Fig.Spec = { name: ['--location', '-l'], description: 'Location of the template source. Required when using type flag.', args: [ - { - name: 'location', - }, + { + name: 'location', + }, ], }, { name: ['--name', '-n'], description: 'Display name of the template source.', args: [ - { - name: 'name', - }, + { + name: 'name', + }, ], }, { name: ['--type', '-t'], description: 'Kind of the template source. Supported types are \'file\', \'url\' and \'gh\'.', args: [ - { - name: 'type', - }, + { + name: 'type', + }, ], }, ], @@ -2358,9 +2358,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -2385,9 +2385,9 @@ const completionSpec: Fig.Spec = { name: ['--cwd'], description: 'Path to the azd extension project', args: [ - { - name: 'cwd', - }, + { + name: 'cwd', + }, ], }, { @@ -2402,9 +2402,9 @@ const completionSpec: Fig.Spec = { name: ['--output', '-o'], description: 'Path to the output directory. Defaults to ./bin folder.', args: [ - { - name: 'output', - }, + { + name: 'output', + }, ], }, { @@ -2422,18 +2422,18 @@ const completionSpec: Fig.Spec = { description: 'The list of capabilities for the extension (e.g., custom-commands,lifecycle-events,mcp-server,service-target-provider).', isRepeatable: true, args: [ - { - name: 'capabilities', - }, + { + name: 'capabilities', + }, ], }, { name: ['--cwd'], description: 'Path to the azd extension project', args: [ - { - name: 'cwd', - }, + { + name: 'cwd', + }, ], }, { @@ -2448,36 +2448,36 @@ const completionSpec: Fig.Spec = { name: ['--id'], description: 'The extension identifier (e.g., company.extension).', args: [ - { - name: 'id', - }, + { + name: 'id', + }, ], }, { name: ['--language'], description: 'The programming language for the extension (go, dotnet, javascript, python).', args: [ - { - name: 'language', - }, + { + name: 'language', + }, ], }, { name: ['--name'], description: 'The display name for the extension.', args: [ - { - name: 'name', - }, + { + name: 'name', + }, ], }, { name: ['--namespace'], description: 'The namespace for the extension commands.', args: [ - { - name: 'namespace', - }, + { + name: 'namespace', + }, ], }, { @@ -2498,9 +2498,9 @@ const completionSpec: Fig.Spec = { name: ['--cwd'], description: 'Path to the azd extension project', args: [ - { - name: 'cwd', - }, + { + name: 'cwd', + }, ], }, { @@ -2515,18 +2515,18 @@ const completionSpec: Fig.Spec = { name: ['--input', '-i'], description: 'Path to the input directory.', args: [ - { - name: 'input', - }, + { + name: 'input', + }, ], }, { name: ['--output', '-o'], description: 'Path to the artifacts output directory. If not provided, will use local registry artifacts path.', args: [ - { - name: 'output', - }, + { + name: 'output', + }, ], }, { @@ -2544,18 +2544,18 @@ const completionSpec: Fig.Spec = { description: 'Path to artifacts to process (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', isRepeatable: true, args: [ - { - name: 'artifacts', - }, + { + name: 'artifacts', + }, ], }, { name: ['--cwd'], description: 'Path to the azd extension project', args: [ - { - name: 'cwd', - }, + { + name: 'cwd', + }, ], }, { @@ -2570,27 +2570,27 @@ const completionSpec: Fig.Spec = { name: ['--registry', '-r'], description: 'Path to the extension source registry', args: [ - { - name: 'registry', - }, + { + name: 'registry', + }, ], }, { name: ['--repo'], description: 'Github repository to create the release in (e.g. owner/repo)', args: [ - { - name: 'repo', - }, + { + name: 'repo', + }, ], }, { name: ['--version', '-v'], description: 'Version of the release', args: [ - { - name: 'version', - }, + { + name: 'version', + }, ], }, ], @@ -2604,9 +2604,9 @@ const completionSpec: Fig.Spec = { description: 'Path to artifacts to upload to the release (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', isRepeatable: true, args: [ - { - name: 'artifacts', - }, + { + name: 'artifacts', + }, ], }, { @@ -2617,9 +2617,9 @@ const completionSpec: Fig.Spec = { name: ['--cwd'], description: 'Path to the azd extension project', args: [ - { - name: 'cwd', - }, + { + name: 'cwd', + }, ], }, { @@ -2638,18 +2638,18 @@ const completionSpec: Fig.Spec = { name: ['--notes', '-n'], description: 'Release notes', args: [ - { - name: 'notes', - }, + { + name: 'notes', + }, ], }, { name: ['--notes-file', '-F'], description: 'Read release notes from file (use "-" to read from standard input)', args: [ - { - name: 'notes-file', - }, + { + name: 'notes-file', + }, ], }, { @@ -2660,27 +2660,27 @@ const completionSpec: Fig.Spec = { name: ['--repo', '-r'], description: 'Github repository to create the release in (e.g. owner/repo)', args: [ - { - name: 'repo', - }, + { + name: 'repo', + }, ], }, { name: ['--title', '-t'], description: 'Title of the release', args: [ - { - name: 'title', - }, + { + name: 'title', + }, ], }, { name: ['--version', '-v'], description: 'Version of the release', args: [ - { - name: 'version', - }, + { + name: 'version', + }, ], }, ], @@ -2693,9 +2693,9 @@ const completionSpec: Fig.Spec = { name: ['--cwd'], description: 'Path to the azd extension project', args: [ - { - name: 'cwd', - }, + { + name: 'cwd', + }, ], }, { @@ -2716,9 +2716,9 @@ const completionSpec: Fig.Spec = { name: ['--cwd'], description: 'Path to the azd extension project', args: [ - { - name: 'cwd', - }, + { + name: 'cwd', + }, ], }, { @@ -3240,9 +3240,9 @@ const completionSpec: Fig.Spec = { description: 'Sets the current working directory.', isPersistent: true, args: [ - { - name: 'cwd', - }, + { + name: 'cwd', + }, ], }, { diff --git a/cli/azd/internal/figspec/typescript_renderer.go b/cli/azd/internal/figspec/typescript_renderer.go index 9e52d28e167..84ca0b87cb8 100644 --- a/cli/azd/internal/figspec/typescript_renderer.go +++ b/cli/azd/internal/figspec/typescript_renderer.go @@ -189,7 +189,7 @@ func renderOption(opt *Option, indentLevel int) string { if len(opt.Args) > 0 { lines = append(lines, fmt.Sprintf("%s\t%s: [", indent, fieldArgs)) - argsContent := renderArgs(opt.Args, indentLevel+1) + argsContent := renderArgs(opt.Args, indentLevel+2) lines = append(lines, argsContent) lines = append(lines, fmt.Sprintf("%s\t],", indent)) } From 815ad43f326f71e4a0eee9d60c88b38e3080fc36 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 27 Jan 2026 19:50:01 +0000 Subject: [PATCH 05/12] Add metadata capability checks and improve error handling --- cli/azd/cmd/figspec_test.go | 30 +---- .../internal/figspec/extension_metadata.go | 16 ++- .../figspec/extension_metadata_test.go | 114 ++++++++++++++++++ cli/azd/internal/figspec/spec_builder.go | 23 +++- cli/azd/pkg/extensions/manager.go | 27 ++--- 5 files changed, 160 insertions(+), 50 deletions(-) diff --git a/cli/azd/cmd/figspec_test.go b/cli/azd/cmd/figspec_test.go index 68889c395e0..c9cefd38a9c 100644 --- a/cli/azd/cmd/figspec_test.go +++ b/cli/azd/cmd/figspec_test.go @@ -8,9 +8,6 @@ import ( "testing" "time" - "github.com/azure/azure-dev/cli/azd/internal/figspec" - "github.com/azure/azure-dev/cli/azd/pkg/config" - "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/test/azdcli" "github.com/azure/azure-dev/cli/azd/test/snapshot" "github.com/stretchr/testify/require" @@ -47,31 +44,10 @@ func TestFigSpec(t *testing.T) { uninstallAllExtensions(ctx, t, cli) }) - root := NewRootCmd(false, nil, nil) - - // Create a simple metadata loader using the config directory - metadataLoader := &testExtensionMetadataLoader{} - - builder := figspec.NewSpecBuilder(false). - WithExtensionMetadata(metadataLoader) - spec := builder.BuildSpec(root) - - typescript, err := spec.ToTypeScript() + // Generate the Fig spec using CLI command + result, err := cli.RunCommand(t.Context(), "completion", "fig") require.NoError(t, err) snapshotter := snapshot.NewConfig(".ts") - snapshotter.SnapshotT(t, typescript) -} - -// testExtensionMetadataLoader implements figspec.ExtensionMetadataProvider for testing -// by directly reading metadata from the test's config directory. -type testExtensionMetadataLoader struct{} - -func (l *testExtensionMetadataLoader) LoadMetadata(extensionId string) (*extensions.ExtensionCommandMetadata, error) { - userConfigDir, err := config.GetUserConfigDir() - if err != nil { - return nil, err - } - - return extensions.LoadMetadataFromDir(userConfigDir, extensionId) + snapshotter.SnapshotT(t, result.Stdout) } diff --git a/cli/azd/internal/figspec/extension_metadata.go b/cli/azd/internal/figspec/extension_metadata.go index 07582d66109..f7ddaa2cbd7 100644 --- a/cli/azd/internal/figspec/extension_metadata.go +++ b/cli/azd/internal/figspec/extension_metadata.go @@ -9,6 +9,8 @@ import ( // ExtensionMetadataProvider provides extension metadata for generating figspec completions type ExtensionMetadataProvider interface { + // HasMetadataCapability checks if the extension has the metadata capability + HasMetadataCapability(extensionId string) bool // LoadMetadata loads the cached metadata for an extension by its ID LoadMetadata(extensionId string) (*extensions.ExtensionCommandMetadata, error) } @@ -19,8 +21,11 @@ func convertExtensionCommand(cmd extensions.Command, includeHidden bool) *Subcom return nil } - names := []string{cmd.Name[len(cmd.Name)-1]} - names = append(names, cmd.Aliases...) + if len(cmd.Name) == 0 { + return nil + } + + names := append([]string{cmd.Name[len(cmd.Name)-1]}, cmd.Aliases...) subcommand := &Subcommand{ Name: names, @@ -119,8 +124,11 @@ func convertExtensionCommandForHelp(cmd extensions.Command, includeHidden bool) return nil } - names := []string{cmd.Name[len(cmd.Name)-1]} - names = append(names, cmd.Aliases...) + if len(cmd.Name) == 0 { + return nil + } + + names := append([]string{cmd.Name[len(cmd.Name)-1]}, cmd.Aliases...) subcommand := &Subcommand{ Name: names, diff --git a/cli/azd/internal/figspec/extension_metadata_test.go b/cli/azd/internal/figspec/extension_metadata_test.go index 4842a7fb432..973fcc92c46 100644 --- a/cli/azd/internal/figspec/extension_metadata_test.go +++ b/cli/azd/internal/figspec/extension_metadata_test.go @@ -130,6 +130,12 @@ func TestConvertExtensionCommand(t *testing.T) { wantOptions: 0, wantArgs: 0, }, + { + name: "empty name returns nil", + cmd: extensions.Command{Name: []string{}, Short: "Empty"}, + includeHidden: false, + wantNil: true, + }, } for _, tt := range tests { @@ -320,3 +326,111 @@ func TestConvertExtensionArg(t *testing.T) { }) } } + +func TestConvertExtensionCommandForHelp(t *testing.T) { + tests := []struct { + name string + cmd extensions.Command + includeHidden bool + wantNil bool + wantName []string + wantDesc string + wantSubcmds int + }{ + { + name: "basic command", + cmd: extensions.Command{ + Name: []string{"context"}, + Short: "Display context info", + }, + includeHidden: false, + wantNil: false, + wantName: []string{"context"}, + wantDesc: "Display context info", + wantSubcmds: 0, + }, + { + name: "command with aliases", + cmd: extensions.Command{ + Name: []string{"list"}, + Short: "List items", + Aliases: []string{"ls", "l"}, + }, + includeHidden: false, + wantNil: false, + wantName: []string{"list", "ls", "l"}, + wantDesc: "List items", + wantSubcmds: 0, + }, + { + name: "hidden command not included", + cmd: extensions.Command{ + Name: []string{"hidden"}, + Short: "Hidden command", + Hidden: true, + }, + includeHidden: false, + wantNil: true, + }, + { + name: "hidden command included when flag set", + cmd: extensions.Command{ + Name: []string{"hidden"}, + Short: "Hidden command", + Hidden: true, + }, + includeHidden: true, + wantNil: false, + wantName: []string{"hidden"}, + wantDesc: "Hidden command", + wantSubcmds: 0, + }, + { + name: "command with subcommands (flags/args excluded in help)", + cmd: extensions.Command{ + Name: []string{"service"}, + Short: "Service commands", + Flags: []extensions.Flag{ + {Name: "verbose", Type: "bool"}, + }, + Args: []extensions.Argument{ + {Name: "name", Required: true}, + }, + Subcommands: []extensions.Command{ + {Name: []string{"service", "start"}, Short: "Start service"}, + {Name: []string{"service", "stop"}, Short: "Stop service"}, + }, + }, + includeHidden: false, + wantNil: false, + wantName: []string{"service"}, + wantDesc: "Service commands", + wantSubcmds: 2, + }, + { + name: "empty name returns nil", + cmd: extensions.Command{Name: []string{}, Short: "Empty"}, + includeHidden: false, + wantNil: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertExtensionCommandForHelp(tt.cmd, tt.includeHidden) + + if tt.wantNil { + require.Nil(t, result) + return + } + + require.NotNil(t, result) + require.Equal(t, tt.wantName, result.Name) + require.Equal(t, tt.wantDesc, result.Description) + require.Len(t, result.Subcommands, tt.wantSubcmds) + // Help subcommands should not include options or args + require.Empty(t, result.Options) + require.Empty(t, result.Args) + }) + } +} diff --git a/cli/azd/internal/figspec/spec_builder.go b/cli/azd/internal/figspec/spec_builder.go index e9da42a7cb5..0a6354b842d 100644 --- a/cli/azd/internal/figspec/spec_builder.go +++ b/cli/azd/internal/figspec/spec_builder.go @@ -4,6 +4,7 @@ package figspec import ( + "log" "slices" "strings" @@ -137,9 +138,18 @@ func (sb *SpecBuilder) tryGenerateExtensionSubcommand(cmd *cobra.Command, names return nil } + // Check if extension has metadata capability before attempting to load + if !sb.extensionMetadataProvider.HasMetadataCapability(extensionId) { + return nil + } + // Try to load extension metadata metadata, err := sb.extensionMetadataProvider.LoadMetadata(extensionId) - if err != nil || metadata == nil { + if err != nil { + log.Printf("Failed to load metadata for extension '%s': %v", extensionId, err) + return nil + } + if metadata == nil { return nil } @@ -343,8 +353,17 @@ func (sb *SpecBuilder) tryGenerateExtensionHelpSubcommand(cmd *cobra.Command, na return nil } + // Check if extension has metadata capability before attempting to load + if !sb.extensionMetadataProvider.HasMetadataCapability(extensionId) { + return nil + } + metadata, err := sb.extensionMetadataProvider.LoadMetadata(extensionId) - if err != nil || metadata == nil { + if err != nil { + log.Printf("Failed to load metadata for extension '%s': %v", extensionId, err) + return nil + } + if metadata == nil { return nil } diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 1b2aac3e46d..2a8dddc53c9 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -906,23 +906,7 @@ func (m *Manager) LoadMetadata(extensionId string) (*ExtensionCommandMetadata, e return nil, fmt.Errorf("failed to get user config directory: %w", err) } - extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) - metadataPath := filepath.Join(extensionDir, metadataFileName) - - data, err := os.ReadFile(metadataPath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("metadata not found for extension '%s'", extensionId) - } - return nil, fmt.Errorf("failed to read metadata file: %w", err) - } - - var metadata ExtensionCommandMetadata - if err := json.Unmarshal(data, &metadata); err != nil { - return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) - } - - return &metadata, nil + return LoadMetadataFromDir(userConfigDir, extensionId) } // DeleteMetadata removes cached metadata for an extension @@ -959,6 +943,15 @@ func (m *Manager) MetadataExists(extensionId string) bool { return err == nil } +// HasMetadataCapability checks if the extension with the given ID has the metadata capability. +func (m *Manager) HasMetadataCapability(extensionId string) bool { + extension, err := m.GetInstalled(FilterOptions{Id: extensionId}) + if err != nil || extension == nil { + return false + } + return extension.HasCapability(MetadataCapability) +} + // LoadMetadataFromDir loads extension metadata from a specific config directory. // This is a standalone function useful for testing or when a Manager instance is not available. func LoadMetadataFromDir(configDir, extensionId string) (*ExtensionCommandMetadata, error) { From df6add44a1a02f921ac1e9ca878049faf790e50d Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 27 Jan 2026 20:57:11 +0000 Subject: [PATCH 06/12] Update cspell config --- cli/azd/.vscode/cspell.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index ee7c11f59d6..3508fcae038 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -52,6 +52,7 @@ words: - bubbletea - lipgloss - gopxl + - subcmd languageSettings: - languageId: go ignoreRegExpList: From 600294f2c89f4a72ce7dc2fedb39ad4f13338ea6 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 27 Jan 2026 22:44:48 +0000 Subject: [PATCH 07/12] Support dynamic suggestions for `azd config` commands --- cli/azd/cmd/testdata/TestFigSpec.ts | 31 +++++++++++++++++++ cli/azd/internal/figspec/customizations.go | 4 +++ cli/azd/internal/figspec/fig_generators.go | 3 ++ .../internal/figspec/resources/generators.ts | 28 +++++++++++++++++ 4 files changed, 66 insertions(+) diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 55849f8f7d3..c48893e6329 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -22,6 +22,15 @@ interface AzdExtensionListItem { source: string; } +interface AzdConfigOption { + key: string; + description: string; + type: string; + allowedValues?: string[]; + example?: string; + envVar?: string; +} + const azdGenerators: Record = { listEnvironments: { script: ['azd', 'env', 'list', '--output', 'json'], @@ -176,6 +185,25 @@ const azdGenerators: Record = { } }, }, + listConfigKeys: { + script: ['azd', 'config', 'options', '--output', 'json'], + postProcess: (out) => { + try { + const options: AzdConfigOption[] = JSON.parse(out); + return options + .filter((opt) => opt.type !== 'envvar') // Exclude environment-only options + .map((opt) => ({ + name: opt.key, + description: opt.description, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, }; const completionSpec: Fig.Spec = { @@ -959,6 +987,7 @@ const completionSpec: Fig.Spec = { description: 'Gets a configuration.', args: { name: 'path', + generators: azdGenerators.listConfigKeys, }, }, { @@ -986,6 +1015,7 @@ const completionSpec: Fig.Spec = { args: [ { name: 'path', + generators: azdGenerators.listConfigKeys, }, { name: 'value', @@ -1001,6 +1031,7 @@ const completionSpec: Fig.Spec = { description: 'Unsets a configuration.', args: { name: 'path', + generators: azdGenerators.listConfigKeys, }, }, ], diff --git a/cli/azd/internal/figspec/customizations.go b/cli/azd/internal/figspec/customizations.go index 66f60b5b63d..4e74e7842c2 100644 --- a/cli/azd/internal/figspec/customizations.go +++ b/cli/azd/internal/figspec/customizations.go @@ -96,6 +96,10 @@ func (c *Customizations) GetCommandArgGenerator(ctx *CommandContext, argName str if argName == "extension-id" { return FigGenListInstalledExtensions } + case "azd config get", "azd config set", "azd config unset": + if argName == "path" { + return FigGenListConfigKeys + } } return "" diff --git a/cli/azd/internal/figspec/fig_generators.go b/cli/azd/internal/figspec/fig_generators.go index 85c34030f88..4815533ee01 100644 --- a/cli/azd/internal/figspec/fig_generators.go +++ b/cli/azd/internal/figspec/fig_generators.go @@ -29,6 +29,9 @@ const ( // FigGenListInstalledExtensions generates suggestions from installed extensions only FigGenListInstalledExtensions = "azdGenerators.listInstalledExtensions" + + // FigGenListConfigKeys generates suggestions from available azd config keys + FigGenListConfigKeys = "azdGenerators.listConfigKeys" ) //go:embed resources/generators.ts diff --git a/cli/azd/internal/figspec/resources/generators.ts b/cli/azd/internal/figspec/resources/generators.ts index b0a0152451f..3a84cf48e51 100644 --- a/cli/azd/internal/figspec/resources/generators.ts +++ b/cli/azd/internal/figspec/resources/generators.ts @@ -22,6 +22,15 @@ interface AzdExtensionListItem { source: string; } +interface AzdConfigOption { + key: string; + description: string; + type: string; + allowedValues?: string[]; + example?: string; + envVar?: string; +} + const azdGenerators: Record = { listEnvironments: { script: ['azd', 'env', 'list', '--output', 'json'], @@ -176,4 +185,23 @@ const azdGenerators: Record = { } }, }, + listConfigKeys: { + script: ['azd', 'config', 'options', '--output', 'json'], + postProcess: (out) => { + try { + const options: AzdConfigOption[] = JSON.parse(out); + return options + .filter((opt) => opt.type !== 'envvar') // Exclude environment-only options + .map((opt) => ({ + name: opt.key, + description: opt.description, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, }; \ No newline at end of file From 4c4aaa2539690be18acd8891909aa2eccb99cef1 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 27 Jan 2026 22:52:03 +0000 Subject: [PATCH 08/12] Fix indentation --- cli/azd/cmd/testdata/TestFigSpec.ts | 42 +++++++++---------- .../internal/figspec/typescript_renderer.go | 3 +- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index c48893e6329..229bf10445d 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -1013,13 +1013,13 @@ const completionSpec: Fig.Spec = { name: ['set'], description: 'Sets a configuration.', args: [ - { - name: 'path', - generators: azdGenerators.listConfigKeys, - }, - { - name: 'value', - }, + { + name: 'path', + generators: azdGenerators.listConfigKeys, + }, + { + name: 'value', + }, ], }, { @@ -1272,12 +1272,12 @@ const completionSpec: Fig.Spec = { }, ], args: [ - { - name: 'path', - }, - { - name: 'value', - }, + { + name: 'path', + }, + { + name: 'value', + }, ], }, { @@ -1457,14 +1457,14 @@ const completionSpec: Fig.Spec = { }, ], args: [ - { - name: 'key', - isOptional: true, - }, - { - name: 'value', - isOptional: true, - }, + { + name: 'key', + isOptional: true, + }, + { + name: 'value', + isOptional: true, + }, ], }, { diff --git a/cli/azd/internal/figspec/typescript_renderer.go b/cli/azd/internal/figspec/typescript_renderer.go index 84ca0b87cb8..671cf61de8f 100644 --- a/cli/azd/internal/figspec/typescript_renderer.go +++ b/cli/azd/internal/figspec/typescript_renderer.go @@ -126,11 +126,12 @@ func renderSubcommand(sub *Subcommand, indentLevel int) string { } if len(sub.Args) > 0 { - argsContent := renderArgs(sub.Args, indentLevel+1) if len(sub.Args) == 1 { + argsContent := renderArgs(sub.Args, indentLevel+1) trimmed := strings.TrimSuffix(strings.TrimSpace(argsContent), ",") lines = append(lines, fmt.Sprintf("%s\t%s: %s,", indent, fieldArgs, trimmed)) } else { + argsContent := renderArgs(sub.Args, indentLevel+2) lines = append(lines, fmt.Sprintf("%s\t%s: [", indent, fieldArgs)) lines = append(lines, argsContent) lines = append(lines, fmt.Sprintf("%s\t],", indent)) From 87a71a8e76caf2fb1d39938e1d982999fb948fb7 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Wed, 28 Jan 2026 00:10:19 +0000 Subject: [PATCH 09/12] Exclude global persistent flags --- cli/azd/cmd/testdata/TestFigSpec.ts | 423 +----------------- .../internal/figspec/extension_metadata.go | 32 +- .../figspec/extension_metadata_test.go | 34 +- cli/azd/internal/figspec/spec_builder.go | 16 +- 4 files changed, 74 insertions(+), 431 deletions(-) diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 229bf10445d..8a201b7053a 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -23,12 +23,12 @@ interface AzdExtensionListItem { } interface AzdConfigOption { - key: string; - description: string; - type: string; - allowedValues?: string[]; - example?: string; - envVar?: string; + Key: string; + Description: string; + Type: string; + AllowedValues?: string[] | null; + Example?: string; + EnvVar?: string; } const azdGenerators: Record = { @@ -191,10 +191,10 @@ const azdGenerators: Record = { try { const options: AzdConfigOption[] = JSON.parse(out); return options - .filter((opt) => opt.type !== 'envvar') // Exclude environment-only options + .filter((opt) => opt.Type !== 'envvar') // Exclude environment-only options .map((opt) => ({ - name: opt.key, - description: opt.description, + name: opt.Key, + description: opt.Description, })); } catch { return []; @@ -226,10 +226,6 @@ const completionSpec: Fig.Spec = { name: ['init'], description: 'Initialize a new AI agent project. (Preview)', options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, { name: ['--environment', '-e'], description: 'The name of the azd environment to use.', @@ -239,10 +235,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--help', '-h'], - description: 'help for init', - }, { name: ['--host'], description: '[Optional] For container based agents, can override the default host to target a container app instead. Accepted values: \'containerapp\'', @@ -261,10 +253,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--no-prompt'], - description: 'Accepts the default value instead of prompting, or it fails if there is no default.', - }, { name: ['--project-id', '-p'], description: 'Existing Microsoft Foundry Project Id to initialize your azd environment with', @@ -288,20 +276,6 @@ const completionSpec: Fig.Spec = { { name: ['version'], description: 'Prints the version of the application', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for version', - }, - { - name: ['--no-prompt'], - description: 'Accepts the default value instead of prompting, or it fails if there is no default.', - }, - ], }, ], }, @@ -313,10 +287,6 @@ const completionSpec: Fig.Spec = { name: ['init'], description: 'Initialize a new AI Fine-tuning project. (Preview)', options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, { name: ['--environment', '-n'], description: 'The name of the azd environment to use.', @@ -335,14 +305,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--help', '-h'], - description: 'help for init', - }, - { - name: ['--no-prompt'], - description: 'accepts the default value instead of prompting, or fails if there is no default', - }, { name: ['--project-endpoint', '-e'], description: 'Azure AI Foundry project endpoint URL (e.g., https://account.services.ai.azure.com/api/projects/project-name)', @@ -398,19 +360,11 @@ const completionSpec: Fig.Spec = { name: ['cancel'], description: 'Cancels a running or queued fine-tuning job.', options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, { name: ['--force'], description: 'Skip confirmation prompt', isDangerous: true, }, - { - name: ['--help', '-h'], - description: 'help for cancel', - }, { name: ['--id', '-i'], description: 'Job ID (required)', @@ -420,10 +374,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--no-prompt'], - description: 'accepts the default value instead of prompting, or fails if there is no default', - }, ], }, { @@ -439,10 +389,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--debug'], - description: 'Enable debug mode', - }, { name: ['--deployment-name', '-d'], description: 'Deployment name (required)', @@ -452,10 +398,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--help', '-h'], - description: 'help for deploy', - }, { name: ['--job-id', '-i'], description: 'Fine-tuning job ID (required)', @@ -474,10 +416,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--no-prompt'], - description: 'accepts the default value instead of prompting, or fails if there is no default', - }, { name: ['--no-wait'], description: 'Do not wait for deployment to complete', @@ -515,18 +453,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for list', - }, - { - name: ['--no-prompt'], - description: 'accepts the default value instead of prompting, or fails if there is no default', - }, { name: ['--output', '-o'], description: 'Output format: table, json', @@ -551,14 +477,6 @@ const completionSpec: Fig.Spec = { name: ['pause'], description: 'Pauses a running fine-tuning job.', options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for pause', - }, { name: ['--id', '-i'], description: 'Job ID (required)', @@ -568,24 +486,12 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--no-prompt'], - description: 'accepts the default value instead of prompting, or fails if there is no default', - }, ], }, { name: ['resume'], description: 'Resumes a paused fine-tuning job.', options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for resume', - }, { name: ['--id', '-i'], description: 'Job ID (required)', @@ -595,24 +501,12 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--no-prompt'], - description: 'accepts the default value instead of prompting, or fails if there is no default', - }, ], }, { name: ['show'], description: 'Shows detailed information about a specific job.', options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for show', - }, { name: ['--id', '-i'], description: 'Job ID (required)', @@ -626,10 +520,6 @@ const completionSpec: Fig.Spec = { name: ['--logs'], description: 'Include recent training logs', }, - { - name: ['--no-prompt'], - description: 'accepts the default value instead of prompting, or fails if there is no default', - }, { name: ['--output', '-o'], description: 'Output format: table, json, yaml', @@ -645,10 +535,6 @@ const completionSpec: Fig.Spec = { name: ['submit'], description: 'submit fine tuning job', options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, { name: ['--file', '-f'], description: 'Path to the config file.', @@ -658,10 +544,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--help', '-h'], - description: 'help for submit', - }, { name: ['--model', '-m'], description: 'Base model to fine-tune. Overrides config file. Required if --file is not provided', @@ -671,10 +553,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--no-prompt'], - description: 'accepts the default value instead of prompting, or fails if there is no default', - }, { name: ['--seed', '-r'], description: 'Random seed for reproducibility of the job. If a seed is not specified, one will be generated for you. Overrides config file.', @@ -714,38 +592,10 @@ const completionSpec: Fig.Spec = { ], }, ], - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for jobs', - }, - { - name: ['--no-prompt'], - description: 'accepts the default value instead of prompting, or fails if there is no default', - }, - ], }, { name: ['version'], description: 'Prints the version of the application', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for version', - }, - { - name: ['--no-prompt'], - description: 'accepts the default value instead of prompting, or fails if there is no default', - }, - ], }, ], }, @@ -855,10 +705,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--debug'], - description: 'Enables debugging and diagnostics logging.', - }, { name: ['--github-host-name'], description: 'The hostname to use with GitHub commands', @@ -868,10 +714,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--help', '-h'], - description: 'help for config', - }, { name: ['--managed-identity-name'], description: 'The name to use for the managed identity, if created.', @@ -905,16 +747,6 @@ const completionSpec: Fig.Spec = { { name: ['version'], description: 'Prints the version of the application', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for version', - }, - ], }, ], }, @@ -951,30 +783,10 @@ const completionSpec: Fig.Spec = { { name: ['up'], description: 'Runs azd up in concurrent mode', - options: [ - { - name: ['--debug'], - description: 'Enable debug logging for azd commands', - }, - { - name: ['--help', '-h'], - description: 'help for up', - }, - ], }, { name: ['version'], description: 'Prints the version of the application', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for version', - }, - ], }, ], }, @@ -1043,72 +855,22 @@ const completionSpec: Fig.Spec = { { name: ['colors', 'colours'], description: 'Displays all ASCII colors with their standard and high-intensity variants.', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for colors', - }, - ], }, { name: ['config'], description: 'Setup monitoring configuration for the project and services', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for config', - }, - ], }, { name: ['context'], description: 'Get the context of the AZD project & environment.', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for context', - }, - ], }, { name: ['gh-url-parse'], description: 'Parse a GitHub URL and extract repository information.', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for gh-url-parse', - }, - ], }, { name: ['listen'], description: 'Starts the extension and listens for events.', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for listen', - }, - ], }, { name: ['mcp'], @@ -1117,56 +879,16 @@ const completionSpec: Fig.Spec = { { name: ['start'], description: 'Start MCP server with demo tools', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for start', - }, - ], - }, - ], - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for mcp', }, ], }, { name: ['prompt'], description: 'Examples of prompting the user for input.', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for prompt', - }, - ], }, { name: ['version'], description: 'Prints the version of the application', - options: [ - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for version', - }, - ], }, ], }, @@ -2412,23 +2134,6 @@ const completionSpec: Fig.Spec = { name: ['--all'], description: 'When set builds for all os/platforms. Defaults to the current os/platform only.', }, - { - name: ['--cwd'], - description: 'Path to the azd extension project', - args: [ - { - name: 'cwd', - }, - ], - }, - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for build', - }, { name: ['--output', '-o'], description: 'Path to the output directory. Defaults to ./bin folder.', @@ -2458,23 +2163,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--cwd'], - description: 'Path to the azd extension project', - args: [ - { - name: 'cwd', - }, - ], - }, - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for init', - }, { name: ['--id'], description: 'The extension identifier (e.g., company.extension).', @@ -2511,10 +2199,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--no-prompt'], - description: 'Skip all prompts by providing all required parameters via command-line flags.', - }, { name: ['--registry', '-r'], description: 'When set will create a local extension source registry.', @@ -2525,23 +2209,6 @@ const completionSpec: Fig.Spec = { name: ['pack'], description: 'Build and pack extension artifacts', options: [ - { - name: ['--cwd'], - description: 'Path to the azd extension project', - args: [ - { - name: 'cwd', - }, - ], - }, - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for pack', - }, { name: ['--input', '-i'], description: 'Path to the input directory.', @@ -2580,23 +2247,6 @@ const completionSpec: Fig.Spec = { }, ], }, - { - name: ['--cwd'], - description: 'Path to the azd extension project', - args: [ - { - name: 'cwd', - }, - ], - }, - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for publish', - }, { name: ['--registry', '-r'], description: 'Path to the extension source registry', @@ -2644,27 +2294,10 @@ const completionSpec: Fig.Spec = { name: ['--confirm'], description: 'Skip confirmation prompt', }, - { - name: ['--cwd'], - description: 'Path to the azd extension project', - args: [ - { - name: 'cwd', - }, - ], - }, - { - name: ['--debug'], - description: 'Enable debug mode', - }, { name: ['--draft', '-d'], description: 'Create a draft release', }, - { - name: ['--help', '-h'], - description: 'help for release', - }, { name: ['--notes', '-n'], description: 'Release notes', @@ -2719,48 +2352,10 @@ const completionSpec: Fig.Spec = { { name: ['version'], description: 'Prints the version of the application', - options: [ - { - name: ['--cwd'], - description: 'Path to the azd extension project', - args: [ - { - name: 'cwd', - }, - ], - }, - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for version', - }, - ], }, { name: ['watch'], description: 'Watches the AZD extension project for file changes and rebuilds it.', - options: [ - { - name: ['--cwd'], - description: 'Path to the azd extension project', - args: [ - { - name: 'cwd', - }, - ], - }, - { - name: ['--debug'], - description: 'Enable debug mode', - }, - { - name: ['--help', '-h'], - description: 'help for watch', - }, - ], }, ], }, diff --git a/cli/azd/internal/figspec/extension_metadata.go b/cli/azd/internal/figspec/extension_metadata.go index f7ddaa2cbd7..2b4b4cd0cd9 100644 --- a/cli/azd/internal/figspec/extension_metadata.go +++ b/cli/azd/internal/figspec/extension_metadata.go @@ -15,45 +15,49 @@ type ExtensionMetadataProvider interface { LoadMetadata(extensionId string) (*extensions.ExtensionCommandMetadata, error) } -// convertExtensionCommand converts an extension command to a Fig subcommand recursively -func convertExtensionCommand(cmd extensions.Command, includeHidden bool) *Subcommand { - if !includeHidden && cmd.Hidden { +// convertExtensionCommand converts an extension command to a Fig subcommand recursively. +// globalFlagNames contains flag names that should be excluded (already defined at root level). +func convertExtensionCommand(extCmd extensions.Command, includeHidden bool, globalFlagNames map[string]bool) *Subcommand { + if !includeHidden && extCmd.Hidden { return nil } - if len(cmd.Name) == 0 { + if len(extCmd.Name) == 0 { return nil } - names := append([]string{cmd.Name[len(cmd.Name)-1]}, cmd.Aliases...) + names := append([]string{extCmd.Name[len(extCmd.Name)-1]}, extCmd.Aliases...) subcommand := &Subcommand{ Name: names, - Description: cmd.Short, - Hidden: cmd.Hidden, + Description: extCmd.Short, + Hidden: extCmd.Hidden, } - // Convert flags to options - for _, flag := range cmd.Flags { + // Convert flags to options, excluding global flags which are already defined at root level + for _, flag := range extCmd.Flags { if !includeHidden && flag.Hidden { continue } + if globalFlagNames[flag.Name] { + continue + } option := convertExtensionFlag(flag) subcommand.Options = append(subcommand.Options, option) } // Convert args - for _, arg := range cmd.Args { + for _, arg := range extCmd.Args { figArg := convertExtensionArg(arg) subcommand.Args = append(subcommand.Args, figArg) } // Convert subcommands recursively - for _, subcmd := range cmd.Subcommands { - figSubcmd := convertExtensionCommand(subcmd, includeHidden) - if figSubcmd != nil { - subcommand.Subcommands = append(subcommand.Subcommands, *figSubcmd) + for _, childCmd := range extCmd.Subcommands { + figChild := convertExtensionCommand(childCmd, includeHidden, globalFlagNames) + if figChild != nil { + subcommand.Subcommands = append(subcommand.Subcommands, *figChild) } } diff --git a/cli/azd/internal/figspec/extension_metadata_test.go b/cli/azd/internal/figspec/extension_metadata_test.go index 973fcc92c46..af7e03f719e 100644 --- a/cli/azd/internal/figspec/extension_metadata_test.go +++ b/cli/azd/internal/figspec/extension_metadata_test.go @@ -11,6 +11,17 @@ import ( ) func TestConvertExtensionCommand(t *testing.T) { + // Simulate global flags as they would be collected from root.PersistentFlags() + globalFlagNames := map[string]bool{ + "help": true, + "docs": true, + "cwd": true, + "debug": true, + "no-prompt": true, + "trace-log-file": true, + "trace-log-url": true, + } + tests := []struct { name string cmd extensions.Command @@ -94,6 +105,27 @@ func TestConvertExtensionCommand(t *testing.T) { wantOptions: 2, wantArgs: 0, }, + { + name: "global flags are filtered out", + cmd: extensions.Command{ + Name: []string{"test"}, + Short: "Test command", + Flags: []extensions.Flag{ + {Name: "custom", Description: "Custom flag", Type: "bool"}, + {Name: "help", Shorthand: "h", Description: "Help", Type: "bool"}, + {Name: "no-prompt", Description: "No prompt", Type: "bool"}, + {Name: "debug", Description: "Debug", Type: "bool"}, + {Name: "cwd", Shorthand: "C", Description: "Working dir", Type: "string"}, + }, + }, + includeHidden: false, + wantNil: false, + wantName: []string{"test"}, + wantDesc: "Test command", + wantSubcmds: 0, + wantOptions: 1, // Only "custom" flag should be included + wantArgs: 0, + }, { name: "command with args", cmd: extensions.Command{ @@ -140,7 +172,7 @@ func TestConvertExtensionCommand(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result := convertExtensionCommand(tt.cmd, tt.includeHidden) + result := convertExtensionCommand(tt.cmd, tt.includeHidden, globalFlagNames) if tt.wantNil { require.Nil(t, result) diff --git a/cli/azd/internal/figspec/spec_builder.go b/cli/azd/internal/figspec/spec_builder.go index 0a6354b842d..e1df05011e0 100644 --- a/cli/azd/internal/figspec/spec_builder.go +++ b/cli/azd/internal/figspec/spec_builder.go @@ -22,6 +22,7 @@ type SpecBuilder struct { flagArgsProvider CustomFlagArgsProvider extensionMetadataProvider ExtensionMetadataProvider includeHidden bool + globalFlagNames map[string]bool // Flag names that are global (persistent + non-persistent) } // NewSpecBuilder creates a new Fig spec builder @@ -58,6 +59,17 @@ func (sb *SpecBuilder) generateNonPersistentGlobalOptions(root *cobra.Command) [ // BuildSpec generates a Fig spec from a Cobra root command func (sb *SpecBuilder) BuildSpec(root *cobra.Command) *Spec { + // Collect global flag names from the root command's persistent flags + // These flags are defined via CreateGlobalFlagSet() and inherited by all subcommands + sb.globalFlagNames = make(map[string]bool) + root.PersistentFlags().VisitAll(func(f *pflag.Flag) { + sb.globalFlagNames[f.Name] = true + }) + // Also include non-persistent global flags (help, docs) which appear on every command + for _, name := range cmd.NonPersistentGlobalFlags { + sb.globalFlagNames[name] = true + } + persistentOpts := sb.generateOptions(root.PersistentFlags(), "", true) // Include non-persistent global flags (--help, --docs) as persistent since they appear on all commands @@ -160,9 +172,9 @@ func (sb *SpecBuilder) tryGenerateExtensionSubcommand(cmd *cobra.Command, names Hidden: cmd.Hidden, } - // Add subcommands from metadata + // Add subcommands from metadata, filtering out global flags for _, extCmd := range metadata.Commands { - figSubcmd := convertExtensionCommand(extCmd, sb.includeHidden) + figSubcmd := convertExtensionCommand(extCmd, sb.includeHidden, sb.globalFlagNames) if figSubcmd != nil { subcommand.Subcommands = append(subcommand.Subcommands, *figSubcmd) } From 8849992a6e4de37baaec6b114bcae4e4d99e2a47 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Wed, 28 Jan 2026 00:10:26 +0000 Subject: [PATCH 10/12] Fix generator --- .../internal/figspec/resources/generators.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cli/azd/internal/figspec/resources/generators.ts b/cli/azd/internal/figspec/resources/generators.ts index 3a84cf48e51..0a63e900ced 100644 --- a/cli/azd/internal/figspec/resources/generators.ts +++ b/cli/azd/internal/figspec/resources/generators.ts @@ -23,12 +23,12 @@ interface AzdExtensionListItem { } interface AzdConfigOption { - key: string; - description: string; - type: string; - allowedValues?: string[]; - example?: string; - envVar?: string; + Key: string; + Description: string; + Type: string; + AllowedValues?: string[] | null; + Example?: string; + EnvVar?: string; } const azdGenerators: Record = { @@ -191,10 +191,10 @@ const azdGenerators: Record = { try { const options: AzdConfigOption[] = JSON.parse(out); return options - .filter((opt) => opt.type !== 'envvar') // Exclude environment-only options + .filter((opt) => opt.Type !== 'envvar') // Exclude environment-only options .map((opt) => ({ - name: opt.key, - description: opt.description, + name: opt.Key, + description: opt.Description, })); } catch { return []; From bb377f63397658ec8336e649a95250bbeed6678c Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Wed, 28 Jan 2026 00:15:47 +0000 Subject: [PATCH 11/12] Revert go.mod changes --- cli/azd/extensions/azure.ai.finetune/go.mod | 2 +- cli/azd/extensions/microsoft.azd.concurx/go.mod | 4 ++-- cli/azd/extensions/microsoft.azd.concurx/go.sum | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.finetune/go.mod b/cli/azd/extensions/azure.ai.finetune/go.mod index c056931f2b3..90b0dd3d22c 100644 --- a/cli/azd/extensions/azure.ai.finetune/go.mod +++ b/cli/azd/extensions/azure.ai.finetune/go.mod @@ -12,7 +12,6 @@ require ( github.com/openai/openai-go/v3 v3.2.0 github.com/sethvargo/go-retry v0.3.0 github.com/spf13/cobra v1.10.1 - github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -70,6 +69,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/theckman/yacspin v0.13.12 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.mod b/cli/azd/extensions/microsoft.azd.concurx/go.mod index f6ca13b2857..1164ab05cfb 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.mod +++ b/cli/azd/extensions/microsoft.azd.concurx/go.mod @@ -76,9 +76,9 @@ require ( go.opentelemetry.io/otel/sdk v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/atomic v1.11.0 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.39.0 // indirect - golang.org/x/term v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.32.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251007200510-49b9836ed3ff // indirect google.golang.org/grpc v1.76.0 // indirect diff --git a/cli/azd/extensions/microsoft.azd.concurx/go.sum b/cli/azd/extensions/microsoft.azd.concurx/go.sum index a3768ddd5b4..f9299daa7c5 100644 --- a/cli/azd/extensions/microsoft.azd.concurx/go.sum +++ b/cli/azd/extensions/microsoft.azd.concurx/go.sum @@ -205,8 +205,8 @@ go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0 golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -214,8 +214,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= From eb2625535a268c67b7ae1519f7950f240c44f189 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Wed, 28 Jan 2026 17:38:33 +0000 Subject: [PATCH 12/12] Revert refactor --- cli/azd/pkg/extensions/manager.go | 40 +++++++++++++------------------ 1 file changed, 17 insertions(+), 23 deletions(-) diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 2a8dddc53c9..7cd259d29bf 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -906,7 +906,23 @@ func (m *Manager) LoadMetadata(extensionId string) (*ExtensionCommandMetadata, e return nil, fmt.Errorf("failed to get user config directory: %w", err) } - return LoadMetadataFromDir(userConfigDir, extensionId) + extensionDir := filepath.Join(userConfigDir, "extensions", extensionId) + metadataPath := filepath.Join(extensionDir, metadataFileName) + + data, err := os.ReadFile(metadataPath) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("metadata not found for extension '%s'", extensionId) + } + return nil, fmt.Errorf("failed to read metadata file: %w", err) + } + + var metadata ExtensionCommandMetadata + if err := json.Unmarshal(data, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) + } + + return &metadata, nil } // DeleteMetadata removes cached metadata for an extension @@ -951,25 +967,3 @@ func (m *Manager) HasMetadataCapability(extensionId string) bool { } return extension.HasCapability(MetadataCapability) } - -// LoadMetadataFromDir loads extension metadata from a specific config directory. -// This is a standalone function useful for testing or when a Manager instance is not available. -func LoadMetadataFromDir(configDir, extensionId string) (*ExtensionCommandMetadata, error) { - extensionDir := filepath.Join(configDir, "extensions", extensionId) - metadataPath := filepath.Join(extensionDir, metadataFileName) - - data, err := os.ReadFile(metadataPath) - if err != nil { - if os.IsNotExist(err) { - return nil, fmt.Errorf("metadata not found for extension '%s'", extensionId) - } - return nil, fmt.Errorf("failed to read metadata file: %w", err) - } - - var metadata ExtensionCommandMetadata - if err := json.Unmarshal(data, &metadata); err != nil { - return nil, fmt.Errorf("failed to parse metadata JSON: %w", err) - } - - return &metadata, nil -}