Skip to content

feat(completion): add shell autocompletion#141

Open
nicknisi wants to merge 5 commits intomainfrom
feat/shell-completion
Open

feat(completion): add shell autocompletion#141
nicknisi wants to merge 5 commits intomainfrom
feat/shell-completion

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented May 4, 2026

Summary

  • Add workos completion <shell> command supporting bash, zsh, fish, and powershell
  • Add hidden --get-yargs-completions fast path intercepted before yargs parses, avoiding validation errors on partial tab input
  • Completion engine walks the existing help-json.ts command registry with normalization to handle the mixed flat/nested naming (e.g. auth login vs skills > install)
  • Descriptions appear alongside candidates in shells that support them (zsh with fzf-tab, fish, powershell)
  • 14 unit tests covering command/subcommand/option completion, compound name normalization, and shell script generation

Usage

eval "$(workos completion zsh)"   # load into current session
workos completion bash > /etc/bash_completion.d/workos  # permanent

Test plan

  • workos completion bash outputs a valid bash completion script
  • workos completion zsh outputs a valid zsh completion script
  • workos completion fish outputs a valid fish completion script
  • workos completion powershell outputs a valid powershell completion script
  • eval "$(workos completion zsh)" then workos <TAB> shows all commands with descriptions
  • workos env <TAB> drills into subcommands (add, remove, switch, list, claim)
  • workos doctor --<TAB> shows available options
  • workos auth <TAB> shows login, logout, status (normalized from flat compound names)
  • npx tsc --noEmit passes
  • npx vitest run src/utils/completion.spec.ts — 14/14 pass
  • Full test suite unchanged (pre-existing token-refresh failure only)

Open in Devin Review

Summary by CodeRabbit

  • New Features

    • Adds a new workos completion command to generate shell autocompletion scripts for bash, zsh, fish, and PowerShell; validates the shell argument and writes the script to stdout.
  • Documentation

    • Adds a "Shell Completion" README section with usage and session vs. permanent installation examples.
  • Tests

    • Adds unit tests covering completion generation and shell script output for supported shells.

nicknisi added 2 commits May 4, 2026 15:32
…owershell

Add `workos completion <shell>` 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).
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 4, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 72e9c772-2e65-4cd1-a9e3-ea8b6d984778

📥 Commits

Reviewing files that changed from the base of the PR and between 3dd7a85 and 291817a.

📒 Files selected for processing (1)
  • src/utils/help-json.spec.ts

📝 Walkthrough

Walkthrough

Adds shell autocompletion: a completion engine that normalizes the command registry, computes completions for commands and options, emits a server-format response, and generates Bash/Zsh/Fish/PowerShell scripts. Adds an early CLI fast-path for yargs completion requests and a workos completion <shell> command; tests and README docs added.

Changes

Shell Completion Engine

Layer / File(s) Summary
Completion Engine (core API & behavior)
src/utils/completion.ts
Implements generateCompletions(args), completeHandler(args), normalization/caching of the command registry, command-tree walking, option-use detection, completion filtering (skip hidden/already-used options), and sets the final directive to suppress file completion. Removes previous _resetCache() export. Exports SUPPORTED_SHELLS, SupportedShell, and generateShellScript.
Shell Script Generators
src/utils/completion.ts
Adds generateBash, generateZsh, generateFish, generatePowershell and generateShellScript(shell, binaryName) producing shell-specific scripts that invoke --get-yargs-completions and parse the returned lines.
CLI Fast Path & Command
src/bin.ts
Adds an early pre-yargs exit path that intercepts --get-yargs-completions, dynamically imports ./utils/completion.js, calls completeHandler(rawArgs.slice(1)), writes output and exits. Adds workos completion [shell] yargs command that validates the shell positional and prints generateShellScript(argv.shell, 'workos').
Help Registry (data shape / wiring)
src/utils/help-json.ts
Inserts a top-level completion command entry with a required shell positional (string) and description; adds exports exposing commands and globalOptions as commandRegistry and globalOptionRegistry.
Tests
src/utils/completion.spec.ts, src/utils/help-json.spec.ts
Adds Vitest suites: comprehensive tests for generateCompletions (subcommand traversal, option completion rules, exclusion of used/hidden options, directive handling, virtual-parent normalization) and generateShellScript (per-supported-shell output and unsupported-shell error). help-json.spec.ts gains a registry-parity test asserting expected top-level commands exist.
Documentation
README.md
Adds a new "Shell Completion" section with session and permanent installation examples for Bash, Zsh, Fish, and PowerShell and the workos completion <shell> usage.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(completion): add shell autocompletion' accurately and concisely summarizes the main change: implementing shell completion functionality for the CLI with support for bash, zsh, fish, and powershell.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/shell-completion

Comment @coderabbitai help to get the list of available commands and usage tips.

nicknisi added 2 commits May 4, 2026 15:34
- 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)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3311d846-0107-405b-8cfa-5f37121338ed

📥 Commits

Reviewing files that changed from the base of the PR and between 9179874 and af85f34.

📒 Files selected for processing (5)
  • README.md
  • src/bin.ts
  • src/utils/completion.spec.ts
  • src/utils/completion.ts
  • src/utils/help-json.ts

Comment thread src/utils/completion.ts
Comment on lines +142 to +146
if (word.startsWith('-')) {
usedOptions.add(word);
const opt = findOption(current, globalOptions, word);
i += opt && optionTakesValue(opt) ? 2 : 1;
continue;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Used-option filtering misses aliases and can emit duplicate flags.

Line 143 stores flags exactly as typed, but Line 174 only checks canonical --name. If a user already used -D, --direct is still suggested. Also, command + global option merge can duplicate entries like --json.

✅ Suggested normalization + dedupe
 function walkCommandTree(
@@
     if (word.startsWith('-')) {
-      usedOptions.add(word);
       const opt = findOption(current, globalOptions, word);
+      if (opt) {
+        usedOptions.add(`--${opt.name}`);
+        if (opt.alias) usedOptions.add(`-${opt.alias}`);
+      } else {
+        usedOptions.add(word);
+      }
       i += opt && optionTakesValue(opt) ? 2 : 1;
       continue;
     }
@@
 function completeOptions(
@@
   const opts = [...(command?.options ?? []), ...globalOptions];
   const completions: Completion[] = [];
+  const emitted = new Set<string>();
 
   for (const opt of opts) {
     if (opt.hidden) continue;
     const flag = `--${opt.name}`;
+    if (emitted.has(flag)) continue;
+    emitted.add(flag);
     if (usedOptions.has(flag)) continue;
     if (!flag.startsWith(partial)) continue;
     completions.push({ name: flag, description: opt.description });
   }

Also applies to: 168-176

Comment thread src/utils/completion.ts
Comment on lines +249 to +252
local directive
directive=$(echo "$output" | tail -n1 | tr -d ':')
output=$(echo "$output" | head -n-1)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does BSD/macOS headsupporthead -n-1? What POSIX-portable alternative is recommended to drop the last line from shell output?

💡 Result:

No. BSD/macOS head does not support the GNU extension head -n -1 (or generally -n with a negative count). On macOS/BSD head, using a negative line count produces an error like “head: illegal line count -- -4”. [1] What’s POSIX-portable to drop the last line from shell output: Use sed '$d' (delete the last line of the stream): sed '$d' This is a common POSIX-style approach; e.g., it’s suggested as “If you want to see all but the last line … use: sed '$d'”. [2] Notes: - POSIX head is specified with -n number where the number must be a positive decimal integer, so head -n -1 is not POSIX-conforming. [3] - If you specifically install GNU coreutils on macOS, ghead -n -1 can work, but that’s not the native BSD/macOS head. [1]

Citations:


🏁 Script executed:

# First, check if the file exists and read the specific lines mentioned
if [ -f "src/utils/completion.ts" ]; then
  echo "=== Lines 245-255 from src/utils/completion.ts ==="
  sed -n '245,255p' src/utils/completion.ts
else
  echo "File not found, searching for completion.ts"
  find . -name "completion.ts" -type f
fi

Repository: workos/cli

Length of output: 326


🏁 Script executed:

# Search for all instances of 'head -n-' patterns in the file to understand scope
rg 'head\s+-n-' src/utils/completion.ts -A 2 -B 2

Repository: workos/cli

Length of output: 196


🏁 Script executed:

# Check file type and context - is this generating shell scripts?
rg -i 'shell|bash|zsh|completion|script' src/utils/completion.ts | head -20

Repository: workos/cli

Length of output: 1132


Shell completion script uses non-portable head -n-1 that breaks on macOS.

The generated bash completion script at line 251 uses head -n-1, which is a GNU extension not supported by BSD head (macOS default). This causes the completion function to fail when parsing directives on macOS systems with error: "head: illegal line count -- -1".

The POSIX-portable fix is to use sed '$d' instead, which is compatible across all POSIX-compliant systems.

💡 Portable fix
-    directive=$(echo "$output" | tail -n1 | tr -d ':')
-    output=$(echo "$output" | head -n-1)
+    directive=$(printf '%s\n' "$output" | tail -n 1 | tr -d ':')
+    output=$(printf '%s\n' "$output" | sed '$d')

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 4, 2026

Greptile Summary

This PR adds workos completion <shell> (bash, zsh, fish, powershell) plus a --get-yargs-completions fast path that exits before yargs/clack load, keeping tab response times low. The completion engine walks the help-json.ts registry and normalizes flat compound names (e.g. auth login) into virtual parent nodes for correct sub-command drilling.

  • P1 – PowerShell tab-split is broken: $line.Split(\"\\\ \", 2) generates PowerShell $line.Split(\"\ \", 2). PowerShell's double-quoted \"\ \" is the two-character literal \ , not a tab character (PowerShell uses backtick escapes). .Split() is a .NET literal-split method, so it never matches the real tab in the output line. Every PowerShell completion inserts the full name\ description string instead of just the command name. Fix: use \"\\t" in the TypeScript source to emit PowerShell's `` \"t" `` tab literal.

Confidence Score: 4/5

Safe to merge after fixing the PowerShell tab-split bug; all other shells are unaffected

One P1 bug (PowerShell .Split(" ") never matching actual tab characters) caps the score at 4. The bash/zsh/fish paths and the core completion engine are correct. The fix is a single-character change in the TypeScript source.

src/utils/completion.ts — specifically the generatePowershell function's tab-split logic

Important Files Changed

Filename Overview
src/utils/completion.ts New completion engine with shell script generators; contains a P1 bug where PowerShell tab-splitting uses literal \t string instead of the tab character, breaking all PowerShell completions
src/bin.ts Adds fast-path interception of --get-yargs-completions before yargs loads, and registers the completion [shell] command; logic is correct
src/utils/completion.spec.ts 14 new unit tests covering command/subcommand/option completion and shell script generation; coverage is thorough for the happy paths
src/utils/help-json.ts Adds completion command entry to registry and exports commandRegistry / globalOptionRegistry for use by the completion engine
src/utils/help-json.spec.ts Adds registry-parity test ensuring every public command in bin.ts has a matching help-json entry; useful guard against future drift
README.md Adds Shell Completion documentation section with per-shell install instructions

Sequence Diagram

sequenceDiagram
    participant Shell
    participant bin.ts
    participant completion.ts
    participant help-json.ts

    Shell->>bin.ts: workos --get-yargs-completions env TAB
    Note over bin.ts: Fast path: rawArgs[0] === '--get-yargs-completions'
    bin.ts->>completion.ts: completeHandler(['env', ''])
    completion.ts->>help-json.ts: commandRegistry + globalOptionRegistry
    help-json.ts-->>completion.ts: CommandSchema[], OptionSchema[]
    completion.ts->>completion.ts: normalizeRegistry() → cachedNormalized
    completion.ts->>completion.ts: walkCommandTree() → command=env
    completion.ts->>completion.ts: completeSubcommands(env) → [add, remove, ...]
    completion.ts-->>bin.ts: stdout: add TAB ...newline remove TAB ... newline :4 newline
    bin.ts->>Shell: process.exit(0)

    Shell->>bin.ts: workos completion zsh
    bin.ts->>completion.ts: generateShellScript('zsh', 'workos')
    completion.ts-->>bin.ts: zsh script string
    bin.ts->>Shell: stdout: #compdef workos ...
Loading

Reviews (2): Last reviewed commit: "test: add registry parity check and addi..." | Re-trigger Greptile

Comment thread src/utils/completion.ts
Comment on lines +239 to +240
directive=$(echo "$output" | tail -n1 | tr -d ':')
output=$(echo "$output" | head -n-1)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 head -n-1 not portable on macOS (BSD utilities)

head -n-1 is a GNU extension. On macOS, BSD head only accepts positive line counts and returns an error for negative values, so $output would silently be set to empty, breaking bash completions entirely for macOS users without GNU coreutils. The portable equivalent is sed '$d' (delete the last line).

Comment thread src/utils/completion.ts
Comment on lines +290 to +294
if [[ "$comp" == "$desc" ]]; then
candidates+=("$comp")
else
candidates+=("$comp:$desc")
fi
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Zsh _describe breaks when descriptions contain :

_describe uses the first : in each array entry as the separator between the completion name and its description. Several existing descriptions in help-json.ts contain literal colons (e.g. "Format: sk_live_* or sk_test_*", "domain:state"). When such an entry is assembled as "$comp:$desc" and passed to _describe, zsh truncates the description at the first colon, producing garbled output. Colons in descriptions must be escaped as \: before building the candidates array.

Suggested change
if [[ "$comp" == "$desc" ]]; then
candidates+=("$comp")
else
candidates+=("$comp:$desc")
fi
if [[ "$comp" == "$desc" ]]; then
candidates+=("$comp")
else
local escaped_desc="${desc//:/'\\:'}"
candidates+=("$comp:$escaped_desc")
fi

Comment thread src/utils/completion.ts
Comment on lines +84 to +86
if (seen.has(prefix)) {
const existing = result.find((c) => c.name === prefix)!;
existing.commands = [...(existing.commands ?? []), ...normalizeRegistry(children)];
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 normalizeRegistry mutates original commandRegistry objects in-place

When a flat compound name (e.g. auth login) matches an already-seen non-compound entry (auth), existing.commands is written directly onto the object pushed from the input array — the same reference that lives in the exported commandRegistry. It is harmless today because the completion fast-path calls process.exit(0) immediately, but a shallow clone before mutation eliminates the silent coupling.

Suggested change
if (seen.has(prefix)) {
const existing = result.find((c) => c.name === prefix)!;
existing.commands = [...(existing.commands ?? []), ...normalizeRegistry(children)];
if (seen.has(prefix)) {
const idx = result.findIndex((c) => c.name === prefix);
const existing = { ...result[idx]! };
result[idx] = existing;
existing.commands = [...(existing.commands ?? []), ...normalizeRegistry(children)];

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 2 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment thread 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.

Comment thread src/utils/completion.ts
if ($line -match '^:(\\d+)$') {
$directive = [int]$matches[1]
} elseif ($line.Trim()) {
$parts = $line.Split("\\t", 2)
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.

🔴 PowerShell completion script splits on literal \t instead of tab character

The generated PowerShell completion script uses $line.Split("\t", 2) to parse tab-separated completions. However, in PowerShell, "\t" is the literal two-character string backslash-t — PowerShell uses backtick escapes ("`t") for tab characters, not backslash escapes. The .Split() method is not regex-based, so it won't interpret \t as a tab.

Impact on different PowerShell versions

On PowerShell 7+ (.NET 6+): .Split("\t", 2) matches String.Split(String, Int32) and looks for the literal two-character string \t, which never appears in the output (the actual output uses real tab characters from src/utils/completion.ts:29). The entire line becomes $comp, including the tab and description text.

On PowerShell 5 (.NET Framework): .Split("\t", 2) resolves to Split(Char[], Int32), splitting on either \ or t as individual characters. This incorrectly splits any completion containing the letter t (e.g., --git-check splits into --gi and -check...).

Prompt for agents
In src/utils/completion.ts, the generatePowershell function at line 364 outputs `$parts = $line.Split("\\t", 2)` which generates PowerShell code `$parts = $line.Split("\t", 2)`. In PowerShell, `"\t"` is a literal backslash-t, not a tab character. PowerShell uses backtick escapes for special characters.

The fix needs to produce PowerShell code that splits on an actual tab character. Two options:

1. Use PowerShell's [char]9 for a tab: change the line to produce `$parts = $line.Split([char]9, 2)` — this works on both PowerShell 5 and 7.

2. Use PowerShell's backtick escape: change to produce `$parts = $line.Split("`t", 2)` — but this requires careful escaping in the JavaScript template literal since backtick is the template delimiter.

Option 1 is simpler and avoids JS escaping complexity. In the JS template literal, the line should be: `$parts = $line.Split([char]9, 2)`
Open in Devin Review

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

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.
Comment thread src/utils/completion.ts
if ($line -match '^:(\\d+)$') {
$directive = [int]$matches[1]
} elseif ($line.Trim()) {
$parts = $line.Split("\\t", 2)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 PowerShell .Split("\t") never splits on tab — completions are broken

In PowerShell, double-quoted strings use backtick (`) for escape sequences, not backslash. "\t" is the two-character literal \t (backslash + t), not a tab. The .Split() .NET method performs a literal (non-regex) split, so it will never find a tab character in the line. As a result $parts always has exactly one element: the entire name\tdescription string gets used as $comp, inserting garbage text on tab completion.

Use "`t" (PowerShell backtick-t) to produce an actual tab character. Note: -split "\\n" is fine because -split uses regex where \n matches a newline.

Suggested change
$parts = $line.Split("\\t", 2)
$parts = $line.Split("\`t", 2)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant