Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,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
32 changes: 29 additions & 3 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 node

import { hideBin } from 'yargs/helpers';

// Fast path for shell completion — exit before loading yargs, clack, etc.
// so Tab presses are fast (~50ms vs ~200ms+).
const rawArgs = hideBin(process.argv);
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.INSTALLER_DEV) {
const { config } = await import('dotenv');
Expand All @@ -12,7 +23,6 @@ import { red } from './utils/logging.js';
import { getConfig, getVersion } from './lib/settings.js';

import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { ensureAuthenticated } from './lib/ensure-auth.js';
import { checkForUpdates } from './lib/version-check.js';

Expand All @@ -32,8 +42,6 @@ import { resolveOutputMode, setOutputMode, isJsonMode, outputJson, exitWithError
import clack from './utils/clack.js';
import { registerSubcommand } from './utils/register-subcommand.js';

// Resolve output mode early from raw argv (before yargs parses)
const rawArgs = hideBin(process.argv);
const hasJsonFlag = rawArgs.includes('--json');
setOutputMode(resolveOutputMode(hasJsonFlag));

Expand Down Expand Up @@ -2272,6 +2280,24 @@ yargs(rawArgs)
);
return yargs.demandCommand(1, 'Run "workos debug <command>" for debug tools.').strict();
})
.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) {
console.error(`Usage: workos completion <shell>\nSupported shells: bash, zsh, fish, powershell`);
process.exit(1);
}
const { generateShellScript } = await import('./utils/completion.js');
process.stdout.write(generateShellScript(argv.shell, 'workos'));
},
)
.command(
'dashboard',
false, // hidden from help
Expand Down
138 changes: 138 additions & 0 deletions src/utils/completion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, it, expect } from 'vitest';
import { generateCompletions, generateShellScript, SUPPORTED_SHELLS } from './completion.js';

describe('generateCompletions', () => {
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('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('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).not.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('throws for unsupported shell', () => {
expect(() => generateShellScript('cmd', 'workos')).toThrow('Unsupported shell');
});
});
Loading
Loading