source dump of claude code
at main 204 lines 6.2 kB view raw
1import { buildPrefix } from '../shell/specPrefix.js' 2import { splitCommand_DEPRECATED } from './commands.js' 3import { extractCommandArguments, parseCommand } from './parser.js' 4import { getCommandSpec } from './registry.js' 5 6const NUMERIC = /^\d+$/ 7const ENV_VAR = /^[A-Za-z_][A-Za-z0-9_]*=/ 8 9// Wrapper commands with complex option handling that can't be expressed in specs 10const WRAPPER_COMMANDS = new Set([ 11 'nice', // command position varies based on options 12]) 13 14const toArray = <T>(val: T | T[]): T[] => (Array.isArray(val) ? val : [val]) 15 16// Check if args[0] matches a known subcommand (disambiguates wrapper commands 17// that also have subcommands, e.g. the git spec has isCommand args for aliases). 18function isKnownSubcommand( 19 arg: string, 20 spec: { subcommands?: { name: string | string[] }[] } | null, 21): boolean { 22 if (!spec?.subcommands?.length) return false 23 return spec.subcommands.some(sub => 24 Array.isArray(sub.name) ? sub.name.includes(arg) : sub.name === arg, 25 ) 26} 27 28export async function getCommandPrefixStatic( 29 command: string, 30 recursionDepth = 0, 31 wrapperCount = 0, 32): Promise<{ commandPrefix: string | null } | null> { 33 if (wrapperCount > 2 || recursionDepth > 10) return null 34 35 const parsed = await parseCommand(command) 36 if (!parsed) return null 37 if (!parsed.commandNode) { 38 return { commandPrefix: null } 39 } 40 41 const { envVars, commandNode } = parsed 42 const cmdArgs = extractCommandArguments(commandNode) 43 44 const [cmd, ...args] = cmdArgs 45 if (!cmd) return { commandPrefix: null } 46 47 // Check if this is a wrapper command by looking at its spec 48 const spec = await getCommandSpec(cmd) 49 // Check if this is a wrapper command 50 let isWrapper = 51 WRAPPER_COMMANDS.has(cmd) || 52 (spec?.args && toArray(spec.args).some(arg => arg?.isCommand)) 53 54 // Special case: if the command has subcommands and the first arg matches a subcommand, 55 // treat it as a regular command, not a wrapper 56 if (isWrapper && args[0] && isKnownSubcommand(args[0], spec)) { 57 isWrapper = false 58 } 59 60 const prefix = isWrapper 61 ? await handleWrapper(cmd, args, recursionDepth, wrapperCount) 62 : await buildPrefix(cmd, args, spec) 63 64 if (prefix === null && recursionDepth === 0 && isWrapper) { 65 return null 66 } 67 68 const envPrefix = envVars.length ? `${envVars.join(' ')} ` : '' 69 return { commandPrefix: prefix ? envPrefix + prefix : null } 70} 71 72async function handleWrapper( 73 command: string, 74 args: string[], 75 recursionDepth: number, 76 wrapperCount: number, 77): Promise<string | null> { 78 const spec = await getCommandSpec(command) 79 80 if (spec?.args) { 81 const commandArgIndex = toArray(spec.args).findIndex(arg => arg?.isCommand) 82 83 if (commandArgIndex !== -1) { 84 const parts = [command] 85 86 for (let i = 0; i < args.length && i <= commandArgIndex; i++) { 87 if (i === commandArgIndex) { 88 const result = await getCommandPrefixStatic( 89 args.slice(i).join(' '), 90 recursionDepth + 1, 91 wrapperCount + 1, 92 ) 93 if (result?.commandPrefix) { 94 parts.push(...result.commandPrefix.split(' ')) 95 return parts.join(' ') 96 } 97 break 98 } else if ( 99 args[i] && 100 !args[i]!.startsWith('-') && 101 !ENV_VAR.test(args[i]!) 102 ) { 103 parts.push(args[i]!) 104 } 105 } 106 } 107 } 108 109 const wrapped = args.find( 110 arg => !arg.startsWith('-') && !NUMERIC.test(arg) && !ENV_VAR.test(arg), 111 ) 112 if (!wrapped) return command 113 114 const result = await getCommandPrefixStatic( 115 args.slice(args.indexOf(wrapped)).join(' '), 116 recursionDepth + 1, 117 wrapperCount + 1, 118 ) 119 120 return !result?.commandPrefix ? null : `${command} ${result.commandPrefix}` 121} 122 123/** 124 * Computes prefixes for a compound command (with && / || / ;). 125 * For single commands, returns a single-element array with the prefix. 126 * 127 * For compound commands, computes per-subcommand prefixes and collapses 128 * them: subcommands sharing a root (first word) are collapsed via 129 * word-aligned longest common prefix. 130 * 131 * @param excludeSubcommand — optional filter; return true for subcommands 132 * that should be excluded from the prefix suggestion (e.g. read-only 133 * commands that are already auto-allowed). 134 */ 135export async function getCompoundCommandPrefixesStatic( 136 command: string, 137 excludeSubcommand?: (subcommand: string) => boolean, 138): Promise<string[]> { 139 const subcommands = splitCommand_DEPRECATED(command) 140 if (subcommands.length <= 1) { 141 const result = await getCommandPrefixStatic(command) 142 return result?.commandPrefix ? [result.commandPrefix] : [] 143 } 144 145 const prefixes: string[] = [] 146 for (const subcmd of subcommands) { 147 const trimmed = subcmd.trim() 148 if (excludeSubcommand?.(trimmed)) continue 149 const result = await getCommandPrefixStatic(trimmed) 150 if (result?.commandPrefix) { 151 prefixes.push(result.commandPrefix) 152 } 153 } 154 155 if (prefixes.length === 0) return [] 156 157 // Group prefixes by their first word (root command) 158 const groups = new Map<string, string[]>() 159 for (const prefix of prefixes) { 160 const root = prefix.split(' ')[0]! 161 const group = groups.get(root) 162 if (group) { 163 group.push(prefix) 164 } else { 165 groups.set(root, [prefix]) 166 } 167 } 168 169 // Collapse each group via word-aligned LCP 170 const collapsed: string[] = [] 171 for (const [, group] of groups) { 172 collapsed.push(longestCommonPrefix(group)) 173 } 174 return collapsed 175} 176 177/** 178 * Compute the longest common prefix of strings, aligned to word boundaries. 179 * e.g. ["git fetch", "git worktree"] → "git" 180 * ["npm run test", "npm run lint"] → "npm run" 181 */ 182function longestCommonPrefix(strings: string[]): string { 183 if (strings.length === 0) return '' 184 if (strings.length === 1) return strings[0]! 185 186 const first = strings[0]! 187 const words = first.split(' ') 188 let commonWords = words.length 189 190 for (let i = 1; i < strings.length; i++) { 191 const otherWords = strings[i]!.split(' ') 192 let shared = 0 193 while ( 194 shared < commonWords && 195 shared < otherWords.length && 196 words[shared] === otherWords[shared] 197 ) { 198 shared++ 199 } 200 commonWords = shared 201 } 202 203 return words.slice(0, Math.max(1, commonWords)).join(' ') 204}