Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
e9c0d42
feat(wrangler/cli): add tab completions
AmirSa12 Oct 27, 2025
f5f65e1
chore: update tab to version 0.0.9
AmirSa12 Nov 4, 2025
3e0e733
Merge remote-tracking branch 'upstream/main' into feat/tab-completions
AmirSa12 Nov 9, 2025
4b2b606
refactor(wrangler): use experimental_getWranglerCommands function for…
AmirSa12 Nov 9, 2025
b74ad8d
Update changelog for wrangler tab completions
ascorbic Dec 15, 2025
f0f739b
Merge branch 'main' into feat/tab-completions
ascorbic Dec 15, 2025
768ef7a
Format
ascorbic Dec 15, 2025
e38e836
Merge remote-tracking branch 'upstream/main' into feat/tab-completions
AmirSa12 Jan 5, 2026
be3b023
chore: update bomb.sh/tab to version 0.0.11
AmirSa12 Jan 5, 2026
15d5e4c
Merge branch 'main' into feat/tab-completions
NuroDev Jan 6, 2026
b1e60dd
Merge branch 'main' into feat/tab-completions
NuroDev Jan 7, 2026
412fa27
Merge branch 'main' into feat/tab-completions
NuroDev Jan 8, 2026
f49e16b
add tests
AmirSa12 Jan 8, 2026
20222b6
address Ben's review
AmirSa12 Jan 8, 2026
3437d21
update changeset .md file
AmirSa12 Jan 8, 2026
8c87c89
Merge branch 'main' into feat/tab-completions
NuroDev Jan 8, 2026
77a6ef3
Merge branch 'main' into feat/tab-completions
NuroDev Jan 9, 2026
89757a4
Merge branch 'main' into feat/tab-completions
NuroDev Jan 9, 2026
85ca3e7
Merge branch 'main' into feat/tab-completions
NuroDev Jan 9, 2026
4f5476c
Merge branch 'main' into feat/tab-completions
NuroDev Jan 9, 2026
7752c58
Merge branch 'main' into feat/tab-completions
NuroDev Jan 9, 2026
2f05cd5
Merge branch 'main' into feat/tab-completions
NuroDev Jan 9, 2026
c8d2486
Merge branch 'main' into feat/tab-completions
NuroDev Jan 12, 2026
e8a27f9
Merge branch 'main' into feat/tab-completions
NuroDev Jan 12, 2026
d18b46e
Merge branch 'main' into feat/tab-completions
NuroDev Jan 20, 2026
25bee9d
Removed self-explanitory doc comments
NuroDev Jan 21, 2026
ac25016
Switched to top-level `expect` import for completion tests
NuroDev Jan 21, 2026
149ae0f
Minor linting fixes
NuroDev Jan 21, 2026
137fb10
Added Fish shell to changeset
NuroDev Jan 21, 2026
6fd7f18
Refactored to use dedicated completion command namespace handler
NuroDev Jan 21, 2026
72e17bb
Minor help menu formatting fixes
NuroDev Jan 21, 2026
b4364b0
Minor status label fixes
NuroDev Jan 21, 2026
76fc750
Overhauled `complete` command logic
NuroDev Jan 21, 2026
5efb94b
Merge branch 'main' into feat/tab-completions
NuroDev Jan 21, 2026
a016b97
Fixed test help menu snapshots
NuroDev Jan 21, 2026
e9e79a1
Merge branch 'main' into feat/tab-completions
NuroDev Jan 21, 2026
b2fc39e
Minor `completionArgs` filtering tweak
NuroDev Jan 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/rude-cows-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"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

# PowerShell
wrangler complete powershell > $PROFILE
```

- Uses `@bomb.sh/tab` library for cross-shell compatibility
- Completions are dynamically generated from `experimental_getWranglerCommands()` API
1 change: 1 addition & 0 deletions packages/wrangler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
204 changes: 204 additions & 0 deletions packages/wrangler/src/__tests__/complete.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import { execSync } from "node:child_process";
import { describe, 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 ({ expect }) => {
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 ({ expect }) => {
await runWrangler('complete -- kv ""');

expect(std.out).toContain("namespace\t");
expect(std.out).toContain("key\t");
});

test("should return flags for a command", async ({ expect }) => {
await runWrangler("complete -- dev --");

expect(std.out).toContain("--port\t");
expect(std.out).toContain("--ip\t");
});

test("should not include internal commands", async ({ expect }) => {
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 ({ expect }) => {
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 ({ expect }) => {
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 ({ expect }) => {
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 ({ expect }) => {
await runWrangler(`complete ${shell}`);

expect(std.out.length).toBeGreaterThan(100);
});

test("should reference wrangler complete", async ({ expect }) => {
await runWrangler(`complete ${shell}`);

expect(std.out).toContain("wrangler complete --");
});
});

describe("bash", () => {
test.skipIf(!shellAvailable("bash"))(
"should generate valid bash syntax",
async ({ expect }) => {
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 ({ expect }) => {
await runWrangler("complete bash");

expect(std.out).toContain("__wrangler_complete()");
});

test("should register completion with complete builtin", async ({
expect,
}) => {
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 ({ expect }) => {
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 ({ expect }) => {
await runWrangler("complete zsh");

expect(std.out).toContain("#compdef wrangler");
});

test("should define _wrangler function", async ({ expect }) => {
await runWrangler("complete zsh");

expect(std.out).toContain("_wrangler()");
});

test("should register with compdef", async ({ expect }) => {
await runWrangler("complete zsh");

expect(std.out).toContain("compdef _wrangler wrangler");
});
});

describe("fish", () => {
test.skipIf(!shellAvailable("fish"))(
"should generate valid fish syntax",
async ({ expect }) => {
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 ({
expect,
}) => {
await runWrangler("complete fish");

expect(std.out).toContain("function __wrangler_perform_completion");
});

test("should register completion with complete builtin", async ({
expect,
}) => {
await runWrangler("complete fish");

expect(std.out).toContain("complete -c wrangler");
});

test("should use commandline for token extraction", async ({
expect,
}) => {
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");
});
});
});
});
117 changes: 117 additions & 0 deletions packages/wrangler/src/complete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import t from "@bomb.sh/tab";
import { experimental_getWranglerCommands } from "./experimental-commands-api";
import type { DefinitionTreeNode } from "./core/types";

function setupCompletions() {
const { registry, globalFlags } = experimental_getWranglerCommands();

// global flags that work on every command
for (const [flagName, flagDef] of Object.entries(globalFlags)) {
// skip hidden flags
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[] = []
) {
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);

return t;
}
// Handle completion requests from the shell
export function handleCompletion(args: string[]) {
const shell = args[0];

if (shell === "--") {
// Parse completion request from shell
setupCompletions();
t.parse(args.slice(1));
} else {
// Generate shell completion script
setupCompletions();
t.setup("wrangler", "wrangler", shell);
}
}
7 changes: 7 additions & 0 deletions packages/wrangler/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from "./cert/cert";
import { checkNamespace, checkStartupCommand } from "./check/commands";
import { cloudchamber } from "./cloudchamber";
import { handleCompletion } from "./complete";
import { getDefaultEnvFiles, loadDotEnv } from "./config/dot-env";
import { containers } from "./containers";
import { demandSingleValue } from "./core";
Expand Down Expand Up @@ -1730,6 +1731,12 @@ export function createCLIParser(argv: string[]) {
export async function main(argv: string[]): Promise<void> {
setupSentry();

// Handle shell completion requests
if (argv[0] === "complete") {
handleCompletion(argv.slice(1));
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way we can instead make this follow the existing command format / structure.
That way we can have the wrangler complete command itself have:

  • Auto completion
  • Suggestions for shells it supports
  • A help menu

We can also reference this code from another PR on how to implement this.


checkMacOSVersion({ shouldThrow: false });

const startTime = Date.now();
Expand Down
Loading