From 811ac809a529a520753c57d33698763fdb872dfb Mon Sep 17 00:00:00 2001 From: salnika Date: Tue, 10 Feb 2026 09:28:15 +0100 Subject: [PATCH 1/3] feat: add completions scripts command --- .../sources/commands/completions.test.ts | 141 ++++++ .../sources/commands/completions.ts | 407 ++++++++++++++++++ packages/plugin-essentials/sources/index.ts | 3 + 3 files changed, 551 insertions(+) create mode 100644 packages/acceptance-tests/pkg-tests-specs/sources/commands/completions.test.ts create mode 100644 packages/plugin-essentials/sources/commands/completions.ts diff --git a/packages/acceptance-tests/pkg-tests-specs/sources/commands/completions.test.ts b/packages/acceptance-tests/pkg-tests-specs/sources/commands/completions.test.ts new file mode 100644 index 000000000000..5a81bdf9ab13 --- /dev/null +++ b/packages/acceptance-tests/pkg-tests-specs/sources/commands/completions.test.ts @@ -0,0 +1,141 @@ +import {PortablePath, npath, ppath, xfs} from '@yarnpkg/fslib'; +import {execFileSync} from 'child_process'; + +function getDefaultTestBinary() { + const candidates: Array = [ + ppath.join(npath.toPortablePath(__dirname), `../../../../../scripts/run-yarn.js` as PortablePath), + ppath.join(npath.toPortablePath(__dirname), `../../../../yarnpkg-cli/bundles/yarn.js` as PortablePath), + ppath.join(npath.toPortablePath(__dirname), `../../../../yarnpkg-cli/bin/yarn.js` as PortablePath), + ppath.join(npath.toPortablePath(__dirname), `../../../../berry-cli/bin/berry.js` as PortablePath), + ]; + + const found = candidates.find(candidate => xfs.existsSync(candidate)); + if (typeof found === `undefined`) + throw new Error(`No suitable Yarn binary was found (tried: ${candidates.map(candidate => npath.fromPortablePath(candidate)).join(`, `)})`); + + return npath.fromPortablePath(found); +} + +const initialTestBinary = process.env.TEST_BINARY; + +beforeAll(() => { + if (typeof initialTestBinary === `undefined`) { + process.env.TEST_BINARY = getDefaultTestBinary(); + } +}); + +afterAll(() => { + if (typeof initialTestBinary === `undefined`) { + delete process.env.TEST_BINARY; + } else { + process.env.TEST_BINARY = initialTestBinary; + } +}); + +describe(`Commands`, () => { + describe(`completions`, () => { + test( + `it should print a bash completion script including the yarn binary and common commands`, + makeTemporaryEnv({}, async ({path, run}) => { + await run(`install`); + + const {stdout} = await run(`completions`, `bash`); + + expect(stdout).toContain(`_yarn_completions`); + expect(stdout).toContain(`complete -o default -F _yarn_completions yarn`); + expect(stdout).toContain(`__yarn_get_children()`); + expect(stdout).toContain(`__yarn_get_options()`); + expect(stdout).not.toContain(`declare -A`); + expect(stdout).not.toContain(`declare -gA`); + + let hasBash = false; + try { + execFileSync(`bash`, [`--version`], {stdio: `ignore`}); + hasBash = true; + } catch { + hasBash = false; + } + + if (hasBash) { + const completionFile = ppath.join(path, `yarn.completions.bash` as PortablePath); + await xfs.writeFilePromise(completionFile, stdout, `utf8`); + + const completionFileNative = npath.fromPortablePath(completionFile); + + { + const out = execFileSync(`bash`, [`-c`, [ + `source "${completionFileNative}"`, + `COMP_WORDS=(yarn --cwd test-cwd "")`, + `COMP_CWORD=3`, + `_yarn_completions`, + `printf '%s\\n' "\${COMPREPLY[@]}"`, + ].join(`\n`)], {encoding: `utf8`}); + expect(out.split(/\n/)).toContain(`add`); + } + + { + const out = execFileSync(`bash`, [`-c`, [ + `source "${completionFileNative}"`, + `COMP_WORDS=(yarn --cwd "")`, + `COMP_CWORD=2`, + `_yarn_completions`, + `printf '%s\\n' "\${COMPREPLY[@]}"`, + ].join(`\n`)], {encoding: `utf8`}); + expect(out.trim()).toBe(``); + } + + { + const out = execFileSync(`bash`, [`-c`, [ + `source "${completionFileNative}"`, + `COMP_WORDS=(yarn completions "")`, + `COMP_CWORD=2`, + `_yarn_completions`, + `printf '%s\\n' "\${COMPREPLY[@]}"`, + ].join(`\n`)], {encoding: `utf8`}); + expect(out.split(/\n/)).toContain(`bash`); + } + } + }), + ); + + test( + `it should install the completion script under the XDG data directory when requested`, + makeTemporaryEnv({}, async ({path, run}) => { + const dataHome: PortablePath = ppath.join(path, `xdg-data`); + const target = ppath.join(dataHome, `yarn/completions/yarn.bash`); + + await run(`install`); + + await run(`completions`, `bash`, `--install`, `--yes`, { + env: { + XDG_DATA_HOME: npath.fromPortablePath(dataHome), + }, + }); + + expect(xfs.existsSync(target)).toBe(true); + + const content = await xfs.readFilePromise(target, `utf8`); + expect(content).toContain(`_yarn_completions`); + expect(content).toContain(`complete -o default -F _yarn_completions yarn`); + }), + ); + + test( + `it should use a consistent key encoding for fish and powershell multi-word commands`, + makeTemporaryEnv({}, async ({run}) => { + await run(`install`); + + const {stdout: fishStdout} = await run(`completions`, `fish`); + + expect(fishStdout).toContain(`case "set version from"`); + expect(fishStdout).toContain(`echo sources`); + expect(fishStdout).toContain(`set -l key (string join " " $cmd_tokens)`); + + const {stdout: powerShellStdout} = await run(`completions`, `powershell`); + + expect(powerShellStdout).toContain(`"set_version_from" = @('sources')`); + expect(powerShellStdout).toContain(`-join ' '`); + }), + ); + }); +}); diff --git a/packages/plugin-essentials/sources/commands/completions.ts b/packages/plugin-essentials/sources/commands/completions.ts new file mode 100644 index 000000000000..31a9b5e2fd50 --- /dev/null +++ b/packages/plugin-essentials/sources/commands/completions.ts @@ -0,0 +1,407 @@ +import {BaseCommand} from '@yarnpkg/cli'; +import {PortablePath, npath, ppath, xfs} from '@yarnpkg/fslib'; +import {Usage, Command, Option, UsageError} from 'clipanion'; +import type {Definition} from 'clipanion'; +import {homedir} from 'os'; + +type Shell = `bash` | `zsh` | `fish` | `powershell`; + +type CompletionEntry = { + path: Array; + options: Array; +}; + +const ROOT_KEY = `__root__`; +const SUPPORTED_SHELLS: Array = [`bash`, `zsh`, `fish`, `powershell`]; + +// eslint-disable-next-line arca/no-default-export +export default class CompletionsCommand extends BaseCommand { + static paths = [[`completions`]]; + + static usage: Usage = Command.Usage({ + description: `generate shell completion scripts`, + details: ` + This command outputs a shell completion script for Yarn's CLI. + Use \`--install\` to write the script to the appropriate shell configuration directory. + `, + examples: [[ + `Print a ZSH completion script`, + `$0 completions zsh`, + ], [ + `Install Bash completions`, + `$0 completions bash --install`, + ]], + }); + + shell = Option.String({required: false}); + install = Option.Boolean(`--install`, false, {description: `Write the script to the shell's completions directory`}); + yes = Option.Boolean(`-y,--yes`, false, {description: `Overwrite existing script without prompting`}); + + async execute() { + const shell = this.normalizeShell(this.shell ?? this.detectShell()); + if (!shell) + throw new UsageError(`Unsupported shell or unable to detect. Use one of: ${SUPPORTED_SHELLS.join(`, `)}`); + + const onBrokenPipe = (error: NodeJS.ErrnoException) => { + if (error.code === `EPIPE`) return; + throw error; + }; + this.context.stdout.on(`error`, onBrokenPipe); + + try { + const definitions = this.cli.definitions(); + const completionEntries = buildEntries(definitions); + const script = renderScript({shell, binaryName: this.cli.binaryName, entries: completionEntries}); + + if (!this.install) { + this.context.stdout.write(`${script}\n`); + return 0; + } + + const target = this.getInstallPath(shell); + if (xfs.existsSync(target) && !this.yes) + throw new UsageError(`The completion script already exists at ${npath.fromPortablePath(target)} (use --yes to overwrite)`); + + await xfs.mkdirpPromise(ppath.dirname(target)); + await xfs.writeFilePromise(target, script, `utf8`); + + const hint = this.activationHint(shell, target); + this.context.stdout.write(`Wrote completions to ${npath.fromPortablePath(target)}\n\n${hint}\n`); + + return 0; + } finally { + this.context.stdout.off(`error`, onBrokenPipe); + } + } + + private normalizeShell(candidate: string | null): Shell | null { + const normalized = candidate?.toLowerCase(); + if (!normalized) return null; + if (normalized.includes(`powershell`) || normalized === `pwsh`) return `powershell`; + if ((SUPPORTED_SHELLS as Array).includes(normalized)) return normalized as Shell; + return null; + } + + private detectShell(): string | null { + const shellPath = process.env.SHELL; + if (!shellPath) return null; + return ppath.basename(npath.toPortablePath(shellPath)); + } + + private getXdgDataHome() { + return process.env.XDG_DATA_HOME + ? npath.toPortablePath(process.env.XDG_DATA_HOME) + : ppath.join(npath.toPortablePath(homedir()), `.local/share`); + } + + private getXdgConfigHome() { + return process.env.XDG_CONFIG_HOME + ? npath.toPortablePath(process.env.XDG_CONFIG_HOME) + : ppath.join(npath.toPortablePath(homedir()), `.config`); + } + + private getInstallPath(shell: Shell) { + switch (shell) { + case `bash`: return ppath.join(this.getXdgDataHome(), `yarn/completions/yarn.bash`); + case `zsh`: return ppath.join(this.getXdgDataHome(), `yarn/completions/yarn.zsh`); + case `fish`: return ppath.join(this.getXdgConfigHome(), `fish/completions/yarn.fish`); + case `powershell`: return ppath.join(this.getXdgDataHome(), `yarn/completions/yarn.ps1`); + } + throw new Error(`Assertion failed: Unsupported shell`); + } + + private activationHint(shell: Shell, target: PortablePath) { + const path = npath.fromPortablePath(target); + switch (shell) { + case `bash`: return `Add to ~/.bashrc or ~/.profile:\n source ${path}`; + case `zsh`: return `Add to ~/.zshrc:\n source ${path}`; + case `fish`: return `Restart your shell. Fish autoloads from ${ppath.dirname(target)}.`; + case `powershell`: return `Add to your $PROFILE:\n . "${path}"`; + } + throw new Error(`Assertion failed: Unsupported shell`); + } +} + +function buildEntries(definitions: Array): Array { + const entries: Array = []; + for (const definition of definitions) { + const path = definition.path.split(` `).slice(1).filter(Boolean); + const options = new Set(); + + for (const option of definition.options) + for (const name of option.nameSet) + options.add(name); + + entries.push({path, options: Array.from(options).sort()}); + } + return entries; +} + +function buildChildMap(entries: Array) { + const children = new Map>(); + for (const entry of entries) { + for (let idx = 0; idx < entry.path.length; idx++) { + const prefix = entry.path.slice(0, idx); + const prefixKey = formatKey(prefix); + const nextToken = entry.path[idx]; + + const bucket = children.get(prefixKey) ?? new Set(); + bucket.add(nextToken); + children.set(prefixKey, bucket); + } + } + return children; +} + +function buildOptionsMap(entries: Array) { + const options = new Map>(); + for (const entry of entries) { + const key = formatKey(entry.path); + const bucket = options.get(key) ?? new Set(); + for (const opt of entry.options) bucket.add(opt); + options.set(key, bucket); + } + return options; +} + +function formatKey(segments: Array) { + return segments.length === 0 ? ROOT_KEY : segments.join(` `); +} + +function sanitizeKeyForIdentifier(key: string) { + return key.replace(/[^A-Za-z0-9]/g, `_`); +} + +function renderScript(ctx: {shell: Shell, binaryName: string, entries: Array}) { + const childMap = buildChildMap(ctx.entries); + const optionsMap = buildOptionsMap(ctx.entries); + + switch (ctx.shell) { + case `bash`: return renderBash({...ctx, childMap, optionsMap, zshCompatible: false}); + case `zsh`: return renderBash({...ctx, childMap, optionsMap, zshCompatible: true}); + case `fish`: return renderFish({...ctx, childMap, optionsMap}); + case `powershell`: return renderPowerShell({...ctx, childMap, optionsMap}); + } + throw new Error(`Assertion failed: Unsupported shell`); +} + +function renderBash({binaryName, childMap, optionsMap, zshCompatible}: {binaryName: string, childMap: Map>, optionsMap: Map>, zshCompatible: boolean}) { + const generateCase = (map: Map>) => { + return Array.from(map.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, values]) => ` "${key}") echo "${Array.from(values).sort().join(` `)}";;`) + .join(`\n`); + }; + + return ` +${zshCompatible ? `if [[ -n \${ZSH_VERSION-} ]]; then + __yarn_zsh_bashcompinit() { + emulate -L zsh + autoload -Uz bashcompinit + bashcompinit + } + __yarn_zsh_bashcompinit + unset -f __yarn_zsh_bashcompinit +fi` : ``} + +__yarn_get_children() { + case "$1" in +${generateCase(childMap)} + esac +} + +__yarn_get_options() { + case "$1" in +${generateCase(optionsMap)} + esac +} + +_yarn_completions() { + if [[ -n \${ZSH_VERSION-} ]]; then + setopt localoptions KSH_ARRAYS + fi + local cur="\${COMP_WORDS[COMP_CWORD]}" + local prev="" + if [[ $COMP_CWORD -gt 0 ]]; then + prev="\${COMP_WORDS[COMP_CWORD-1]}" + fi + local cmd_tokens=() + local i + + if [[ $prev == --cwd ]]; then + COMPREPLY=() + return + fi + + for ((i=1; i>, optionsMap: Map>}) { + const generateSwitch = (map: Map>) => { + return Array.from(map.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, values]) => { + const safeKey = key === ROOT_KEY ? `` : key; + return ` case "${safeKey}"\n echo ${Array.from(values).sort().join(` `)}`; + }).join(`\n`); + }; + + return ` +function __yarn_get_children + switch "$argv[1]" +${generateSwitch(childMap)} + end +end + +function __yarn_get_options + switch "$argv[1]" +${generateSwitch(optionsMap)} + end +end + +function __yarn_complete + set -l tokens (commandline -opc) + set -e tokens[1] + set -l cur (commandline -ct) + + set -l cmd_tokens + set -l skip_next 0 + for t in $tokens + if test $skip_next -eq 1 + set skip_next 0 + continue + end + if test "$t" = "--cwd" + set skip_next 1 + continue + end + if string match -q -- "-*" "$t" + continue + end + set cmd_tokens $cmd_tokens $t + end + + set -l key (string join " " $cmd_tokens) + + if string match -q -- "-*" "$cur" + set -l opts (__yarn_get_options "$key") + if test -z "$opts" + set opts (__yarn_get_options "") + end + for opt in $opts; echo $opt; end + return + end + + set -l children (__yarn_get_children "$key") + + if test "$key" = "completions" + for s in ${SUPPORTED_SHELLS.join(` `)}; echo $s; end + return + end + + for child in $children; echo $child; end +end + +complete -c ${binaryName} -f -a "(__yarn_complete)" +`.trim(); +} + +function renderPowerShell({binaryName, childMap, optionsMap}: {binaryName: string, childMap: Map>, optionsMap: Map>}) { + const toEntries = (map: Map>) => + Array.from(map.entries()) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, val]) => ` "${sanitizeKeyForIdentifier(key)}" = @(${Array.from(val).sort().map(v => `'${v}'`).join(`, `)})`) + .join(`\n`); + const supportedShells = SUPPORTED_SHELLS.map(shell => `'${shell}'`).join(`, `); + + return ` +$__yarnChildren = @{ +${toEntries(childMap)} +} + +$__yarnOptions = @{ +${toEntries(optionsMap)} +} + +Register-ArgumentCompleter -CommandName ${binaryName} -ScriptBlock { + param($wordToComplete, $commandAst, $cursorPosition) + + $elements = $commandAst.CommandElements | ForEach-Object { $_.Value } + $parts = @() + $skipNext = $false + + foreach ($element in $elements) { + if ($element -eq '${binaryName}') { continue } + if ([string]::IsNullOrWhiteSpace($element)) { continue } + if ($skipNext) { $skipNext = $false; continue } + + if ($element -eq '--cwd') { $skipNext = $true; continue } + if ($element.StartsWith('-')) { continue } + + $parts += $element + } + + $prefixParts = $parts + if ($wordToComplete -ne '' -and $wordToComplete -notlike '-*' -and $parts.Length -gt 0) { + $prefixParts = $parts[0..($parts.Length - 2)] + } + + $prefixKey = if ($prefixParts.Length -gt 0) { ($prefixParts -join ' ') -replace '[^A-Za-z0-9]', '_' } else { "${ROOT_KEY}" } + + if ($wordToComplete -like '-*') { + if ($__yarnOptions.ContainsKey($prefixKey)) { + $__yarnOptions[$prefixKey] | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterName', $_) + } + } + } else { + if ($prefixKey -eq "completions") { + ${supportedShells} | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + return + } + if ($__yarnChildren.ContainsKey($prefixKey)) { + $__yarnChildren[$prefixKey] | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + } + } + } +} +`.trim(); +} diff --git a/packages/plugin-essentials/sources/index.ts b/packages/plugin-essentials/sources/index.ts index d3a330165791..f5c1a5b5d750 100644 --- a/packages/plugin-essentials/sources/index.ts +++ b/packages/plugin-essentials/sources/index.ts @@ -5,6 +5,7 @@ import {isCI} from 'ci-info'; import AddCommand from './commands/add'; import BinCommand from './commands/bin'; import CacheCleanCommand from './commands/cache/clean'; +import CompletionsCommand from './commands/completions'; import ConfigGetCommand from './commands/config/get'; import ConfigSetCommand from './commands/config/set'; import ConfigUnsetCommand from './commands/config/unset'; @@ -45,6 +46,7 @@ import * as suggestUtils from './suggestU export {AddCommand}; export {BinCommand}; export {CacheCleanCommand}; +export {CompletionsCommand}; export {ConfigGetCommand}; export {ConfigSetCommand}; export {ConfigUnsetCommand}; @@ -178,6 +180,7 @@ const plugin: Plugin = { SetVersionSourcesCommand, SetVersionCommand, WorkspacesListCommand, + CompletionsCommand, ClipanionCommand, HelpCommand, EntryCommand, From 7c0156faa0304b4938f5eaaee46c1af6192265c8 Mon Sep 17 00:00:00 2001 From: salnika Date: Tue, 10 Feb 2026 09:43:58 +0100 Subject: [PATCH 2/3] chore: bump minor versions --- .yarn/versions/c1af72a8.yml | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .yarn/versions/c1af72a8.yml diff --git a/.yarn/versions/c1af72a8.yml b/.yarn/versions/c1af72a8.yml new file mode 100644 index 000000000000..54642a287609 --- /dev/null +++ b/.yarn/versions/c1af72a8.yml @@ -0,0 +1,23 @@ +releases: + "@yarnpkg/cli": minor + "@yarnpkg/plugin-essentials": minor + +declined: + - "@yarnpkg/plugin-compat" + - "@yarnpkg/plugin-constraints" + - "@yarnpkg/plugin-dlx" + - "@yarnpkg/plugin-init" + - "@yarnpkg/plugin-interactive-tools" + - "@yarnpkg/plugin-nm" + - "@yarnpkg/plugin-npm-cli" + - "@yarnpkg/plugin-pack" + - "@yarnpkg/plugin-patch" + - "@yarnpkg/plugin-pnp" + - "@yarnpkg/plugin-pnpm" + - "@yarnpkg/plugin-stage" + - "@yarnpkg/plugin-typescript" + - "@yarnpkg/plugin-version" + - "@yarnpkg/plugin-workspace-tools" + - "@yarnpkg/builder" + - "@yarnpkg/core" + - "@yarnpkg/doctor" From 9d63bf81ba87d61599429c93cccccee5412bd7c5 Mon Sep 17 00:00:00 2001 From: salnika Date: Tue, 10 Feb 2026 09:50:41 +0100 Subject: [PATCH 3/3] chore(docs): add comletions command --- .../docs/getting-started/basics/usage.mdx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/docusaurus/docs/getting-started/basics/usage.mdx b/packages/docusaurus/docs/getting-started/basics/usage.mdx index 18eac458e6b1..5c9ab763d365 100644 --- a/packages/docusaurus/docs/getting-started/basics/usage.mdx +++ b/packages/docusaurus/docs/getting-started/basics/usage.mdx @@ -14,4 +14,20 @@ If you're coming from npm, the main changes are: - Your scripts are aliased. Calling `yarn build` is the same as `yarn run build`! - Most registry-related commands are moved behind (ex: `yarn npm audit`). +## Shell completions + +Yarn can generate completion scripts for Bash, Zsh, Fish, and PowerShell: + +``` +yarn completions bash +``` + +To install the script into the standard completion directories: + +``` +yarn completions zsh --install +``` + +You can also use `--yes` to overwrite an existing script without being prompted. + To see the full list of commands, check the [CLI reference](/cli).