[READ-ONLY] Mirror of https://github.com/bombshell-dev/tab. shell autocompletions for javascript CLIs
4

Configure Feed

Select the types of activity you want to include in your feed.

feat: add manual completions for pnpm options that take values and add npm handler (#48)

* init

* update

* update

* feat: add npm handler (#49)

* add npm handler

* big update

* clean npm handler

* clean shared

* clean pnpm handler

* clean bun handler

* update

* update

* ci

* update

* rm debug log

authored by

AmirHossein Sakhravi and committed by
GitHub
(Sep 13, 2025, 3:12 PM +0330) 3ccc3221 0a1cc2fb

+547 -164
+154 -1
bin/handlers/npm-handler.ts
··· 1 1 import type { PackageManagerCompletion } from '../package-manager-completion.js'; 2 + import { stripAnsiEscapes, type ParsedOption } from '../utils/text-utils.js'; 3 + import { 4 + LazyCommand, 5 + OptionHandlers, 6 + commonOptionHandlers, 7 + setupLazyOptionLoading, 8 + setupCommandArguments, 9 + safeExec, 10 + safeExecSync, 11 + createLogLevelHandler, 12 + } from '../utils/shared.js'; 13 + 14 + const ALL_COMMANDS_RE = /^All commands:\s*$/i; 15 + const OPTIONS_SECTION_RE = /^Options:\s*$/i; 16 + const SECTION_END_RE = /^(aliases|run|more)/i; // marks end of Options: block 17 + const COMMAND_VALIDATION_RE = /^[a-z][a-z0-9-]*$/; 18 + const NPM_OPTION_RE = 19 + /(?:\[)?(?:-([a-z])\|)?--([a-z][a-z0-9-]+)(?:\s+<[^>]+>)?(?:\])?/gi; 20 + const ANGLE_VALUE_RE = /<[^>]+>/; 21 + const INDENTED_LINE_RE = /^\s/; 22 + 23 + function toLines(helpText: string): string[] { 24 + return stripAnsiEscapes(helpText).split(/\r?\n/); 25 + } 26 + 27 + function readIndentedBlockAfter(lines: string[], headerRe: RegExp): string { 28 + const start = lines.findIndex((l) => headerRe.test(l.trim())); 29 + if (start === -1) return ''; 30 + 31 + let buf = ''; 32 + for (let i = start + 1; i < lines.length; i++) { 33 + const line = lines[i]; 34 + if (!INDENTED_LINE_RE.test(line) && line.trim() && !line.includes(',')) 35 + break; 36 + if (INDENTED_LINE_RE.test(line)) buf += ' ' + line.trim(); 37 + } 38 + return buf; 39 + } 40 + 41 + const listHandler = 42 + (values: string[], describe: (v: string) => string = () => ' ') => 43 + (complete: (value: string, description: string) => void) => 44 + values.forEach((v) => complete(v, describe(v))); 45 + 46 + const npmOptionHandlers: OptionHandlers = { 47 + ...commonOptionHandlers, 48 + 49 + loglevel: createLogLevelHandler([ 50 + 'silent', 51 + 'error', 52 + 'warn', 53 + 'notice', 54 + 'http', 55 + 'info', 56 + 'verbose', 57 + 'silly', 58 + ]), 59 + 60 + 'install-strategy': listHandler( 61 + ['hoisted', 'nested', 'shallow', 'linked'], 62 + () => ' ' 63 + ), 64 + 65 + omit: listHandler(['dev', 'optional', 'peer'], () => ' '), 66 + 67 + include: listHandler(['prod', 'dev', 'optional', 'peer'], () => ' '), 68 + }; 69 + 70 + export function parseNpmHelp(helpText: string): Record<string, string> { 71 + const lines = toLines(helpText); 72 + const commandsBlob = readIndentedBlockAfter(lines, ALL_COMMANDS_RE); 73 + if (!commandsBlob) return {}; 74 + 75 + const commands: Record<string, string> = {}; 76 + 77 + commandsBlob 78 + .split(',') 79 + .map((c) => c.trim()) 80 + .filter((c) => c && COMMAND_VALIDATION_RE.test(c)) 81 + .forEach((cmd) => { 82 + // npm main help has no per-command descriptions 83 + commands[cmd] = ' '; 84 + }); 85 + 86 + // this is the most common used aliase that isn't in the main list 87 + commands['run'] = ' '; 88 + 89 + return commands; 90 + } 91 + 92 + // Get npm commands from the main help output 93 + export async function getNpmCommandsFromMainHelp(): Promise< 94 + Record<string, string> 95 + > { 96 + const output = await safeExec('npm --help'); 97 + return output ? parseNpmHelp(output) : {}; 98 + } 99 + 100 + export function parseNpmOptions( 101 + helpText: string, 102 + { flagsOnly = true }: { flagsOnly?: boolean } = {} 103 + ): ParsedOption[] { 104 + const lines = toLines(helpText); 105 + 106 + const start = lines.findIndex((l) => OPTIONS_SECTION_RE.test(l.trim())); 107 + if (start === -1) return []; 108 + 109 + const out: ParsedOption[] = []; 110 + 111 + for (const line of lines.slice(start + 1)) { 112 + const trimmed = line.trim(); 113 + if (SECTION_END_RE.test(trimmed)) break; 114 + 115 + const matches = line.matchAll(NPM_OPTION_RE); 116 + for (const m of matches) { 117 + const short = m[1] || undefined; 118 + const long = m[2]; 119 + const takesValue = ANGLE_VALUE_RE.test(m[0]); 120 + if (flagsOnly && takesValue) continue; 121 + 122 + out.push({ short, long, desc: ' ' }); 123 + } 124 + } 125 + 126 + return out; 127 + } 128 + 129 + function loadNpmOptionsSync(cmd: LazyCommand, command: string): void { 130 + const output = safeExecSync(`npm ${command} --help`); 131 + if (!output) return; 132 + 133 + const allOptions = parseNpmOptions(output, { flagsOnly: false }); 134 + 135 + for (const { long, short, desc } of allOptions) { 136 + const exists = cmd.optionsRaw?.get?.(long); 137 + if (exists) continue; 138 + 139 + const handler = npmOptionHandlers[long]; 140 + if (handler) cmd.option(long, desc, handler, short); 141 + else cmd.option(long, desc, short); 142 + } 143 + } 2 144 3 145 export async function setupNpmCompletions( 4 146 completion: PackageManagerCompletion 5 - ): Promise<void> {} 147 + ): Promise<void> { 148 + try { 149 + const commands = await getNpmCommandsFromMainHelp(); 150 + for (const [command, description] of Object.entries(commands)) { 151 + const c = completion.command(command, description); 152 + 153 + setupCommandArguments(c, command, 'npm'); 154 + 155 + setupLazyOptionLoading(c, command, 'npm', loadNpmOptionsSync); 156 + } 157 + } catch {} 158 + }
+263 -163
bin/handlers/pnpm-handler.ts
··· 1 - import { promisify } from 'node:util'; 2 - import child_process from 'node:child_process'; 3 - 4 - const exec = promisify(child_process.exec); 5 - const { execSync } = child_process; 6 1 import type { PackageManagerCompletion } from '../package-manager-completion.js'; 7 - import { Command, Option } from '../../src/t.js'; 8 - 9 - interface LazyCommand extends Command { 10 - _lazyCommand?: string; 11 - _optionsLoaded?: boolean; 12 - optionsRaw?: Map<string, Option>; 13 - } 14 - 2 + import { getWorkspacePatterns } from '../utils/filesystem-utils.js'; 15 3 import { 16 - packageJsonScriptCompletion, 17 - packageJsonDependencyCompletion, 18 - } from '../completions/completion-producers.js'; 4 + LazyCommand, 5 + OptionHandlers, 6 + commonOptionHandlers, 7 + setupLazyOptionLoading, 8 + setupCommandArguments, 9 + safeExec, 10 + safeExecSync, 11 + createLogLevelHandler, 12 + } from '../utils/shared.js'; 19 13 import { 20 14 stripAnsiEscapes, 21 15 measureIndent, ··· 26 20 type ParsedOption, 27 21 } from '../utils/text-utils.js'; 28 22 29 - // regex to detect options section in help text 30 23 const OPTIONS_SECTION_RE = /^\s*Options:/i; 24 + const LEVEL_MATCH_RE = /(?:levels?|options?|values?)[^:]*:\s*([^.]+)/i; 25 + const LINE_SPLIT_RE = /\r?\n/; 26 + const COMMA_SPACE_SPLIT_RE = /[,\s]+/; 27 + const OPTION_WITH_VALUE_RE = 28 + /^\s*(?:-\w,?\s*)?--(\w+(?:-\w+)*)\s+(\w+(?:-\w+)*)\s+(.+)$/; 29 + const OPTION_ALIAS_RE = 30 + /^\s*-\w,?\s*--\w+(?:,\s*--(\w+(?:-\w+)*)\s+(\w+(?:-\w+)*))?\s+(.+)$/; 31 + const CONTINUATION_LINE_RE = /^\s{20,}/; 32 + const SECTION_HEADER_RE = /^\s*[A-Z][^:]*:\s*$/; 31 33 32 - // we parse the pnpm help text to extract commands and their descriptions! 33 - export function parsePnpmHelp(helpText: string): Record<string, string> { 34 - const helpLines = stripAnsiEscapes(helpText).split(/\r?\n/); 34 + function toLines(text: string): string[] { 35 + return stripAnsiEscapes(text).split(LINE_SPLIT_RE); 36 + } 37 + 38 + function findCommandDescColumn(lines: string[]): number { 39 + let col = Number.POSITIVE_INFINITY; 40 + for (const line of lines) { 41 + const m = line.match(COMMAND_ROW_RE); 42 + if (!m) continue; 43 + const idx = line.indexOf(m[2]); 44 + if (idx >= 0 && idx < col) col = idx; 45 + } 46 + return col; 47 + } 48 + 49 + function findOptionDescColumn(lines: string[], flagsOnly: boolean): number { 50 + let col = Number.POSITIVE_INFINITY; 51 + for (const line of lines) { 52 + const m = line.match(OPTION_ROW_RE); 53 + if (!m) continue; 54 + if (flagsOnly && m.groups?.val) continue; // skip value-taking options in flagsOnly mode 55 + const idx = line.indexOf(m.groups!.desc); 56 + if (idx >= 0 && idx < col) col = idx; 57 + } 58 + return col; 59 + } 60 + 61 + function extractValidValuesFromHelp( 62 + helpText: string, 63 + optionName: string 64 + ): Array<{ value: string; desc: string }> { 65 + const lines = toLines(helpText); 66 + const results: Array<{ value: string; desc: string }> = []; 67 + 68 + for (let i = 0; i < lines.length; i++) { 69 + const line = lines[i]; 70 + 71 + // Look for options with values in any section 72 + const optionMatch = line.match(OPTION_WITH_VALUE_RE); 73 + if (optionMatch) { 74 + const [, option, value, initialDesc] = optionMatch; 75 + if (option === optionName) { 76 + // capture continuation lines for complete description 77 + let fullDesc = initialDesc.trim(); 78 + let j = i + 1; 79 + 80 + // Look ahead for continuation lines (indented lines that don't start new options) 81 + while (j < lines.length) { 82 + const nextLine = lines[j]; 83 + const isIndented = CONTINUATION_LINE_RE.test(nextLine); 84 + const isNewOption = 85 + OPTION_WITH_VALUE_RE.test(nextLine) || 86 + OPTION_ALIAS_RE.test(nextLine); 87 + const isEmptyOrSection = 88 + !nextLine.trim() || SECTION_HEADER_RE.test(nextLine); 89 + 90 + if (isIndented && !isNewOption && !isEmptyOrSection) { 91 + fullDesc += ' ' + nextLine.trim(); 92 + j++; 93 + } else { 94 + break; 95 + } 96 + } 97 + 98 + results.push({ value, desc: fullDesc }); 99 + } 100 + } 101 + 102 + const aliasMatch = line.match(OPTION_ALIAS_RE); 103 + if (aliasMatch) { 104 + const [, option, value, initialDesc] = aliasMatch; 105 + if (option === optionName && value) { 106 + // capture continuation lines for alias descriptions too 107 + let fullDesc = initialDesc.trim(); 108 + let j = i + 1; 109 + 110 + while (j < lines.length) { 111 + const nextLine = lines[j]; 112 + const isIndented = CONTINUATION_LINE_RE.test(nextLine); 113 + const isNewOption = 114 + OPTION_WITH_VALUE_RE.test(nextLine) || 115 + OPTION_ALIAS_RE.test(nextLine); 116 + const isEmptyOrSection = 117 + !nextLine.trim() || SECTION_HEADER_RE.test(nextLine); 118 + 119 + if (isIndented && !isNewOption && !isEmptyOrSection) { 120 + fullDesc += ' ' + nextLine.trim(); 121 + j++; 122 + } else { 123 + break; 124 + } 125 + } 35 126 36 - // we find the earliest description column across command rows. 37 - let descColumnIndex = Number.POSITIVE_INFINITY; 38 - for (const line of helpLines) { 39 - const rowMatch = line.match(COMMAND_ROW_RE); 40 - if (!rowMatch) continue; 41 - const descColumnIndexOnThisLine = line.indexOf(rowMatch[2]); 42 - if ( 43 - descColumnIndexOnThisLine >= 0 && 44 - descColumnIndexOnThisLine < descColumnIndex 45 - ) { 46 - descColumnIndex = descColumnIndexOnThisLine; 127 + results.push({ value, desc: fullDesc }); 128 + } 47 129 } 48 130 } 49 - if (!Number.isFinite(descColumnIndex)) return {}; 50 131 51 - // we fold rows, and join continuation lines aligned to descColumnIndex or deeper. 52 - type PendingRow = { names: string[]; desc: string } | null; 53 - let pendingRow: PendingRow = null; 132 + if (results.length) return results; 54 133 55 - const commandMap = new Map<string, string>(); 56 - const flushPendingRow = () => { 57 - if (!pendingRow) return; 58 - const desc = pendingRow.desc.trim(); 59 - for (const name of pendingRow.names) commandMap.set(name, desc); 60 - pendingRow = null; 134 + for (let i = 0; i < lines.length; i++) { 135 + const ln = lines[i]; 136 + if (ln.includes(`--${optionName}`) || ln.includes(`${optionName}:`)) { 137 + for (let j = i; j < Math.min(i + 3, lines.length); j++) { 138 + const probe = lines[j]; 139 + const m = probe.match(LEVEL_MATCH_RE); 140 + if (m) { 141 + return m[1] 142 + .split(COMMA_SPACE_SPLIT_RE) 143 + .map((v) => v.trim()) 144 + .filter((v) => v && !v.includes('(') && !v.includes(')')) 145 + .map((value) => ({ value, desc: `Log level: ${value}` })); 146 + } 147 + } 148 + } 149 + } 150 + return []; 151 + } 152 + 153 + const pnpmOptionHandlers: OptionHandlers = { 154 + ...commonOptionHandlers, 155 + 156 + loglevel(complete) { 157 + const fromHelp = extractValidValuesFromHelp( 158 + safeExecSync('pnpm install --help'), 159 + 'loglevel' 160 + ); 161 + if (fromHelp.length) { 162 + fromHelp.forEach(({ value, desc }) => complete(value, desc)); 163 + } else { 164 + createLogLevelHandler(['debug', 'info', 'warn', 'error', 'silent'])( 165 + complete 166 + ); 167 + } 168 + }, 169 + 170 + reporter(complete) { 171 + const out = extractValidValuesFromHelp( 172 + safeExecSync('pnpm install --help'), 173 + 'reporter' 174 + ); 175 + if (out.length) { 176 + out.forEach(({ value, desc }) => complete(value, desc)); 177 + } else { 178 + createLogLevelHandler(['default', 'append-only', 'ndjson', 'silent'])( 179 + complete 180 + ); 181 + } 182 + }, 183 + 184 + filter(complete) { 185 + complete('.', 'Current working directory'); 186 + complete('!<selector>', 'Exclude packages matching selector'); 187 + 188 + const patterns = getWorkspacePatterns(); 189 + patterns.forEach((p) => { 190 + complete(p, `Workspace pattern: ${p}`); 191 + complete(`${p}...`, `Include dependencies of ${p}`); 192 + }); 193 + 194 + complete('@*/*', 'All scoped packages'); 195 + complete('...<pattern>', 'Include dependencies of pattern'); 196 + complete('<pattern>...', 'Include dependents of pattern'); 197 + }, 198 + }; 199 + 200 + export function parsePnpmHelp(helpText: string): Record<string, string> { 201 + const lines = toLines(helpText); 202 + 203 + const descCol = findCommandDescColumn(lines); 204 + if (!Number.isFinite(descCol)) return {}; 205 + 206 + type Pending = { names: string[]; desc: string } | null; 207 + let pending: Pending = null; 208 + 209 + const out = new Map<string, string>(); 210 + 211 + const flush = () => { 212 + if (!pending) return; 213 + const desc = pending.desc.trim(); 214 + for (const n of pending.names) out.set(n, desc); 215 + pending = null; 61 216 }; 62 217 63 - for (const line of helpLines) { 64 - if (OPTIONS_SECTION_RE.test(line)) break; // we stop at options 218 + for (const line of lines) { 219 + if (OPTIONS_SECTION_RE.test(line)) break; // end of commands section 65 220 66 - // we match the command row 67 - const rowMatch = line.match(COMMAND_ROW_RE); 68 - if (rowMatch) { 69 - flushPendingRow(); 70 - pendingRow = { 71 - names: parseAliasList(rowMatch[1]), 72 - desc: rowMatch[2].trim(), 221 + const row = line.match(COMMAND_ROW_RE); 222 + if (row) { 223 + flush(); 224 + pending = { 225 + names: parseAliasList(row[1]), 226 + desc: row[2].trim(), 73 227 }; 74 228 continue; 75 229 } 76 230 77 - // we join continuation lines aligned to descColumnIndex or deeper 78 - if (pendingRow) { 79 - const indentWidth = measureIndent(line); 80 - if (indentWidth >= descColumnIndex && line.trim()) { 81 - pendingRow.desc += ' ' + line.trim(); 231 + if (pending) { 232 + const indent = measureIndent(line); 233 + if (indent >= descCol && line.trim()) { 234 + pending.desc += ' ' + line.trim(); 82 235 } 83 236 } 84 237 } 85 - // we flush the pending row and return the command map 86 - flushPendingRow(); 238 + flush(); 87 239 88 - return Object.fromEntries(commandMap); 240 + return Object.fromEntries(out); 89 241 } 90 242 91 - // now we get the pnpm commands from the main help output 92 243 export async function getPnpmCommandsFromMainHelp(): Promise< 93 244 Record<string, string> 94 245 > { 95 - try { 96 - const { stdout } = await exec('pnpm --help', { 97 - encoding: 'utf8', 98 - timeout: 500, 99 - maxBuffer: 4 * 1024 * 1024, 100 - }); 101 - return parsePnpmHelp(stdout); 102 - } catch { 103 - return {}; 104 - } 246 + const output = await safeExec('pnpm --help'); 247 + return output ? parsePnpmHelp(output) : {}; 105 248 } 106 249 107 - // here we parse the pnpm options from the help text 108 250 export function parsePnpmOptions( 109 251 helpText: string, 110 252 { flagsOnly = true }: { flagsOnly?: boolean } = {} 111 253 ): ParsedOption[] { 112 - // we strip the ANSI escapes from the help text 113 - const helpLines = stripAnsiEscapes(helpText).split(/\r?\n/); 254 + const lines = toLines(helpText); 114 255 115 - // we find the earliest description column among option rows we care about 116 - let descColumnIndex = Number.POSITIVE_INFINITY; 117 - for (const line of helpLines) { 118 - const optionMatch = line.match(OPTION_ROW_RE); 119 - if (!optionMatch) continue; 120 - if (flagsOnly && optionMatch.groups?.val) continue; // skip value-taking options, we will add them manually with their value 121 - const descColumnIndexOnThisLine = line.indexOf(optionMatch.groups!.desc); 122 - if ( 123 - descColumnIndexOnThisLine >= 0 && 124 - descColumnIndexOnThisLine < descColumnIndex 125 - ) { 126 - descColumnIndex = descColumnIndexOnThisLine; 127 - } 128 - } 129 - if (!Number.isFinite(descColumnIndex)) return []; 256 + const descCol = findOptionDescColumn(lines, flagsOnly); 257 + if (!Number.isFinite(descCol)) return []; 130 258 131 - // we fold the option rows and join the continuations 132 - const optionsOut: ParsedOption[] = []; 133 - let pendingOption: ParsedOption | null = null; 259 + const out: ParsedOption[] = []; 260 + let pending: ParsedOption | null = null; 134 261 135 - const flushPendingOption = () => { 136 - if (!pendingOption) return; 137 - pendingOption.desc = pendingOption.desc.trim(); 138 - optionsOut.push(pendingOption); 139 - pendingOption = null; 262 + const flush = () => { 263 + if (!pending) return; 264 + pending.desc = pending.desc.trim(); 265 + out.push(pending); 266 + pending = null; 140 267 }; 141 268 142 - // we match the option row 143 - for (const line of helpLines) { 144 - const optionMatch = line.match(OPTION_ROW_RE); 145 - if (optionMatch) { 146 - if (flagsOnly && optionMatch.groups?.val) continue; 147 - flushPendingOption(); 148 - pendingOption = { 149 - short: optionMatch.groups?.short || undefined, 150 - long: optionMatch.groups!.long, 151 - desc: optionMatch.groups!.desc.trim(), 269 + for (const line of lines) { 270 + const m = line.match(OPTION_ROW_RE); 271 + if (m) { 272 + if (flagsOnly && m.groups?.val) continue; 273 + flush(); 274 + pending = { 275 + short: m.groups?.short || undefined, 276 + long: m.groups!.long, 277 + desc: m.groups!.desc.trim(), 152 278 }; 153 279 continue; 154 280 } 155 281 156 - // we join the continuations 157 - if (pendingOption) { 158 - const indentWidth = measureIndent(line); 159 - const startsNewOption = OPTION_HEAD_RE.test(line); 160 - if (indentWidth >= descColumnIndex && line.trim() && !startsNewOption) { 161 - pendingOption.desc += ' ' + line.trim(); 282 + if (pending) { 283 + const indent = measureIndent(line); 284 + const startsNew = OPTION_HEAD_RE.test(line); 285 + if (indent >= descCol && line.trim() && !startsNew) { 286 + pending.desc += ' ' + line.trim(); 162 287 } 163 288 } 164 289 } 165 - // we flush the pending option 166 - flushPendingOption(); 290 + flush(); 167 291 168 - return optionsOut; 292 + return out; 169 293 } 170 294 171 - // we load the dynamic options synchronously when requested ( separated from the command loading ) 172 - export function loadDynamicOptionsSync( 173 - cmd: LazyCommand, 174 - command: string 175 - ): void { 176 - try { 177 - const stdout = execSync(`pnpm ${command} --help`, { 178 - encoding: 'utf8', 179 - timeout: 500, 180 - }); 181 - 182 - const parsedOptions = parsePnpmOptions(stdout, { flagsOnly: true }); 183 - 184 - for (const { long, short, desc } of parsedOptions) { 185 - const alreadyDefined = cmd.optionsRaw?.get?.(long); 186 - if (!alreadyDefined) cmd.option(long, desc, short); 187 - } 188 - } catch (_err) {} 189 - } 295 + function loadPnpmOptionsSync(cmd: LazyCommand, command: string): void { 296 + const output = safeExecSync(`pnpm ${command} --help`); 297 + if (!output) return; 190 298 191 - // we setup the lazy option loading for a command 299 + const options = parsePnpmOptions(output, { flagsOnly: false }); 192 300 193 - function setupLazyOptionLoading(cmd: LazyCommand, command: string): void { 194 - cmd._lazyCommand = command; 195 - cmd._optionsLoaded = false; 301 + for (const { long, short, desc } of options) { 302 + const exists = cmd.optionsRaw?.get?.(long); 303 + if (exists) continue; 196 304 197 - const optionsStore = cmd.options; 198 - cmd.optionsRaw = optionsStore; 305 + const handler = pnpmOptionHandlers[long]; 306 + if (handler) cmd.option(long, desc, handler, short); 307 + else cmd.option(long, desc, short); 308 + } 199 309 200 - Object.defineProperty(cmd, 'options', { 201 - get() { 202 - if (!this._optionsLoaded) { 203 - this._optionsLoaded = true; 204 - loadDynamicOptionsSync(this, this._lazyCommand); // block until filled 310 + // Register options found by general algorithm but not in standard parsing 311 + for (const [optionName, handler] of Object.entries(pnpmOptionHandlers)) { 312 + if (!cmd.optionsRaw?.get?.(optionName)) { 313 + const values = extractValidValuesFromHelp(output, optionName); 314 + if (values.length > 0) { 315 + cmd.option(optionName, ' ', handler); 205 316 } 206 - return optionsStore; 207 - }, 208 - configurable: true, 209 - }); 317 + } 318 + } 210 319 } 211 320 212 321 export async function setupPnpmCompletions( 213 322 completion: PackageManagerCompletion 214 323 ): Promise<void> { 215 324 try { 216 - const commandsWithDescriptions = await getPnpmCommandsFromMainHelp(); 217 - 218 - for (const [command, description] of Object.entries( 219 - commandsWithDescriptions 220 - )) { 221 - const cmd = completion.command(command, description); 222 - 223 - if (['remove', 'rm', 'update', 'up'].includes(command)) { 224 - cmd.argument('package', packageJsonDependencyCompletion); 225 - } 226 - if (command === 'run') { 227 - cmd.argument('script', packageJsonScriptCompletion, true); 228 - } 325 + const commands = await getPnpmCommandsFromMainHelp(); 229 326 230 - setupLazyOptionLoading(cmd, command); 327 + for (const [command, description] of Object.entries(commands)) { 328 + const c = completion.command(command, description); 329 + setupCommandArguments(c, command, 'pnpm'); 330 + setupLazyOptionLoading(c, command, 'pnpm', loadPnpmOptionsSync); 231 331 } 232 - } catch (_err) {} 332 + } catch {} 233 333 }
+26
bin/utils/filesystem-utils.ts
··· 1 + import { readFileSync } from 'node:fs'; 2 + 3 + export function getWorkspacePatterns(): string[] { 4 + try { 5 + let content: string; 6 + try { 7 + content = readFileSync('pnpm-workspace.yaml', 'utf8'); 8 + } catch { 9 + content = readFileSync('pnpm-workspace.yml', 'utf8'); 10 + } 11 + 12 + const packagesMatch = content.match(/packages:\s*\n((?:\s*-\s*.+\n?)*)/); 13 + if (!packagesMatch) return []; 14 + 15 + const patterns = packagesMatch[1] 16 + .split('\n') 17 + .map((line) => line.trim()) 18 + .filter((line) => line.startsWith('-')) 19 + .map((line) => line.substring(1).trim()) 20 + .filter((pattern) => pattern && !pattern.startsWith('#')); 21 + 22 + return patterns; 23 + } catch { 24 + return []; 25 + } 26 + }
+104
bin/utils/shared.ts
··· 1 + import { promisify } from 'node:util'; 2 + import child_process from 'node:child_process'; 3 + 4 + export const exec = promisify(child_process.exec); 5 + export const { execSync } = child_process; 6 + 7 + import { Command, Option } from '../../src/t.js'; 8 + import { 9 + packageJsonScriptCompletion, 10 + packageJsonDependencyCompletion, 11 + } from '../completions/completion-producers.js'; 12 + import { getWorkspacePatterns } from './filesystem-utils.js'; 13 + 14 + export interface LazyCommand extends Command { 15 + _lazyCommand?: string; 16 + _optionsLoaded?: boolean; 17 + optionsRaw?: Map<string, Option>; 18 + } 19 + 20 + export type CompletionHandler = ( 21 + complete: (value: string, description: string) => void 22 + ) => void; 23 + export type OptionHandlers = Record<string, CompletionHandler>; 24 + 25 + export const commonOptionHandlers: OptionHandlers = { 26 + workspace(complete) { 27 + const patterns = getWorkspacePatterns(); 28 + patterns.forEach((p) => complete(p, `Workspace pattern: ${p}`)); 29 + }, 30 + }; 31 + 32 + export function setupLazyOptionLoading( 33 + cmd: LazyCommand, 34 + command: string, 35 + _packageManager: string, 36 + loadOptionsSync: (cmd: LazyCommand, command: string) => void 37 + ): void { 38 + cmd._lazyCommand = command; 39 + cmd._optionsLoaded = false; 40 + 41 + const store = cmd.options; 42 + cmd.optionsRaw = store; 43 + 44 + Object.defineProperty(cmd, 'options', { 45 + get() { 46 + if (!this._optionsLoaded) { 47 + this._optionsLoaded = true; 48 + loadOptionsSync(this, this._lazyCommand); 49 + } 50 + return store; 51 + }, 52 + configurable: true, 53 + }); 54 + } 55 + 56 + export function setupCommandArguments( 57 + cmd: LazyCommand, 58 + command: string, 59 + _packageManager: string 60 + ): void { 61 + if (['remove', 'rm', 'uninstall', 'un', 'update', 'up'].includes(command)) { 62 + cmd.argument('package', packageJsonDependencyCompletion); 63 + } 64 + 65 + if (['run', 'run-script'].includes(command)) { 66 + cmd.argument('script', packageJsonScriptCompletion, true); 67 + } 68 + } 69 + 70 + export async function safeExec( 71 + command: string, 72 + options: any = {} 73 + ): Promise<string> { 74 + try { 75 + const { stdout } = await exec(command, { 76 + encoding: 'utf8' as const, 77 + timeout: 500, 78 + maxBuffer: 4 * 1024 * 1024, 79 + ...options, 80 + }); 81 + return stdout as unknown as string; 82 + } catch (error) { 83 + if (error instanceof Error && 'stdout' in error) { 84 + return (error as any).stdout as string; 85 + } 86 + return ''; 87 + } 88 + } 89 + 90 + export function safeExecSync(command: string, options: any = {}): string { 91 + try { 92 + return execSync(command, { 93 + encoding: 'utf8' as const, 94 + timeout: 500, 95 + ...options, 96 + }) as unknown as string; 97 + } catch (error: any) { 98 + return error?.stdout ? (error.stdout as string) : ''; 99 + } 100 + } 101 + 102 + export function createLogLevelHandler(levels: string[]): CompletionHandler { 103 + return (complete) => levels.forEach((lvl) => complete(lvl, ' ')); 104 + }