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
20 changes: 19 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -283,7 +287,21 @@ 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
```

If you use Zsh you must enable compinit in your `.zshrc` with

```zsh
autoload -Uz compinit
compinit
```

Load completions into the current shell instead with

```bash
# bash
Expand Down
39 changes: 22 additions & 17 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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'

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
117 changes: 113 additions & 4 deletions src/lib/commands/local/completion.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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<void> => {
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: <shell>',
{ 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 = (
Expand All @@ -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,
Expand All @@ -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' }
},
})
Expand Down
88 changes: 88 additions & 0 deletions src/lib/completion/detect-shell.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading