import { buildPrefix } from '../shell/specPrefix.js' import { splitCommand_DEPRECATED } from './commands.js' import { extractCommandArguments, parseCommand } from './parser.js' import { getCommandSpec } from './registry.js' const NUMERIC = /^\d+$/ const ENV_VAR = /^[A-Za-z_][A-Za-z0-9_]*=/ // Wrapper commands with complex option handling that can't be expressed in specs const WRAPPER_COMMANDS = new Set([ 'nice', // command position varies based on options ]) const toArray = (val: T | T[]): T[] => (Array.isArray(val) ? val : [val]) // Check if args[0] matches a known subcommand (disambiguates wrapper commands // that also have subcommands, e.g. the git spec has isCommand args for aliases). function isKnownSubcommand( arg: string, spec: { subcommands?: { name: string | string[] }[] } | null, ): boolean { if (!spec?.subcommands?.length) return false return spec.subcommands.some(sub => Array.isArray(sub.name) ? sub.name.includes(arg) : sub.name === arg, ) } export async function getCommandPrefixStatic( command: string, recursionDepth = 0, wrapperCount = 0, ): Promise<{ commandPrefix: string | null } | null> { if (wrapperCount > 2 || recursionDepth > 10) return null const parsed = await parseCommand(command) if (!parsed) return null if (!parsed.commandNode) { return { commandPrefix: null } } const { envVars, commandNode } = parsed const cmdArgs = extractCommandArguments(commandNode) const [cmd, ...args] = cmdArgs if (!cmd) return { commandPrefix: null } // Check if this is a wrapper command by looking at its spec const spec = await getCommandSpec(cmd) // Check if this is a wrapper command let isWrapper = WRAPPER_COMMANDS.has(cmd) || (spec?.args && toArray(spec.args).some(arg => arg?.isCommand)) // Special case: if the command has subcommands and the first arg matches a subcommand, // treat it as a regular command, not a wrapper if (isWrapper && args[0] && isKnownSubcommand(args[0], spec)) { isWrapper = false } const prefix = isWrapper ? await handleWrapper(cmd, args, recursionDepth, wrapperCount) : await buildPrefix(cmd, args, spec) if (prefix === null && recursionDepth === 0 && isWrapper) { return null } const envPrefix = envVars.length ? `${envVars.join(' ')} ` : '' return { commandPrefix: prefix ? envPrefix + prefix : null } } async function handleWrapper( command: string, args: string[], recursionDepth: number, wrapperCount: number, ): Promise { const spec = await getCommandSpec(command) if (spec?.args) { const commandArgIndex = toArray(spec.args).findIndex(arg => arg?.isCommand) if (commandArgIndex !== -1) { const parts = [command] for (let i = 0; i < args.length && i <= commandArgIndex; i++) { if (i === commandArgIndex) { const result = await getCommandPrefixStatic( args.slice(i).join(' '), recursionDepth + 1, wrapperCount + 1, ) if (result?.commandPrefix) { parts.push(...result.commandPrefix.split(' ')) return parts.join(' ') } break } else if ( args[i] && !args[i]!.startsWith('-') && !ENV_VAR.test(args[i]!) ) { parts.push(args[i]!) } } } } const wrapped = args.find( arg => !arg.startsWith('-') && !NUMERIC.test(arg) && !ENV_VAR.test(arg), ) if (!wrapped) return command const result = await getCommandPrefixStatic( args.slice(args.indexOf(wrapped)).join(' '), recursionDepth + 1, wrapperCount + 1, ) return !result?.commandPrefix ? null : `${command} ${result.commandPrefix}` } /** * Computes prefixes for a compound command (with && / || / ;). * For single commands, returns a single-element array with the prefix. * * For compound commands, computes per-subcommand prefixes and collapses * them: subcommands sharing a root (first word) are collapsed via * word-aligned longest common prefix. * * @param excludeSubcommand — optional filter; return true for subcommands * that should be excluded from the prefix suggestion (e.g. read-only * commands that are already auto-allowed). */ export async function getCompoundCommandPrefixesStatic( command: string, excludeSubcommand?: (subcommand: string) => boolean, ): Promise { const subcommands = splitCommand_DEPRECATED(command) if (subcommands.length <= 1) { const result = await getCommandPrefixStatic(command) return result?.commandPrefix ? [result.commandPrefix] : [] } const prefixes: string[] = [] for (const subcmd of subcommands) { const trimmed = subcmd.trim() if (excludeSubcommand?.(trimmed)) continue const result = await getCommandPrefixStatic(trimmed) if (result?.commandPrefix) { prefixes.push(result.commandPrefix) } } if (prefixes.length === 0) return [] // Group prefixes by their first word (root command) const groups = new Map() for (const prefix of prefixes) { const root = prefix.split(' ')[0]! const group = groups.get(root) if (group) { group.push(prefix) } else { groups.set(root, [prefix]) } } // Collapse each group via word-aligned LCP const collapsed: string[] = [] for (const [, group] of groups) { collapsed.push(longestCommonPrefix(group)) } return collapsed } /** * Compute the longest common prefix of strings, aligned to word boundaries. * e.g. ["git fetch", "git worktree"] → "git" * ["npm run test", "npm run lint"] → "npm run" */ function longestCommonPrefix(strings: string[]): string { if (strings.length === 0) return '' if (strings.length === 1) return strings[0]! const first = strings[0]! const words = first.split(' ') let commonWords = words.length for (let i = 1; i < strings.length; i++) { const otherWords = strings[i]!.split(' ') let shared = 0 while ( shared < commonWords && shared < otherWords.length && words[shared] === otherWords[shared] ) { shared++ } commonWords = shared } return words.slice(0, Math.max(1, commonWords)).join(' ') }