import { Param, Primitive } from "effect/unstable/cli" import { Spec } from "../framework/spec.ts" type FlagInfo = { readonly takesValue: boolean } type WordResult = | { readonly _tag: "Success"; readonly words: ReadonlyArray } | { readonly _tag: "Failure"; readonly reason: string } export interface PreparedArguments { readonly args: ReadonlyArray readonly warnings: ReadonlyArray } type Suppression = { readonly canonical: ReadonlyMap readonly explicit: ReadonlySet readonly partners: ReadonlyMap> } export function prepareArguments( commands: Spec.Any, args: ReadonlyArray, environment: Readonly>, globalParams: ReadonlyArray, conflicts: ReadonlyArray = [], ): PreparedArguments { if (!environment.OPENCODE_ARGS && !environment.OPENCODE_TUI_ARGS) { return { args, warnings: [] } } const warnings: string[] = [] const route = commandRoute(commands, args, globalParams) const sources: Array> = [] const knownFlags = flagsInTree(commands, globalParams) const supportedFlags = flagsFor(route, globalParams) const suppression: Suppression = { canonical: canonicalFlagsFor(route, globalParams), explicit: explicitFlagsIn(args, commands, globalParams), partners: conflictPartners(conflicts), } // TUI-scoped defaults are inactive outside the root TUI and `mini` routes. if (environment.OPENCODE_TUI_ARGS && (route === commands || route.name === "mini")) { addSource( "OPENCODE_TUI_ARGS", environment.OPENCODE_TUI_ARGS, supportedFlags, knownFlags, suppression, sources, warnings, ) } if (environment.OPENCODE_ARGS) { addSource("OPENCODE_ARGS", environment.OPENCODE_ARGS, supportedFlags, knownFlags, suppression, sources, warnings) } const defaults = sources.flatMap((source) => source) const separator = args.indexOf("--") // Explicit arguments also suppress their environment counterparts below, so // appended defaults never defeat a caller's choice even if a parser ever // resolves repeated flags by last value instead of first. const effective = separator === -1 ? [...args, ...defaults] : [...args.slice(0, separator), ...defaults, ...args.slice(separator)] return { args: effective, warnings } } function addSource( name: string, value: string, supported: ReadonlyMap, known: ReadonlyMap, suppression: Suppression, sources: Array>, warnings: string[], ) { const parsed = shellWords(value) if (parsed._tag === "Failure") { warnings.push(`${name} ignored: invalid shell-word syntax (${parsed.reason}).`) return } const filtered = filterEnvironmentWords(parsed.words, supported, known, suppression) if (filtered.ignored.length) { warnings.push(`${name} dropped unsupported arguments: ${filtered.ignored.join(", ")}.`) } if (filtered.suppressed.length) { warnings.push(`${name} dropped defaults overruled by explicit arguments: ${filtered.suppressed.join(", ")}.`) } if (filtered.words.length) sources.push(filtered.words) } function commandRoute(root: Spec.Any, args: ReadonlyArray, globals: ReadonlyArray) { let current = root let index = 0 while (index < args.length) { const token = args[index] if (token === "--") break if (token.startsWith("-") && token !== "-") { const flags = flagsFor(current, globals) if (takesNextToken(token, flags) && !token.includes("=")) index++ index++ continue } const child = [...Object.values(current.commands), ...current.aliases].find((item) => item.name === token) if (!child) break current = child index++ } return current } function flagsFor(command: Spec.Any, globals: ReadonlyArray) { return flagsFromParams([...paramsIn(command.params), ...globals]) } function flagsInTree(root: Spec.Any, globals: ReadonlyArray) { const output = flagsFromParams(globals) const seen = new Set() function visit(command: Spec.Any) { if (seen.has(command)) return seen.add(command) for (const [name, info] of flagsFromParams(paramsIn(command.params))) { const previous = output.get(name) output.set(name, { takesValue: info.takesValue || previous?.takesValue === true, }) } Object.values(command.commands).forEach(visit) command.aliases.forEach(visit) } visit(root) return output } // Resolve every accepted option spelling (long form, `--no-` negation, short // alias) to the primary `--name` flag so explicit-set detection and environment // suppression compare the same identity regardless of spelling. function canonicalFlagsFor(command: Spec.Any, globals: ReadonlyArray) { const output = new Map() for (const param of [...paramsIn(command.params), ...globals]) { for (const single of singlesIn(param)) { if (single.kind !== Param.flagKind) continue const primary = `--${single.name}` const takesValue = single.primitiveType !== Primitive.boolean output.set(primary, primary) if (!takesValue) output.set(`--no-${single.name}`, primary) for (const alias of single.aliases) { output.set(alias.length === 1 ? `-${alias}` : `--${alias}`, primary) } } } return output } // Collect flags the caller set explicitly, resolving aliases and negations to // primary names. Only tokens before `--` count; trailing operands are data. function explicitFlagsIn( args: ReadonlyArray, root: Spec.Any, globals: ReadonlyArray, ): ReadonlySet { const explicit = new Set() let current = root let canonical: ReadonlyMap | undefined let index = 0 while (index < args.length) { const token = args[index] if (token === "--") break if (token.startsWith("-") && token !== "-") { canonical ??= canonicalFlagsFor(current, globals) const flags = flagsFor(current, globals) const equal = token.indexOf("=") if (token.startsWith("--")) { const option = equal === -1 ? token : token.slice(0, equal) const info = flags.get(option) if (info) { const primary = canonical.get(option) if (primary) explicit.add(primary) if (equal === -1 && info.takesValue) index++ } index++ continue } const cluster = token.slice(1, equal === -1 ? undefined : equal) const characters = [...cluster] for (let position = 0; position < characters.length; position++) { const option = `-${characters[position]}` const info = flags.get(option) if (!info) continue const primary = canonical.get(option) if (primary) explicit.add(primary) if (info.takesValue) { if (position === characters.length - 1) index++ break } } index++ continue } const child = [...Object.values(current.commands), ...current.aliases].find((item) => item.name === token) if (!child) break current = child canonical = undefined index++ } return explicit } function conflictPartners(conflicts: ReadonlyArray) { const partners = new Map>() function link(left: string, right: string) { let set = partners.get(left) if (!set) { set = new Set() partners.set(left, set) } set.add(right) } for (const [left, right] of conflicts) { link(`--${left}`, `--${right}`) link(`--${right}`, `--${left}`) } return partners } // Returns "explicit" when the flag itself was set, a partner flag name when a // declared conflict was set, or undefined when the default may stand. function suppressionReason( primary: string, explicit: ReadonlySet, partners: ReadonlyMap>, ) { if (explicit.has(primary)) return "explicit" for (const partner of partners.get(primary) ?? []) { if (explicit.has(partner)) return partner } return undefined } function paramsIn(config: unknown): Array { if (Param.isParam(config)) return [config] if (Array.isArray(config)) return config.flatMap(paramsIn) if (config && typeof config === "object") return Object.values(config).flatMap(paramsIn) return [] } function flagsFromParams(params: ReadonlyArray) { const output = new Map() for (const param of params) { for (const single of singlesIn(param)) { if (single.kind !== Param.flagKind) continue const info = { takesValue: single.primitiveType !== Primitive.boolean } output.set(`--${single.name}`, info) if (!info.takesValue) output.set(`--no-${single.name}`, info) for (const alias of single.aliases) { output.set(alias.length === 1 ? `-${alias}` : `--${alias}`, info) } } } return output } function singlesIn(param: Param.Any): Array> { if (Param.isSingle(param)) return [param] if (param._tag === "Map") return singlesIn((param as Param.Map).param) if (param._tag === "Transform") { const transform = param as Param.Transform return [...singlesIn(transform.param), ...transform.alternatives.flatMap((alternative) => singlesIn(alternative()))] } if (param._tag === "Optional") return singlesIn((param as Param.Optional).param) if (param._tag === "Variadic") return singlesIn((param as Param.Variadic).param) return [] } function takesNextToken(token: string, flags: ReadonlyMap) { if (token.startsWith("--")) return flags.get(token.split("=", 1)[0])?.takesValue === true const cluster = token.slice(1).split("=", 1)[0] return [...cluster].some((flag) => flags.get(`-${flag}`)?.takesValue === true) } function filterEnvironmentWords( words: ReadonlyArray, supported: ReadonlyMap, known: ReadonlyMap, suppression: Suppression, ) { const kept: string[] = [] const ignored = new Set() const suppressedSet = new Set() for (let index = 0; index < words.length; index++) { const word = words[index] if (word === "--") { ignored.add("--") break } if (word.startsWith("--")) { const equal = word.indexOf("=") const option = equal === -1 ? word : word.slice(0, equal) const accepted = supported.get(option) if (!accepted) { ignored.add(option) if (equal === -1 && known.get(option)?.takesValue && words[index + 1] && !words[index + 1].startsWith("-")) { index++ } continue } const primary = suppression.canonical.get(option) ?? option const reason = suppressionReason(primary, suppression.explicit, suppression.partners) if (reason !== undefined) { suppressedSet.add(reason === "explicit" ? option : `${option} (explicit ${reason})`) if (equal === -1 && accepted.takesValue && words[index + 1] !== undefined) index++ continue } if (equal === -1 && accepted.takesValue && words[index + 1] === undefined) { ignored.add(`${option} (missing value)`) continue } kept.push(word) if (equal === -1 && accepted.takesValue && words[index + 1] !== undefined) kept.push(words[++index]) continue } if (word.startsWith("-") && word !== "-") { const equal = word.indexOf("=") const cluster = word.slice(1, equal === -1 ? undefined : equal) if (cluster.length === 1) { const option = `-${cluster}` const accepted = supported.get(option) if (!accepted) { ignored.add(option) if (equal === -1 && known.get(option)?.takesValue && words[index + 1] && !words[index + 1].startsWith("-")) { index++ } continue } const primary = suppression.canonical.get(option) ?? option const reason = suppressionReason(primary, suppression.explicit, suppression.partners) if (reason !== undefined) { suppressedSet.add(reason === "explicit" ? option : `${option} (explicit ${reason})`) if (equal === -1 && accepted.takesValue && words[index + 1] !== undefined) index++ continue } if (equal === -1 && accepted.takesValue && words[index + 1] === undefined) { ignored.add(`${option} (missing value)`) continue } kept.push(word) if (equal === -1 && accepted.takesValue && words[index + 1] !== undefined) kept.push(words[++index]) continue } const optionNames = [...cluster].map((flag) => `-${flag}`) const infos = optionNames.map((option) => supported.get(option)) if (infos.every((info) => info && !info.takesValue)) { let reason: string | undefined for (const option of optionNames) { const primary = suppression.canonical.get(option) ?? option reason = suppressionReason(primary, suppression.explicit, suppression.partners) if (reason !== undefined) break } if (reason !== undefined) { const clusterWord = word.slice(0, equal === -1 ? undefined : equal) suppressedSet.add(reason === "explicit" ? clusterWord : `${clusterWord} (explicit ${reason})`) continue } kept.push(word) continue } ignored.add(word.slice(0, equal === -1 ? undefined : equal)) if ( equal === -1 && optionNames.some((option) => known.get(option)?.takesValue) && words[index + 1] && !words[index + 1].startsWith("-") ) { index++ } continue } ignored.add("") } return { words: kept, ignored: [...ignored], suppressed: [...suppressedSet] } } function shellWords(input: string): WordResult { const words: string[] = [] let word = "" let quote: "'" | '"' | undefined let escaped = false let started = false for (const character of input) { if (escaped) { word += character escaped = false started = true continue } if (quote === "'") { if (character === "'") quote = undefined else word += character started = true continue } if (character === "\\") { escaped = true started = true continue } if (quote === '"') { if (character === '"') quote = undefined else word += character started = true continue } if (character === "'" || character === '"') { quote = character started = true continue } if (/\s/.test(character)) { if (started) words.push(word) word = "" started = false continue } word += character started = true } if (escaped) return { _tag: "Failure", reason: "trailing backslash" } if (quote) return { _tag: "Failure", reason: `unterminated ${quote === "'" ? "single" : "double"} quote`, } if (started) words.push(word) return { _tag: "Success", words } }