import * as FS from 'node:fs'; import * as OS from 'node:os'; import * as PATH from 'node:path'; import { spawnSync } from 'node:child_process'; import { findSubcommand, optionsOf, type CommandSpec, type Completer, type OptionSpec } from './command.ts'; import { readConfig } from './config.ts'; // Tab completion for bash, zsh, fish and PowerShell — after tangled's, which // is after bundle's. // // A shell does not know a program's options; it asks. bash runs a command // with the line in COMP_LINE/COMP_POINT and reads candidates back one per // line (`complete -C`), zsh does the same through `bashcompinit`, fish calls a // function whose output may carry descriptions, and PowerShell calls a script // block registered with `Register-ArgumentCompleter -Native`. All of them are // pointed at `atcli __complete`, and `atcli completion ` prints the // lines that wire a shell to it. // // What is offered comes from the command tree itself — the same subcommands, // options, choices and descriptions the parser and help use — plus signed-in // accounts wherever an account goes. Completion never touches the network: a // Tab that hangs is worse than one that offers nothing. export interface Candidate { value: string; description?: string | undefined; } export interface Completion { candidates: Candidate[]; /** Complete file names instead: the shell does that better. */ files: boolean; } export const SHELLS = ['bash', 'zsh', 'fish', 'powershell'] as const; export type Shell = (typeof SHELLS)[number]; export function complete(root: CommandSpec, words: string[], current: string): Completion { const none: Completion = { candidates: [], files: false }; const filter = (candidates: readonly Candidate[], prefix = ''): Completion => ({ candidates: candidates .filter(({ value }) => value.startsWith(current.slice(prefix.length))) .map((each) => ({ ...each, value: prefix + each.value })), files: false, }); // The command, as far as the words name one. let node = root; const rest: string[] = []; let named = true; for (const word of words) { const sub = named && !word.startsWith('-') ? findSubcommand(node, word) : undefined; if (sub) { node = sub; continue; } if (!word.startsWith('-')) named = false; rest.push(word); } // A group that runs a default subcommand takes that subcommand's options: // `notifications --reason` is `notifications list --reason`. const effective = !node.run && node.default ? findSubcommand(node, node.default) ?? node : node; const options = optionsOf(effective); // `--option=value`, completed as a whole word. const inline = /^(--[A-Za-z0-9-]+=)(.*)$/.exec(current); if (inline) { const spec = options.find((option) => option.name === inline[1]!.slice(2, -1)); if (!spec || spec.type === 'boolean') return none; return offer(hintOf(spec), inline[1]!, filter); } // The value of the option just before. const previous = rest.at(-1); if (previous?.startsWith('-') && !previous.includes('=') && previous !== '--') { const spec = optionNamed(previous, options); if (spec && spec.type !== 'boolean') return offer(hintOf(spec), '', filter); } if (current.startsWith('-')) { return filter(options.filter((spec) => !spec.hidden && (effective.run || ['help', 'json'].includes(spec.name))) .map((spec) => ({ value: `--${spec.name}`, description: spec.description }))); } const positionals = positionalsOf(rest, options); if (node.subcommands?.length && positionals.length === 0) { return filter(node.subcommands.filter((sub) => !sub.hidden).map((sub) => ({ value: sub.name, description: sub.summary }))); } const args = node.args ?? []; const arg = args[positionals.length] ?? (args.at(-1)?.variadic ? args.at(-1) : undefined); return offer(arg?.complete, '', filter); } function hintOf(spec: OptionSpec): Completer | undefined { return spec.choices ?? spec.complete; } function offer(hint: Completer | undefined, prefix: string, filter: (candidates: readonly Candidate[], prefix?: string) => Completion): Completion { if (!hint) return { candidates: [], files: false }; if (hint === 'file' || hint === 'directory') return { candidates: [], files: true }; if (typeof hint !== 'string') return filter(hint.map((value) => ({ value })), prefix); if (hint === 'shell') return filter(SHELLS.map((value) => ({ value })), prefix); if (hint === 'account') { try { const config = readConfig(); return filter(Object.values(config.accounts).map((account) => ({ value: account.handle, description: `${account.did}${account.did === config.default ? ' (default)' : ''}`, })), prefix); } catch { return { candidates: [], files: false }; } } return { candidates: [], files: false }; } // The positionals among `words`: what is left once options, and the values of // options that take one, are set aside. function positionalsOf(words: string[], options: OptionSpec[]): string[] { const found: string[] = []; for (let at = 0; at < words.length; at++) { const word = words[at]!; if (word === '--') return [...found, ...words.slice(at + 1)]; if (word.startsWith('-') && word !== '-') { const spec = optionNamed(word, options); if (spec && spec.type !== 'boolean' && !word.includes('=') && !(word.length > 2 && !word.startsWith('--'))) at++; continue; } found.push(word); } return found; } function optionNamed(word: string, options: OptionSpec[]): OptionSpec | undefined { if (word.startsWith('--')) { const name = word.slice(2).split('=')[0]!; return options.find((spec) => spec.name === name) ?? options.find((spec) => `no-${spec.name}` === name); } return options.find((spec) => spec.short === word.slice(1, 2)); } // ---------------------------------------------------------- the shell's side --- /** * Answer a shell. `argv` is what follows `atcli __complete`: * * --shell fish -- the words as the shell split them * --shell bash|zsh|powershell the line in COMP_LINE and COMP_POINT */ export function respond(root: CommandSpec, argv: string[], env: NodeJS.ProcessEnv = process.env): string[] { const shellAt = argv.indexOf('--shell'); const shell = shellAt >= 0 ? argv[shellAt + 1] : 'bash'; const dashes = argv.indexOf('--'); let words: string[]; let current: string; if (dashes >= 0) { const given = argv.slice(dashes + 1); current = given.length ? given[given.length - 1]! : ''; words = given.slice(0, -1); } else { const line = (env['COMP_LINE'] ?? '').slice(0, Number(env['COMP_POINT'] ?? env['COMP_LINE']?.length ?? 0)); // PowerShell's escape character is the backtick; a backslash there is a // path separator and must survive. const split = splitLine(line, { backslash: shell !== 'powershell' }); words = split.words.slice(1); current = split.current; } const { candidates, files } = complete(root, words, current); if (shell === 'fish' || shell === 'powershell') { if (files) return [':files']; return candidates.map(({ value, description }) => (description ? `${value}\t${description}` : value)); } // Printing nothing makes bash and zsh fall back to file names // (`complete -o default`). bash replaces only what follows the last // word-break character — `:` and `=` among them, and DIDs are full of // colons — so that is all it is handed back. if (files) return []; if (shell === 'zsh') return candidates.map(({ value }) => value); const breaks = env['COMP_WORDBREAKS'] ?? ' \t\n"\'><=;|&(:'; let cut = -1; for (const char of breaks) cut = Math.max(cut, current.lastIndexOf(char)); return candidates.map(({ value }) => value.slice(cut + 1)); } /** Split a command line the way a shell would, enough for completion. */ export function splitLine(line: string, { backslash = true }: { backslash?: boolean } = {}): { words: string[]; current: string } { const words: string[] = []; let word = ''; let quote: string | null = null; let started = false; for (let at = 0; at < line.length; at++) { const char = line[at]!; if (quote) { if (char === quote) quote = null; else if (backslash && char === '\\' && quote === '"' && at + 1 < line.length) word += line[++at]; else word += char; } else if (char === '"' || char === "'") { quote = char; started = true; } else if (backslash && char === '\\' && at + 1 < line.length) { word += line[++at]; started = true; } else if (/\s/.test(char)) { if (started) words.push(word); word = ''; started = false; } else { word += char; started = true; } } return { words, current: word }; } // ---------------------------------------------------------------- scripts --- function psQuote(text: string): string { return `'${text.replace(/'/g, "''")}'`; } /** What to evaluate at shell start for Tab completion. */ export function script(shell: string, name = 'atcli'): string { const fn = `__${name.replace(/\W/g, '_')}_complete`; const lines = (...parts: string[]) => `${parts.join('\n')}\n`; switch (shell) { case 'bash': return lines( `# ${name} completion for bash. In ~/.bashrc: eval "$(${name} completion bash)"`, `complete -o default -C '${name} __complete --shell bash' ${name}`, ); case 'zsh': return lines( `# ${name} completion for zsh. In ~/.zshrc: eval "$(${name} completion zsh)"`, // Through zsh's bash compatibility; compinit only if nothing has // set it up yet, since most configurations do. `(( $+functions[compdef] )) || { autoload -Uz compinit && compinit; }`, `autoload -U +X bashcompinit && bashcompinit`, `complete -o default -C '${name} __complete --shell zsh' ${name}`, ); case 'fish': return lines( `# ${name} completion for fish. In ~/.config/fish/config.fish: ${name} completion fish | source`, `complete -c ${name} -e`, `function ${fn}`, ` set -l tokens (commandline -opc)`, ` set -l current (commandline -ct)`, ` set -l out (${name} __complete --shell fish -- $tokens[2..-1] "$current" 2>/dev/null)`, ` if test "$out[1]" = ':files'`, ` __fish_complete_path "$current"`, ` else`, ` printf '%s\\n' $out`, ` end`, `end`, `complete -c ${name} -f -a '(${fn})'`, ); case 'powershell': case 'pwsh': { const names = [name, `${name}.cmd`, `${name}.nzip`].map(psQuote).join(', '); return lines( `# ${name} completion for PowerShell. In $PROFILE: ${name} completion powershell | Out-String | Invoke-Expression`, `Register-ArgumentCompleter -Native -CommandName ${names} -ScriptBlock {`, ` param($wordToComplete, $commandAst, $cursorPosition)`, ` $at = $cursorPosition - $commandAst.Extent.StartOffset`, ` $line = $commandAst.Extent.Text.PadRight($at).Substring(0, $at)`, ` $encoding = [Console]::OutputEncoding`, ` try {`, ` [Console]::OutputEncoding = [Text.Encoding]::UTF8`, ` $env:COMP_LINE = $line`, ` $env:COMP_POINT = $line.Length`, ` $out = @(& ${psQuote(name)} __complete --shell powershell 2>$null)`, ` } finally {`, ` [Console]::OutputEncoding = $encoding`, ` Remove-Item Env:COMP_LINE, Env:COMP_POINT -ErrorAction SilentlyContinue`, ` }`, ` if ($out.Count -gt 0 -and $out[0] -eq ':files') { return }`, ` foreach ($candidate in $out) {`, " $value, $description = $candidate -split \"`t\", 2", ` $type = if ($value.StartsWith('-')) { 'ParameterName' } else { 'ParameterValue' }`, ` [System.Management.Automation.CompletionResult]::new($value, $value, $type, $(if ($description) { $description } else { $value }))`, ` }`, `}`, ); } default: throw new Error(`no completion for the shell '${shell}': bash, zsh, fish or powershell`); } } // ----------------------------------------------------------- startup files --- const BEGIN = '# >>> atcli: Tab completion >>>'; const END = '# <<< atcli <<<'; /** The shell this runs in: the nearest shell among its ancestors, else $SHELL. */ export function detectShell(env: NodeJS.ProcessEnv = process.env): Shell | null { for (const name of ancestry()) { const shell = shellNamed(name); if (shell) return shell; } const named = shellNamed(env['SHELL'] ?? ''); if (named) return named; return process.platform === 'win32' ? 'powershell' : null; } /** The shell a startup file belongs to, from its name: config.fish, .zshrc, .bashrc, a .ps1. */ export function shellOfProfile(path: string): Shell | null { const name = path.split(/[\\/]/).pop()?.toLowerCase() ?? ''; if (name.endsWith('.fish')) return 'fish'; if (/^\.?zsh(rc|env|_profile)?$|\.zsh$/.test(name)) return 'zsh'; if (/^\.?bash(rc|_profile)?$|\.bash$|^\.profile$/.test(name)) return 'bash'; if (name.endsWith('.ps1')) return 'powershell'; return null; } export function shellNamed(path: string): Shell | null { const name = path.split(/[\\/]/).pop()?.replace(/\.exe$/i, '').replace(/^-/, '').toLowerCase() ?? ''; if (name === 'bash' || name === 'zsh' || name === 'fish') return name; if (name === 'pwsh' || name === 'powershell') return 'powershell'; return null; } function ancestry(limit = 8): string[] { if (process.platform === 'win32') return []; const names: string[] = []; let pid = process.ppid; for (let depth = 0; depth < limit && pid > 1; depth++) { let name: string | undefined; let parent: number | undefined; try { name = FS.readFileSync(`/proc/${pid}/comm`, 'utf-8').trim(); parent = Number(/^PPid:\s*(\d+)/m.exec(FS.readFileSync(`/proc/${pid}/status`, 'utf-8'))?.[1]); } catch { const result = spawnSync('ps', ['-o', 'ppid=', '-o', 'comm=', '-p', String(pid)], { encoding: 'utf-8', timeout: 2000 }); const match = /^\s*(\d+)\s+(.+)$/.exec(result.stdout?.trim() ?? ''); if (!match) break; parent = Number(match[1]); name = match[2]!.trim(); } if (!name || !parent) break; names.push(name); pid = parent; } return names; } /** The file a shell reads at start. PowerShell's is asked of PowerShell. */ export function startupFile(shell: Shell, env: NodeJS.ProcessEnv = process.env, profile?: string): string { if (profile) return profile; const home = env['HOME'] || env['USERPROFILE'] || OS.homedir(); if (shell === 'bash') return PATH.join(home, '.bashrc'); if (shell === 'zsh') return PATH.join(env['ZDOTDIR'] || home, '.zshrc'); if (shell === 'fish') return PATH.join(env['XDG_CONFIG_HOME'] || PATH.join(home, '.config'), 'fish', 'config.fish'); for (const edition of process.platform === 'win32' ? ['pwsh', 'powershell'] : ['pwsh']) { const asked = spawnSync(edition, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$PROFILE.CurrentUserCurrentHost'], { encoding: 'utf-8', timeout: 10_000 }); const file = asked.stdout?.trim(); if (asked.status === 0 && file) return file; } return process.platform === 'win32' ? PATH.join(home, 'Documents', 'PowerShell', 'Microsoft.PowerShell_profile.ps1') : PATH.join(home, '.config', 'powershell', 'Microsoft.PowerShell_profile.ps1'); } /** The line that loads completion, guarded so a shell still starts without atcli. */ export function hookLine(shell: Shell, name = 'atcli'): string { switch (shell) { case 'bash': return `command -v ${name} >/dev/null 2>&1 && eval "$(${name} completion bash)"`; case 'zsh': return `(( $+commands[${name}] )) && eval "$(${name} completion zsh)"`; case 'fish': return `type -q ${name}; and ${name} completion fish | source`; case 'powershell': return `if (Get-Command ${name} -ErrorAction SilentlyContinue) { ${name} completion powershell | Out-String | Invoke-Expression }`; } } export function addHook(shell: Shell, env: NodeJS.ProcessEnv = process.env, profile?: string): { file: string; added: boolean } { const file = startupFile(shell, env, profile); const existing = FS.existsSync(file) ? FS.readFileSync(file, 'utf-8') : ''; if (existing.includes(BEGIN)) return { file, added: false }; FS.mkdirSync(PATH.dirname(file), { recursive: true }); const separator = existing && !existing.endsWith('\n') ? '\n' : ''; FS.appendFileSync(file, `${separator}\n${BEGIN}\n${hookLine(shell)}\n${END}\n`); return { file, added: true }; } export function removeHook(shell: Shell, env: NodeJS.ProcessEnv = process.env, profile?: string): { file: string; removed: boolean } { const file = startupFile(shell, env, profile); if (!FS.existsSync(file)) return { file, removed: false }; const text = FS.readFileSync(file, 'utf-8'); const pattern = new RegExp(`\\n?${escape(BEGIN)}[\\s\\S]*?${escape(END)}\\n?`, 'g'); if (!pattern.test(text)) return { file, removed: false }; FS.writeFileSync(file, text.replace(pattern, '\n').replace(/\n{3,}$/, '\n')); return { file, removed: true }; } function escape(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }