diff --git a/.gitattributes b/.gitattributes index e3fe6123ea7..eb70c16f417 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,3 +5,4 @@ *.snap text eol=lf *.txt text eol=lf *.sh text eol=lf +*.ts text eol=lf diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 027bd1e8be8..556e4e140e2 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -40,6 +40,7 @@ words: - tekuri - jsonschema - rustc + - figspec languageSettings: - languageId: go ignoreRegExpList: diff --git a/cli/azd/cmd/completion.go b/cli/azd/cmd/completion.go index 21cea828559..a57c1d77a12 100644 --- a/cli/azd/cmd/completion.go +++ b/cli/azd/cmd/completion.go @@ -8,6 +8,7 @@ import ( "fmt" "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/output" "github.com/spf13/cobra" ) @@ -17,6 +18,7 @@ const ( shellZsh = "zsh" shellFish = "fish" shellPowerShell = "powershell" + shellFig = "fig" ) func completionActions(root *actions.ActionDescriptor) { @@ -90,6 +92,18 @@ See each sub-command's help for details on how to use the generated script.`, Footer: getCmdCompletionPowerShellHelpFooter, }, }) + + figCmd := &cobra.Command{ + Short: "Generate Fig autocomplete spec.", + DisableFlagsInUseLine: true, + } + completionGroup.Add(shellFig, &actions.ActionDescriptorOptions{ + Command: figCmd, + ActionResolver: newCompletionFigAction, + FlagsResolver: newCompletionFigFlags, + OutputFormats: []output.Format{output.NoneFormat}, + DefaultFormat: output.NoneFormat, + }) } type completionAction struct { @@ -113,6 +127,30 @@ func newCompletionPowerShellAction(cmd *cobra.Command) actions.Action { return &completionAction{shell: shellPowerShell, cmd: cmd} } +// Fig completion action and flags +type completionFigFlags struct { + includeHidden bool +} + +func newCompletionFigFlags(cmd *cobra.Command) *completionFigFlags { + flags := &completionFigFlags{} + cmd.Flags().BoolVar(&flags.includeHidden, "include-hidden", false, "Include hidden commands in the Fig spec") + _ = cmd.Flags().MarkHidden("include-hidden") + return flags +} + +type completionFigAction struct { + flags *completionFigFlags + cmd *cobra.Command +} + +func newCompletionFigAction(cmd *cobra.Command, flags *completionFigFlags) actions.Action { + return &completionFigAction{ + flags: flags, + cmd: cmd, + } +} + func (a *completionAction) Run(ctx context.Context) (*actions.ActionResult, error) { rootCmd := a.cmd.Root() @@ -137,6 +175,25 @@ func (a *completionAction) Run(ctx context.Context) (*actions.ActionResult, erro return &actions.ActionResult{}, nil } +func (a *completionFigAction) Run(ctx context.Context) (*actions.ActionResult, error) { + rootCmd := a.cmd.Root() + + // Generate the Fig spec + builder := figspec.NewSpecBuilder(a.flags.includeHidden) + spec := builder.BuildSpec(rootCmd) + + // Convert to TypeScript + tsCode, err := spec.ToTypeScript() + if err != nil { + return nil, fmt.Errorf("failed to generate Fig spec: %w", err) + } + + // Write to stdout + fmt.Fprint(a.cmd.OutOrStdout(), tsCode) + + return &actions.ActionResult{}, nil +} + // Help functions for completion commands func getCmdCompletionBashHelpDescription(cmd *cobra.Command) string { diff --git a/cli/azd/cmd/figspec_test.go b/cli/azd/cmd/figspec_test.go new file mode 100644 index 00000000000..0a613bd9e37 --- /dev/null +++ b/cli/azd/cmd/figspec_test.go @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/internal/figspec" + "github.com/azure/azure-dev/cli/azd/test/snapshot" + "github.com/stretchr/testify/require" +) + +// TestFigSpec generates a Fig autocomplete spec for azd, powering VS Code terminal IntelliSense. +// The generated TypeScript spec must be committed to the vscode repository to enable completions. +// +// To update snapshots (assuming your current directory is cli/azd): +// +// For Bash, +// UPDATE_SNAPSHOTS=true go test ./cmd -run TestFigSpec +// +// For Pwsh, +// $env:UPDATE_SNAPSHOTS='true'; go test ./cmd -run TestFigSpec; $env:UPDATE_SNAPSHOTS=$null +func TestFigSpec(t *testing.T) { + root := NewRootCmd(false, nil, nil) + + builder := figspec.NewSpecBuilder(false) + spec := builder.BuildSpec(root) + + typescript, err := spec.ToTypeScript() + require.NoError(t, err) + + snapshotter := snapshot.NewConfig(".ts") + snapshotter.SnapshotT(t, typescript) +} diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts new file mode 100644 index 00000000000..bbefbb3f659 --- /dev/null +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -0,0 +1,1803 @@ +interface AzdEnvListItem { + Name: string; + DotEnvPath: string; + HasLocal: boolean; + HasRemote: boolean; + IsDefault: boolean; +} + +interface AzdTemplateListItem { + name: string; + description: string; + repositoryPath: string; + tags: string[]; +} + +interface AzdExtensionListItem { + id: string; + name: string; + namespace: string; + version: string; + installedVersion: string; + source: string; +} + +const azdGenerators: Record = { + listEnvironments: { + script: ['azd', 'env', 'list', '--output', 'json'], + postProcess: (out) => { + try { + const envs: AzdEnvListItem[] = JSON.parse(out); + return envs.map((env) => ({ + name: env.Name, + displayName: env.IsDefault ? 'Default' : undefined, + })); + } catch { + return []; + } + }, + }, + listEnvironmentVariables: { + script: ['azd', 'env', 'get-values', '--output', 'json'], + postProcess: (out) => { + try { + const envVars: Record = JSON.parse(out); + return Object.keys(envVars).map((key) => ({ + name: key, + })); + } catch { + return []; + } + }, + }, + listTemplates: { + script: ['azd', 'template', 'list', '--output', 'json'], + postProcess: (out) => { + try { + const templates: AzdTemplateListItem[] = JSON.parse(out); + return templates.map((template) => ({ + name: template.repositoryPath, + description: template.name, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, + listTemplateTags: { + script: ['azd', 'template', 'list', '--output', 'json'], + postProcess: (out) => { + try { + const templates: AzdTemplateListItem[] = JSON.parse(out); + const tagsSet = new Set(); + + // Collect all unique tags from all templates + templates.forEach((template) => { + if (template.tags && Array.isArray(template.tags)) { + template.tags.forEach((tag) => tagsSet.add(tag)); + } + }); + + // Convert set to array and return as suggestions + return Array.from(tagsSet).sort().map((tag) => ({ + name: tag, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, + listTemplatesFiltered: { + custom: async (tokens, executeCommand, generatorContext) => { + // Find if there's a -f or --filter flag in the tokens + let filterValue: string | undefined; + for (let i = 0; i < tokens.length; i++) { + if ((tokens[i] === '-f' || tokens[i] === '--filter') && i + 1 < tokens.length) { + filterValue = tokens[i + 1]; + break; + } + } + + // Build the azd command with filter if present + const args = ['template', 'list', '--output', 'json']; + if (filterValue) { + args.push('--filter', filterValue); + } + + try { + const { stdout } = await executeCommand({ + command: 'azd', + args: args, + }); + + const templates: AzdTemplateListItem[] = JSON.parse(stdout); + return templates.map((template) => ({ + name: template.repositoryPath, + description: template.name, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, + listExtensions: { + script: ['azd', 'ext', 'list', '--output', 'json'], + postProcess: (out) => { + try { + const extensions: AzdExtensionListItem[] = JSON.parse(out); + const uniqueExtensions = new Map(); + + extensions.forEach((ext) => { + if (!uniqueExtensions.has(ext.id)) { + uniqueExtensions.set(ext.id, ext); + } + }); + + return Array.from(uniqueExtensions.values()).map((ext) => ({ + name: ext.id, + description: ext.name, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, + listInstalledExtensions: { + script: ['azd', 'ext', 'list', '--installed', '--output', 'json'], + postProcess: (out) => { + try { + const extensions: AzdExtensionListItem[] = JSON.parse(out); + const uniqueExtensions = new Map(); + + extensions.forEach((ext) => { + if (!uniqueExtensions.has(ext.id)) { + uniqueExtensions.set(ext.id, ext); + } + }); + + return Array.from(uniqueExtensions.values()).map((ext) => ({ + name: ext.id, + description: ext.name, + })); + } catch { + return []; + } + }, + }, +}; + +const completionSpec: Fig.Spec = { + name: 'azd', + description: 'Azure Developer CLI', + subcommands: [ + { + name: ['add'], + description: 'Add a component to your project.', + }, + { + name: ['auth'], + description: 'Authenticate with Azure.', + subcommands: [ + { + name: ['login'], + description: 'Log in to Azure.', + options: [ + { + name: ['--check-status'], + description: 'Checks the log-in status instead of logging in.', + }, + { + name: ['--client-certificate'], + description: 'The path to the client certificate for the service principal to authenticate with.', + args: [ + { + name: 'client-certificate', + }, + ], + }, + { + name: ['--client-id'], + description: 'The client id for the service principal to authenticate with.', + args: [ + { + 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: ['--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: ['--managed-identity'], + description: 'Use a managed identity to authenticate.', + }, + { + name: ['--redirect-port'], + description: 'Choose the port to be used as part of the redirect URI during interactive login.', + args: [ + { + name: 'redirect-port', + }, + ], + }, + { + name: ['--tenant-id'], + description: 'The tenant id or domain name to authenticate with.', + args: [ + { + name: 'tenant-id', + }, + ], + }, + { + name: ['--use-device-code'], + description: 'When true, log in by using a device code instead of a browser.', + }, + ], + }, + { + name: ['logout'], + description: 'Log out of Azure.', + }, + ], + }, + { + name: ['completion'], + description: 'Generate shell completion scripts.', + subcommands: [ + { + name: ['bash'], + description: 'Generate bash completion script.', + }, + { + name: ['fig'], + description: 'Generate Fig autocomplete spec.', + }, + { + name: ['fish'], + description: 'Generate fish completion script.', + }, + { + name: ['powershell'], + description: 'Generate PowerShell completion script.', + }, + { + name: ['zsh'], + description: 'Generate zsh completion script.', + }, + ], + }, + { + name: ['config'], + description: 'Manage azd configurations (ex: default Azure subscription, location).', + subcommands: [ + { + name: ['get'], + description: 'Gets a configuration.', + args: { + name: 'path', + }, + }, + { + name: ['list-alpha'], + description: 'Display the list of available features in alpha stage.', + }, + { + name: ['reset'], + description: 'Resets configuration to default.', + options: [ + { + 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: ['deploy'], + description: 'Deploy your project code to Azure.', + options: [ + { + name: ['--all'], + description: 'Deploys all services that are listed in azure.yaml', + }, + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + 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', + }, + ], + }, + ], + args: { + name: 'service', + isOptional: true, + }, + }, + { + name: ['down'], + description: 'Delete your project\'s Azure resources.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--force'], + description: 'Does not require confirmation before it deletes resources.', + isDangerous: true, + }, + { + name: ['--purge'], + description: 'Does not require confirmation before it permanently deletes resources that are soft-deleted by default (for example, key vaults).', + isDangerous: true, + }, + ], + }, + { + name: ['env'], + description: 'Manage environments (ex: default environment, environment variables).', + subcommands: [ + { + name: ['get-value'], + description: 'Get specific environment value.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + args: { + name: 'keyName', + generators: azdGenerators.listEnvironmentVariables, + }, + }, + { + name: ['get-values'], + description: 'Get all environment values.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + }, + { + name: ['list', 'ls'], + description: 'List environments.', + }, + { + name: ['new'], + description: 'Create a new environment and set it as the default.', + options: [ + { + name: ['--location', '-l'], + description: 'Azure location for the new environment', + args: [ + { + name: 'location', + }, + ], + }, + { + name: ['--subscription'], + description: 'Name or ID of an Azure subscription to use for the new environment', + args: [ + { + name: 'subscription', + }, + ], + }, + ], + args: { + name: 'environment', + }, + }, + { + name: ['refresh'], + description: 'Refresh environment values by using information from a previous infrastructure provision.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--hint'], + description: 'Hint to help identify the environment to refresh', + args: [ + { + name: 'hint', + }, + ], + }, + ], + args: { + name: 'environment', + }, + }, + { + name: ['select'], + description: 'Set the default environment.', + args: { + name: 'environment', + generators: azdGenerators.listEnvironments, + }, + }, + { + name: ['set'], + description: 'Set one or more environment values.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--file'], + description: 'Path to .env formatted file to load environment values from.', + args: [ + { + name: 'file', + }, + ], + }, + ], + args: [ + { + name: 'key', + isOptional: true, + }, + { + name: 'value', + isOptional: true, + }, + ], + }, + { + name: ['set-secret'], + description: 'Set a name as a reference to a Key Vault secret in the environment.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + args: { + name: 'name', + }, + }, + ], + }, + { + name: ['extension', 'ext'], + description: 'Manage azd extensions.', + subcommands: [ + { + name: ['install'], + description: 'Installs specified extensions.', + options: [ + { + name: ['--force', '-f'], + description: 'Force installation even if it would downgrade the current version', + isDangerous: true, + }, + { + name: ['--source', '-s'], + description: 'The extension source to use for installs', + args: [ + { + name: 'source', + }, + ], + }, + { + name: ['--version', '-v'], + description: 'The version of the extension to install', + args: [ + { + name: 'version', + }, + ], + }, + ], + args: { + name: 'extension-id', + generators: azdGenerators.listExtensions, + }, + }, + { + name: ['list'], + description: 'List available extensions.', + options: [ + { + name: ['--installed'], + description: 'List installed extensions', + }, + { + name: ['--source'], + description: 'Filter extensions by source', + args: [ + { + name: 'source', + }, + ], + }, + { + name: ['--tags'], + description: 'Filter extensions by tags', + isRepeatable: true, + args: [ + { + name: 'tags', + }, + ], + }, + ], + }, + { + name: ['show'], + description: 'Show details for a specific extension.', + options: [ + { + name: ['--source', '-s'], + description: 'The extension source to use.', + args: [ + { + name: 'source', + }, + ], + }, + ], + args: { + name: 'extension-name', + }, + }, + { + name: ['source'], + description: 'View and manage extension sources', + subcommands: [ + { + name: ['add'], + description: 'Add an extension source with the specified name', + options: [ + { + name: ['--location', '-l'], + description: 'The location of the extension source', + args: [ + { + name: 'location', + }, + ], + }, + { + name: ['--name', '-n'], + description: 'The name of the extension source', + args: [ + { + name: 'name', + }, + ], + }, + { + name: ['--type', '-t'], + description: 'The type of the extension source. Supported types are \'file\' and \'url\'', + args: [ + { + name: 'type', + }, + ], + }, + ], + }, + { + name: ['list'], + description: 'List extension sources', + }, + { + name: ['remove'], + description: 'Remove an extension source with the specified name', + args: { + name: 'name', + }, + }, + ], + }, + { + name: ['uninstall'], + description: 'Uninstall specified extensions.', + options: [ + { + name: ['--all'], + description: 'Uninstall all installed extensions', + }, + ], + args: { + name: 'extension-id', + isOptional: true, + generators: azdGenerators.listInstalledExtensions, + }, + }, + { + name: ['upgrade'], + description: 'Upgrade specified extensions.', + options: [ + { + name: ['--all'], + description: 'Upgrade all installed extensions', + }, + { + name: ['--source', '-s'], + description: 'The extension source to use for upgrades', + args: [ + { + name: 'source', + }, + ], + }, + { + name: ['--version', '-v'], + description: 'The version of the extension to upgrade to', + args: [ + { + name: 'version', + }, + ], + }, + ], + args: { + name: 'extension-id', + isOptional: true, + generators: azdGenerators.listInstalledExtensions, + }, + }, + ], + }, + { + name: ['hooks'], + description: 'Develop, test and run hooks for a project.', + subcommands: [ + { + name: ['run'], + description: 'Runs the specified hook for the project and services', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--platform'], + description: 'Forces hooks to run for the specified platform.', + args: [ + { + name: 'platform', + }, + ], + }, + { + name: ['--service'], + description: 'Only runs hooks for the specified service.', + args: [ + { + name: 'service', + }, + ], + }, + ], + args: { + name: 'name', + suggestions: [ + 'prebuild', + 'postbuild', + 'predeploy', + 'postdeploy', + 'predown', + 'postdown', + 'prepackage', + 'postpackage', + 'preprovision', + 'postprovision', + 'prepublish', + 'postpublish', + 'prerestore', + 'postrestore', + 'preup', + 'postup', + ], + }, + }, + ], + }, + { + name: ['infra'], + description: 'Manage your Infrastructure as Code (IaC).', + subcommands: [ + { + name: ['generate', 'gen', 'synth'], + description: 'Write IaC for your project to disk, allowing you to manually manage it.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--force'], + description: 'Overwrite any existing files without prompting', + isDangerous: true, + }, + ], + }, + ], + }, + { + name: ['init'], + description: 'Initialize a new application.', + options: [ + { + name: ['--branch', '-b'], + description: 'The template branch to initialize from. Must be used with a template argument (--template or -t).', + args: [ + { + name: 'branch', + }, + ], + }, + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + 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: ['--from-code'], + description: 'Initializes a new application from your existing code.', + }, + { + name: ['--location', '-l'], + description: 'Azure location for the new environment', + args: [ + { + name: 'location', + }, + ], + }, + { + name: ['--minimal', '-m'], + description: 'Initializes a minimal project.', + }, + { + name: ['--subscription', '-s'], + description: 'Name or ID of an Azure subscription to use for the new environment', + args: [ + { + 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: ['--up'], + description: 'Provision and deploy to Azure after initializing the project from a template.', + }, + ], + }, + { + name: ['mcp'], + description: 'Manage Model Context Protocol (MCP) server. (Alpha)', + subcommands: [ + { + name: ['consent'], + description: 'Manage MCP tool consent.', + subcommands: [ + { + name: ['grant'], + description: 'Grant consent trust rules.', + options: [ + { + name: ['--action'], + description: 'Action type: \'all\' or \'readonly\'', + args: [ + { + name: 'action', + suggestions: ['all', 'readonly'], + }, + ], + }, + { + name: ['--global'], + description: 'Apply globally to all servers', + }, + { + name: ['--operation'], + description: 'Operation type: \'tool\' or \'sampling\'', + args: [ + { + name: 'operation', + suggestions: ['tool', 'sampling'], + }, + ], + }, + { + name: ['--permission'], + description: 'Permission: \'allow\', \'deny\', or \'prompt\'', + args: [ + { + name: 'permission', + suggestions: ['allow', 'deny', 'prompt'], + }, + ], + }, + { + name: ['--scope'], + description: 'Rule scope: \'global\', or \'project\'', + args: [ + { + name: 'scope', + suggestions: ['global', 'project'], + }, + ], + }, + { + name: ['--server'], + description: 'Server name', + args: [ + { + name: 'server', + }, + ], + }, + { + name: ['--tool'], + description: 'Specific tool name (requires --server)', + args: [ + { + name: 'tool', + }, + ], + }, + ], + }, + { + name: ['list'], + description: 'List consent rules.', + options: [ + { + name: ['--action'], + description: 'Action type to filter by (readonly, any)', + args: [ + { + name: 'action', + suggestions: ['all', 'readonly'], + }, + ], + }, + { + name: ['--operation'], + description: 'Operation to filter by (tool, sampling)', + args: [ + { + name: 'operation', + suggestions: ['tool', 'sampling'], + }, + ], + }, + { + name: ['--permission'], + description: 'Permission to filter by (allow, deny, prompt)', + args: [ + { + 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: ['--target'], + description: 'Specific target to operate on (server/tool format)', + args: [ + { + name: 'target', + }, + ], + }, + ], + }, + { + name: ['revoke'], + description: 'Revoke consent rules.', + options: [ + { + name: ['--action'], + description: 'Action type to filter by (readonly, any)', + args: [ + { + name: 'action', + suggestions: ['all', 'readonly'], + }, + ], + }, + { + name: ['--operation'], + description: 'Operation to filter by (tool, sampling)', + args: [ + { + name: 'operation', + suggestions: ['tool', 'sampling'], + }, + ], + }, + { + name: ['--permission'], + description: 'Permission to filter by (allow, deny, prompt)', + args: [ + { + 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: ['--target'], + description: 'Specific target to operate on (server/tool format)', + args: [ + { + name: 'target', + }, + ], + }, + ], + }, + ], + }, + { + name: ['start'], + description: 'Starts the MCP server.', + }, + ], + }, + { + name: ['monitor'], + description: 'Monitor a deployed project.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--live'], + description: 'Open a browser to Application Insights Live Metrics. Live Metrics is currently not supported for Python apps.', + }, + { + name: ['--logs'], + description: 'Open a browser to Application Insights Logs.', + }, + { + name: ['--overview'], + description: 'Open a browser to Application Insights Overview Dashboard.', + }, + ], + }, + { + name: ['package'], + description: 'Packages the project\'s code to be deployed to Azure.', + options: [ + { + name: ['--all'], + description: 'Packages all services that are listed in azure.yaml', + }, + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--output-path'], + description: 'File or folder path where the generated packages will be saved.', + args: [ + { + name: 'output-path', + }, + ], + }, + ], + args: { + name: 'service', + isOptional: true, + }, + }, + { + name: ['pipeline'], + description: 'Manage and configure your deployment pipelines.', + subcommands: [ + { + name: ['config'], + description: 'Configure your deployment pipeline to connect securely to Azure. (Beta)', + options: [ + { + 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: ['--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: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + 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-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-role'], + 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: ['--provider'], + description: 'The pipeline provider to use (github for Github Actions and azdo for Azure Pipelines).', + args: [ + { + 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: ['provision'], + description: 'Provision Azure resources for your project.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--no-state'], + description: '(Bicep only) Forces a fresh deployment based on current Bicep template files, ignoring any stored deployment state.', + }, + { + name: ['--preview'], + description: 'Preview changes to Azure resources.', + }, + ], + }, + { + name: ['publish'], + description: 'Publish a service to a container registry.', + options: [ + { + name: ['--all'], + description: 'Publishes all services that are listed in azure.yaml', + }, + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--from-package'], + description: 'Publishes the service from a container image (image tag).', + args: [ + { + name: 'image-tag', + }, + ], + }, + { + name: ['--to'], + description: 'The target container image in the form \'[registry/]repository[:tag]\' to publish to.', + args: [ + { + name: 'image-tag', + }, + ], + }, + ], + args: { + name: 'service', + isOptional: true, + }, + }, + { + name: ['restore'], + description: 'Restores the project\'s dependencies.', + options: [ + { + name: ['--all'], + description: 'Restores all services that are listed in azure.yaml', + }, + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + ], + args: { + name: 'service', + isOptional: true, + }, + }, + { + name: ['show'], + description: 'Display information about your project and its resources.', + options: [ + { + name: ['--environment', '-e'], + description: 'The name of the environment to use.', + args: [ + { + name: 'environment', + }, + ], + }, + { + name: ['--show-secrets'], + description: 'Unmask secrets in output.', + isDangerous: true, + }, + ], + 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: ['help'], + description: 'Help about any command', + subcommands: [ + { + name: ['add'], + description: 'Add a component to your project.', + }, + { + name: ['auth'], + description: 'Authenticate with Azure.', + subcommands: [ + { + name: ['login'], + description: 'Log in to Azure.', + }, + { + name: ['logout'], + description: 'Log out of Azure.', + }, + ], + }, + { + name: ['completion'], + description: 'Generate shell completion scripts.', + subcommands: [ + { + name: ['bash'], + description: 'Generate bash completion script.', + }, + { + name: ['fig'], + description: 'Generate Fig autocomplete spec.', + }, + { + name: ['fish'], + description: 'Generate fish completion script.', + }, + { + name: ['powershell'], + description: 'Generate PowerShell completion script.', + }, + { + name: ['zsh'], + description: 'Generate zsh completion script.', + }, + ], + }, + { + name: ['config'], + description: 'Manage azd configurations (ex: default Azure subscription, location).', + subcommands: [ + { + name: ['get'], + description: 'Gets a configuration.', + }, + { + name: ['list-alpha'], + description: 'Display the list of available features in alpha stage.', + }, + { + name: ['reset'], + description: 'Resets configuration to default.', + }, + { + name: ['set'], + description: 'Sets a configuration.', + }, + { + name: ['show'], + description: 'Show all the configuration values.', + }, + { + name: ['unset'], + description: 'Unsets a configuration.', + }, + ], + }, + { + name: ['deploy'], + description: 'Deploy your project code to Azure.', + }, + { + name: ['down'], + description: 'Delete your project\'s Azure resources.', + }, + { + name: ['env'], + description: 'Manage environments (ex: default environment, environment variables).', + subcommands: [ + { + name: ['get-value'], + description: 'Get specific environment value.', + }, + { + name: ['get-values'], + description: 'Get all environment values.', + }, + { + name: ['list', 'ls'], + description: 'List environments.', + }, + { + name: ['new'], + description: 'Create a new environment and set it as the default.', + }, + { + name: ['refresh'], + description: 'Refresh environment values by using information from a previous infrastructure provision.', + }, + { + name: ['select'], + description: 'Set the default environment.', + }, + { + name: ['set'], + description: 'Set one or more environment values.', + }, + { + name: ['set-secret'], + description: 'Set a name as a reference to a Key Vault secret in the environment.', + }, + ], + }, + { + name: ['extension', 'ext'], + description: 'Manage azd extensions.', + subcommands: [ + { + name: ['install'], + description: 'Installs specified extensions.', + }, + { + name: ['list'], + description: 'List available extensions.', + }, + { + name: ['show'], + description: 'Show details for a specific extension.', + }, + { + name: ['source'], + description: 'View and manage extension sources', + subcommands: [ + { + name: ['add'], + description: 'Add an extension source with the specified name', + }, + { + name: ['list'], + description: 'List extension sources', + }, + { + name: ['remove'], + description: 'Remove an extension source with the specified name', + }, + ], + }, + { + name: ['uninstall'], + description: 'Uninstall specified extensions.', + }, + { + name: ['upgrade'], + description: 'Upgrade specified extensions.', + }, + ], + }, + { + name: ['hooks'], + description: 'Develop, test and run hooks for a project.', + subcommands: [ + { + name: ['run'], + description: 'Runs the specified hook for the project and services', + }, + ], + }, + { + name: ['infra'], + description: 'Manage your Infrastructure as Code (IaC).', + subcommands: [ + { + name: ['generate', 'gen', 'synth'], + description: 'Write IaC for your project to disk, allowing you to manually manage it.', + }, + ], + }, + { + name: ['init'], + description: 'Initialize a new application.', + }, + { + name: ['mcp'], + description: 'Manage Model Context Protocol (MCP) server. (Alpha)', + subcommands: [ + { + name: ['consent'], + description: 'Manage MCP tool consent.', + subcommands: [ + { + name: ['grant'], + description: 'Grant consent trust rules.', + }, + { + name: ['list'], + description: 'List consent rules.', + }, + { + name: ['revoke'], + description: 'Revoke consent rules.', + }, + ], + }, + { + name: ['start'], + description: 'Starts the MCP server.', + }, + ], + }, + { + name: ['monitor'], + description: 'Monitor a deployed project.', + }, + { + name: ['package'], + description: 'Packages the project\'s code to be deployed to Azure.', + }, + { + name: ['pipeline'], + description: 'Manage and configure your deployment pipelines.', + subcommands: [ + { + name: ['config'], + description: 'Configure your deployment pipeline to connect securely to Azure. (Beta)', + }, + ], + }, + { + name: ['provision'], + description: 'Provision Azure resources for your project.', + }, + { + name: ['publish'], + description: 'Publish a service to a container registry.', + }, + { + name: ['restore'], + description: 'Restores the project\'s dependencies.', + }, + { + name: ['show'], + description: 'Display information about your project and its resources.', + }, + { + name: ['template'], + description: 'Find and view template details.', + subcommands: [ + { + name: ['list', 'ls'], + description: 'Show list of sample azd templates. (Beta)', + }, + { + name: ['show'], + description: 'Show details for a given template. (Beta)', + }, + { + name: ['source'], + description: 'View and manage template sources. (Beta)', + subcommands: [ + { + name: ['add'], + description: 'Adds an azd template source with the specified key. (Beta)', + }, + { + name: ['list', 'ls'], + description: 'Lists the configured azd template sources. (Beta)', + }, + { + name: ['remove'], + description: 'Removes the specified azd template source (Beta)', + }, + ], + }, + ], + }, + { + name: ['up'], + description: 'Provision and deploy your project to Azure with a single command.', + }, + { + name: ['version'], + description: 'Print the version number of Azure Developer CLI.', + }, + ], + }, + ], + options: [ + { + name: ['--cwd', '-C'], + description: 'Sets the current working directory.', + isPersistent: true, + args: [ + { + name: 'cwd', + }, + ], + }, + { + name: ['--debug'], + description: 'Enables debugging and diagnostics logging.', + isPersistent: true, + }, + { + name: ['--no-prompt'], + description: 'Accepts the default value instead of prompting, or it fails if there is no default.', + isPersistent: true, + }, + { + name: ['--docs'], + description: 'Opens the documentation for azd in your web browser.', + isPersistent: true, + }, + { + name: ['--help', '-h'], + description: 'Gets help for azd.', + isPersistent: true, + }, + ], +}; + +export default completionSpec; diff --git a/cli/azd/cmd/testdata/TestUsage-azd-completion-fig.snap b/cli/azd/cmd/testdata/TestUsage-azd-completion-fig.snap new file mode 100644 index 00000000000..fc17f0e82bf --- /dev/null +++ b/cli/azd/cmd/testdata/TestUsage-azd-completion-fig.snap @@ -0,0 +1,16 @@ + +Generate Fig autocomplete spec. + +Usage + azd completion fig + +Global Flags + -C, --cwd string : Sets the current working directory. + --debug : Enables debugging and diagnostics logging. + --docs : Opens the documentation for azd completion fig in your web browser. + -h, --help : Gets help for fig. + --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. + +Find a bug? Want to let us know how we're doing? Fill out this brief survey: https://aka.ms/azure-dev/hats. + + diff --git a/cli/azd/cmd/testdata/TestUsage-azd-completion.snap b/cli/azd/cmd/testdata/TestUsage-azd-completion.snap index de9b9574160..b4d297240d5 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-completion.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-completion.snap @@ -6,6 +6,7 @@ Usage Available Commands bash : Generate bash completion script. + fig : Generate Fig autocomplete spec. fish : Generate fish completion script. powershell : Generate PowerShell completion script. zsh : Generate zsh completion script. diff --git a/cli/azd/docs/fig-spec.md b/cli/azd/docs/fig-spec.md new file mode 100644 index 00000000000..be5f527c6a6 --- /dev/null +++ b/cli/azd/docs/fig-spec.md @@ -0,0 +1,182 @@ +# Fig spec generation (for VS Code terminal IntelliSense) + +## Overview + +The `azd completion fig` command automatically generates a [Fig autocomplete specification](https://fig.io/docs/reference/subcommand) from azd's Cobra command structure. This TypeScript-based spec powers **IntelliSense** in VS Code's integrated terminal, providing context-aware suggestions for commands, flags, and arguments. + +**What is a Fig Spec?** + +A Fig spec is a TypeScript object that declaratively describes a CLI tool's interface, including commands, subcommands, flags/options, positional arguments, and dynamic generators for context-aware completions. The type definition used by VS Code is available at [`index.d.ts`](https://github.com/microsoft/vscode/blob/main/extensions/terminal-suggest/src/completions/index.d.ts). The original reference documentation can be found on [Fig's official docs](https://fig.io/docs/reference/subcommand). + +The Fig spec used for IntelliSense lives in the VS Code repo at [`extensions/terminal-suggest/src/completions/azd.ts`](https://github.com/microsoft/vscode/blob/main/extensions/terminal-suggest/src/completions/azd.ts). + +A copy of it exists in this repo under `cli/azd/cmd/testdata/TestFigSpec.ts` for snapshot testing purposes. + +## Usage + +### Generating the spec + +```bash +azd completion fig > azd.ts +``` + +### Testing locally + +The generated spec is automatically tested via snapshot tests: + +```bash +# Run tests +cd cli/azd +go test ./cmd -run TestFigSpec + +# Update snapshot if command structure has changed, similar to the TestUsage tests +UPDATE_SNAPSHOTS=true go test ./cmd -run TestFigSpec +``` + +The snapshot is stored at `cli/azd/cmd/testdata/TestFigSpec.ts`. + +## Updating the spec in VS Code + +After azd command or flag changes have been released, update the Fig spec in the VS Code repository to keep IntelliSense up to date. + +### Process + +1. **Set up VS Code development environment**: Follow the [VS Code contribution guide](https://github.com/microsoft/vscode/wiki/How-to-Contribute) to clone and set up the VS Code repository for local development. + +2. **Generate the updated spec**: + ```bash + azd completion fig > azd-spec.ts + ``` + +3. **Update VS Code repository**: In your local VS Code checkout, update `extensions/terminal-suggest/src/completions/azd.ts` with the newly generated spec. You may need to add in the vscode copyright header. + +4. **Build and run VS Code locally**: Follow the instructions in the [VS Code contribution guide](https://github.com/microsoft/vscode/wiki/How-to-Contribute). + +5. **Test IntelliSense**: Open the integrated terminal, type `azd ` and verify that: + - Commands and flags are suggested correctly + - Dynamic completions work (e.g. `azd init -t ` lists templates) + - New/changed commands appear as expected + + Note that on Windows, only `pwsh` supports IntelliSense completions. + +6. **Submit a PR**: Create a PR with your changes following VS Code's contribution guidelines. Example: [microsoft/vscode#272348](https://github.com/microsoft/vscode/pull/272348) + +## Architecture + +### Package structure + +The Fig spec generation is implemented in `cli/azd/internal/figspec/`: + +``` +figspec/ +├── types.go # Core data structures and interfaces +├── spec_builder.go # Main spec generation logic +├── fig_generators.go # Generator constants and embeddings +├── typescript_renderer.go # TypeScript code generation +├── customizations.go # azd-specific customizations +└── resources/ + ├── generators.ts # TypeScript generator implementations (embedded) + └── index.d.ts # Type definitions +``` + +### Generation flow + +``` +Cobra Command Tree + ↓ +SpecBuilder.BuildSpec() + ↓ + [Apply Customizations] + ↓ +TypeScript Renderer + ↓ +Fig Spec (.ts file) +``` + +1. **Input**: Cobra command tree with all commands, flags, and arguments +2. **Processing**: SpecBuilder walks the command tree and applies customizations +3. **Output**: TypeScript code defining a Fig spec object + +### Key components + +#### 1. SpecBuilder (`spec_builder.go`) + +The central orchestrator that: +- Traverses the Cobra command hierarchy +- Extracts command metadata (names, descriptions, aliases) +- Generates flag/option specifications +- Parses argument definitions from command `Use` fields +- Applies customization providers for azd-specific behavior + +**Key Methods:** +- `BuildSpec(root *cobra.Command)`: Entry point for spec generation +- `generateSubcommands()`: Recursively processes command tree +- `generateOptions()`: Converts Cobra flags to Fig options +- `generateCommandArgs()`: Extracts positional arguments + +#### 2. Customizations (`customizations.go`) + +Implements customization interfaces to add azd-specific intelligence: + +- **`CustomSuggestionProvider`**: Static suggestions (e.g. `--provider github|azdo`) +- **`CustomGeneratorProvider`**: Dynamically generated suggestions (e.g. `azd env select` suggesting environment names) +- **`CustomArgsProvider`**: Custom argument patterns (e.g. `azd env set [key] [value]`) +- **`CustomFlagArgsProvider`**: Custom flag argument names (e.g. `from-package` → `file-path|image-tag`) + +#### 3. Generators (`fig_generators.go` + `resources/generators.ts`) + +Defines [**generators**](https://fig.io/docs/reference/generator/basic) that execute azd commands to dynamically provide context-aware suggestions. + +The generator implementations are written in TypeScript and stored in `resources/generators.ts`, which is embedded into the Go binary using Go's `embed` directive. The `fig_generators.go` file contains: +- Constants that reference each generator (e.g., `FigGenListEnvironments`) +- The embed directive to include `generators.ts` +- Mapping between Go constants and TypeScript generator keys + +The `resources/index.d.ts` file contains a copy of [VS Code's Fig type definitions](https://github.com/microsoft/vscode/blob/main/extensions/terminal-suggest/src/completions/index.d.ts), enabling IntelliSense and type checking when editing `generators.ts` in an IDE. + +| Generator | Command | Purpose | +|-----------|---------|---------| +| `listEnvironments` | `azd env list --output json` | Suggest environment names in current azd project | +| `listEnvironmentVariables` | `azd env get-values --output json` | Suggest environment variable keys in current environment | +| `listTemplates` | `azd template list --output json` | Suggest templates | +| `listTemplateTags` | `azd template list --output json` | Suggest template tags | +| `listTemplatesFiltered` | `azd template list --filter --output json` | Suggest templates filtered by `--filter` flag | +| `listExtensions` | `azd ext list --output json` | Suggest available extension IDs | +| `listInstalledExtensions` | `azd ext list --installed --output json` | Suggest installed extension IDs | + +**Basic Generator Structure:** +```typescript +{ + script: ['azd', 'command', '--output', 'json'], + postProcess: (out) => { + const items = JSON.parse(out); + return items.map(item => ({ name: item.name, description: item.description })); + }, + cache: { strategy: 'stale-while-revalidate' } +} +``` + +**Advanced Generator: `listTemplatesFiltered`** + +This generator uses `custom` field instead of `postProcess`, which offers more flexibility for complex logic like: +- Inspects command line tokens to find the `--filter` flag value +- Dynamically builds the `azd template list` command that is executed with appropriate filters + +## Advanced customization + +Fig offers more advanced ways to enhance the spec, such as: + +- **[Custom icons](https://fig.io/docs/reference/suggestion#icon)**: VS Code provides its own icon set (e.g. see [GitHub CLI spec](https://github.com/microsoft/vscode/blob/main/extensions/terminal-suggest/src/completions/gh.ts)) +- **[File path templates](https://fig.io/docs/reference/arg#template)**: Built-in file/folder completion for path arguments +- **[Priority & sorting](https://fig.io/docs/reference/suggestion#priority)**: Rank suggestions with custom priorities +- **[Caching generator results](https://fig.io/docs/reference/generator#cache)**: Optimize performance with `stale-while-revalidate` or `max-age` + +See [Fig documentation](https://fig.io/docs) and [other VS Code Fig specs](https://github.com/microsoft/vscode/tree/main/extensions/terminal-suggest/src/completions) for reference. + +## References + +- **Fig Documentation**: [https://fig.io/docs](https://fig.io/docs) +- **VS Code**: + - [Fig spec type definition](https://github.com/microsoft/vscode/blob/main/extensions/terminal-suggest/src/completions/index.d.ts) + - [GitHub CLI spec](https://github.com/microsoft/vscode/blob/main/extensions/terminal-suggest/src/completions/gh.ts) + - [Other tool Fig specs](https://github.com/microsoft/vscode/tree/main/extensions/terminal-suggest/src/completions) diff --git a/cli/azd/internal/figspec/customizations.go b/cli/azd/internal/figspec/customizations.go new file mode 100644 index 00000000000..d9d51718326 --- /dev/null +++ b/cli/azd/internal/figspec/customizations.go @@ -0,0 +1,199 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package figspec + +import ( + "slices" + "strings" + + "github.com/spf13/pflag" +) + +// azd-specific customizations for Fig spec generation +type Customizations struct{} + +var _ CustomSuggestionProvider = (*Customizations)(nil) +var _ CustomGeneratorProvider = (*Customizations)(nil) +var _ CustomArgsProvider = (*Customizations)(nil) +var _ CustomFlagArgsProvider = (*Customizations)(nil) + +var hookNameValues = []string{ + "prebuild", "postbuild", + "predeploy", "postdeploy", + "predown", "postdown", + "prepackage", "postpackage", + "preprovision", "postprovision", + "prepublish", "postpublish", + "prerestore", "postrestore", + "preup", "postup", +} + +var serviceCommandPaths = []string{ + "azd build", + "azd deploy", + "azd package", + "azd publish", + "azd restore", +} + +// Flags that should only appear at root level, not duplicated on subcommands +var persistentOnlyFlags = []string{ + "help", + "debug", + "cwd", + "no-prompt", + "docs", +} + +// GetSuggestions returns static suggestion values for flags that accept a fixed set of options +func (c *Customizations) GetSuggestions(ctx *FlagContext) []string { + path := ctx.CommandPath + flagName := ctx.Flag.Name + + switch path { + case "azd auth login": + if flagName == "federated-credential-provider" { + return []string{"github", "azure-pipelines", "oidc"} + } + + case "azd pipeline config": + switch flagName { + case "provider": + return []string{"github", "azdo"} + case "auth-type": + return []string{"federated", "client-credentials"} + } + } + + if strings.HasPrefix(path, "azd mcp consent") { + switch flagName { + case "action": + return []string{"all", "readonly"} + case "operation": + return []string{"tool", "sampling"} + case "permission": + return []string{"allow", "deny", "prompt"} + case "scope": + return []string{"global", "project"} + } + } + + return nil +} + +// GetCommandArgGenerator returns the Fig generator name for dynamically completing command arguments +func (c *Customizations) GetCommandArgGenerator(ctx *CommandContext, argName string) string { + path := ctx.CommandPath + + switch path { + case "azd env get-value": + if argName == "keyName" { + return FigGenListEnvironmentVariables + } + case "azd env select": + if argName == "environment" { + return FigGenListEnvironments + } + case "azd template show": + if argName == "template" { + return FigGenListTemplates + } + case "azd extension install": + if argName == "extension-id" { + return FigGenListExtensions + } + case "azd extension upgrade", "azd extension uninstall": + if argName == "extension-id" { + return FigGenListInstalledExtensions + } + } + + return "" +} + +// GetFlagGenerator returns the Fig generator name for dynamically completing flag arguments +func (c *Customizations) GetFlagGenerator(ctx *FlagContext) string { + flagName := ctx.Flag.Name + path := ctx.CommandPath + + switch path { + case "azd init": + switch flagName { + case "filter": + return FigGenListTemplateTags + case "template": + return FigGenListTemplatesFiltered + } + + case "azd template list": + if flagName == "filter" { + return FigGenListTemplateTags + } + } + + return "" +} + +// GetCommandArgs returns custom argument specifications for commands with complex arg patterns +func (c *Customizations) GetCommandArgs(ctx *CommandContext) []Arg { + switch ctx.CommandPath { + case "azd env set": + return []Arg{ + {Name: "key", IsOptional: true}, + {Name: "value", IsOptional: true}, + } + case "azd show": + return []Arg{ + {Name: "resource-name|resource-id", IsOptional: true}, + } + case "azd hooks run": + return []Arg{ + {Name: "name", Suggestions: hookNameValues}, + } + case "azd extension install": + return []Arg{ + {Name: "extension-id"}, + } + case "azd extension uninstall", "azd extension upgrade": + return []Arg{ + {Name: "extension-id", IsOptional: true}, + } + } + + if slices.Contains(serviceCommandPaths, ctx.CommandPath) { + return []Arg{ + {Name: "service", IsOptional: true}, + } + } + return nil +} + +// GetFlagArgs returns custom argument names/descriptions for flags (e.g., "image-tag" instead of "from-package") +func (c *Customizations) GetFlagArgs(ctx *FlagContext) *Arg { + flagName := ctx.Flag.Name + path := ctx.CommandPath + + switch path { + case "azd deploy": + if flagName == "from-package" { + return &Arg{ + Name: "file-path|image-tag", + } + } + case "azd publish": + switch flagName { + case "from-package", "to": + return &Arg{ + Name: "image-tag", + } + } + } + + return nil +} + +// ShouldSkipPersistentFlag returns whether a flag should only be defined at root, not repeated on subcommands +func ShouldSkipPersistentFlag(flag *pflag.Flag) bool { + return slices.Contains(persistentOnlyFlags, flag.Name) +} diff --git a/cli/azd/internal/figspec/fig_generators.go b/cli/azd/internal/figspec/fig_generators.go new file mode 100644 index 00000000000..85c34030f88 --- /dev/null +++ b/cli/azd/internal/figspec/fig_generators.go @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package figspec + +import _ "embed" + +// Fig generator names used for dynamic autocomplete suggestions. +// These constants map to the TypeScript generator implementations defined in resources/figspec-generators.ts. +// The constant names should match the keys in the azdGenerators object (e.g., FigGenListEnvironments -> listEnvironments). +const ( + // FigGenListEnvironments generates suggestions from available azd environments + FigGenListEnvironments = "azdGenerators.listEnvironments" + + // FigGenListEnvironmentVariables generates suggestions from environment variables + FigGenListEnvironmentVariables = "azdGenerators.listEnvironmentVariables" + + // FigGenListTemplates generates suggestions from available azd templates + FigGenListTemplates = "azdGenerators.listTemplates" + + // FigGenListTemplateTags generates suggestions from all available template tags + FigGenListTemplateTags = "azdGenerators.listTemplateTags" + + // FigGenListTemplatesFiltered generates suggestions from templates filtered by --filter flag + FigGenListTemplatesFiltered = "azdGenerators.listTemplatesFiltered" + + // FigGenListExtensions generates suggestions from all available extensions + FigGenListExtensions = "azdGenerators.listExtensions" + + // FigGenListInstalledExtensions generates suggestions from installed extensions only + FigGenListInstalledExtensions = "azdGenerators.listInstalledExtensions" +) + +//go:embed resources/generators.ts +var figGeneratorDefinitionsTS string diff --git a/cli/azd/internal/figspec/resources/generators.ts b/cli/azd/internal/figspec/resources/generators.ts new file mode 100644 index 00000000000..b0a0152451f --- /dev/null +++ b/cli/azd/internal/figspec/resources/generators.ts @@ -0,0 +1,179 @@ +interface AzdEnvListItem { + Name: string; + DotEnvPath: string; + HasLocal: boolean; + HasRemote: boolean; + IsDefault: boolean; +} + +interface AzdTemplateListItem { + name: string; + description: string; + repositoryPath: string; + tags: string[]; +} + +interface AzdExtensionListItem { + id: string; + name: string; + namespace: string; + version: string; + installedVersion: string; + source: string; +} + +const azdGenerators: Record = { + listEnvironments: { + script: ['azd', 'env', 'list', '--output', 'json'], + postProcess: (out) => { + try { + const envs: AzdEnvListItem[] = JSON.parse(out); + return envs.map((env) => ({ + name: env.Name, + displayName: env.IsDefault ? 'Default' : undefined, + })); + } catch { + return []; + } + }, + }, + listEnvironmentVariables: { + script: ['azd', 'env', 'get-values', '--output', 'json'], + postProcess: (out) => { + try { + const envVars: Record = JSON.parse(out); + return Object.keys(envVars).map((key) => ({ + name: key, + })); + } catch { + return []; + } + }, + }, + listTemplates: { + script: ['azd', 'template', 'list', '--output', 'json'], + postProcess: (out) => { + try { + const templates: AzdTemplateListItem[] = JSON.parse(out); + return templates.map((template) => ({ + name: template.repositoryPath, + description: template.name, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, + listTemplateTags: { + script: ['azd', 'template', 'list', '--output', 'json'], + postProcess: (out) => { + try { + const templates: AzdTemplateListItem[] = JSON.parse(out); + const tagsSet = new Set(); + + // Collect all unique tags from all templates + templates.forEach((template) => { + if (template.tags && Array.isArray(template.tags)) { + template.tags.forEach((tag) => tagsSet.add(tag)); + } + }); + + // Convert set to array and return as suggestions + return Array.from(tagsSet).sort().map((tag) => ({ + name: tag, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, + listTemplatesFiltered: { + custom: async (tokens, executeCommand, generatorContext) => { + // Find if there's a -f or --filter flag in the tokens + let filterValue: string | undefined; + for (let i = 0; i < tokens.length; i++) { + if ((tokens[i] === '-f' || tokens[i] === '--filter') && i + 1 < tokens.length) { + filterValue = tokens[i + 1]; + break; + } + } + + // Build the azd command with filter if present + const args = ['template', 'list', '--output', 'json']; + if (filterValue) { + args.push('--filter', filterValue); + } + + try { + const { stdout } = await executeCommand({ + command: 'azd', + args: args, + }); + + const templates: AzdTemplateListItem[] = JSON.parse(stdout); + return templates.map((template) => ({ + name: template.repositoryPath, + description: template.name, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, + listExtensions: { + script: ['azd', 'ext', 'list', '--output', 'json'], + postProcess: (out) => { + try { + const extensions: AzdExtensionListItem[] = JSON.parse(out); + const uniqueExtensions = new Map(); + + extensions.forEach((ext) => { + if (!uniqueExtensions.has(ext.id)) { + uniqueExtensions.set(ext.id, ext); + } + }); + + return Array.from(uniqueExtensions.values()).map((ext) => ({ + name: ext.id, + description: ext.name, + })); + } catch { + return []; + } + }, + cache: { + strategy: 'stale-while-revalidate', + } + }, + listInstalledExtensions: { + script: ['azd', 'ext', 'list', '--installed', '--output', 'json'], + postProcess: (out) => { + try { + const extensions: AzdExtensionListItem[] = JSON.parse(out); + const uniqueExtensions = new Map(); + + extensions.forEach((ext) => { + if (!uniqueExtensions.has(ext.id)) { + uniqueExtensions.set(ext.id, ext); + } + }); + + return Array.from(uniqueExtensions.values()).map((ext) => ({ + name: ext.id, + description: ext.name, + })); + } catch { + return []; + } + }, + }, +}; \ No newline at end of file diff --git a/cli/azd/internal/figspec/resources/index.d.ts b/cli/azd/internal/figspec/resources/index.d.ts new file mode 100644 index 00000000000..73f35d4135f --- /dev/null +++ b/cli/azd/internal/figspec/resources/index.d.ts @@ -0,0 +1,1305 @@ +// This is a copy of the Fig spec type definition in the VS Code repo: +// https://github.com/microsoft/vscode/blob/main/extensions/terminal-suggest/src/completions/index.d.ts +// +// This is here to provide type checking and IntelliSense support when editing generators.ts. + +/* eslint-disable @typescript-eslint/ban-types */ +declare namespace Fig { + /** + * Templates are generators prebuilt by Fig. + * @remarks + * Here are the three templates: + * - filepaths: show folders and filepaths. Allow autoexecute on filepaths + * - folders: show folders only. Allow autoexecute on folders + * - history: show suggestions for all items in history matching this pattern + * - help: show subcommands. Only includes the 'siblings' of the nearest 'parent' subcommand + */ + type TemplateStrings = "filepaths" | "folders" | "history" | "help"; + + /** + * A template which is a single TemplateString or an array of TemplateStrings + * + * @remarks + * Templates are generators prebuilt by Fig. Here are the three templates: + * - filepaths: show folders and filepaths. Allow autoexecute on filepaths + * - folders: show folders only. Allow autoexecute on folders + * - history: show suggestions for all items in history matching this pattern + * - help: show subcommands. Only includes the 'siblings' of the nearest 'parent' subcommand + * + * @example + * `cd` uses the "folders" template + * `ls` used ["filepaths", "folders"]. Why both? Because if I `ls` a directory, we want to enable a user to autoexecute on this directory. If we just did "filepaths" they couldn't autoexecute. + * + */ + type Template = TemplateStrings | TemplateStrings[]; + + type HistoryContext = { + currentWorkingDirectory: string; + time: number; + exitCode: number; + shell: string; + }; + + type TemplateSuggestionContext = + | { templateType: "filepaths" } + | { templateType: "folders" } + | { templateType: "help" } + | ({ templateType: "history" } & Partial); + + type TemplateSuggestion = Modify< + Suggestion, + { name?: string; context: TemplateSuggestionContext } + >; + + /** + * + * The SpecLocation object defines well... the location of the completion spec we want to load. + * Specs can be "global" (ie hosted by Fig's cloud) or "local" (ie stored on your local machine) + * + * @remarks + * **The `SpecLocation` Object** + * + * The SpecLocation object defines well... the location of the completion spec we want to load. + * Specs can be "global" (ie hosted by Fig's cloud) or "local" (ie stored on your local machine). + * + * - Global `SpecLocation`: + * Load specs hosted in Fig's Cloud. Assume the current working directory is here: https://github.com/withfig/autocomplete/tree/master/src. Now set the value for the "name" prop to the relative location of your spec (without the .js file extension) + * ```js + * // e.g. + * { type: "global", name: "aws/s3" } // Loads up the aws s3 completion spec + * { type: "global", name: "python/http.server" } // Loads up the http.server completion spec + * ``` + * + * - Local `SpecLocation`: + * Load specs saved on your local system / machine. Assume the current working directory is the user's current working directory. + * The `name` prop should take the name of the spec (without the .js file extension) e.g. my_cli_tool + * The `path` prop should take an absolute path OR a relative path (relative to the user's current working directory). The path should be to the directory that contains the `.fig` folder. Fig will then assume your spec is located in `.fig/autocomplete/build/` + * ```js + * // e.g. + * { type: "global", path: "node_modules/cowsay", name: "cowsay_cli" } // will look for `cwd/node_modules/cowsay/.fig/autocomplete/build/cowsay_cli.js` + * { type: "global", path: "~", name: "my_cli" } // will look for `~/.fig/autocomplete/build/my_cli.js` + * ``` + * @irreplaceable + */ + type SpecLocation = + | { type: "local"; path?: string; name: string } + | { type: "global"; name: string }; + + /** + * Dynamically load up another completion spec at runtime. + * + * See [`loadSpec` property in Subcommand Object](https://fig.io/docs/reference/subcommand#loadspec). + */ + type LoadSpec = + | string + | Subcommand + | (( + token: string, + executeCommand: ExecuteCommandFunction + ) => Promise); + + /** + * The type of a suggestion object. + * @remarks + * The type determines: + * - the default icon Fig uses (e.g. a file or folder searches for the system icon, a subcommand has a specific icon etc) + * - whether we allow users to auto-execute a command + */ + type SuggestionType = + | "folder" + | "file" + | "arg" + | "subcommand" + | "option" + | "special" + | "mixin" + | "shortcut"; + + /** + * A single object of type `T` or an array of objects of type `T`. + */ + type SingleOrArray = T | T[]; + + /** + * An async function that returns the version of a given CLI tool. + * @remarks + * This is used in completion specs that want to version themselves the same way CLI tools are versioned. See fig.io/docs + * + * @param executeCommand -an async function that allows you to execute a shell command on the user's system and get the output as a string. + * @returns The version of a CLI tool + * + * @example + * `1.0.22` + * + * @example + * `v26` + * + */ + type GetVersionCommand = (executeCommand: ExecuteCommandFunction) => Promise; + + /** + * Context about a current shell session. + */ + type ShellContext = { + /** + * The current directory the shell is in + */ + currentWorkingDirectory: string; + /** + * Exported environment variables from the shell + */ + environmentVariables: Record; + /** + * The name of the current process + */ + currentProcess: string; + /** + * @hidden + * @deprecated + */ + sshPrefix: string; + }; + + type GeneratorContext = ShellContext & { + isDangerous?: boolean; + searchTerm: string; + }; + + /** + * A function which can have a `T` argument and a `R` result. + * @param param - A param of type `R` + * @returns Something of type `R` + */ + type Function = (param: T) => R; + + /** + * A utility type to modify a property type + * @irreplaceable + */ + type Modify = Omit & R; + + /** + * A `string` OR a `function` which can have a `T` argument and a `R` result. + * @param param - A param of type `R` + * @returns Something of type `R` + */ + type StringOrFunction = string | Function; + + /** + * @excluded + * @irreplaceable + */ + type ArgDiff = Modify; + + /** + * @excluded + * @irreplaceable + */ + type OptionDiff = Modify< + Fig.Option, + { + args?: ArgDiff | ArgDiff[]; + remove?: true; + } + >; + + /** + * @excluded + * @irreplaceable + */ + type SubcommandDiff = Modify< + Fig.Subcommand, + { + subcommands?: SubcommandDiff[]; + options?: OptionDiff[]; + args?: ArgDiff | ArgDiff[]; + remove?: true; + } + >; + + /** + * @excluded + * @irreplaceable + */ + type SpecDiff = Omit; + + /** + * @excluded + * @irreplaceable + */ + type VersionDiffMap = Record; + + /** + * A spec object. + * Can be one of + * 1. A subcommand + * 2. A function that dynamically computes a subcommand + * 3. A function that returns the path to a versioned spec files (that exports a base subcommand and { versions: VersionDiffMap } + */ + type Spec = + | Subcommand + | ((version?: string) => Subcommand) + | ((version?: string) => { + versionedSpecPath: string; + version?: string; + }); + + type ExecuteCommandInput = { + /** + * The command to execute + */ + command: string; + /** + * The arguments to the command to be run + */ + args: string[]; + /** + * The directory to run the command in + */ + cwd?: string; + /** + * The environment variables to set when executing the command, `undefined` will unset the variable if it set + */ + env?: Record; + /** + * Duration of timeout in milliseconds, if the command takes longer than the timeout a error will be thrown. + * @defaultValue 5000 + */ + timeout?: number; + }; + + /** + * The output of running a command + */ + type ExecuteCommandOutput = { + /** + * The stdout (1) of running a command + */ + stdout: string; + /** + * The stderr (2) of running a command + */ + stderr: string; + /** + * The exit status of running a command + */ + status: number; + }; + + /** + * An async function to execute a command + * @returns The output of the command + */ + type ExecuteCommandFunction = (args: ExecuteCommandInput) => Promise; + + type CacheMaxAge = { + strategy: "max-age"; + /** + * The time to live for the cache in milliseconds. + * @example + * 3600 + */ + ttl: number; + }; + + type CacheStaleWhileRevalidate = { + strategy?: "stale-while-revalidate"; + /** + * The time to live for the cache in milliseconds. + * @example + * 3600 + */ + ttl?: number; + }; + + type Cache = (CacheMaxAge | CacheStaleWhileRevalidate) & { + /** + * Whether the cache should be based on the directory the user was currently in or not. + * @defaultValue false + */ + cacheByDirectory?: boolean; + + /** + * Hardcoded cache key that can be used to cache a single generator across + * multiple argument locations in a spec. + */ + cacheKey?: string; + }; + + type TriggerOnChange = { + /** Trigger on any change to the token */ + on: "change"; + }; + + type TriggerOnThreshold = { + /** Trigger when the length of the token changes past a threshold */ + on: "threshold"; + length: number; + }; + + type TriggerOnMatch = { + /** Trigger when the index of a string changes */ + on: "match"; + string: string | string[]; + }; + + type Trigger = + | string + | ((newToken: string, oldToken: string) => boolean) + | TriggerOnChange + | TriggerOnThreshold + | TriggerOnMatch; + + /** + * The BaseSuggestion object is the root of the Suggestion, Subcommand, and Option objects. + * It is where key properties like description, icon, and displayName are found + * @excluded + */ + interface BaseSuggestion { + /** + * The string that is displayed in the UI for a given suggestion. + * @defaultValue the name prop + * + * @example + * The npm CLI has a subcommand called `install`. If we wanted + * to display some custom text like `Install an NPM package 📦` we would set + * `name: "install"` and `displayName: "Install an NPM package 📦"` + */ + displayName?: string; + /** + * The value that's inserted into the terminal when a user presses enter/tab or clicks on a menu item. + * + * @remarks + * You can use `\n` to insert a newline or `\b` to insert a backspace. + * You can also optionally specify {cursor} in the string and Fig will automatically place the cursor there after insert. + * + * @defaultValue The value of the name prop. + * + * @example + * For the `git commit` subcommand, the `-m` option has an insert value of `-m '{cursor}'` + */ + insertValue?: string; + /** + * When the suggestion is inserted, replace the command with this string + * + * @remarks + * You can use `\n` to insert a newline or `\b` to insert a backspace. + * You can also optionally specify {cursor} in the string and Fig will automatically place the cursor there after insert. + * Note that currently the entire edit buffer will be replaced. Eventually, only the root command will be replaced, preserving pipes and continuations. + */ + replaceValue?: string; + /** + * The text that gets rendered at the bottom of the autocomplete box (or the side if you hit ⌘i) + * + * @example + * "Your commit message" + */ + description?: string; + /** + * The icon that is rendered is based on the type. + * + * @remarks + * Icons can be a 1 character string, a URL, or Fig's [icon protocol](https://fig.io/docs/reference/suggestion/icon-api) (fig://) which lets you generate + * colorful and fun systems icons. + * + * @defaultValue related to the type of the object (e.g. `Suggestion`, `Subcommand`, `Option`, `Arg`) + * + * @example + * `A` + * @example + * `😊` + * @example + * `https://www.herokucdn.com/favicon.ico` + * @example + * `fig://icon?type=file` + * + */ + icon?: string; + /** + * Specifies whether the suggestion is "dangerous". + * + * @remarks + * If true, Fig will not enable its autoexecute functionality. Autoexecute means if a user selects a suggestion it will insert the text and run the command. We signal this by changing the icon to red. + * Setting `isDangerous` to `true` will make it harder for a user to accidentally run a dangerous command. + * + * @defaultValue false + * + * @example + * This is used in the `rm` spec. Why? Because we don't want users to accidentally delete their files so we make it just a little bit harder... + */ + isDangerous?: boolean; + /** + * The number used to rank suggestions in autocomplete. Number must be from 0-100. Higher priorities rank higher. + * + * @defaultValue 50 + * @remarks + * Fig ranks suggestions by recency. To do this, we check if a suggestion has been selected before. If yes and the suggestions has: + * - a priority between 50-75, the priority will be replaced with 75, then we will add the timestamp of when that suggestion was selected as a decimal. + * - a priority outside of 50-75, the priority will be increased by the timestamp of when that suggestion was selected as a decimal. + * If it has not been selected before, Fig will keep the same priority as was set in the completion spec + * If it was not set in the spec, it will default to 50. + * + * @example + * Let's say a user has previously selected a suggestion at unix timestamp 1634087677: + * - If completion spec did not set a priority (Fig treats this as priority 50), its priority would change to 75 + 0.1634087677 = 75.1634087677; + * - If completion spec set a priority of 49 or less, its priority would change to 49 + 0.1634087677 = 49.1634087677; + * - If completion spec set a priority of 76 or more, its priority would change to 76 + 0.1634087677 = 76.1634087677; + * - If a user had never selected a suggestion, then its priority would just stay as is (or if not set, default to 50). + * + * @example + * If you want your suggestions to always be: + * - at the top order, rank them 76 or above. + * - at the bottom, rank them 49 or below + */ + priority?: number; + /** + * Specifies whether a suggestion should be hidden from results. + * @remarks + * Fig will only show it if the user exactly types the name. + * @defaultValue false + * @example + * The "-" suggestion is hidden in the `cd` spec. You will only see it if you type exactly `cd -` + */ + hidden?: boolean; + /** + * + * Specifies whether a suggestion is deprecated. + * @remarks + * It is possible to specify a suggestion to replace the deprecated one. + * - The `description` of the deprecated object (e.g `deprecated: { description: 'The --no-ansi option has been deprecated in v2' }`) is used to provide infos about the deprecation. + * - `deprecated: true` and `deprecated: { }` behave the same and will just display the suggestion as deprecated. + * @example + * ```js + * deprecated: { insertValue: '--ansi never', description: 'The --no-ansi option has been deprecated in v2' } + * ``` + */ + deprecated?: boolean | Omit; + + /** + * Specifies which component to use to render the preview window. + * + * @remarks This should be the path within the `src` directory to the component without the extension. + * + * @example 'ls/filepathPreview' + */ + previewComponent?: string; + + /** + * This is a way to pass data to the Autocomplete Engine that is not formalized in the spec, do not use this in specs as it may change at any time + * + * @ignore + */ + _internal?: Record; + } + + /** + * Each item in Fig's autocomplete popup window is a Suggestion object. It is probably the most important object in Fig. + * Subcommand and Option objects compile down to Suggestion objects. Generators return Suggestion objects. + * The main things you can customize in your suggestion object is the text that's displayed, the icon, and what's inserted after being selected. In saying that, most of these have very sane defaults. + */ + interface Suggestion extends BaseSuggestion { + /** + * The string Fig uses when filtering over a list of suggestions to check for a match. + * @remarks + * When a a user is typing in the terminal, the query term (the token they are currently typing) filters over all suggestions in a list by checking if the queryTerm matches the prefix of the name. + * The `displayName` prop also defaults to the value of name. + * + * The `name` props of suggestion, subcommand, option, and arg objects are all different. It's important to read them all carefully. + * + * @example + * If a user types git `c`, any Suggestion objects with a name prop that has a value starting with "c" will match. + * + */ + name?: SingleOrArray; + /** + * The type of a suggestion object. + * @remarks + * The type determines + * - the default icon Fig uses (e.g. a file or folder searches for the system icon, a subcommand has a specific icon etc) + * - whether we allow users to auto-execute a command + */ + type?: SuggestionType; + } + + /** + * The subcommand object represent the tree structure of a completion spec. We sometimes also call it the skeleton. + * + * A subcommand can nest options, arguments, and more subcommands (it's recursive) + */ + interface Subcommand extends BaseSuggestion { + /** + * The name of the subcommand. Should exactly match the name defined by the CLI tool. + * + * @remarks + * If a subcommand has multiple aliases, they should be included as an array. + * + * Note that Fig's autocomplete engine requires this `name` to match the text typed by the user in the shell. + * + * To customize the title that is displayed to the user, use `displayName`. + * + * + * @example + * For `git checkout`, the subcommand `checkout` would have `name: "checkout"` + * @example + * For `npm install`, the subcommand `install` would have `name: ["install", "i"]` as these two values both represent the same subcommand. + */ + name: SingleOrArray; + + /** + * An array of `Subcommand` objects representing all the subcommands that exist beneath the current command. + * * + * To support large CLI tools, `Subcommands` can be nested recursively. + * + * @example + * A CLI tool like `aws` is composed of many top-level subcommands (`s3`, `ec2`, `eks`...), each of which include child subcommands of their own. + */ + subcommands?: Subcommand[]; + + /** + * Specifies whether the command requires a subcommand. This is false by default. + * + * A space will always be inserted after this command if `requiresSubcommand` is true. + * If the property is omitted, a space will be inserted if there is at least one required argument. + */ + requiresSubcommand?: boolean; + + /** + * An array of `Option` objects representing the options that are available on this subcommand. + * + * @example + * A command like `git commit` accepts various flags and options, such as `--message` and `--all`. These `Option` objects would be included in the `options` field. + */ + options?: Option[]; + + /** + * An array of `Arg` objects representing the various parameters or "arguments" that can be passed to this subcommand. + * + */ + args?: SingleOrArray; + /** + * This option allows to enforce the suggestion filtering strategy for a specific subcommand. + * @remarks + * Users always want to have the most accurate results at the top of the suggestions list. + * For example we can enable fuzzy search on a subcommand that always requires fuzzy search to show the best suggestions. + * This property is also useful when subcommands or options have a prefix (e.g. the npm package scope) because enabling fuzzy search users can omit that part (see the second example below) + * @example + * yarn workspace [name] with fuzzy search is way more useful since we can omit the npm package scope + * @example + * fig settings uses fuzzy search to prevent having to add the `autocomplete.` prefix to each searched setting + * ```typescript + * const figSpec: Fig.Spec { + * name: "fig", + * subcommands: [ + * { + * name: "settings", + * filterStrategy: "fuzzy", + * subcommands: [ + * { + * name: "autocomplete.theme", // if a user writes `fig settings theme` it gets the correct suggestions + * }, + * // ... other settings + * ] + * }, + * // ... other fig subcommands + * ] + * } + * ``` + */ + filterStrategy?: "fuzzy" | "prefix" | "default"; + /** + * A list of Suggestion objects that are appended to the suggestions shown beneath a subcommand. + * + * @remarks + * You can use this field to suggest common workflows. + * + */ + additionalSuggestions?: (string | Suggestion)[]; + /** + * Dynamically load another completion spec at runtime. + * + * @param tokens - a tokenized array of the text the user has typed in the shell. + * @param executeCommand - an async function that can execute a shell command on behalf of the user. The output is a string. + * @returns A `SpecLocation` object or an array of `SpecLocation` objects. + * + * @remarks + * `loadSpec` can be invoked as string (recommended) or a function (advanced). + * + * The API tells the autocomplete engine where to look for a completion spec. If you pass a string, the engine will attempt to locate a matching spec that is hosted by Fig. + * + * @example + * Suppose you have an internal CLI tool that wraps `kubectl`. Instead of copying the `kubectl` completion spec, you can include the spec at runtime. + * ```typescript + * { + * name: "kube", + * description: "a wrapper around kubectl" + * loadSpec: "kubectl" + * } + * ``` + * @example + * In the `aws` completion spec, `loadSpec` is used to optimize performance. The completion spec is split into multiple files, each of which can be loaded separately. + * ```typescript + * { + * name: "s3", + * loadSpec: "aws/s3" + * } + * ``` + */ + loadSpec?: LoadSpec; + /** + * Dynamically *generate* a `Subcommand` object a runtime. The generated `Subcommand` is merged with the current subcommand. + * + * @remarks + * This API is often used by CLI tools where the structure of the CLI tool is not *static*. For instance, if the tool can be extended by plugins or otherwise shows different subcommands or options depending on the environment. + * + * @param tokens - a tokenized array of the text the user has typed in the shell. + * @param executeCommand - an async function that can execute a shell command on behalf of the user. The output is a string. + * @returns a `Fig.Spec` object + * + * @example + * The `python` spec uses `generateSpec` to include the`django-admin` spec if `django manage.py` exists. + * ```typescript + * generateSpec: async (tokens, executeCommand) => { + * // Load the contents of manage.py + * const managePyContents = await executeCommand("cat manage.py"); + * // Heuristic to determine if project uses django + * if (managePyContents.contains("django")) { + * return { + * name: "python", + * subcommands: [{ name: "manage.py", loadSpec: "django-admin" }], + * }; + * } + * }, + * ``` + */ + generateSpec?: (tokens: string[], executeCommand: ExecuteCommandFunction) => Promise; + + /** + * Generating a spec can be expensive, but due to current guarantees they are not cached. + * This function generates a cache key which is used to cache the result of generateSpec. + * If `undefined` is returned, the cache will not be used. + */ + generateSpecCacheKey?: Function<{ tokens: string[] }, string | undefined> | string; + + /** + * Configure how the autocomplete engine will map the raw tokens to a given completion spec. + * + * @param flagsArePosixNoncompliant - Indicates that flags with one hyphen may have *more* than one character. Enabling this directive, turns off support for option chaining. + * @param optionsMustPrecedeArguments - Options will not be suggested after any argument of the Subcommand has been typed. + * @param optionArgSeparators - Indicate that options which take arguments will require one of the specified separators between the 'verbose' option name and the argument. + * + * @example + * The `-work` option from the `go` spec is parsed as a single flag when `parserDirectives.flagsArePosixNoncompliant` is set to true. Normally, this would be chained and parsed as `-w -o -r -k` if `flagsArePosixNoncompliant` is not set to true. + */ + parserDirectives?: { + flagsArePosixNoncompliant?: boolean; + optionsMustPrecedeArguments?: boolean; + optionArgSeparators?: SingleOrArray; + }; + + /** + * Specifies whether or not to cache the result of loadSpec and generateSpec + * + * @remarks + * Caching is good because it reduces the time to completion on subsequent calls to a dynamic subcommand, but when the data does not outlive the cache this allows a mechanism for opting out of it. + */ + cache?: boolean; + } + + /** + * The option object represent CLI options (sometimes called flags). + * + * A option can have an argument. An option can NOT have subcommands or other option + */ + interface Option extends BaseSuggestion { + /** + * The exact name of the subcommand as defined in the CLI tool. + * + * @remarks + * Fig's parser relies on your option name being exactly what the user would type. (e.g. if the user types `git "-m"`, you must have `name: "-m"` and not something like `name: "your message"` or even with an `=` sign like`name: "-m="`) + * + * If you want to customize what the text the popup says, use `displayName`. + * + * The name prop in an Option object compiles down to the name prop in a Suggestion object + * + * Final note: the name prop can be a string (most common) or an array of strings + * + * + * @example + * For `git commit -m` in the, message option nested beneath `commit` would have `name: ["-m", "--message"]` + * @example + * For `ls -l` the `-l` option would have `name: "-l"` + */ + name: SingleOrArray; + + /** + * An array of arg objects or a single arg object + * + * @remarks + * If a subcommand takes an argument, please at least include an empty Arg Object. (e.g. `{ }`). Why? If you don't, Fig will assume the subcommand does not take an argument. When the user types their argument + * If the argument is optional, signal this by saying `isOptional: true`. + * + * @example + * `npm run` takes one mandatory argument. This can be represented by `args: { }` + * @example + * `git push` takes two optional arguments. This can be represented by: `args: [{ isOptional: true }, { isOptional: true }]` + * @example + * `git clone` takes one mandatory argument and one optional argument. This can be represented by: `args: [{ }, { isOptional: true }]` + */ + args?: SingleOrArray; + /** + * + * Signals whether an option is persistent, meaning that it will still be available + * as an option for all child subcommands. + * + * @remarks + * As of now there is no way to disable this + * persistence for certain children. Also see + * https://github.com/spf13/cobra/blob/master/user_guide.md#persistent-flags. + * + * @defaultValue false + * + * @example + * Say the `git` spec had an option at the top level with `{ name: "--help", isPersistent: true }`. + * Then the spec would recognize both `git --help` and `git commit --help` + * as a valid as we are passing the `--help` option to all `git` subcommands. + * + */ + isPersistent?: boolean; + /** + * Signals whether an option is required. + * + * @defaultValue false (option is NOT required) + * @example + * The `-m` option of `git commit` is required + * + */ + isRequired?: boolean; + /** + * + * Signals whether an equals sign is required to pass an argument to an option (e.g. `git commit --message="msg"`) + * @defaultValue false (does NOT require an equal) + * + * @example + * When `requiresEqual: true` the user MUST do `--opt=value` and cannot do `--opt value` + * + * @deprecated use `requiresSeparator` instead + * + */ + requiresEquals?: boolean; + /** + * + * Signals whether one of the separators specified in parserDirectives is required to pass an argument to an option (e.g. `git commit --message[separator]"msg"`) + * If set to true this will automatically insert an equal after the option name. + * If set to a separator (string) this will automatically insert the separator specified after the option name. + * @defaultValue false (does NOT require a separator) + * + * @example + * When `requiresSeparator: true` the user MUST do `--opt=value` and cannot do `--opt value` + * @example + * When `requiresSeparator: ':'` the user MUST do `--opt:value` and cannot do `--opt value` + */ + requiresSeparator?: boolean | string; + /** + * + * Signals whether an option can be passed multiple times. + * + * @defaultValue false (option is NOT repeatable) + * + * @remarks + * Passing `isRepeatable: true` will allow an option to be passed any number + * of times, while passing `isRepeatable: 2` will allow it to be passed + * twice, etc. Passing `isRepeatable: false` is the same as passing + * `isRepeatable: 1`. + * + * If you explicitly specify the isRepeatable option in a spec, this + * constraint will be enforced at the parser level, meaning after the option + * (say `-o`) has been passed the maximum number of times, Fig's parser will + * not recognize `-o` as an option if the user types it again. + * + * @example + * In `npm install` doesn't specify `isRepeatable` for `{ name: ["-D", "--save-dev"] }`. + * When the user types `npm install -D`, Fig will no longer suggest `-D`. + * If the user types `npm install -D -D`. Fig will still parse the second + * `-D` as an option. + * + * Suppose `npm install` explicitly specified `{ name: ["-D", "--save-dev"], isRepeatable: false }`. + * Now if the user types `npm install -D -D`, Fig will instead parse the second + * `-D` as the argument to the `install` subcommand instead of as an option. + * + * @example + * SSH has `{ name: "-v", isRepeatable: 3 }`. When the user types `ssh -vv`, Fig + * will still suggest `-v`, when the user types `ssh -vvv` Fig will stop + * suggesting `-v` as an option. Finally if the user types `ssh -vvvv` Fig's + * parser will recognize that this is not a valid string of chained options + * and will treat this as an argument to `ssh`. + * + */ + isRepeatable?: boolean | number; + /** + * + * Signals whether an option is mutually exclusive with other options (ie if the user has this option, Fig should not show the options specified). + * @defaultValue false + * + * @remarks + * Options that are mutually exclusive with flags the user has already passed will not be shown in the suggestions list. + * + * @example + * You might see `[-a | --interactive | --patch]` in a man page. This means each of these options are mutually exclusive on each other. + * If we were defining the exclusive prop of the "-a" option, then we would have `exclusive: ["--interactive", "--patch"]` + * + */ + exclusiveOn?: string[]; + /** + * + * + * Signals whether an option depends on other options (ie if the user has this option, Fig should only show these options until they are all inserted). + * + * @defaultValue false + * + * @remarks + * If the user has an unmet dependency for a flag they've already typed, this dependency will have boosted priority in the suggestion list. + * + * @example + * In a tool like firebase, we may want to delete a specific extension. The command might be `firebase delete --project ABC --extension 123` This would mean we delete the 123 extension from the ABC project. + * In this case, `--extension` dependsOn `--project` + * + */ + dependsOn?: string[]; + } + + /** + * The arg object represent CLI arguments (sometimes called positional arguments). + * + * An argument is different to a subcommand object and option object. It does not compile down to a suggestion object. Rather, it represents custom user input. If you want to generate suggestions for this custom user input, you should use the generator prop nested beneath an Arg object + */ + interface Arg { + /** + * The name of an argument. This is different to the `name` prop for subcommands, options, and suggestion objects so please read carefully. + * This `name` prop signals a normal, human readable string. It usually signals to the user the type of argument they are inserting if there are no available suggestions. + * Unlike subcommands and options, Fig does NOT use this value for parsing. Therefore, it can be whatever you want. + * + * @example + * The name prop for the `git commit -m ` arg object is "msg". But you could also make it "message" or "your message". It is only used for description purposes (you see it when you type the message), not for parsing! + */ + name?: string; + + /** + * The text that gets rendered at the bottom of the autocomplete box a) when the user is inputting an argument and there are no suggestions and b) for all generated suggestions for an argument + * Keep it short and direct! + * + * @example + * "Your commit message" + */ + description?: string; + + /** + * Specifies whether the suggestions generated for this argument are "dangerous". + * + * @remarks + * If true, Fig will not enable its autoexecute functionality. Autoexecute means if a user selects a suggestion it will insert the text and run the command. We signal this by changing the icon to red. + * Turning on isDangerous will make it harder for a user to accidentally run a dangerous command. + * + * @defaultValue false + * + * @example + * This is used for all arguments in the `rm` spec. + */ + isDangerous?: boolean; + + /** + * A list of Suggestion objects that are shown when a user is typing an argument. + * + * @remarks + * These suggestions are static meaning you know them beforehand and they are not generated at runtime. If you want to generate suggestions at runtime, use a generator + * + * @example + * For `git reset `, a two common arguments to pass are "head" and "head^". Therefore, the spec suggests both of these by using the suggestion prop + */ + suggestions?: (string | Suggestion)[]; + /** + * A template which is a single TemplateString or an array of TemplateStrings + * + * @remarks + * Templates are generators prebuilt by Fig. Here are the three templates: + * - filepaths: show folders and filepaths. Allow autoexecute on filepaths + * - folders: show folders only. Allow autoexecute on folders + * - history: show suggestions for all items in history matching this pattern + * - help: show subcommands. Only includes the 'siblings' of the nearest 'parent' subcommand + * + * @example + * `cd` uses the "folders" template + * @example + * `ls` used ["filepaths", "folders"]. Why both? Because if I `ls` a directory, we want to enable a user to autoexecute on this directory. If we just did "filepaths" they couldn't autoexecute. + * + */ + template?: Template; + /** + * + * Generators let you dynamically generate suggestions for arguments by running shell commands on a user's device. + * + * This takes a single generator or an array of generators + */ + generators?: SingleOrArray; + /** + * This option allows to enforce the suggestion filtering strategy for a specific argument suggestions. + * @remarks + * Users always want to have the most accurate results at the top of the suggestions list. + * For example we can enable fuzzy search on an argument that always requires fuzzy search to show the best suggestions. + * This property is also useful when argument suggestions have a prefix (e.g. the npm package scope) because enabling fuzzy search users can omit that part (see the second example below) + * @example + * npm uninstall [packages...] uses fuzzy search to allow searching for installed packages ignoring the package scope + * ```typescript + * const figSpec: Fig.Spec { + * name: "npm", + * subcommands: [ + * { + * args: { + * name: "packages", + * filterStrategy: "fuzzy", // search in suggestions provided by the generator (in this case) using fuzzy search + * generators: generateNpmDeps, + * isVariadic: true, + * }, + * }, + * // ... other npm commands + * ], + * } + * ``` + */ + filterStrategy?: "fuzzy" | "prefix" | "default"; + /** + * Provide a suggestion at the top of the list with the current token that is being typed by the user. + */ + suggestCurrentToken?: boolean; + /** + * Specifies that the argument is variadic and therefore repeats infinitely. + * + * @remarks + * Man pages represent variadic arguments with an ellipsis e.g. `git add ` + * + * @example + * `echo` takes a variadic argument (`echo hello world ...`) + * @example + * `git add` also takes a variadic argument + */ + isVariadic?: boolean; + + /** + * Specifies whether options can interrupt variadic arguments. There is + * slightly different behavior when this is used on an option argument and + * on a subcommand argument: + * + * - When an option breaks a *variadic subcommand argument*, after the option + * and any arguments are parsed, the parser will continue parsing variadic + * arguments to the subcommand + * - When an option breaks a *variadic option argument*, after the breaking + * option and any arguments are parsed, the original variadic options + * arguments will be terminated. See the second examples below for details. + * + * + * @defaultValue true + * + * @example + * When true for git add's argument: + * `git add file1 -v file2` will interpret `-v` as an option NOT an + * argument, and will continue interpreting file2 as a variadic argument to + * add after + * + * @example + * When true for -T's argument, where -T is a variadic list of tags: + * `cmd -T tag1 tag2 -p project tag3` will interpret `-p` as an option, but + * will then terminate the list of tags. So tag3 is not parsed as an + * argument to `-T`, but rather as a subcommand argument to `cmd` if `cmd` + * takes any arguments. + * + * @example + * When false: + * `echo hello -n world` will treat -n as an argument NOT an option. + * However, in `echo -n hello world` it will treat -n as an option as + * variadic arguments haven't started yet + * + */ + optionsCanBreakVariadicArg?: boolean; + + /** + * `true` if an argument is optional (ie the CLI spec says it is not mandatory to include an argument, but you can if you want to). + * + * @remarks + * NOTE: It is important you include this for our parsing. If you don't, Fig will assume the argument is mandatory. When we assume an argument is mandatory, we force the user to input the argument and hide all other suggestions. + * + * @example + * `git push [remote] [branch]` takes two optional args. + */ + isOptional?: boolean; + /** + * Syntactic sugar over the `loadSpec` prop. + * + * @remarks + * Specifies that the argument is an entirely new command which Fig should start completing on from scratch. + * + * @example + * `time` and `builtin` have only one argument and this argument has the `isCommand` property. If I type `time git`, Fig will load up the git completion spec because the isCommand property is set. + */ + isCommand?: boolean; + /** + * The same as the `isCommand` prop, except Fig will look for a completion spec in the `.fig/autocomplete/build` folder in the user's current working directory. + * + * @remarks + * See our docs for more on building completion specs for local scripts [Fig for Teams](https://fig.io/docs/) + * @example + * `python` take one argument which is a `.py` file. If I have a `main.py` file on my desktop and my current working directory is my desktop, if I type `python main.py[space]` Fig will look for a completion spec in `~/Desktop/.fig/autocomplete/build/main.py.js` + */ + isScript?: boolean; + /** + * The same as the `isCommand` prop, except you specify a string to prepend to what the user inputs and fig will load the completion spec accordingly. + * @remarks + * If isModule: "python/", Fig would load up the `python/USER_INPUT.js` completion spec from the `~/.fig/autocomplete` folder. + * @example + * For `python -m`, the user can input a specific module such as http.server. Each module is effectively a mini CLI tool that should have its own completions. Therefore the argument object for -m has `isModule: "python/"`. Whatever the modules user inputs, Fig will look under the `~/.fig/autocomplete/python/` directory for completion spec. + * + * @deprecated use `loadSpec` instead + */ + isModule?: string; + + /** + * This will debounce every keystroke event for this particular arg. + * @remarks + * If there are no keystroke events after 100ms, Fig will execute all the generators in this arg and return the suggestions. + * + * @example + * `npm install` and `pip install` send debounced network requests after inactive typing from users. + */ + debounce?: boolean; + /** + * The default value for an optional argument. + * + * @remarks + * Note: This is currently not used anywhere in Fig's autocomplete popup, but will be soon. + * + */ + default?: string; + /** + * See [`loadSpec` in Subcommand Object](https://fig.io/docs/reference/subcommand#loadspec). + * + * @remarks + * There is a very high chance you want to use one of the following: + * 1. `isCommand` (See [Arg Object](https://fig.io/docs/reference/arg#iscommand)) + * 2. `isScript` (See [Arg Object](https://fig.io/docs/reference/arg#isscript)) + * + */ + loadSpec?: LoadSpec; + + /** + * The `arg.parserDirective.alias` prop defines whether Fig's tokenizer should expand out an alias into separate tokens then offer completions accordingly. + * + * @remarks + * This is similar to how Fig is able to offer autocomplete for user defined shell aliases, but occurs at the completion spec level. + * + * @param token - The token that the user has just typed that is an alias for something else + * @param executeCommand -an async function that allows you to execute a shell command on the user's system and get the output as a string. + * @returns The expansion of the alias that Fig's bash parser will reparse as if it were typed out in full, rather than the alias. + * + * If for some reason you know exactly what it will be, you may also just pass in the expanded alias, not a function that returns the expanded alias. + * + * @example + * git takes git aliases. These aliases are defined in a user's gitconfig file. Let's say a user has an alias for `p=push`, then if a user typed `git p[space]`, this function would take the `p` token, return `push` and then offer suggestions as if the user had typed `git push[space]` + * + * @example + * `npm run