Skip to content
Merged
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/commands/completion.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const {args} = await this.parse(Completion)
process.stdout.write(`${completionScript(args.shell as CompletionShell)}\n`)
}
}
28 changes: 28 additions & 0 deletions src/commands/completion/suggest.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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`)
}
}
}
196 changes: 196 additions & 0 deletions src/services/completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
/** 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[]
readonly type?: string
}

export interface CompletionCommand {
readonly aliases?: readonly string[]
readonly flags?: Readonly<Record<string, CompletionFlag>>
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.type === 'boolean' && flag.allowNo
? [[`--no-${name}`, flag] as [string, CompletionFlag]]
: []),
...(flag.char === undefined || !SAFE_WORD.test(flag.char)
? []
: [[`-${flag.char}`, flag] as [string, CompletionFlag]]),
Comment thread
purpshell marked this conversation as resolved.
],
)
}

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))}")
Comment thread
purpshell marked this conversation as resolved.
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" ]] || 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`
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)
Comment thread
purpshell marked this conversation as resolved.
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)'`
}
}
35 changes: 35 additions & 0 deletions test/commands/completion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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')
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()
})
})
Loading