From 44f968c8b7e94a76dd5a6a7c816ba05029417e67 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 23:21:28 +0200 Subject: [PATCH 1/4] feat: add local shell completion for bash zsh and fish --- README.md | 26 +++++ src/commands/completion.ts | 27 +++++ src/commands/completion/suggest.ts | 28 +++++ src/services/completion.ts | 179 +++++++++++++++++++++++++++++ test/commands/completion.test.ts | 30 +++++ test/services/completion.test.ts | 100 ++++++++++++++++ 6 files changed, 390 insertions(+) create mode 100644 src/commands/completion.ts create mode 100644 src/commands/completion/suggest.ts create mode 100644 src/services/completion.ts create mode 100644 test/commands/completion.test.ts create mode 100644 test/services/completion.test.ts diff --git a/README.md b/README.md index 9cf433d..83caac8 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,32 @@ An explicit `--profile` selects that profile. Otherwise, one environment credential takes precedence over the active profile; multiple credential environment variables fail as ambiguous. Output never contains credential values. +## Shell completion + +Generate and load completion for your shell after installing a CLI source build: + +```bash +source <(polymorfa completion bash) +``` + +For Zsh, initialize its completion system before loading the script: + +```zsh +autoload -Uz compinit && compinit +source <(polymorfa completion zsh) +``` + +For Fish: + +```fish +polymorfa completion fish | source +``` + +Completion suggests command names, flags, and fixed flag choices from the +installed CLI. It does not look up profile names, project IDs, credentials, or +other argument values, and it does not contact the API. Add the command for +your shell to its startup file if you want completion in later sessions. + ## QuickLinks A QuickLink is a persistent hosted link that connects one WhatsApp number to a diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..1677fe1 --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,27 @@ +import {Args, Command} from '@oclif/core' + +import {completionScript, type CompletionShell} from '../services/completion.js' + +export default class Completion extends Command { + static override args = { + shell: Args.string({ + description: 'Shell to generate completion for.', + options: ['bash', 'zsh', 'fish'], + required: true, + }), + } + + static override description = + 'Print a Bash, Zsh, or Fish completion script using local command metadata.' + + static override examples = [ + '<%= config.bin %> completion bash', + '<%= config.bin %> completion zsh', + '<%= config.bin %> completion fish', + ] + + async run(): Promise { + const {args} = await this.parse(Completion) + process.stdout.write(`${completionScript(args.shell as CompletionShell)}\n`) + } +} diff --git a/src/commands/completion/suggest.ts b/src/commands/completion/suggest.ts new file mode 100644 index 0000000..903b06a --- /dev/null +++ b/src/commands/completion/suggest.ts @@ -0,0 +1,28 @@ +import {Args, Command, Flags} from '@oclif/core' + +import {suggestCompletions} from '../../services/completion.js' + +/** Called by generated shell scripts. No auth, keyring, project, or HTTP path. */ +export default class Suggest extends Command { + static override args = { + words: Args.string({multiple: true}), + } + + static override flags = { + current: Flags.string({default: ''}), + } + + static override hidden = true + + async run(): Promise { + const {args, flags} = await this.parse(Suggest) + const suggestions = suggestCompletions( + this.config.commands, + args.words ?? [], + flags.current, + ) + if (suggestions.length > 0) { + process.stdout.write(`${suggestions.join('\n')}\n`) + } + } +} diff --git a/src/services/completion.ts b/src/services/completion.ts new file mode 100644 index 0000000..987fdc2 --- /dev/null +++ b/src/services/completion.ts @@ -0,0 +1,179 @@ +/** Completion uses only local command metadata. It never resolves a profile or calls an API. */ +export interface CompletionFlag { + readonly char?: string + readonly hidden?: boolean + readonly options?: readonly string[] + readonly type?: string +} + +export interface CompletionCommand { + readonly aliases?: readonly string[] + readonly flags?: Readonly> + readonly hidden?: boolean + readonly id: string +} + +const SAFE_WORD = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/ + +export function suggestCompletions( + commands: readonly CompletionCommand[], + priorWords: readonly string[], + current = '', +): string[] { + const visible = commands.filter((command) => !command.hidden) + const paths = visible.flatMap((command) => + [command.id, ...(command.aliases ?? [])] + .map((id) => id.split(':')) + .filter((parts) => parts.every((part) => SAFE_WORD.test(part))), + ) + let path: string[] = [] + let pending: CompletionFlag | undefined + let positional = false + let stopped = false + + for (const word of priorWords) { + if (pending !== undefined) { + pending = undefined + continue + } + if (word === '--') { + stopped = true + continue + } + if (stopped) continue + if (word.startsWith('-')) { + const [name, value] = word.split('=', 2) + const flag = availableFlags(visible, path).find( + ([candidate]) => candidate === name, + )?.[1] + if (flag?.type === 'option' && value === undefined) pending = flag + continue + } + if ( + !positional && + paths.some( + (parts) => + parts.length > path.length && + path.every((part, index) => parts[index] === part) && + parts[path.length] === word, + ) + ) { + path = [...path, word] + continue + } + // Positional values are never a source of suggestions. Flags can still + // follow them. + if (!paths.some((parts) => parts.join(':') === path.join(':'))) return [] + positional = true + } + + if (stopped) return [] + if (pending !== undefined) { + return safeCandidates(pending.options ?? [], current) + } + + const equals = current.indexOf('=') + if (current.startsWith('-') && equals > 0) { + const flag = availableFlags(visible, path).find( + ([candidate]) => candidate === current.slice(0, equals), + )?.[1] + return flag?.type === 'option' + ? safeCandidates(flag.options ?? [], current.slice(equals + 1)).map( + (option) => `${current.slice(0, equals + 1)}${option}`, + ) + : [] + } + if (current.startsWith('-')) { + return safeCandidates( + [...availableFlags(visible, path).map(([name]) => name), '--help', '-h'], + current, + ) + } + + if (positional) return [] + + return safeCandidates( + paths + .filter( + (parts) => + parts.length > path.length && + path.every((part, index) => parts[index] === part), + ) + .map((parts) => parts[path.length] ?? ''), + current, + ) +} + +function availableFlags( + commands: readonly CompletionCommand[], + path: readonly string[], +): [string, CompletionFlag][] { + const command = commands.find((item) => item.id === path.join(':')) + const root = commands.find((item) => item.id === 'version') + return Object.entries(command?.flags ?? root?.flags ?? {}).flatMap( + ([name, flag]) => + flag.hidden || !SAFE_WORD.test(name) + ? [] + : [ + [`--${name}`, flag] as [string, CompletionFlag], + ...(flag.char === undefined || !SAFE_WORD.test(flag.char) + ? [] + : [[`-${flag.char}`, flag] as [string, CompletionFlag]]), + ], + ) +} + +function safeCandidates(values: readonly string[], prefix: string): string[] { + return [ + ...new Set( + values.filter( + (value) => + SAFE_WORD.test(value.replace(/^-+/, '')) && value.startsWith(prefix), + ), + ), + ].sort() +} + +export type CompletionShell = 'bash' | 'fish' | 'zsh' + +export function completionScript(shell: CompletionShell): string { + switch (shell) { + case 'bash': + return `# Polymorfa Bash completion. Suggestions use only local command metadata. +_polymorfa_completion() { + local current="\${COMP_WORDS[COMP_CWORD]}" + local -a prior=("\${COMP_WORDS[@]:1:$((COMP_CWORD-1))}") + local output candidate + COMPREPLY=() + output="$(command polymorfa completion suggest --current="$current" -- "\${prior[@]}" 2>/dev/null)" || return + while IFS= read -r candidate; do + [[ -n "$candidate" ]] && COMPREPLY+=("$candidate") + done <<< "$output" +} +complete -o bashdefault -o default -F _polymorfa_completion polymorfa` + case 'zsh': + return `#compdef polymorfa +# Polymorfa Zsh completion. Suggestions use only local command metadata. +_polymorfa_completion() { + local -a prior candidates + local output i + for (( i = 2; i < CURRENT; i++ )); do + prior+=("\${words[i]}") + done + output="$(command polymorfa completion suggest --current="\${words[CURRENT]}" -- "\${prior[@]}" 2>/dev/null)" || return + [[ -n "$output" ]] || return + candidates=("\${(@f)output}") + compadd -- "\${candidates[@]}" +} +compdef _polymorfa_completion polymorfa` + case 'fish': + return `# Polymorfa Fish completion. Suggestions use only local command metadata. +function __polymorfa_completion + set -l prior (commandline -xpc) + set -e prior[1] + set -l current (commandline -ct) + command polymorfa completion suggest --current="$current" -- $prior 2>/dev/null +end +complete -c polymorfa -f -a '(__polymorfa_completion)'` + } +} diff --git a/test/commands/completion.test.ts b/test/commands/completion.test.ts new file mode 100644 index 0000000..960b35c --- /dev/null +++ b/test/commands/completion.test.ts @@ -0,0 +1,30 @@ +import {runCommand} from '@oclif/test' +import {AsyncEntry} from '@napi-rs/keyring' +import {afterEach, describe, expect, test, vi} from 'vitest' + +describe('completion commands', () => { + afterEach(() => vi.restoreAllMocks()) + + test('prints scripts for each supported shell', async () => { + for (const shell of ['bash', 'zsh', 'fish']) { + const result = await runCommand(`completion ${shell}`) + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toContain('polymorfa completion suggest') + } + }) + + test('suggests local commands and fixed flags without auth or network calls', async () => { + const fetch = vi.spyOn(globalThis, 'fetch') + const keyring = vi.spyOn(AsyncEntry.prototype, 'getPassword') + const command = await runCommand('completion suggest --current=lo -- auth') + expect(command.error, command.stderr).toBeUndefined() + expect(command.stdout.split('\n')).toContain('login') + const flags = await runCommand( + 'completion suggest --current=--j -- auth login', + ) + expect(flags.error, flags.stderr).toBeUndefined() + expect(flags.stdout.split('\n')).toContain('--json') + expect(fetch).not.toHaveBeenCalled() + expect(keyring).not.toHaveBeenCalled() + }) +}) diff --git a/test/services/completion.test.ts b/test/services/completion.test.ts new file mode 100644 index 0000000..b5dd918 --- /dev/null +++ b/test/services/completion.test.ts @@ -0,0 +1,100 @@ +import {spawnSync} from 'node:child_process' +import {describe, expect, test} from 'vitest' + +import { + completionScript, + suggestCompletions, + type CompletionCommand, +} from '../../src/services/completion.js' + +const commands: CompletionCommand[] = [ + { + id: 'version', + flags: { + json: {type: 'boolean'}, + profile: {type: 'option'}, + }, + }, + { + id: 'api', + flags: { + json: {type: 'boolean'}, + profile: {type: 'option'}, + surface: {options: ['messaging', 'platform'], type: 'option'}, + token: {type: 'option'}, + }, + }, + {id: 'auth:login', flags: {json: {type: 'boolean'}}}, + {id: 'auth:list', flags: {json: {type: 'boolean'}}}, + {id: 'completion:suggest', hidden: true}, +] + +describe('shell completion', () => { + test('suggests command hierarchy and flags without positional values', () => { + expect(suggestCompletions(commands, [], 'a')).toEqual(['api', 'auth']) + expect(suggestCompletions(commands, ['auth'], 'l')).toEqual([ + 'list', + 'login', + ]) + expect(suggestCompletions(commands, ['api'], '--s')).toEqual(['--surface']) + expect( + suggestCompletions(commands, ['api', '/platform/projects'], ''), + ).toEqual([]) + expect( + suggestCompletions(commands, ['api', '/platform/projects'], '--s'), + ).toEqual(['--surface']) + expect(suggestCompletions(commands, ['completion'], 's')).toEqual([]) + }) + + test('suggests only fixed option values, never profile or token values', () => { + expect(suggestCompletions(commands, ['api', '--surface'], 'm')).toEqual([ + 'messaging', + ]) + expect(suggestCompletions(commands, ['api'], '--surface=p')).toEqual([ + '--surface=platform', + ]) + expect( + suggestCompletions(commands, ['--profile', 'work', 'auth'], 'l'), + ).toEqual(['list', 'login']) + expect(suggestCompletions(commands, ['api', '--token'], '')).toEqual([]) + expect(suggestCompletions(commands, ['api', '--profile'], '')).toEqual([]) + expect(suggestCompletions(commands, ['api', '--', 'secret'], '')).toEqual( + [], + ) + }) + + test('ignores unsafe metadata and never echoes an entered secret', () => { + const secret = 'secret;$(curl private-host)' + const unsafe: CompletionCommand[] = [ + ...commands, + {id: `bad:${secret}`}, + {id: 'other', flags: {credential: {options: [secret], type: 'option'}}}, + ] + expect(suggestCompletions(unsafe, [], '')).not.toContain('bad') + expect(suggestCompletions(unsafe, ['other', '--credential'], '')).toEqual( + [], + ) + expect( + JSON.stringify(suggestCompletions(unsafe, ['api', secret], '')), + ).not.toContain(secret) + }) + + test('generates Bash and Zsh scripts with valid syntax', () => { + for (const shell of ['bash', 'zsh'] as const) { + const script = completionScript(shell) + const syntax = spawnSync(shell, ['-n'], {encoding: 'utf8', input: script}) + expect(syntax.status, syntax.stderr).toBe(0) + expect(script).toContain('polymorfa completion suggest') + expect(script).not.toContain('POLYMORFA_API_KEY') + } + }) + + test('generates Fish completion from tokens before the cursor', () => { + const script = completionScript('fish') + expect(script).toContain('commandline -xpc') + expect(script).toContain('commandline -ct') + expect(script).toContain('--current="$current"') + expect(script).toContain('complete -c polymorfa -f') + expect(script).toContain('polymorfa completion suggest') + }) +}) From 12566b815baf72c0caa839f61878c70e54fcca98 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 23:25:06 +0200 Subject: [PATCH 2/4] test: check shell syntax only when installed --- test/services/completion.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/test/services/completion.test.ts b/test/services/completion.test.ts index b5dd918..b2d5e7e 100644 --- a/test/services/completion.test.ts +++ b/test/services/completion.test.ts @@ -82,10 +82,15 @@ describe('shell completion', () => { test('generates Bash and Zsh scripts with valid syntax', () => { for (const shell of ['bash', 'zsh'] as const) { const script = completionScript(shell) - const syntax = spawnSync(shell, ['-n'], {encoding: 'utf8', input: script}) - expect(syntax.status, syntax.stderr).toBe(0) expect(script).toContain('polymorfa completion suggest') expect(script).not.toContain('POLYMORFA_API_KEY') + const syntax = spawnSync(shell, ['-n'], {encoding: 'utf8', input: script}) + if ( + (syntax.error as NodeJS.ErrnoException | undefined)?.code === 'ENOENT' + ) { + continue + } + expect(syntax.status, syntax.stderr).toBe(0) } }) From 8b49dd0a56584c61eecded74b480bc83d25beede Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 23:33:07 +0200 Subject: [PATCH 3/4] fix: complete negated boolean flags --- src/services/completion.ts | 4 ++++ test/commands/completion.test.ts | 5 +++++ test/services/completion.test.ts | 4 ++++ 3 files changed, 13 insertions(+) diff --git a/src/services/completion.ts b/src/services/completion.ts index 987fdc2..34bd132 100644 --- a/src/services/completion.ts +++ b/src/services/completion.ts @@ -1,5 +1,6 @@ /** Completion uses only local command metadata. It never resolves a profile or calls an API. */ export interface CompletionFlag { + readonly allowNo?: boolean readonly char?: string readonly hidden?: boolean readonly options?: readonly string[] @@ -116,6 +117,9 @@ function availableFlags( ? [] : [ [`--${name}`, flag] as [string, CompletionFlag], + ...(flag.type === 'boolean' && flag.allowNo + ? [[`--no-${name}`, flag] as [string, CompletionFlag]] + : []), ...(flag.char === undefined || !SAFE_WORD.test(flag.char) ? [] : [[`-${flag.char}`, flag] as [string, CompletionFlag]]), diff --git a/test/commands/completion.test.ts b/test/commands/completion.test.ts index 960b35c..e3462c2 100644 --- a/test/commands/completion.test.ts +++ b/test/commands/completion.test.ts @@ -24,6 +24,11 @@ describe('completion commands', () => { ) expect(flags.error, flags.stderr).toBeUndefined() expect(flags.stdout.split('\n')).toContain('--json') + const negated = await runCommand( + 'completion suggest --current=--no- -- calls settings set', + ) + expect(negated.error, negated.stderr).toBeUndefined() + expect(negated.stdout.split('\n')).toContain('--no-conference-mode') expect(fetch).not.toHaveBeenCalled() expect(keyring).not.toHaveBeenCalled() }) diff --git a/test/services/completion.test.ts b/test/services/completion.test.ts index b2d5e7e..7b88f49 100644 --- a/test/services/completion.test.ts +++ b/test/services/completion.test.ts @@ -22,6 +22,7 @@ const commands: CompletionCommand[] = [ profile: {type: 'option'}, surface: {options: ['messaging', 'platform'], type: 'option'}, token: {type: 'option'}, + verbose: {allowNo: true, type: 'boolean'}, }, }, {id: 'auth:login', flags: {json: {type: 'boolean'}}}, @@ -37,6 +38,9 @@ describe('shell completion', () => { 'login', ]) expect(suggestCompletions(commands, ['api'], '--s')).toEqual(['--surface']) + expect(suggestCompletions(commands, ['api'], '--no-v')).toEqual([ + '--no-verbose', + ]) expect( suggestCompletions(commands, ['api', '/platform/projects'], ''), ).toEqual([]) From 43abaf6d94094085f0dec1038ba16bc287a06e54 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 23:39:25 +0200 Subject: [PATCH 4/4] fix: complete Bash equals-form flag choices --- src/services/completion.ts | 17 ++++++++++-- test/services/completion.test.ts | 45 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/services/completion.ts b/src/services/completion.ts index 34bd132..888154c 100644 --- a/src/services/completion.ts +++ b/src/services/completion.ts @@ -147,11 +147,24 @@ export function completionScript(shell: CompletionShell): string { _polymorfa_completion() { local current="\${COMP_WORDS[COMP_CWORD]}" local -a prior=("\${COMP_WORDS[@]:1:$((COMP_CWORD-1))}") - local output candidate + local output candidate option_prefix="" + if (( COMP_CWORD >= 3 )) && [[ \${COMP_WORDS[COMP_CWORD-1]} == '=' && \${COMP_WORDS[COMP_CWORD-2]} == --* ]]; then + option_prefix="\${COMP_WORDS[COMP_CWORD-2]}=" + current="$option_prefix$current" + prior=("\${COMP_WORDS[@]:1:$((COMP_CWORD-3))}") + elif (( COMP_CWORD >= 2 )) && [[ $current == '=' && \${COMP_WORDS[COMP_CWORD-1]} == --* ]]; then + option_prefix="\${COMP_WORDS[COMP_CWORD-1]}=" + current="$option_prefix" + prior=("\${COMP_WORDS[@]:1:$((COMP_CWORD-2))}") + fi COMPREPLY=() output="$(command polymorfa completion suggest --current="$current" -- "\${prior[@]}" 2>/dev/null)" || return while IFS= read -r candidate; do - [[ -n "$candidate" ]] && COMPREPLY+=("$candidate") + [[ -n "$candidate" ]] || continue + if [[ -n "$option_prefix" && $candidate == "$option_prefix"* ]]; then + candidate="\${candidate#"$option_prefix"}" + fi + COMPREPLY+=("$candidate") done <<< "$output" } complete -o bashdefault -o default -F _polymorfa_completion polymorfa` diff --git a/test/services/completion.test.ts b/test/services/completion.test.ts index 7b88f49..5cb71d2 100644 --- a/test/services/completion.test.ts +++ b/test/services/completion.test.ts @@ -1,4 +1,13 @@ import {spawnSync} from 'node:child_process' +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' import {describe, expect, test} from 'vitest' import { @@ -98,6 +107,42 @@ describe('shell completion', () => { } }) + test('Bash reconstructs a flag value split by COMP_WORDBREAKS', () => { + const directory = mkdtempSync(join(tmpdir(), 'polymorfa-completion-')) + const command = join(directory, 'polymorfa') + const argumentsFile = join(directory, 'arguments') + try { + writeFileSync( + command, + '#!/bin/sh\nprintf "%s\\n" "$@" > "$COMP_TEST_ARGS"\nprintf "%s\\n" --surface=platform\n', + ) + chmodSync(command, 0o755) + const result = spawnSync( + 'bash', + [ + '-c', + 'eval "$POLYMORFA_SCRIPT"; COMP_WORDS=(polymorfa api --surface = p); COMP_CWORD=4; _polymorfa_completion; printf "%s\\n" "${COMPREPLY[@]}"', + ], + { + encoding: 'utf8', + env: { + ...process.env, + COMP_TEST_ARGS: argumentsFile, + PATH: `${directory}:${process.env.PATH ?? ''}`, + POLYMORFA_SCRIPT: completionScript('bash'), + }, + }, + ) + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toBe('platform\n') + expect(readFileSync(argumentsFile, 'utf8')).toBe( + 'completion\nsuggest\n--current=--surface=p\n--\napi\n', + ) + } finally { + rmSync(directory, {force: true, recursive: true}) + } + }) + test('generates Fish completion from tokens before the cursor', () => { const script = completionScript('fish') expect(script).toContain('commandline -xpc')