Skip to content
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,33 @@ workos env --help --json # Subcommand tree
workos organization --help --json # With positionals and option types
```

### Shell Completion

Generate autocompletion scripts for your shell:

```bash
# Bash — current session
eval "$(workos completion bash)"

# Bash — permanent
workos completion bash > /etc/bash_completion.d/workos

# Zsh — current session
eval "$(workos completion zsh)"

# Zsh — permanent
mkdir -p ~/.zfunc
workos completion zsh > ~/.zfunc/_workos
# Add to ~/.zshrc: fpath=(~/.zfunc $fpath); autoload -Uz compinit && compinit

# Fish — auto-discovered
mkdir -p ~/.config/fish/completions
workos completion fish > ~/.config/fish/completions/workos.fish

# PowerShell — current session
workos completion powershell | Out-String | Invoke-Expression
```

## Authentication

The CLI uses WorkOS Connect OAuth device flow for authentication:
Expand Down
39 changes: 34 additions & 5 deletions src/bin.ts

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 completion command not excluded from unclaimed-env middleware

The completion command is missing from the middleware exclusion list at src/bin.ts:200. The middleware comment explicitly states that utility commands like skills, doctor, env, and debug are excluded because the warning is unnecessary — completion is also a utility command but was not added. When completion runs through the middleware, maybeWarnUnclaimed() may make an API call (adding latency) and emit a stderr warning. While it won't corrupt the stdout script output, running eval "$(workos completion bash)" could flash a confusing "Unclaimed environment" warning.

(Refers to lines 200-202)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
#!/usr/bin/env bun

// Fast path for shell completion — exit before loading yargs, clack, etc.
// so Tab presses are fast (~50ms vs ~200ms+). Bun preserves the Node-style
// [runtime, entrypoint, ...args] argv shape in both source mode and
// standalone executables.
const rawArgs = process.argv.slice(2);
if (rawArgs[0] === '--get-yargs-completions') {
const { completeHandler } = await import('./utils/completion.js');
completeHandler(rawArgs.slice(1));
process.exit(0);
}

// Load .env.local for local development when --local flag is used
if (process.argv.includes('--local') || process.env.WORKOS_DEV) {
const { config } = await import('dotenv');
Expand Down Expand Up @@ -80,9 +91,6 @@ await loadDeviceId();
recoverPendingEvents();

// Resolve output mode early from raw argv (before yargs parses)
// Bun preserves the Node-style [runtime, entrypoint, ...args] argv shape in
// both source mode and standalone executables.
const rawArgs = process.argv.slice(2);
const hasJsonFlag = rawArgs.includes('--json');
const baseOutputMode = resolveOutputMode(hasJsonFlag);
setOutputMode(baseOutputMode);
Expand Down Expand Up @@ -301,15 +309,16 @@ async function runCli(): Promise<void> {
.middleware(async (argv) => {
// Warn about unclaimed environments before management commands.
// Excluded: auth/claim/install/setup/dashboard handle their own credential
// or onboarding flows; skills/doctor/env/debug are utility commands where
// the warning is unnecessary.
// or onboarding flows; skills/doctor/env/debug/completion are utility
// commands where the warning is unnecessary.
const command = String(argv._?.[0] ?? '');
if (
[
'auth',
'skills',
'doctor',
'env',
'completion',
'claim',
'install',
'setup',
Expand Down Expand Up @@ -2748,6 +2757,26 @@ async function runCli(): Promise<void> {
await runMigrations(passthrough, resolveOptionalApiKey({ apiKey: argv.apiKey }), endpoint);
},
)
.command(
'completion [shell]',
'Generate shell autocompletion script',
(yargs) =>
yargs.positional('shell', {
type: 'string',
describe: 'Shell type (bash, zsh, fish, powershell)',
choices: ['bash', 'zsh', 'fish', 'powershell'] as const,
}),
async (argv) => {
if (!argv.shell) {
exitWithError({
code: 'missing_args',
message: 'Usage: workos completion <shell>\nSupported shells: bash, zsh, fish, powershell',
});
}
const { generateShellScript } = await import('./utils/completion.js');
process.stdout.write(generateShellScript(argv.shell as string, 'workos'));
},
)
.command(
'dashboard',
false, // hidden from help
Expand Down
184 changes: 184 additions & 0 deletions src/utils/completion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { describe, it, expect } from 'vitest';
import { generateCompletions, generateShellScript, SUPPORTED_SHELLS } from './completion.js';
import { commandRegistry } from './help-json.js';

describe('generateCompletions', () => {
it('does not mutate command registry during normalization', () => {
const skills = commandRegistry.find((c) => c.name === 'skills');
const originalCommands = skills?.commands;
generateCompletions(['auth', '']);
expect(skills?.commands).toBe(originalCommands);
});

it('returns top-level commands for empty input', () => {
const result = generateCompletions(['']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('auth');
expect(names).toContain('env');
expect(names).toContain('organization');
expect(names).toContain('completion');
expect(names).toContain('doctor');
});

it('filters commands by partial prefix', () => {
const result = generateCompletions(['or']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('organization');
expect(names).toContain('org-domain');
expect(names).not.toContain('auth');
});

it('returns subcommands when parent is given', () => {
const result = generateCompletions(['env', '']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('add');
expect(names).toContain('remove');
expect(names).toContain('switch');
expect(names).toContain('list');
expect(names).toContain('claim');
});

it('returns options when partial starts with -', () => {
const result = generateCompletions(['doctor', '--']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('--verbose');
expect(names).toContain('--fix');
expect(names).toContain('--json');
});

it('excludes used options', () => {
const result = generateCompletions(['doctor', '--verbose', '--']);
const names = result.completions.map((c) => c.name);
expect(names).not.toContain('--verbose');
expect(names).toContain('--fix');
});

it('excludes options used by alias', () => {
const result = generateCompletions(['skills', 'install', '-s', 'authkit', '--']);
const names = result.completions.map((c) => c.name);
expect(names).not.toContain('--skill');
expect(names).toContain('--agent');
});

it('deduplicates command and global options', () => {
const result = generateCompletions(['doctor', '--']);
const names = result.completions.map((c) => c.name);
expect(names.filter((name) => name === '--json')).toHaveLength(1);
});

it('sets NO_FILE_COMP directive', () => {
const result = generateCompletions(['']);
expect(result.directive).toBe(4);
});

it('normalizes flat compound names into virtual parent (auth)', () => {
const result = generateCompletions(['auth', '']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('login');
expect(names).toContain('logout');
expect(names).toContain('status');
});

it('completes nested subcommands (config redirect)', () => {
const result = generateCompletions(['config', '']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('redirect');
expect(names).toContain('cors');
expect(names).toContain('homepage-url');
});

it('handles two-level deep subcommands (config redirect add)', () => {
const result = generateCompletions(['config', 'redirect', '']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('add');
});

it('skips option values for non-boolean options', () => {
const result = generateCompletions(['doctor', '--install-dir', '/tmp/foo', '--']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('--verbose');
expect(names).not.toContain('--install-dir');
});

it('handles inline option values', () => {
const result = generateCompletions(['doctor', '--install-dir=/tmp/foo', '--verbose', '--']);
const names = result.completions.map((c) => c.name);
expect(names).not.toContain('--install-dir');
expect(names).not.toContain('--verbose');
expect(names).toContain('--fix');
});

it('does not skip next word after boolean options', () => {
const result = generateCompletions(['doctor', '--verbose', 'unknownword', '--']);
const names = result.completions.map((c) => c.name);
expect(names).not.toContain('--verbose');
});

it('returns top-level commands for completely empty args', () => {
const result = generateCompletions([]);
const names = result.completions.map((c) => c.name);
expect(names).toContain('auth');
expect(names.length).toBeGreaterThan(0);
});

it('returns options and subcommands when unknown word precedes partial', () => {
const result = generateCompletions(['env', 'nonexistent', '']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('--json');
});

it('includes descriptions in completions', () => {
const result = generateCompletions(['']);
const doctor = result.completions.find((c) => c.name === 'doctor');
expect(doctor).toBeDefined();
expect(doctor!.description).toBeTruthy();
expect(doctor!.description.length).toBeGreaterThan(0);
});

it('filters options by partial prefix', () => {
const result = generateCompletions(['doctor', '--ver']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('--verbose');
expect(names).toContain('--version');
expect(names).not.toContain('--fix');
});

it('does not complete hidden commands absent from registry', () => {
const result = generateCompletions(['']);
const names = result.completions.map((c) => c.name);
expect(names).toContain('emulate');
expect(names).not.toContain('dashboard');
expect(names).not.toContain('debug');
});
});

describe('generateShellScript', () => {
for (const shell of SUPPORTED_SHELLS) {
it(`generates script for ${shell}`, () => {
const script = generateShellScript(shell, 'workos');
expect(script).toContain('workos');
expect(script).toContain('--get-yargs-completions');
});
}

it('uses portable bash output parsing', () => {
const script = generateShellScript('bash', 'workos');
expect(script).toContain("sed '$d'");
expect(script).not.toContain('head -n-1');
});

it('escapes zsh descriptions with colons', () => {
const script = generateShellScript('zsh', 'workos');
expect(script).toContain('desc="${desc//:/\\:}"');
});

it('splits PowerShell completions on tab characters', () => {
const script = generateShellScript('powershell', 'workos');
expect(script).toContain('$parts = $line.Split("`t", 2)');
expect(script).not.toContain('$line.Split("\\t", 2)');
});

it('throws for unsupported shell', () => {
expect(() => generateShellScript('cmd', 'workos')).toThrow('Unsupported shell');
});
});
Loading
Loading