diff --git a/.changeset/rude-cows-cheat.md b/.changeset/rude-cows-cheat.md new file mode 100644 index 000000000000..9db2766b77b6 --- /dev/null +++ b/.changeset/rude-cows-cheat.md @@ -0,0 +1,24 @@ +--- +"wrangler": minor +--- + +Add `wrangler complete` command for shell completion scripts (bash, zsh, powershell) + +Usage: + +```bash +# Bash +wrangler complete bash >> ~/.bashrc + +# Zsh +wrangler complete zsh >> ~/.zshrc + +# Fish +wrangler complete fish >> ~/.config/fish/completions/wrangler.fish + +# PowerShell +wrangler complete powershell > $PROFILE +``` + +- Uses `@bomb.sh/tab` library for cross-shell compatibility +- Completions are dynamically generated from `experimental_getWranglerCommands()` API diff --git a/packages/wrangler/package.json b/packages/wrangler/package.json index fe7ed2e84865..ab04d53d92df 100644 --- a/packages/wrangler/package.json +++ b/packages/wrangler/package.json @@ -77,6 +77,7 @@ }, "devDependencies": { "@aws-sdk/client-s3": "^3.721.0", + "@bomb.sh/tab": "^0.0.11", "@cloudflare/cli": "workspace:*", "@cloudflare/containers-shared": "workspace:*", "@cloudflare/eslint-config-shared": "workspace:*", diff --git a/packages/wrangler/src/__tests__/complete.test.ts b/packages/wrangler/src/__tests__/complete.test.ts new file mode 100644 index 000000000000..f579af724ac7 --- /dev/null +++ b/packages/wrangler/src/__tests__/complete.test.ts @@ -0,0 +1,196 @@ +import { execSync } from "node:child_process"; +import { describe, expect, test } from "vitest"; +import { mockConsoleMethods } from "./helpers/mock-console"; +import { runInTempDir } from "./helpers/run-in-tmp"; +import { runWrangler } from "./helpers/run-wrangler"; + +function shellAvailable(shell: string): boolean { + try { + execSync(`which ${shell}`, { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +describe("wrangler", () => { + describe("complete", () => { + const std = mockConsoleMethods(); + runInTempDir(); + + describe("complete --", () => { + test("should return top-level commands", async () => { + await runWrangler('complete -- ""'); + + expect(std.out).toContain("deploy\t"); + expect(std.out).toContain("dev\t"); + expect(std.out).toContain("kv\t"); + }); + + test("should return subcommands for namespace", async () => { + await runWrangler('complete -- kv ""'); + + expect(std.out).toContain("namespace\t"); + expect(std.out).toContain("key\t"); + }); + + test("should return flags for a command", async () => { + await runWrangler("complete -- dev --"); + + expect(std.out).toContain("--port\t"); + expect(std.out).toContain("--ip\t"); + }); + + test("should not include internal commands", async () => { + await runWrangler('complete -- ""'); + + expect(std.out).toContain("deploy\t"); + expect(std.out).toContain("dev\t"); + // Internal commands like "_dev" should not be exposed + expect(std.out).not.toMatch(/^_dev\t/m); + }); + + test("should handle deeply nested commands", async () => { + await runWrangler('complete -- queues consumer http ""'); + + expect(std.out).toContain("add\t"); + expect(std.out).toContain("remove\t"); + }); + + test("should output tab-separated format", async () => { + await runWrangler('complete -- ""'); + + // Most lines should be "value\tdescription" format + const lines = std.out.trim().split("\n"); + let tabSeparatedCount = 0; + for (const line of lines) { + if (line.trim() && !line.startsWith(":")) { + if (line.includes("\t")) { + tabSeparatedCount++; + } + } + } + // Most commands should have descriptions + expect(tabSeparatedCount).toBeGreaterThan(10); + }); + + test("should return options with choices", async () => { + await runWrangler('complete -- dev --log-level=""'); + + expect(std.out).toContain("debug\t"); + expect(std.out).toContain("info\t"); + expect(std.out).toContain("warn\t"); + expect(std.out).toContain("error\t"); + }); + }); + + const shells = ["bash", "zsh", "fish"] as const; + + describe.each(shells)("%s", (shell) => { + test("should output valid shell script", async () => { + await runWrangler(`complete ${shell}`); + + expect(std.out.length).toBeGreaterThan(100); + }); + + test("should reference wrangler complete", async () => { + await runWrangler(`complete ${shell}`); + + expect(std.out).toContain("wrangler complete --"); + }); + }); + + describe("bash", () => { + test.skipIf(!shellAvailable("bash"))( + "should generate valid bash syntax", + async () => { + await runWrangler("complete bash"); + + // bash -n checks syntax without executing + expect(() => { + execSync("bash -n", { input: std.out }); + }).not.toThrow(); + } + ); + + test("should define __wrangler_complete function", async () => { + await runWrangler("complete bash"); + + expect(std.out).toContain("__wrangler_complete()"); + }); + + test("should register completion with complete builtin", async () => { + await runWrangler("complete bash"); + + expect(std.out).toContain("complete -F __wrangler_complete wrangler"); + }); + }); + + describe("zsh", () => { + test.skipIf(!shellAvailable("zsh"))( + "should generate valid zsh syntax", + async () => { + await runWrangler("complete zsh"); + + // zsh -n checks syntax without executing + expect(() => { + execSync("zsh -n", { input: std.out }); + }).not.toThrow(); + } + ); + + test("should start with #compdef directive", async () => { + await runWrangler("complete zsh"); + + expect(std.out).toContain("#compdef wrangler"); + }); + + test("should define _wrangler function", async () => { + await runWrangler("complete zsh"); + + expect(std.out).toContain("_wrangler()"); + }); + + test("should register with compdef", async () => { + await runWrangler("complete zsh"); + + expect(std.out).toContain("compdef _wrangler wrangler"); + }); + }); + + describe("fish", () => { + test.skipIf(!shellAvailable("fish"))( + "should generate valid fish syntax", + async () => { + await runWrangler("complete fish"); + + // fish -n checks syntax without executing + expect(() => { + execSync("fish -n", { input: std.out }); + }).not.toThrow(); + } + ); + + test("should define __wrangler_perform_completion function", async () => { + await runWrangler("complete fish"); + + expect(std.out).toContain("function __wrangler_perform_completion"); + }); + + test("should register completion with complete builtin", async () => { + await runWrangler("complete fish"); + + expect(std.out).toContain("complete -c wrangler"); + }); + + test("should use commandline for token extraction", async () => { + await runWrangler("complete fish"); + + // commandline -opc gets completed tokens + expect(std.out).toContain("commandline -opc"); + // commandline -ct gets current token being typed + expect(std.out).toContain("commandline -ct"); + }); + }); + }); +}); diff --git a/packages/wrangler/src/__tests__/docs.test.ts b/packages/wrangler/src/__tests__/docs.test.ts index 6f1ed5d611b1..f2c9cc2ce914 100644 --- a/packages/wrangler/src/__tests__/docs.test.ts +++ b/packages/wrangler/src/__tests__/docs.test.ts @@ -42,7 +42,6 @@ describe("wrangler docs", () => { 📚 Open Wrangler's command documentation in your browser - POSITIONALS search Enter search terms (e.g. the wrangler command) you want to know more about [array] [default: []] diff --git a/packages/wrangler/src/__tests__/index.test.ts b/packages/wrangler/src/__tests__/index.test.ts index dfc59ffca7c4..08851b3b1a47 100644 --- a/packages/wrangler/src/__tests__/index.test.ts +++ b/packages/wrangler/src/__tests__/index.test.ts @@ -37,6 +37,7 @@ describe("wrangler", () => { COMMANDS wrangler docs [search..] 📚 Open Wrangler's command documentation in your browser + wrangler complete [shell] ⌨️ Generate and handle shell completions ACCOUNT wrangler auth 🔐 Manage authentication @@ -108,6 +109,7 @@ describe("wrangler", () => { COMMANDS wrangler docs [search..] 📚 Open Wrangler's command documentation in your browser + wrangler complete [shell] ⌨️ Generate and handle shell completions ACCOUNT wrangler auth 🔐 Manage authentication diff --git a/packages/wrangler/src/complete.ts b/packages/wrangler/src/complete.ts new file mode 100644 index 000000000000..1a9be2d2da84 --- /dev/null +++ b/packages/wrangler/src/complete.ts @@ -0,0 +1,164 @@ +import t from "@bomb.sh/tab"; +import { CommandLineArgsError } from "@cloudflare/workers-utils"; +import { createCommand } from "./core/create-command"; +import { experimental_getWranglerCommands } from "./experimental-commands-api"; +import type { DefinitionTreeNode } from "./core/types"; + +function setupCompletions(): void { + const { registry, globalFlags } = experimental_getWranglerCommands(); + + // Global flags that work on every command + for (const [flagName, flagDef] of Object.entries(globalFlags)) { + if ("hidden" in flagDef && flagDef.hidden) { + continue; + } + + const description = flagDef.describe || ""; + t.option(flagName, description); + + if ("alias" in flagDef && flagDef.alias) { + const aliases = Array.isArray(flagDef.alias) + ? flagDef.alias + : [flagDef.alias]; + for (const alias of aliases) { + t.option(alias, `Alias for --${flagName}`); + } + } + } + + // Recursively add commands from the registry tree + function addCommandsFromTree( + node: DefinitionTreeNode, + parentPath: string[] = [] + ): void { + for (const [name, childNode] of node.subtree.entries()) { + const commandPath = [...parentPath, name]; + const commandName = commandPath.join(" "); + + if (childNode.definition) { + const def = childNode.definition; + let description = ""; + + if (def.metadata?.description) { + description = def.metadata.description; + } + + if ( + def.metadata?.status && + def.metadata.status !== "stable" && + !def.metadata.hidden + ) { + const statusLabels = { + experimental: "[experimental]", + alpha: "[alpha]", + "private beta": "[private beta]", + "open beta": "[open beta]", + }; + const statusLabel = + statusLabels[def.metadata.status as keyof typeof statusLabels]; + if (statusLabel) { + description = `${description} ${statusLabel}`; + } + } + + if (!def.metadata?.hidden) { + const cmd = t.command(commandName, description); + + if (def.type === "command" && "args" in def) { + const args = def.args || {}; + for (const [argName, argDef] of Object.entries(args)) { + if (argDef.hidden) { + continue; + } + + const argDescription = argDef.describe || ""; + + if (argDef.choices && Array.isArray(argDef.choices)) { + cmd.option(argName, argDescription, (complete) => { + for (const choice of argDef.choices as string[]) { + complete(choice, choice); + } + }); + } else { + cmd.option(argName, argDescription); + } + + if (argDef.alias) { + const aliases = Array.isArray(argDef.alias) + ? argDef.alias + : [argDef.alias]; + for (const alias of aliases) { + cmd.option(alias, `Alias for --${argName}`); + } + } + } + } + } + } + + if (childNode.subtree.size > 0) { + addCommandsFromTree(childNode, commandPath); + } + } + } + + addCommandsFromTree(registry); +} + +export const completionsCommand = createCommand({ + metadata: { + description: "⌨️ Generate and handle shell completions", + owner: "Workers: Authoring and Testing", + status: "stable", + examples: [ + { + description: "Generate bash completion script", + command: "wrangler complete bash", + }, + { + description: "Generate fish completion script", + command: "wrangler complete fish", + }, + { + description: "Generate powershell completion script", + command: "wrangler complete powershell", + }, + { + description: "Generate zsh completion script", + command: "wrangler complete zsh", + }, + ], + }, + behaviour: { + printBanner: false, + provideConfig: false, + }, + positionalArgs: ["shell"], + args: { + shell: { + choices: ["bash", "fish", "powershell", "zsh"], + describe: "Shell type to generate completions for", + type: "string", + }, + }, + handler(args) { + // When shells request completions, they call: wrangler complete -- + // Yargs puts everything after -- into the _ array + const rawArgs = (args as unknown as { _: string[] })._ ?? []; + + const completionArgs = rawArgs.slice(rawArgs[0] === "complete" ? 1 : 0); + + if (completionArgs.length > 0) { + setupCompletions(); + t.parse(completionArgs); + return; + } + + if (!args.shell) { + throw new CommandLineArgsError("Missing required argument: shell"); + } + + setupCompletions(); + t.setup("wrangler", "wrangler", args.shell); + }, +}); diff --git a/packages/wrangler/src/docs/index.ts b/packages/wrangler/src/docs/index.ts index 99334d9d80a9..ba3ebf64aea6 100644 --- a/packages/wrangler/src/docs/index.ts +++ b/packages/wrangler/src/docs/index.ts @@ -6,7 +6,7 @@ import { runSearch } from "./helpers"; export const docs = createCommand({ metadata: { - description: "📚 Open Wrangler's command documentation in your browser\n", + description: "📚 Open Wrangler's command documentation in your browser", owner: "Workers: Authoring and Testing", status: "stable", }, diff --git a/packages/wrangler/src/index.ts b/packages/wrangler/src/index.ts index 78f36508bb37..12684a58e541 100644 --- a/packages/wrangler/src/index.ts +++ b/packages/wrangler/src/index.ts @@ -26,6 +26,7 @@ import { } from "./cert/cert"; import { checkNamespace, checkStartupCommand } from "./check/commands"; import { cloudchamber } from "./cloudchamber"; +import { completionsCommand } from "./complete"; import { getDefaultEnvFiles, loadDotEnv } from "./config/dot-env"; import { containers } from "./containers"; import { demandSingleValue } from "./core"; @@ -640,6 +641,15 @@ export function createCLIParser(argv: string[]) { ]); registry.registerNamespace("docs"); + // completions + registry.define([ + { + command: "wrangler complete", + definition: completionsCommand, + }, + ]); + registry.registerNamespace("complete"); + /******************** CMD GROUP ***********************/ registry.define([ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13e2394b7d7b..06f91e66f60f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3859,6 +3859,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.721.0 version: 3.721.0 + '@bomb.sh/tab': + specifier: ^0.0.11 + version: 0.0.11(cac@6.7.14)(citty@0.1.6) '@cloudflare/cli': specifier: workspace:* version: link:../cli @@ -4572,6 +4575,21 @@ packages: resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} engines: {node: '>=6.9.0'} + '@bomb.sh/tab@0.0.11': + resolution: {integrity: sha512-RSqyreeicYBALcMaNxIUJTBknftXsyW45VRq5gKDNwKroh0Re5SDoWwXZaphb+OTEzVdpm/BA8Uq6y0P+AtVYw==} + hasBin: true + peerDependencies: + cac: ^6.7.14 + citty: ^0.1.6 + commander: ^13.1.0 + peerDependenciesMeta: + cac: + optional: true + citty: + optional: true + commander: + optional: true + '@bugsnag/browser@8.6.0': resolution: {integrity: sha512-7UGqTGnQqXUQ09gOlWbDTFUSbeLIIrP+hML3kTOq8Zdc8nP/iuOEflXGLV2TxWBWW8xIUPc928caFPr9EcaDuw==} @@ -14873,6 +14891,11 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@bomb.sh/tab@0.0.11(cac@6.7.14)(citty@0.1.6)': + optionalDependencies: + cac: 6.7.14 + citty: 0.1.6 + '@bugsnag/browser@8.6.0': dependencies: '@bugsnag/core': 8.6.0