source dump of claude code
at main 241 lines 7.9 kB view raw
1/** 2 * Fig-spec-driven command prefix extraction. 3 * 4 * Given a command name + args array + its @withfig/autocomplete spec, walks 5 * the spec to find how deep into the args a meaningful prefix extends. 6 * `git -C /repo status --short` → `git status` (spec says -C takes a value, 7 * skip it, find `status` as a known subcommand). 8 * 9 * Pure over (string, string[], CommandSpec) — no parser dependency. Extracted 10 * from src/utils/bash/prefix.ts so PowerShell's extractor can reuse it; 11 * external CLIs (git, npm, kubectl) are shell-agnostic. 12 */ 13 14import type { CommandSpec } from '../bash/registry.js' 15 16const URL_PROTOCOLS = ['http://', 'https://', 'ftp://'] 17 18// Overrides for commands whose fig specs aren't available at runtime 19// (dynamic imports don't work in native/node builds). Without these, 20// calculateDepth falls back to 2, producing overly broad prefixes. 21export const DEPTH_RULES: Record<string, number> = { 22 rg: 2, // pattern argument is required despite variadic paths 23 'pre-commit': 2, 24 // CLI tools with deep subcommand trees (e.g. gcloud scheduler jobs list) 25 gcloud: 4, 26 'gcloud compute': 6, 27 'gcloud beta': 6, 28 aws: 4, 29 az: 4, 30 kubectl: 3, 31 docker: 3, 32 dotnet: 3, 33 'git push': 2, 34} 35 36const toArray = <T>(val: T | T[]): T[] => (Array.isArray(val) ? val : [val]) 37 38// Check if an argument matches a known subcommand (case-insensitive: PS 39// callers pass original-cased args; fig spec names are lowercase) 40function isKnownSubcommand(arg: string, spec: CommandSpec | null): boolean { 41 if (!spec?.subcommands?.length) return false 42 const argLower = arg.toLowerCase() 43 return spec.subcommands.some(sub => 44 Array.isArray(sub.name) 45 ? sub.name.some(n => n.toLowerCase() === argLower) 46 : sub.name.toLowerCase() === argLower, 47 ) 48} 49 50// Check if a flag takes an argument based on spec, or use heuristic 51function flagTakesArg( 52 flag: string, 53 nextArg: string | undefined, 54 spec: CommandSpec | null, 55): boolean { 56 // Check if flag is in spec.options 57 if (spec?.options) { 58 const option = spec.options.find(opt => 59 Array.isArray(opt.name) ? opt.name.includes(flag) : opt.name === flag, 60 ) 61 if (option) return !!option.args 62 } 63 // Heuristic: if next arg isn't a flag and isn't a known subcommand, assume it's a flag value 64 if (spec?.subcommands?.length && nextArg && !nextArg.startsWith('-')) { 65 return !isKnownSubcommand(nextArg, spec) 66 } 67 return false 68} 69 70// Find the first subcommand by skipping flags and their values 71function findFirstSubcommand( 72 args: string[], 73 spec: CommandSpec | null, 74): string | undefined { 75 for (let i = 0; i < args.length; i++) { 76 const arg = args[i] 77 if (!arg) continue 78 if (arg.startsWith('-')) { 79 if (flagTakesArg(arg, args[i + 1], spec)) i++ 80 continue 81 } 82 if (!spec?.subcommands?.length) return arg 83 if (isKnownSubcommand(arg, spec)) return arg 84 } 85 return undefined 86} 87 88export async function buildPrefix( 89 command: string, 90 args: string[], 91 spec: CommandSpec | null, 92): Promise<string> { 93 const maxDepth = await calculateDepth(command, args, spec) 94 const parts = [command] 95 const hasSubcommands = !!spec?.subcommands?.length 96 let foundSubcommand = false 97 98 for (let i = 0; i < args.length; i++) { 99 const arg = args[i] 100 if (!arg || parts.length >= maxDepth) break 101 102 if (arg.startsWith('-')) { 103 // Special case: python -c should stop after -c 104 if (arg === '-c' && ['python', 'python3'].includes(command.toLowerCase())) 105 break 106 107 // Check for isCommand/isModule flags that should be included in prefix 108 if (spec?.options) { 109 const option = spec.options.find(opt => 110 Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg, 111 ) 112 if ( 113 option?.args && 114 toArray(option.args).some(a => a?.isCommand || a?.isModule) 115 ) { 116 parts.push(arg) 117 continue 118 } 119 } 120 121 // For commands with subcommands, skip global flags to find the subcommand 122 if (hasSubcommands && !foundSubcommand) { 123 if (flagTakesArg(arg, args[i + 1], spec)) i++ 124 continue 125 } 126 break // Stop at flags (original behavior) 127 } 128 129 if (await shouldStopAtArg(arg, args.slice(0, i), spec)) break 130 if (hasSubcommands && !foundSubcommand) { 131 foundSubcommand = isKnownSubcommand(arg, spec) 132 } 133 parts.push(arg) 134 } 135 136 return parts.join(' ') 137} 138 139async function calculateDepth( 140 command: string, 141 args: string[], 142 spec: CommandSpec | null, 143): Promise<number> { 144 // Find first subcommand by skipping flags and their values 145 const firstSubcommand = findFirstSubcommand(args, spec) 146 const commandLower = command.toLowerCase() 147 const key = firstSubcommand 148 ? `${commandLower} ${firstSubcommand.toLowerCase()}` 149 : commandLower 150 if (DEPTH_RULES[key]) return DEPTH_RULES[key] 151 if (DEPTH_RULES[commandLower]) return DEPTH_RULES[commandLower] 152 if (!spec) return 2 153 154 if (spec.options && args.some(arg => arg?.startsWith('-'))) { 155 for (const arg of args) { 156 if (!arg?.startsWith('-')) continue 157 const option = spec.options.find(opt => 158 Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg, 159 ) 160 if ( 161 option?.args && 162 toArray(option.args).some(arg => arg?.isCommand || arg?.isModule) 163 ) 164 return 3 165 } 166 } 167 168 // Find subcommand spec using the already-found firstSubcommand 169 if (firstSubcommand && spec.subcommands?.length) { 170 const firstSubLower = firstSubcommand.toLowerCase() 171 const subcommand = spec.subcommands.find(sub => 172 Array.isArray(sub.name) 173 ? sub.name.some(n => n.toLowerCase() === firstSubLower) 174 : sub.name.toLowerCase() === firstSubLower, 175 ) 176 if (subcommand) { 177 if (subcommand.args) { 178 const subArgs = toArray(subcommand.args) 179 if (subArgs.some(arg => arg?.isCommand)) return 3 180 if (subArgs.some(arg => arg?.isVariadic)) return 2 181 } 182 if (subcommand.subcommands?.length) return 4 183 // Leaf subcommand with NO args declared (git show, git log, git tag): 184 // the 3rd word is transient (SHA, ref, tag name) → dead over-specific 185 // rule like PowerShell(git show 81210f8:*). NOT the isOptional case — 186 // `git fetch` declares optional remote/branch and `git fetch origin` 187 // is tested (bash/prefix.test.ts:912) as intentional remote scoping. 188 if (!subcommand.args) return 2 189 return 3 190 } 191 } 192 193 if (spec.args) { 194 const argsArray = toArray(spec.args) 195 196 if (argsArray.some(arg => arg?.isCommand)) { 197 return !Array.isArray(spec.args) && spec.args.isCommand 198 ? 2 199 : Math.min(2 + argsArray.findIndex(arg => arg?.isCommand), 3) 200 } 201 202 if (!spec.subcommands?.length) { 203 if (argsArray.some(arg => arg?.isVariadic)) return 1 204 if (argsArray[0] && !argsArray[0].isOptional) return 2 205 } 206 } 207 208 return spec.args && toArray(spec.args).some(arg => arg?.isDangerous) ? 3 : 2 209} 210 211async function shouldStopAtArg( 212 arg: string, 213 args: string[], 214 spec: CommandSpec | null, 215): Promise<boolean> { 216 if (arg.startsWith('-')) return true 217 218 const dotIndex = arg.lastIndexOf('.') 219 const hasExtension = 220 dotIndex > 0 && 221 dotIndex < arg.length - 1 && 222 !arg.substring(dotIndex + 1).includes(':') 223 224 const hasFile = arg.includes('/') || hasExtension 225 const hasUrl = URL_PROTOCOLS.some(proto => arg.startsWith(proto)) 226 227 if (!hasFile && !hasUrl) return false 228 229 // Check if we're after a -m flag for python modules 230 if (spec?.options && args.length > 0 && args[args.length - 1] === '-m') { 231 const option = spec.options.find(opt => 232 Array.isArray(opt.name) ? opt.name.includes('-m') : opt.name === '-m', 233 ) 234 if (option?.args && toArray(option.args).some(arg => arg?.isModule)) { 235 return false // Don't stop at module names 236 } 237 } 238 239 // For actual files/URLs, always stop regardless of context 240 return true 241}