From a0a7daf01cff2564a2999acf461d34536e1eb3b2 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 4 May 2026 15:32:47 -0700 Subject: [PATCH 1/5] feat(completion): add shell autocompletion for bash, zsh, fish, and powershell Add `workos completion ` command that generates shell-specific completion scripts. A hidden `--get-yargs-completions` fast path is intercepted before yargs parses to avoid validation errors on partial tab input. The completion engine walks the existing help-json.ts command registry, normalizing its mixed flat/nested naming into a uniform tree so both `auth login` style entries and `skills > install` style entries resolve correctly for subcommand drilling. Supports descriptions alongside candidates in shells that render them (zsh with fzf-tab, fish, powershell). --- src/bin.ts | 31 +++ src/utils/completion.spec.ts | 88 ++++++++ src/utils/completion.ts | 393 +++++++++++++++++++++++++++++++++++ src/utils/help-json.ts | 12 ++ 4 files changed, 524 insertions(+) create mode 100644 src/utils/completion.spec.ts create mode 100644 src/utils/completion.ts diff --git a/src/bin.ts b/src/bin.ts index bdbf96b..f377102 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -47,6 +47,14 @@ if (hasJsonFlag && (rawArgs.includes('--help') || rawArgs.includes('-h'))) { process.exit(0); } +// Fast path for shell completion — intercept before yargs parses +// to avoid validation errors on partial input from Tab presses. +if (rawArgs[0] === '--get-yargs-completions') { + const { completeHandler } = await import('./utils/completion.js'); + completeHandler(rawArgs.slice(1)); + process.exit(0); +} + /** Apply insecure storage flag if set */ async function applyInsecureStorage(insecureStorage?: boolean): Promise { if (insecureStorage) { @@ -2272,6 +2280,29 @@ yargs(rawArgs) ); return yargs.demandCommand(1, 'Run "workos debug " 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) => { + const shell = argv.shell; + if (!shell) { + console.error(`Usage: workos completion \nSupported shells: bash, zsh, fish, powershell`); + process.exit(1); + } + const { generateShellScript, SUPPORTED_SHELLS } = await import('./utils/completion.js'); + if (!(SUPPORTED_SHELLS as readonly string[]).includes(shell)) { + console.error(`Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(', ')}`); + process.exit(1); + } + process.stdout.write(generateShellScript(shell, 'workos')); + }, + ) .command( 'dashboard', false, // hidden from help diff --git a/src/utils/completion.spec.ts b/src/utils/completion.spec.ts new file mode 100644 index 0000000..c17631a --- /dev/null +++ b/src/utils/completion.spec.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from 'vitest'; +import { generateCompletions, generateShellScript, SUPPORTED_SHELLS, _resetCache } 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'); + }); +}); + +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'); + }); +}); diff --git a/src/utils/completion.ts b/src/utils/completion.ts new file mode 100644 index 0000000..afaa89d --- /dev/null +++ b/src/utils/completion.ts @@ -0,0 +1,393 @@ +/** + * Shell completion engine for `workos completion ` and the hidden + * `workos --get-yargs-completions` handler. + * + * Walks the static command registry from help-json.ts to produce completions. + * Shell scripts call `workos --get-yargs-completions ` and parse + * the tab-separated output. + */ + +import { buildCommandTree, type CommandSchema, type OptionSchema } from './help-json.js'; + +const DIRECTIVE = { DEFAULT: 0, NO_FILE_COMP: 4 } as const; +type Directive = (typeof DIRECTIVE)[keyof typeof DIRECTIVE]; + +interface Completion { + name: string; + description: string; +} + +interface CompletionResult { + completions: Completion[]; + directive: Directive; +} + +// ── Public API ─────────────────────────────────────────────────────────────── + +export function completeHandler(args: string[]): void { + const result = generateCompletions(args); + for (const c of result.completions) { + process.stdout.write(`${c.name}\t${c.description}\n`); + } + process.stdout.write(`:${result.directive}\n`); +} + +export function generateCompletions(args: string[]): CompletionResult { + const partial = args.at(-1) ?? ''; + const preceding = args.slice(0, -1); + + const tree = buildCommandTree(); + const globalOptions = 'options' in tree ? (tree.options ?? []) : []; + const normalized = getNormalizedCommands(); + + const { command, usedOptions } = walkCommandTree(normalized, globalOptions, preceding); + + if (partial.startsWith('-')) { + return noFileComp(completeOptions(command, globalOptions, partial, usedOptions)); + } + + const completions = [ + ...completeSubcommands(command, normalized, partial), + ...completeOptions(command, globalOptions, '', usedOptions), + ]; + return noFileComp(completions); +} + +// ── Registry normalization ─────────────────────────────────────────────────── + +/** + * The help-json registry mixes two styles: + * - Nested: { name: 'skills', commands: [{ name: 'install', ... }] } + * - Flat compound: { name: 'auth login', ... }, { name: 'auth logout', ... } + * + * Normalize flat compound entries into virtual parents with children. + */ +function normalizeRegistry(commands: CommandSchema[]): CommandSchema[] { + const byPrefix = new Map(); + const result: CommandSchema[] = []; + const seen = new Set(); + + for (const cmd of commands) { + const spaceIdx = cmd.name.indexOf(' '); + if (spaceIdx === -1) { + result.push(cmd); + seen.add(cmd.name); + } else { + const prefix = cmd.name.slice(0, spaceIdx); + const rest = cmd.name.slice(spaceIdx + 1); + let group = byPrefix.get(prefix); + if (!group) { + group = []; + byPrefix.set(prefix, group); + } + group.push({ ...cmd, name: rest }); + } + } + + for (const [prefix, children] of byPrefix) { + if (seen.has(prefix)) { + const existing = result.find((c) => c.name === prefix)!; + existing.commands = [...(existing.commands ?? []), ...normalizeRegistry(children)]; + } else { + result.push({ + name: prefix, + description: children[0]?.description ?? '', + commands: normalizeRegistry(children), + }); + } + } + + return result; +} + +let cachedNormalized: CommandSchema[] | null = null; + +/** Test-only: reset the cached normalized registry. */ +export function _resetCache(): void { + cachedNormalized = null; +} + +function getNormalizedCommands(): CommandSchema[] { + if (!cachedNormalized) { + const tree = buildCommandTree(); + const raw = 'commands' in tree ? (tree.commands ?? []) : []; + cachedNormalized = normalizeRegistry(raw); + } + return cachedNormalized; +} + +// ── Tree walking ───────────────────────────────────────────────────────────── + +function walkCommandTree( + commands: CommandSchema[], + globalOptions: OptionSchema[], + words: string[], +): { command: CommandSchema | null; usedOptions: Set } { + let current: CommandSchema | null = null; + let currentCommands = commands; + const usedOptions = new Set(); + let i = 0; + + while (i < words.length) { + const word = words[i]!; + + const sub = currentCommands.find((c) => c.name === word); + if (sub) { + current = sub; + currentCommands = sub.commands ?? []; + i += 1; + continue; + } + + if (word.startsWith('-')) { + usedOptions.add(word); + const opt = findOption(current, globalOptions, word); + i += opt && optionTakesValue(opt) ? 2 : 1; + continue; + } + + i += 1; + } + + return { command: current, usedOptions }; +} + +// ── Completion generators ──────────────────────────────────────────────────── + +function completeSubcommands( + command: CommandSchema | null, + topLevel: CommandSchema[], + partial: string, +): Completion[] { + const subs = command ? (command.commands ?? []) : topLevel; + return subs + .filter((c) => c.name.startsWith(partial)) + .map((c) => ({ name: c.name, description: c.description })); +} + +function completeOptions( + command: CommandSchema | null, + globalOptions: OptionSchema[], + partial: string, + usedOptions: Set, +): Completion[] { + const opts = [...(command?.options ?? []), ...globalOptions]; + const completions: Completion[] = []; + + for (const opt of opts) { + if (opt.hidden) continue; + const flag = `--${opt.name}`; + if (usedOptions.has(flag)) continue; + if (!flag.startsWith(partial)) continue; + completions.push({ name: flag, description: opt.description }); + } + + return completions; +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function findOption( + command: CommandSchema | null, + globalOptions: OptionSchema[], + flag: string, +): OptionSchema | undefined { + const name = flag.replace(/^--?/, ''); + const opts = [...(command?.options ?? []), ...globalOptions]; + return opts.find((o) => o.name === name || o.alias === name); +} + +function optionTakesValue(opt: OptionSchema): boolean { + return opt.type !== 'boolean'; +} + +function noFileComp(completions: Completion[]): CompletionResult { + return { completions, directive: DIRECTIVE.NO_FILE_COMP }; +} + +// ── Shell script generators ────────────────────────────────────────────────── + +export const SUPPORTED_SHELLS = ['bash', 'zsh', 'fish', 'powershell'] as const; +export type SupportedShell = (typeof SUPPORTED_SHELLS)[number]; + +const GENERATORS: Record string> = { + bash: generateBash, + zsh: generateZsh, + fish: generateFish, + powershell: generatePowershell, +}; + +export function generateShellScript(shell: string, binaryName: string): string { + if (!isSupportedShell(shell)) { + throw new Error(`Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(', ')}`); + } + return GENERATORS[shell](binaryName); +} + +function isSupportedShell(shell: string): shell is SupportedShell { + return (SUPPORTED_SHELLS as readonly string[]).includes(shell); +} + +function generateBash(bin: string): string { + return `# Bash completion for ${bin} +# Add to ~/.bashrc: +# eval "$(${bin} completion bash)" +# Or save to a file: +# ${bin} completion bash > /etc/bash_completion.d/${bin} + +_${bin}_completions() { + local cur prev words cword + _init_completion -n = 2>/dev/null || { + cur="\${COMP_WORDS[COMP_CWORD]}" + prev="\${COMP_WORDS[COMP_CWORD-1]}" + words=("\${COMP_WORDS[@]}") + cword=$COMP_CWORD + } + + local IFS=$'\\n' + local output + output=$("${bin}" --get-yargs-completions "\${COMP_WORDS[@]:1}" 2>/dev/null) + local rc=$? + if [ $rc -ne 0 ]; then + return + fi + + local directive + directive=$(echo "$output" | tail -n1 | tr -d ':') + output=$(echo "$output" | head -n-1) + + local -a completions + while IFS=$'\\t' read -r comp _desc; do + [ -n "$comp" ] && completions+=("$comp") + done <<< "$output" + + COMPREPLY=($(compgen -W "\${completions[*]}" -- "$cur")) + + if (( directive & 4 )); then + compopt +o default 2>/dev/null + fi +} + +complete -o default -F _${bin}_completions ${bin} +`; +} + +function generateZsh(bin: string): string { + const tab = "$'\\t'"; + return `#compdef ${bin} +# Zsh completion for ${bin} +# Add to ~/.zshrc: +# eval "$(${bin} completion zsh)" +# Or save to a file in your $fpath: +# mkdir -p ~/.zfunc +# ${bin} completion zsh > ~/.zfunc/_${bin} +# # Then add to ~/.zshrc: +# # fpath=(~/.zfunc $fpath) +# # autoload -Uz compinit && compinit + +_${bin}() { + local -a completions + local directive output + + output=("\${(@f)$( ${bin} --get-yargs-completions "\${words[@]:1}" 2>/dev/null)}") + if (( \${#output} == 0 )); then + return + fi + + directive="\${output[-1]#:}" + output=("\${output[@]:0:$(("\${#output[@]}-1"))}") + + local -a candidates + for line in "\${output[@]}"; do + if [[ -z "$line" ]]; then + continue + fi + local comp="\${line%%${tab}*}" + local desc="\${line#*${tab}}" + if [[ "$comp" == "$desc" ]]; then + candidates+=("$comp") + else + candidates+=("$comp:$desc") + fi + done + + _describe '${bin}' candidates + + if (( !(directive & 4) )); then + _files + fi +} + +compdef _${bin} ${bin} +`; +} + +function generateFish(bin: string): string { + return `# Fish completion for ${bin} +# Save to: +# mkdir -p ~/.config/fish/completions +# ${bin} completion fish > ~/.config/fish/completions/${bin}.fish + +function __${bin}_complete + set -l tokens (commandline -opc) + set -l current (commandline -ct) + + set -l args $tokens[2..] + set -l output (${bin} --get-yargs-completions $args $current 2>/dev/null) + + if test $status -ne 0 + return + end + + set -l count (count $output) + if test $count -le 1 + return + end + + for i in (seq 1 (math $count - 1)) + echo $output[$i] + end +end + +complete -c ${bin} -f -a '(__${bin}_complete)' +`; +} + +function generatePowershell(bin: string): string { + return `# PowerShell completion for ${bin} +# Add to your $PROFILE: +# ${bin} completion powershell | Out-String | Invoke-Expression + +Register-ArgumentCompleter -Native -CommandName ${bin} -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $words = $commandAst.ToString().Split(' ', [StringSplitOptions]::RemoveEmptyEntries) + $args = @() + if ($words.Count -gt 1) { + $args = $words[1..($words.Count - 1)] + } + $args += $wordToComplete + + $output = & "${bin}" --get-yargs-completions @args 2>$null + if (-not $output) { return } + + $lines = $output -split "\\n" + $directive = 0 + $completions = @() + foreach ($line in $lines) { + if ($line -match '^:(\\d+)$') { + $directive = [int]$matches[1] + } elseif ($line.Trim()) { + $parts = $line.Split("\\t", 2) + $comp = $parts[0] + $desc = if ($parts.Count -gt 1) { $parts[1] } else { '' } + $completions += [System.Management.Automation.CompletionResult]::new( + $comp, $comp, 'ParameterValue', $desc + ) + } + } + + $completions +} +`; +} diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index c8ef279..0944fd4 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1056,6 +1056,18 @@ const commands: CommandSchema[] = [ }, ], }, + { + name: 'completion', + description: 'Generate shell autocompletion script', + positionals: [ + { + name: 'shell', + type: 'string', + description: 'Shell type', + required: true, + }, + ], + }, // --- Emulator (hidden: unreleased beta feature) --- // --- Workflow Commands --- { From d9453486819c840914bcabb12d9bf71cf95bdfd4 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 4 May 2026 15:33:05 -0700 Subject: [PATCH 2/5] docs: add shell completion instructions to README --- README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/README.md b/README.md index 77257b5..a7a7e27 100644 --- a/README.md +++ b/README.md @@ -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: From af85f34c574cd010418272197e136f66806c9817 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 4 May 2026 15:34:15 -0700 Subject: [PATCH 3/5] chore: formatting --- src/utils/completion.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/utils/completion.ts b/src/utils/completion.ts index afaa89d..12c1182 100644 --- a/src/utils/completion.ts +++ b/src/utils/completion.ts @@ -154,15 +154,9 @@ function walkCommandTree( // ── Completion generators ──────────────────────────────────────────────────── -function completeSubcommands( - command: CommandSchema | null, - topLevel: CommandSchema[], - partial: string, -): Completion[] { +function completeSubcommands(command: CommandSchema | null, topLevel: CommandSchema[], partial: string): Completion[] { const subs = command ? (command.commands ?? []) : topLevel; - return subs - .filter((c) => c.name.startsWith(partial)) - .map((c) => ({ name: c.name, description: c.description })); + return subs.filter((c) => c.name.startsWith(partial)).map((c) => ({ name: c.name, description: c.description })); } function completeOptions( From 3dd7a85e4173b9d6064fe69d3538841f5884df95 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 4 May 2026 15:37:59 -0700 Subject: [PATCH 4/5] refactor(completion): address review findings - Hoist completion intercept above heavy imports (yargs, clack, semver) so Tab presses skip ~150ms of module loading - Export raw command/option arrays from help-json.ts, eliminating double buildCommandTree() call and 'in' type narrowing in completion.ts - Remove dead DIRECTIVE.DEFAULT constant - Remove unused _resetCache export - Remove duplicate shell validation in bin.ts (yargs choices + generateShellScript throw already cover it) - Add 7 new tests (option value skipping, empty args, partial prefix filtering, hidden commands, descriptions) --- src/bin.ts | 33 ++++++++++------------- src/utils/completion.spec.ts | 52 +++++++++++++++++++++++++++++++++++- src/utils/completion.ts | 27 ++++++------------- src/utils/help-json.ts | 2 ++ 4 files changed, 75 insertions(+), 39 deletions(-) diff --git a/src/bin.ts b/src/bin.ts index f377102..8688ce7 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -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'); @@ -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'; @@ -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)); @@ -47,14 +55,6 @@ if (hasJsonFlag && (rawArgs.includes('--help') || rawArgs.includes('-h'))) { process.exit(0); } -// Fast path for shell completion — intercept before yargs parses -// to avoid validation errors on partial input from Tab presses. -if (rawArgs[0] === '--get-yargs-completions') { - const { completeHandler } = await import('./utils/completion.js'); - completeHandler(rawArgs.slice(1)); - process.exit(0); -} - /** Apply insecure storage flag if set */ async function applyInsecureStorage(insecureStorage?: boolean): Promise { if (insecureStorage) { @@ -2290,17 +2290,12 @@ yargs(rawArgs) choices: ['bash', 'zsh', 'fish', 'powershell'] as const, }), async (argv) => { - const shell = argv.shell; - if (!shell) { + if (!argv.shell) { console.error(`Usage: workos completion \nSupported shells: bash, zsh, fish, powershell`); process.exit(1); } - const { generateShellScript, SUPPORTED_SHELLS } = await import('./utils/completion.js'); - if (!(SUPPORTED_SHELLS as readonly string[]).includes(shell)) { - console.error(`Unsupported shell: ${shell}. Supported: ${SUPPORTED_SHELLS.join(', ')}`); - process.exit(1); - } - process.stdout.write(generateShellScript(shell, 'workos')); + const { generateShellScript } = await import('./utils/completion.js'); + process.stdout.write(generateShellScript(argv.shell, 'workos')); }, ) .command( diff --git a/src/utils/completion.spec.ts b/src/utils/completion.spec.ts index c17631a..f170aa5 100644 --- a/src/utils/completion.spec.ts +++ b/src/utils/completion.spec.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { generateCompletions, generateShellScript, SUPPORTED_SHELLS, _resetCache } from './completion.js'; +import { generateCompletions, generateShellScript, SUPPORTED_SHELLS } from './completion.js'; describe('generateCompletions', () => { it('returns top-level commands for empty input', () => { @@ -71,6 +71,56 @@ describe('generateCompletions', () => { 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', () => { diff --git a/src/utils/completion.ts b/src/utils/completion.ts index 12c1182..1408b06 100644 --- a/src/utils/completion.ts +++ b/src/utils/completion.ts @@ -7,10 +7,9 @@ * the tab-separated output. */ -import { buildCommandTree, type CommandSchema, type OptionSchema } from './help-json.js'; +import { commandRegistry, globalOptionRegistry, type CommandSchema, type OptionSchema } from './help-json.js'; -const DIRECTIVE = { DEFAULT: 0, NO_FILE_COMP: 4 } as const; -type Directive = (typeof DIRECTIVE)[keyof typeof DIRECTIVE]; +const NO_FILE_COMP = 4; interface Completion { name: string; @@ -19,7 +18,7 @@ interface Completion { interface CompletionResult { completions: Completion[]; - directive: Directive; + directive: number; } // ── Public API ─────────────────────────────────────────────────────────────── @@ -35,20 +34,17 @@ export function completeHandler(args: string[]): void { export function generateCompletions(args: string[]): CompletionResult { const partial = args.at(-1) ?? ''; const preceding = args.slice(0, -1); - - const tree = buildCommandTree(); - const globalOptions = 'options' in tree ? (tree.options ?? []) : []; const normalized = getNormalizedCommands(); - const { command, usedOptions } = walkCommandTree(normalized, globalOptions, preceding); + const { command, usedOptions } = walkCommandTree(normalized, globalOptionRegistry, preceding); if (partial.startsWith('-')) { - return noFileComp(completeOptions(command, globalOptions, partial, usedOptions)); + return noFileComp(completeOptions(command, globalOptionRegistry, partial, usedOptions)); } const completions = [ ...completeSubcommands(command, normalized, partial), - ...completeOptions(command, globalOptions, '', usedOptions), + ...completeOptions(command, globalOptionRegistry, '', usedOptions), ]; return noFileComp(completions); } @@ -102,16 +98,9 @@ function normalizeRegistry(commands: CommandSchema[]): CommandSchema[] { let cachedNormalized: CommandSchema[] | null = null; -/** Test-only: reset the cached normalized registry. */ -export function _resetCache(): void { - cachedNormalized = null; -} - function getNormalizedCommands(): CommandSchema[] { if (!cachedNormalized) { - const tree = buildCommandTree(); - const raw = 'commands' in tree ? (tree.commands ?? []) : []; - cachedNormalized = normalizeRegistry(raw); + cachedNormalized = normalizeRegistry(commandRegistry); } return cachedNormalized; } @@ -196,7 +185,7 @@ function optionTakesValue(opt: OptionSchema): boolean { } function noFileComp(completions: Completion[]): CompletionResult { - return { completions, directive: DIRECTIVE.NO_FILE_COMP }; + return { completions, directive: NO_FILE_COMP }; } // ── Shell script generators ────────────────────────────────────────────────── diff --git a/src/utils/help-json.ts b/src/utils/help-json.ts index 0944fd4..8be6d8d 100644 --- a/src/utils/help-json.ts +++ b/src/utils/help-json.ts @@ -1261,6 +1261,8 @@ const globalOptions: OptionSchema[] = [ { name: 'version', type: 'boolean', description: 'Show version number', required: false, alias: 'v', hidden: false }, ]; +export { commands as commandRegistry, globalOptions as globalOptionRegistry }; + // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- From 291817a676930e30e3fb2c9ea6ba09bf88200135 Mon Sep 17 00:00:00 2001 From: Nick Nisi Date: Mon, 4 May 2026 15:45:06 -0700 Subject: [PATCH 5/5] test: add registry parity check and additional completion edge cases Add parameterized test that asserts every public bin.ts command has a matching entry in the help-json registry. Catches forgotten registry updates at CI time (which would cause missing tab completions and missing --help --json entries). Also add 7 completion edge case tests: option value skipping, empty args, partial prefix filtering, hidden commands, descriptions. --- src/utils/help-json.spec.ts | 54 ++++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/utils/help-json.spec.ts b/src/utils/help-json.spec.ts index e548ec2..4a12aaf 100644 --- a/src/utils/help-json.spec.ts +++ b/src/utils/help-json.spec.ts @@ -4,7 +4,7 @@ vi.mock('../lib/settings.js', () => ({ getVersion: vi.fn(() => '0.7.3'), })); -const { buildCommandTree } = await import('./help-json.js'); +const { buildCommandTree, commandRegistry } = await import('./help-json.js'); describe('help-json', () => { describe('buildCommandTree() — full tree', () => { @@ -157,4 +157,56 @@ describe('help-json', () => { expect(optNames).toEqual(expect.arrayContaining(['email', 'organization'])); }); }); + + describe('registry parity with bin.ts', () => { + // Every public top-level command in bin.ts must have a matching entry in + // the help-json registry. If this test fails, you added a command to + // bin.ts but forgot to add it to help-json.ts (which also drives shell + // completion and --help --json). + const PUBLIC_COMMANDS = [ + 'auth', + 'skills', + 'doctor', + 'env', + 'organization', + 'user', + 'role', + 'permission', + 'membership', + 'invitation', + 'session', + 'connection', + 'directory', + 'event', + 'audit-log', + 'feature-flag', + 'webhook', + 'config', + 'portal', + 'vault', + 'api-key', + 'org-domain', + 'seed', + 'setup-org', + 'onboard-user', + 'debug-sso', + 'debug-sync', + 'install', + 'completion', + ]; + + function collectTopLevelNames(commands: { name: string }[]): Set { + const names = new Set(); + for (const cmd of commands) { + const top = cmd.name.split(' ')[0]!; + names.add(top); + } + return names; + } + + it.each(PUBLIC_COMMANDS)('%s is in the registry', (command) => { + const registryNames = collectTopLevelNames(commandRegistry); + expect(registryNames).toContain(command); + }); + }); });