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: 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..c9cefd38a9c 100644 --- a/cli/azd/cmd/figspec_test.go +++ b/cli/azd/cmd/figspec_test.go @@ -8,7 +8,6 @@ import ( "testing" "time" - "github.com/azure/azure-dev/cli/azd/internal/figspec" "github.com/azure/azure-dev/cli/azd/test/azdcli" "github.com/azure/azure-dev/cli/azd/test/snapshot" "github.com/stretchr/testify/require" @@ -45,14 +44,10 @@ func TestFigSpec(t *testing.T) { uninstallAllExtensions(ctx, t, cli) }) - root := NewRootCmd(false, nil, nil) - - builder := figspec.NewSpecBuilder(false) - 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) + snapshotter.SnapshotT(t, result.Stdout) } diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index f395422c9f0..8a201b7053a 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[] | null; + 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 = { @@ -193,10 +221,383 @@ 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: ['--environment', '-e'], + description: 'The name of the azd environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + 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: ['--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', + }, + ], }, { name: ['finetuning'], description: 'Extension for Foundry Fine Tuning. (Preview)', + subcommands: [ + { + name: ['init'], + description: 'Initialize a new AI Fine-tuning project. (Preview)', + options: [ + { + 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: ['--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: ['--force'], + description: 'Skip confirmation prompt', + isDangerous: true, + }, + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + ], + }, + { + name: ['deploy'], + description: 'Deploy a fine-tuned model to Azure Cognitive Services', + options: [ + { + name: ['--capacity', '-c'], + description: 'Capacity units', + args: [ + { + name: 'capacity', + }, + ], + }, + { + name: ['--deployment-name', '-d'], + description: 'Deployment name (required)', + args: [ + { + name: 'deployment-name', + }, + ], + }, + { + 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-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: ['--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: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + ], + }, + { + name: ['resume'], + description: 'Resumes a paused fine-tuning job.', + options: [ + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Shows detailed information about a specific job.', + options: [ + { + name: ['--id', '-i'], + description: 'Job ID (required)', + args: [ + { + name: 'id', + }, + ], + }, + { + name: ['--logs'], + description: 'Include recent training logs', + }, + { + name: ['--output', '-o'], + description: 'Output format: table, json, yaml', + args: [ + { + name: 'output', + }, + ], + }, + ], + }, + { + name: ['submit'], + description: 'submit fine tuning job', + options: [ + { + name: ['--file', '-f'], + description: 'Path to the config file.', + args: [ + { + name: 'file', + }, + ], + }, + { + name: ['--model', '-m'], + description: 'Base model to fine-tune. Overrides config file. Required if --file is not provided', + args: [ + { + name: 'model', + }, + ], + }, + { + 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', + }, + ], + }, + ], + }, + ], + }, + { + name: ['version'], + description: 'Prints the version of the application', + }, + ], }, ], }, @@ -216,37 +617,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'], + }, ], }, { @@ -257,18 +658,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', + }, ], }, { @@ -290,6 +691,64 @@ 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: ['--github-host-name'], + description: 'The hostname to use with GitHub commands', + args: [ + { + name: 'github-host-name', + }, + ], + }, + { + 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', + }, + ], }, { name: ['completion'], @@ -320,6 +779,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'], @@ -330,6 +799,7 @@ const completionSpec: Fig.Spec = { description: 'Gets a configuration.', args: { name: 'path', + generators: azdGenerators.listConfigKeys, }, }, { @@ -355,12 +825,13 @@ const completionSpec: Fig.Spec = { name: ['set'], description: 'Sets a configuration.', args: [ - { - name: 'path', - }, - { - name: 'value', - }, + { + name: 'path', + generators: azdGenerators.listConfigKeys, + }, + { + name: 'value', + }, ], }, { @@ -372,6 +843,7 @@ const completionSpec: Fig.Spec = { description: 'Unsets a configuration.', args: { name: 'path', + generators: azdGenerators.listConfigKeys, }, }, ], @@ -379,6 +851,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'], @@ -392,18 +904,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', + }, ], }, ], @@ -420,9 +932,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -457,9 +969,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -475,19 +987,19 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], args: [ - { - name: 'path', - }, - { - name: 'value', - }, + { + name: 'path', + }, + { + name: 'value', + }, ], }, { @@ -498,9 +1010,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -518,9 +1030,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -537,9 +1049,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -556,18 +1068,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', + }, ], }, ], @@ -583,27 +1095,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', + }, ], }, ], @@ -619,9 +1131,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -651,30 +1163,30 @@ 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', + }, ], }, ], args: [ - { - name: 'key', - isOptional: true, - }, - { - name: 'value', - isOptional: true, - }, + { + name: 'key', + isOptional: true, + }, + { + name: 'value', + isOptional: true, + }, ], }, { @@ -685,9 +1197,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -714,18 +1226,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', + }, ], }, ], @@ -746,9 +1258,9 @@ const completionSpec: Fig.Spec = { name: ['--source'], description: 'Filter extensions by source', args: [ - { - name: 'source', - }, + { + name: 'source', + }, ], }, { @@ -756,9 +1268,9 @@ const completionSpec: Fig.Spec = { description: 'Filter extensions by tags', isRepeatable: true, args: [ - { - name: 'tags', - }, + { + name: 'tags', + }, ], }, ], @@ -771,9 +1283,9 @@ const completionSpec: Fig.Spec = { name: ['--source', '-s'], description: 'The extension source to use.', args: [ - { - name: 'source', - }, + { + name: 'source', + }, ], }, ], @@ -794,27 +1306,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', + }, ], }, ], @@ -859,18 +1371,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', + }, ], }, ], @@ -894,27 +1406,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', + }, ], }, ], @@ -954,9 +1466,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -976,18 +1488,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', + }, ], }, { @@ -995,10 +1507,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, + }, ], }, { @@ -1009,9 +1521,9 @@ const completionSpec: Fig.Spec = { name: ['--location', '-l'], description: 'Azure location for the new environment', args: [ - { - name: 'location', - }, + { + name: 'location', + }, ], }, { @@ -1022,19 +1534,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, + }, ], }, { @@ -1059,10 +1571,10 @@ const completionSpec: Fig.Spec = { name: ['--action'], description: 'Action type: \'all\' or \'readonly\'', args: [ - { - name: 'action', - suggestions: ['all', 'readonly'], - }, + { + name: 'action', + suggestions: ['all', 'readonly'], + }, ], }, { @@ -1073,48 +1585,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', + }, ], }, ], @@ -1127,49 +1639,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', + }, ], }, ], @@ -1182,49 +1694,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', + }, ], }, ], @@ -1245,9 +1757,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -1276,18 +1788,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', + }, ], }, ], @@ -1308,46 +1820,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', + }, ], }, { @@ -1355,28 +1867,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', + }, ], }, ], @@ -1391,9 +1903,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -1422,27 +1934,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', + }, ], }, ], @@ -1463,9 +1975,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -1482,9 +1994,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, { @@ -1511,19 +2023,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', + }, ], }, ], @@ -1548,27 +2060,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', + }, ], }, ], @@ -1599,9 +2111,9 @@ const completionSpec: Fig.Spec = { name: ['--environment', '-e'], description: 'The name of the environment to use.', args: [ - { - name: 'environment', - }, + { + name: 'environment', + }, ], }, ], @@ -1613,6 +2125,239 @@ 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', + options: [ + { + name: ['--all'], + description: 'When set builds for all os/platforms. Defaults to the current os/platform only.', + }, + { + 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: ['--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: ['--registry', '-r'], + description: 'When set will create a local extension source registry.', + }, + ], + }, + { + name: ['pack'], + description: 'Build and pack extension artifacts', + options: [ + { + 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: ['publish'], + description: 'Publish the extension to the extension source', + options: [ + { + name: ['--artifacts'], + description: 'Path to artifacts to process (comma-separated glob patterns, e.g. ./artifacts/*.zip,./artifacts/*.tar.gz)', + isRepeatable: true, + args: [ + { + name: 'artifacts', + }, + ], + }, + { + 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: ['release'], + description: 'Create a new extension release from the packaged artifacts', + options: [ + { + 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', + }, + ], + }, + { + name: ['--confirm'], + description: 'Skip confirmation prompt', + }, + { + name: ['--draft', '-d'], + description: 'Create a draft 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: ['version'], + description: 'Prints the version of the application', + }, + { + name: ['watch'], + description: 'Watches the AZD extension project for file changes and rebuilds it.', + }, + ], }, { name: ['help'], @@ -1629,10 +2374,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 +2456,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 +2496,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 +2544,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 +2826,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.', + }, + ], }, ], }, @@ -1977,9 +2866,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/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/extension_metadata.go b/cli/azd/internal/figspec/extension_metadata.go new file mode 100644 index 00000000000..2b4b4cd0cd9 --- /dev/null +++ b/cli/azd/internal/figspec/extension_metadata.go @@ -0,0 +1,151 @@ +// 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 { + // 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) +} + +// 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(extCmd.Name) == 0 { + return nil + } + + names := append([]string{extCmd.Name[len(extCmd.Name)-1]}, extCmd.Aliases...) + + subcommand := &Subcommand{ + Name: names, + Description: extCmd.Short, + Hidden: extCmd.Hidden, + } + + // 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 extCmd.Args { + figArg := convertExtensionArg(arg) + subcommand.Args = append(subcommand.Args, figArg) + } + + // Convert subcommands recursively + for _, childCmd := range extCmd.Subcommands { + figChild := convertExtensionCommand(childCmd, includeHidden, globalFlagNames) + if figChild != nil { + subcommand.Subcommands = append(subcommand.Subcommands, *figChild) + } + } + + 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 + } + + if len(cmd.Name) == 0 { + return nil + } + + names := append([]string{cmd.Name[len(cmd.Name)-1]}, 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..af7e03f719e --- /dev/null +++ b/cli/azd/internal/figspec/extension_metadata_test.go @@ -0,0 +1,468 @@ +// 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) { + // 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 + 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: "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{ + 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, + }, + { + 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 := convertExtensionCommand(tt.cmd, tt.includeHidden, globalFlagNames) + + 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) + }) + } +} + +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/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..0a63e900ced 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[] | null; + 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 diff --git a/cli/azd/internal/figspec/spec_builder.go b/cli/azd/internal/figspec/spec_builder.go index ed13415e477..e1df05011e0 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" @@ -15,11 +16,13 @@ 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 + globalFlagNames map[string]bool // Flag names that are global (persistent + non-persistent) } // NewSpecBuilder creates a new Fig spec builder @@ -34,6 +37,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 @@ -48,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 @@ -88,6 +110,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 +137,52 @@ 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 + } + + // 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 { + log.Printf("Failed to load metadata for extension '%s': %v", extensionId, err) + return nil + } + if metadata == nil { + return nil + } + + // Build the subcommand from metadata + subcommand := &Subcommand{ + Name: names, + Description: cmd.Short, + Hidden: cmd.Hidden, + } + + // Add subcommands from metadata, filtering out global flags + for _, extCmd := range metadata.Commands { + figSubcmd := convertExtensionCommand(extCmd, sb.includeHidden, sb.globalFlagNames) + 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 +336,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 +353,44 @@ 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 + } + + // 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 { + log.Printf("Failed to load metadata for extension '%s': %v", extensionId, err) + return nil + } + if 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/internal/figspec/typescript_renderer.go b/cli/azd/internal/figspec/typescript_renderer.go index 9e52d28e167..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)) @@ -189,7 +190,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)) } diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index ba059af3f32..7cd259d29bf 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -958,3 +958,12 @@ func (m *Manager) MetadataExists(extensionId string) bool { _, err = os.Stat(metadataPath) 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) +}