From c1d209c16bb4767aed4c45962b34918807367abc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 02:35:20 +0000 Subject: [PATCH 1/2] feat: Add seam completion --install Installing completions meant knowing where each shell keeps them. Do that instead. 'seam completion --install' works out which shell it was run from, or takes one as an argument, and adds a line loading the completion loader to that shell's config, or writes the loader itself for fish, which loads a completion file on demand. It is safe to repeat, since a config that already has the line is left alone. Installing by hand is the same line: echo 'eval "$(seam completion --loader zsh 2> /dev/null)"' >> ~/.zshrc The shell comes from the process that ran the command rather than from SHELL, which names the login shell and so answers bash for every shell started from one. The loader is asked for quietly because a shell config runs it on every new shell: an older seam that cannot print a loader, or no seam at all, then completes nothing rather than reporting itself over and over at a prompt. The loader keeps its job of being what a system package installs, and now also survives being evaluated by a shell config. It could not be before: the zsh loader declared a local outside a function, which is an error anywhere but the completion function it was assumed to be, and registered nothing when it was not one. Bash gains the same, registering its own completion so that it needs neither the bash-completion package nor a config entry, and completing on demand wherever bash is new enough to. The zsh loader never runs compinit: that would override the dumpfile and options the shell owner chose. It registers once the completion system is up, and --install says so when nothing in the config turns it on. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NS3fVoYWNDEQWAhrp3PnPj --- README.md | 21 +++- src/bin/cli.ts | 39 ++++--- src/lib/commands/local/completion.ts | 117 +++++++++++++++++++- src/lib/completion/detect-shell.ts | 88 +++++++++++++++ src/lib/completion/install.ts | 128 ++++++++++++++++++++++ src/lib/render/completion/index.ts | 47 +++++--- src/lib/render/help.ts | 4 +- test/cli.test.ts | 80 ++++++++++++++ test/completion/detect-shell.test.ts | 48 ++++++++ test/completion/install.test.ts | 157 +++++++++++++++++++++++++++ test/render/completion.test.ts | 32 ++++++ 11 files changed, 724 insertions(+), 37 deletions(-) create mode 100644 src/lib/completion/detect-shell.ts create mode 100644 src/lib/completion/install.ts create mode 100644 test/completion/detect-shell.test.ts create mode 100644 test/completion/install.test.ts diff --git a/README.md b/README.md index 514ef213..cba7e96b 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,14 @@ On Arch Linux, install the [`seam-bin`][aur] package from the AUR with $ paru -S seam-bin ``` +The AUR and Homebrew packages install [shell completion] themselves. After an +npm or manual install, run `seam completion --install`. + [aur]: https://aur.archlinux.org/packages/seam-bin [latest GitHub release]: https://github.com/seamapi/cli/releases/latest [npm]: https://www.npmjs.com/ [Seam Wizard]: https://github.com/seamapi/wizard +[shell completion]: #shell-completion ## Usage @@ -283,7 +287,22 @@ seam devices list --help The CLI can print a completion script for bash, fish, and zsh that completes commands, flags, and flag values such as device types. -Load completions into the current shell with +Install them into the shell you are in, or one you name, with + +```bash +seam completion --install +seam completion --install zsh +``` + +Zsh completes nothing until the completion system is on. If your config does +not turn it on already, add this above the installed line: + +```zsh +autoload -Uz compinit +compinit +``` + +Load completions into the current shell instead with ```bash # bash diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 232fddd8..d52ef7c3 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -14,8 +14,11 @@ import { import { assertKnownArgs, assertNoAuthOverrides } from 'lib/args/validate.js' import { getApiBlueprint } from 'lib/blueprint/index.js' import { + installCompletionForShell, printCompletion, printCompletionLoader, + readCompletionAction, + resolveCompletionShell, } from 'lib/commands/local/completion.js' import { runWizard } from 'lib/commands/local/wizard.js' import { @@ -36,10 +39,6 @@ import { parseJsonParams, readStdinJson } from 'lib/output/read-stdin-json.js' import { resolveOutputFormat } from 'lib/output/resolve-output-format.js' import { setAuthOverrides } from 'lib/overrides.js' import { canPrompt } from 'lib/prompt.js' -import { - completionShells, - isCompletionShell, -} from 'lib/render/completion/index.js' import { renderHelp } from 'lib/render/help.js' import seamapiCliVersion from 'lib/version.js' @@ -118,22 +117,27 @@ async function cli(args: ParsedArgs, argv: string[]) { } if (args._[0] === 'completion') { - const shell = args._[1] + const action = readCompletionAction(args) + const shellArg = args._[1] + const shell = resolveCompletionShell(shellArg, action) - if (!isCompletionShell(shell)) { - output.error(`Usage: seam completion <${completionShells.join('|')}>`) - process.exitCode = 1 + const command = findLocalCommand(['completion', shell]) + assertKnownArgs( + argParams, + shellArg == null ? ['completion'] : ['completion', shell], + { + accepted: + command == null ? new Set() : acceptedParamsOf(command.definition), + isLocal: true, + }, + ) + + if (action === 'install') { + await installCompletionForShell(shell) return } - const command = findLocalCommand(['completion', shell]) - assertKnownArgs(argParams, ['completion', shell], { - accepted: - command == null ? new Set() : acceptedParamsOf(command.definition), - isLocal: true, - }) - - if (args['loader'] === true) { + if (action === 'loader') { printCompletionLoader(shell) return } @@ -253,7 +257,8 @@ const run = async (argv: string[]) => { } const args = parseCliArgs(argv, { - booleanKeys: argv[0]?.toLowerCase() === 'completion' ? ['loader'] : [], + booleanKeys: + argv[0]?.toLowerCase() === 'completion' ? ['install', 'loader'] : [], }) const isTty = process.stdout.isTTY === true diff --git a/src/lib/commands/local/completion.ts b/src/lib/commands/local/completion.ts index 1f0c4b0d..80d8b641 100644 --- a/src/lib/commands/local/completion.ts +++ b/src/lib/commands/local/completion.ts @@ -1,13 +1,29 @@ +import type { ParsedArgs } from 'minimist' + import type { ApiBlueprint } from 'lib/blueprint/index.js' import type { Command } from 'lib/commands/registry.js' import type { CommandSpec } from 'lib/commands/spec.js' +import { detectShell } from 'lib/completion/detect-shell.js' +import { + type CompletionTarget, + installCompletion, + type InstallOutcome, + resolveCompletionTarget, +} from 'lib/completion/install.js' +import { UsageError } from 'lib/errors.js' import { getOutput } from 'lib/output/get-output.js' import { type CompletionShell, + completionShells, + isCompletionShell, renderCompletion, renderCompletionStub, } from 'lib/render/completion/index.js' +export type CompletionAction = 'script' | 'install' | 'loader' + +const completionActionFlags = ['install', 'loader'] as const + /** * Print the completion script for a shell. * @@ -25,11 +41,92 @@ export const printCompletion = ( getOutput().text(renderCompletion(shell, spec)) } -/** Print the network-free loader installed in a shell completion directory. */ export const printCompletionLoader = (shell: CompletionShell): void => { getOutput().text(renderCompletionStub(shell)) } +export const installCompletionForShell = async ( + shell: CompletionShell, +): Promise => { + const output = getOutput() + const target = resolveCompletionTarget(shell) + const { outcome, notes, warnings } = await installCompletion(target) + + output.info(describeOutcome(outcome, target)) + for (const note of notes) output.info(note) + for (const warning of warnings) output.warn(`\n${warning}`) +} + +export const readCompletionAction = (args: ParsedArgs): CompletionAction => { + const given = completionActionFlags.filter((flag) => args[flag] === true) + + if (given.length > 1) { + throw new UsageError( + `Only one of ${completionActionFlags + .map((flag) => `--${flag}`) + .join(', ')} may be given for seam completion.`, + { hint: completionUsage }, + ) + } + + return given[0] ?? 'script' +} + +export const resolveCompletionShell = ( + shellArg: string | undefined, + action: CompletionAction, +): CompletionShell => { + if (shellArg != null) { + if (isCompletionShell(shellArg)) return shellArg + + throw new UsageError(`Unknown shell for seam completion: ${shellArg}`, { + hint: completionUsage, + }) + } + + if (action === 'script' || action === 'loader') { + throw new UsageError( + 'Missing required argument for seam completion: ', + { hint: completionUsage }, + ) + } + + const detected = detectShell() + + if (detected == null) { + throw new UsageError( + `Could not tell which shell you are in: it is none of ${listShells()}.`, + { + hint: `Name the shell instead, e.g., 'seam completion --${action} zsh'.`, + }, + ) + } + + return detected +} + +const completionUsage = [ + `Usage: seam completion <${completionShells.join('|')}>`, + ` seam completion --install [${completionShells.join('|')}]`, + ` seam completion --loader [${completionShells.join('|')}]`, +].join('\n') + +const listShells = (): string => + `${completionShells.slice(0, -1).join(', ')}, or ${completionShells.at(-1) ?? ''}` + +const describeOutcome = ( + outcome: InstallOutcome, + { shell, file }: CompletionTarget, +): string => { + if (outcome === 'present') { + return `${file} already loads ${shell} completions for seam.` + } + if (outcome === 'written') { + return `Installed ${shell} completions for seam to ${file}.` + } + return `Added ${shell} completions for seam to ${file}.` +} + type BuildSpec = (blueprint: ApiBlueprint) => CommandSpec const completionCommand = ( @@ -42,11 +139,18 @@ const completionCommand = ( title: `Print the ${shell} completion script.`, description: '', flags: [ + { + long: 'install', + short: null, + description: `Add what ${shell} needs to complete seam commands to its config, instead of printing the script.`, + values: [], + takesValue: false, + isRequired: false, + }, { long: 'loader', short: null, - description: - 'Print the dynamic, network-free loader for installation by a package manager.', + description: `Print the loader --install writes, to install it into ${shell} by hand or from a package.`, values: [], takesValue: false, isRequired: false, @@ -55,11 +159,16 @@ const completionCommand = ( }, requiresAuth: false, execute: async (invocation, ctx) => { - if (invocation.args['loader'] === true) { + const action = readCompletionAction(invocation.args) + + if (action === 'install') { + await installCompletionForShell(shell) + } else if (action === 'loader') { printCompletionLoader(shell) } else { printCompletion(shell, buildSpec(ctx.blueprint)) } + return { kind: 'done' } }, }) diff --git a/src/lib/completion/detect-shell.ts b/src/lib/completion/detect-shell.ts new file mode 100644 index 00000000..f1b31f68 --- /dev/null +++ b/src/lib/completion/detect-shell.ts @@ -0,0 +1,88 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { basename } from 'node:path' + +import { + type CompletionShell, + isCompletionShell, +} from 'lib/render/completion/index.js' + +export interface DetectShellOptions { + env?: NodeJS.ProcessEnv + ancestors?: () => string[] +} + +export const detectShell = ({ + env = process.env, + ancestors = processAncestors, +}: DetectShellOptions = {}): CompletionShell | null => { + for (const command of ancestors()) { + const name = toShellName(command) + if (isCompletionShell(name)) return name + } + + const name = toShellName(env['SHELL'] ?? '') + + return isCompletionShell(name) ? name : null +} + +const toShellName = (command: string): string => + basename(command.trim()).replace(/^-/, '') + +const maxAncestors = 10 + +const processAncestors = (): string[] => { + const commands: string[] = [] + + let pid = process.ppid + for (let depth = 0; depth < maxAncestors && pid > 1; depth += 1) { + const parent = readProcess(pid) + if (parent == null) break + commands.push(parent.command) + pid = parent.ppid + } + + return commands +} + +interface ProcessInfo { + command: string + ppid: number +} + +const readProcess = (pid: number): ProcessInfo | null => + readFromProcfs(pid) ?? readFromPs(pid) + +const readFromProcfs = (pid: number): ProcessInfo | null => { + try { + const command = readFileSync(`/proc/${pid}/comm`, 'utf8').trim() + const stat = readFileSync(`/proc/${pid}/stat`, 'utf8') + const afterCommand = stat.slice(stat.lastIndexOf(')') + 1).trim() + const ppid = Number(afterCommand.split(/\s+/)[1]) + + return command !== '' && Number.isInteger(ppid) ? { command, ppid } : null + } catch { + return null + } +} + +const readFromPs = (pid: number): ProcessInfo | null => { + try { + const fields = execFileSync( + 'ps', + ['-p', String(pid), '-o', 'comm=,ppid='], + { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }, + ) + .trim() + .split(/\s+/) + const ppid = Number(fields.pop()) + const command = fields.join(' ') + + return command !== '' && Number.isInteger(ppid) ? { command, ppid } : null + } catch { + return null + } +} diff --git a/src/lib/completion/install.ts b/src/lib/completion/install.ts new file mode 100644 index 00000000..b78964f1 --- /dev/null +++ b/src/lib/completion/install.ts @@ -0,0 +1,128 @@ +import { existsSync } from 'node:fs' +import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' +import { dirname, join } from 'node:path' + +import { + type CompletionShell, + renderCompletionEval, + renderCompletionStub, +} from 'lib/render/completion/index.js' + +export type CompletionFileKind = 'config' | 'completion' + +export interface CompletionTarget { + shell: CompletionShell + file: string + kind: CompletionFileKind +} + +export interface ResolveCompletionTargetOptions { + env?: NodeJS.ProcessEnv + home?: string +} + +export const resolveCompletionTarget = ( + shell: CompletionShell, + { env = process.env, home = homedir() }: ResolveCompletionTargetOptions = {}, +): CompletionTarget => { + if (shell === 'fish') { + const configHome = readPath(env['XDG_CONFIG_HOME']) ?? join(home, '.config') + return { + shell, + file: join(configHome, 'fish', 'completions', 'seam.fish'), + kind: 'completion', + } + } + + if (shell === 'zsh') { + const dotDirectory = readPath(env['ZDOTDIR']) ?? home + return { shell, file: join(dotDirectory, '.zshrc'), kind: 'config' } + } + + return { shell, file: bashConfig(home), kind: 'config' } +} + +const bashConfig = (home: string): string => { + const bashrc = join(home, '.bashrc') + const bashProfile = join(home, '.bash_profile') + + if (existsSync(bashrc)) return bashrc + if (existsSync(bashProfile)) return bashProfile + + return bashrc +} + +export type InstallOutcome = 'added' | 'present' | 'written' + +export interface InstallResult { + outcome: InstallOutcome + notes: string[] + warnings: string[] +} + +export const installCompletion = async ( + target: CompletionTarget, +): Promise => { + await mkdir(dirname(target.file), { recursive: true }) + + if (target.kind === 'completion') { + await writeFile(target.file, renderCompletionStub(target.shell), 'utf8') + return { outcome: 'written', ...adviceFor(target, null) } + } + + const line = renderCompletionEval(target.shell) + const config = await readFileOrNull(target.file) + + if (config != null && config.includes(line)) { + return { outcome: 'present', ...adviceFor(target, config) } + } + + await appendFile(target.file, `${separatorFor(config)}${line}\n`, 'utf8') + + return { outcome: 'added', ...adviceFor(target, config) } +} + +const separatorFor = (config: string | null): string => { + if (config == null || config === '') return '' + return config.endsWith('\n') ? '\n' : '\n\n' +} + +const adviceFor = ( + { shell, file }: CompletionTarget, + config: string | null, +): Pick => ({ + notes: [ + `Open a new shell to complete seam commands, or run 'exec ${shell}' now.`, + ], + warnings: + shell === 'zsh' && config?.includes('compinit') !== true + ? [ + [ + `Nothing in ${file} turns the zsh completion system on. If nothing`, + 'else does either, completions stay inert until you add this above', + 'the block just installed:', + '', + ' autoload -Uz compinit', + ' compinit', + ].join('\n'), + ] + : [], +}) + +const readFileOrNull = async (file: string): Promise => { + try { + return await readFile(file, 'utf8') + } catch (error) { + if (isMissingFileError(error)) return null + throw error + } +} + +const isMissingFileError = (error: unknown): boolean => + error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT' + +const readPath = (value: string | undefined): string | null => { + const trimmedValue = value?.trim() + return trimmedValue == null || trimmedValue === '' ? null : trimmedValue +} diff --git a/src/lib/render/completion/index.ts b/src/lib/render/completion/index.ts index f9f86788..a838a87d 100644 --- a/src/lib/render/completion/index.ts +++ b/src/lib/render/completion/index.ts @@ -41,6 +41,11 @@ export const renderCompletion = ( export const renderCompletionStub = (shell: CompletionShell): string => stubs[shell] +export const renderCompletionEval = (shell: CompletionShell): string => { + const loader = `seam completion --loader ${shell} 2> /dev/null` + return shell === 'fish' ? `${loader} | source` : `eval "$(${loader})"` +} + /** * First line of each generated completion script, which the loaders require * before evaluating one. Must match the output of {@link renderCompletion}. @@ -63,13 +68,21 @@ const stubs: Record = { # # Install to /usr/share/bash-completion/completions/seam -if command -v seam > /dev/null 2>&1; then - __seam_completion_script="$(seam completion bash 2> /dev/null)" +_seam_completion_loader() { + local script + script="$(seam completion bash 2> /dev/null)" # Evaluate only a completion script, never anything else the CLI printed. - case "$__seam_completion_script" in - '${completionScriptSentinels.bash}'*) eval "$__seam_completion_script" ;; + case "$script" in + '${completionScriptSentinels.bash}'*) eval "$script" ;; + *) return 1 ;; esac - unset __seam_completion_script + return 124 +} + +if ((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 1))); then + complete -F _seam_completion_loader seam +else + _seam_completion_loader || : fi `, fish: `${stubHeader('fish')} @@ -89,15 +102,23 @@ ${stubHeader('zsh')} # # Install to a directory in fpath as _seam -local __seam_completion_script -__seam_completion_script="$(seam completion zsh 2> /dev/null)" +_seam() { + local script + script="$(seam completion zsh 2> /dev/null)" + # Evaluate only a completion script, never anything else the CLI printed. + # The script ends by dispatching on funcstack, so evaluating it while this + # _seam runs both redefines _seam and completes the in-flight request. + if [[ "$script" == '${completionScriptSentinels.zsh}'* ]]; then + eval "$script" + fi +} + +if (( $+functions[compdef] )); then + compdef _seam seam +fi -# Evaluate only a completion script, never anything else the CLI printed. -# The script ends by dispatching on funcstack, so evaluating it while this -# autoloaded _seam runs both redefines _seam and completes the in-flight -# request. -if [[ "$__seam_completion_script" == '${completionScriptSentinels.zsh}'* ]]; then - eval "$__seam_completion_script" +if (( \${funcstack[(I)_seam]} )); then + _seam "$@" fi `, } diff --git a/src/lib/render/help.ts b/src/lib/render/help.ts index 0d2774c2..61955121 100644 --- a/src/lib/render/help.ts +++ b/src/lib/render/help.ts @@ -79,8 +79,8 @@ const examples = [ summary: 'Pipe request params in as JSON.', }, { - name: 'seam completion bash', - summary: 'Print a shell completion script for bash, fish, or zsh.', + name: 'seam completion {bold --install}', + summary: 'Complete seam commands in bash, fish, or zsh.', }, ] diff --git a/test/cli.test.ts b/test/cli.test.ts index 941fc89a..27e982c0 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -291,6 +291,86 @@ test('cli: prints an embedded completion loader without generating completions', expect(stdout).not.toContain('complete -F _seam_completion seam') }) +test('cli: installs completions into a shell config', async () => { + const shellHome = await mkdtemp(join(tmpdir(), 'seam-cli-shell-')) + await writeFile(join(shellHome, '.zshrc'), 'export EDITOR=vim\n', 'utf8') + + const { stderr, exitCode } = await runCli( + ['completion', '--install', 'zsh', '--no-json'], + { env: { HOME: shellHome, ZDOTDIR: shellHome } }, + ) + + expect(exitCode).toBe(0) + expect(stderr).toContain(join(shellHome, '.zshrc')) + + const config = await readFile(join(shellHome, '.zshrc'), 'utf8') + expect(config).toContain('export EDITOR=vim') + expect(config).toContain( + 'eval "$(seam completion --loader zsh 2> /dev/null)"', + ) +}) + +test('cli: installs a completion file for fish', async () => { + const shellHome = await mkdtemp(join(tmpdir(), 'seam-cli-shell-')) + + const { exitCode } = await runCli(['completion', '--install', 'fish'], { + env: { HOME: shellHome, XDG_CONFIG_HOME: shellHome }, + }) + + expect(exitCode).toBe(0) + const completions = await readFile( + join(shellHome, 'fish', 'completions', 'seam.fish'), + 'utf8', + ) + expect(completions).toContain('seam completion fish') +}) + +test('cli: prints a loader holding no completions of its own', async () => { + const shellHome = await mkdtemp(join(tmpdir(), 'seam-cli-shell-')) + + const { stdout, exitCode } = await runCli( + ['completion', '--loader', 'bash'], + { + env: { HOME: shellHome, SHELL: '/bin/bash' }, + }, + ) + + expect(exitCode).toBe(0) + expect(stdout).toContain('complete -F _seam_completion_loader seam') + expect(stdout).not.toContain('_seam_subcommands') +}) + +test('cli: reports a shell it cannot install completions for', async () => { + const { stderr, exitCode } = await runCli([ + 'completion', + '--install', + 'nushell', + ]) + + expect(exitCode).toBe(1) + expect(stderr).toContain('Unknown shell for seam completion: nushell') +}) + +test('cli: refuses to both install and print completions', async () => { + const { stderr, exitCode } = await runCli([ + 'completion', + '--install', + '--loader', + 'zsh', + ]) + + expect(exitCode).toBe(1) + expect(stderr).toContain('Only one of --install, --loader') +}) + +test('cli: names the shell to print a completion script for', async () => { + const { stderr, exitCode } = await runCli(['completion']) + + expect(exitCode).toBe(1) + expect(stderr).toContain('Missing required argument for seam completion') + expect(stderr).toContain('seam completion --loader [bash|fish|zsh]') +}) + test('cli: names every unknown argument at once', async () => { requests = [] const { stderr, exitCode } = await runCli([ diff --git a/test/completion/detect-shell.test.ts b/test/completion/detect-shell.test.ts new file mode 100644 index 00000000..fed6f33d --- /dev/null +++ b/test/completion/detect-shell.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from 'vitest' + +import { detectShell } from 'lib/completion/detect-shell.js' + +const noAncestors = () => [] + +test('detectShell: reads the shell that ran the CLI', () => { + expect(detectShell({ ancestors: () => ['/usr/bin/fish'], env: {} })).toBe( + 'fish', + ) + expect(detectShell({ ancestors: () => ['zsh'], env: {} })).toBe('zsh') + expect(detectShell({ ancestors: () => ['-bash'], env: {} })).toBe('bash') +}) + +test('detectShell: looks past whatever the shell ran to reach the CLI', () => { + expect( + detectShell({ + ancestors: () => ['node', 'sh', 'npm', '/usr/bin/fish'], + env: {}, + }), + ).toBe('fish') +}) + +test('detectShell: prefers the shell running it to the login shell', () => { + expect( + detectShell({ + ancestors: () => ['/usr/bin/fish'], + env: { SHELL: '/bin/bash' }, + }), + ).toBe('fish') +}) + +test('detectShell: falls back to SHELL when no ancestor is a shell', () => { + expect( + detectShell({ ancestors: noAncestors, env: { SHELL: '/bin/zsh' } }), + ).toBe('zsh') + expect( + detectShell({ ancestors: () => ['node', 'tmux'], env: { SHELL: '-zsh' } }), + ).toBe('zsh') +}) + +test('detectShell: gives up on a shell completions cannot be installed into', () => { + expect( + detectShell({ ancestors: noAncestors, env: { SHELL: '/bin/sh' } }), + ).toBe(null) + expect(detectShell({ ancestors: noAncestors, env: { SHELL: '' } })).toBe(null) + expect(detectShell({ ancestors: noAncestors, env: {} })).toBe(null) +}) diff --git a/test/completion/install.test.ts b/test/completion/install.test.ts new file mode 100644 index 00000000..2238ea38 --- /dev/null +++ b/test/completion/install.test.ts @@ -0,0 +1,157 @@ +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { beforeEach, expect, test } from 'vitest' + +import { + installCompletion, + resolveCompletionTarget, +} from 'lib/completion/install.js' +import { + renderCompletionEval, + renderCompletionStub, +} from 'lib/render/completion/index.js' + +let home: string + +beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'seam-cli-completion-')) +}) + +test('resolveCompletionTarget: zsh reads its config from ZDOTDIR', () => { + expect(resolveCompletionTarget('zsh', { env: {}, home })).toEqual({ + shell: 'zsh', + file: join(home, '.zshrc'), + kind: 'config', + }) + expect( + resolveCompletionTarget('zsh', { env: { ZDOTDIR: '/dotfiles' }, home }), + ).toEqual({ + shell: 'zsh', + file: join('/dotfiles', '.zshrc'), + kind: 'config', + }) +}) + +test('resolveCompletionTarget: fish owns a completion file of its own', () => { + expect(resolveCompletionTarget('fish', { env: {}, home })).toEqual({ + shell: 'fish', + file: join(home, '.config', 'fish', 'completions', 'seam.fish'), + kind: 'completion', + }) + expect( + resolveCompletionTarget('fish', { + env: { XDG_CONFIG_HOME: '/xdg' }, + home, + }), + ).toEqual({ + shell: 'fish', + file: join('/xdg', 'fish', 'completions', 'seam.fish'), + kind: 'completion', + }) +}) + +test('resolveCompletionTarget: bash takes the config it finds', async () => { + expect(resolveCompletionTarget('bash', { env: {}, home }).file).toBe( + join(home, '.bashrc'), + ) + + await writeFile(join(home, '.bash_profile'), '', 'utf8') + expect(resolveCompletionTarget('bash', { env: {}, home }).file).toBe( + join(home, '.bash_profile'), + ) + + await writeFile(join(home, '.bashrc'), '', 'utf8') + expect(resolveCompletionTarget('bash', { env: {}, home }).file).toBe( + join(home, '.bashrc'), + ) +}) + +test('installCompletion: keeps what the config already holds', async () => { + const file = join(home, '.zshrc') + await writeFile(file, 'export EDITOR=vim', 'utf8') + + const result = await installCompletion({ shell: 'zsh', file, kind: 'config' }) + + expect(result.outcome).toBe('added') + const config = await readFile(file, 'utf8') + expect(config.startsWith('export EDITOR=vim\n\n')).toBe(true) + expect(config).toContain(renderCompletionEval('zsh')) +}) + +test('installCompletion: writes a config that does not exist yet', async () => { + const file = join(home, 'nested', '.bashrc') + + const result = await installCompletion({ + shell: 'bash', + file, + kind: 'config', + }) + + expect(result.outcome).toBe('added') + expect(await readFile(file, 'utf8')).toContain(renderCompletionEval('bash')) +}) + +test('installCompletion: installs the same snippet only once', async () => { + const file = join(home, '.zshrc') + const target = { shell: 'zsh', file, kind: 'config' } as const + + await installCompletion(target) + const installed = await readFile(file, 'utf8') + + const result = await installCompletion(target) + + expect(result.outcome).toBe('present') + expect(await readFile(file, 'utf8')).toBe(installed) +}) + +test('installCompletion: writes a completion file whole', async () => { + const file = join(home, '.config', 'fish', 'completions', 'seam.fish') + + const result = await installCompletion({ + shell: 'fish', + file, + kind: 'completion', + }) + + expect(result.outcome).toBe('written') + expect(await readFile(file, 'utf8')).toBe(renderCompletionStub('fish')) + + await installCompletion({ shell: 'fish', file, kind: 'completion' }) + expect(await readFile(file, 'utf8')).toBe(renderCompletionStub('fish')) +}) + +test('installCompletion: warns when nothing turns the zsh completion system on', async () => { + const file = join(home, '.zshrc') + + const missing = await installCompletion({ + shell: 'zsh', + file, + kind: 'config', + }) + expect(missing.outcome).toBe('added') + expect(missing.warnings.join('\n')).toContain('autoload -Uz compinit') + + await mkdir(join(home, 'other'), { recursive: true }) + const initialized = join(home, 'other', '.zshrc') + await writeFile(initialized, 'autoload -Uz compinit\ncompinit\n', 'utf8') + + const found = await installCompletion({ + shell: 'zsh', + file: initialized, + kind: 'config', + }) + expect(found.warnings).toEqual([]) +}) + +test('installCompletion: says nothing about compinit to another shell', async () => { + const result = await installCompletion({ + shell: 'bash', + file: join(home, '.bashrc'), + kind: 'config', + }) + + expect(result.warnings).toEqual([]) + expect(result.notes.join('\n')).toContain('exec bash') +}) diff --git a/test/render/completion.test.ts b/test/render/completion.test.ts index 3b494fb6..ed56b7e5 100644 --- a/test/render/completion.test.ts +++ b/test/render/completion.test.ts @@ -7,6 +7,7 @@ import { completionShells, isCompletionShell, renderCompletion, + renderCompletionEval, renderCompletionStub, } from 'lib/render/completion/index.js' import { testBlueprint } from 'test/fixtures/blueprint.js' @@ -103,3 +104,34 @@ test('zsh completion: completes the in-flight request when evaluated by the stub 'if (( ${funcstack[(I)_seam]} )); then', ) }) + +test.each(completionShells)( + '%s completion loader: survives being evaluated by a shell config', + (shell) => { + expect(renderCompletionStub(shell)).not.toMatch(/^local /m) + }, +) + +test.each(completionShells)('%s completion eval: loads the loader', (shell) => { + expect(renderCompletionEval(shell)).toContain( + `seam completion --loader ${shell}`, + ) +}) + +test('bash completion loader: completes again with the script it loaded', () => { + const stub = renderCompletionStub('bash') + expect(stub).toContain('complete -F _seam_completion_loader seam') + expect(stub).toContain('return 124') +}) + +test('zsh completion loader: registers itself when sourced', () => { + const stub = renderCompletionStub('zsh') + expect(stub).toContain('if (( $+functions[compdef] )); then') + expect(stub).toContain('compdef _seam seam') + // eslint-disable-next-line no-template-curly-in-string + expect(stub).toContain('if (( ${funcstack[(I)_seam]} )); then') +}) + +test('zsh completion loader: leaves the completion system to the shell', () => { + expect(renderCompletionStub('zsh')).not.toContain('compinit') +}) From 845810d99dddb397767a06bc3255dd08b48bffe7 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Mon, 17 Aug 2026 22:27:54 -0700 Subject: [PATCH 2/2] Apply suggestion from @razor-x --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index cba7e96b..c2b5e2b8 100644 --- a/README.md +++ b/README.md @@ -294,8 +294,7 @@ seam completion --install seam completion --install zsh ``` -Zsh completes nothing until the completion system is on. If your config does -not turn it on already, add this above the installed line: +If you use Zsh you must enable compinit in your `.zshrc` with ```zsh autoload -Uz compinit