UNPKG

@struktoai/mirage-core

Version:

A Unified Virtual Filesystem For AI Agents

282 lines • 13.7 kB
// ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // ========= Copyright 2026 @ Strukto.AI All Rights Reserved. ========= import { compileSpec } from "../../../commands/spec/compile.js"; import { flagKwargName } from "../../../commands/spec/constants.js"; import { parseCommand, parseToKwargs } from "../../../commands/spec/parser.js"; import { ambiguousOptionError, invalidArgumentError, invalidFloatError, invalidIntError, missingRequiredError, missingValueError, oldOptionError, unexpectedValueError, unknownOptionError, } from "../../../commands/spec/usage.js"; import { PathSpec } from "../../../types.js"; import { dottedSpelling, resolvePath } from "../../../utils/path.js"; import { rstripSlash } from "../../../utils/slash.js"; // Single-mount dispatch and cross-mount dispatch both parse through here, // so flags, texts, and parser warnings cannot drift between the two paths // (a cross-mount `grep --bogus` used to lose its warning). The spec comes // from the owning mount on the single-mount path and the shared SPECS // registry on the cross-mount path. /** * A PathSpec for a path the classifier never saw: a relative value * cwd-resolved by `parseCommand`, or a spec-classified PATH operand the * upstream classifier left as text. `vfsPath` stays empty on * purpose: the mount stamps the backend key on every path at execute * time (`Mount.runCommand`), so a parse-time stamp is dead weight — * proven in both languages by running the full suite with this field * set to a sentinel. The empty name, which only an attached value can * spell (`--file=`), names nothing, however it resolved: its walk answers * ENOENT, as a typed '' operand's does. Mirrors `synthesize_path_spec` in * the Python executor. */ function synthesizePathSpec(value, rawPath = value, cwd = '/') { const slash = value.lastIndexOf('/'); return new PathSpec({ vfsPath: '', virtual: value, rawPath, directory: slash >= 0 ? value.slice(0, slash + 1) : '/', resolved: true, dotted: resolvePath(rawPath, cwd) === value ? dottedSpelling(rawPath, cwd) : null, walkError: rawPath === '' ? 'ENOENT' : null, }); } /** * The next classified word spelling `value`, in argv order. Two words can * resolve to one path (`ls -d dir/ link/` with link -> dir, `tar -C dir .`), * each with its own spelling, so every consumer takes the next word for * its path off a queue rather than reading a lookup keyed by the path * alone, which handed them all the last spelling; the parser hands * positionals back in argv order, the guarantee argparse gives too. A path * no word spells (one the parser normalized, a followed link whose target * climbs through `..`) falls back to the map, which still serves a word * the classifier left as text, and is synthesized after that, since a * keyed backend cannot read `b/../a`. Mirrors `take_spelling`. */ function takeSpelling(spellings, scopeMap, value, rawPath, cwd = '/') { const taken = spellings.get(rstripSlash(value) || '/')?.shift(); if (taken !== undefined) return taken; return scopeMap.get(value) ?? synthesizePathSpec(value, rawPath, cwd); } export function parseFlags(parts, spec, cmdName, cwd, // The session environment, so an option declaring one gets its value // from there. Filled inside the parse rather than after it, or an // env-supplied int would go unchecked and an env-supplied path would // stay a bare string. env, // Whether another parser reads this line after mirage, passed straight to // parseCommand. True only for an installed CLI's node, whose spec is // deliberately partial. unknownIsOperand = false, // The program's own long-option table, when it resolves abbreviations // against it (git's parse-options), passed straight to parseCommand. abbreviations) { const argv = parts.map((item) => item instanceof PathSpec ? (item.rawPath === '-' ? '-' : item.virtual) : item); const scopeMap = new Map(); for (const item of parts) { if (item instanceof PathSpec) { scopeMap.set(item.virtual, item); const stripped = rstripSlash(item.virtual); if (stripped !== '' && stripped !== item.virtual) scopeMap.set(stripped, item); } } const spellings = new Map(); for (const item of parts) { if (item instanceof PathSpec) { const key = rstripSlash(item.virtual) || '/'; const queue = spellings.get(key); if (queue === undefined) spellings.set(key, [item]); else queue.push(item); } } if (spec !== null) { const parsed = parseCommand(spec, argv, cwd, cmdName, env, unknownIsOperand, abbreviations); // Widens from ParsedFlagValue to FlagValue: PATH values become PathSpec // just below. const flagKwargs = parseToKwargs(parsed); // Recover PathSpec for PATH flag values, each the word that spelled it, // so an error line names the path as typed; a relative value // cwd-resolved by parseCommand (csplit -f part -> /data/part) is // absent from scopeMap and is synthesized like a positional. A pair // option's list alternates name, value; only the values are paths (jq // --rawfile body /d/f.txt). An option's value is read before the // operands, which is POSIX order and the order -C requires (its value // moves the operands after it), so `tar -cf out.tar -C dir .` hands // `dir` to -C and `.` to the operand. A permuted line spelling one // path twice, once as an option's value typed after the operand, swaps // the two spellings and nothing else. Mirrors Python's parse_flags. const pathOptions = new Map(compileSpec(spec) .options.filter((opt) => opt.type === 'path') .flatMap((opt) => opt.names.map((name) => [flagKwargName(name), opt]))); for (const [key, value] of Object.entries(flagKwargs)) { const option = pathOptions.get(key); if (option === undefined) continue; const raw = parsed.rawPathFlags[key]; const rawParts = Array.isArray(raw) ? raw : []; if (Array.isArray(value)) { const kinds = option.valueTypes; flagKwargs[key] = value.map((part, index) => typeof part === 'string' && (kinds.length === 0 || kinds[index % kinds.length] === 'path') ? takeSpelling(spellings, scopeMap, part, rawParts[index], cwd) : part); } else if (typeof value === 'string') { // A store default or environment fallback stays scalar, // even when typed values use fixed nargs lists. flagKwargs[key] = takeSpelling(spellings, scopeMap, value, typeof raw === 'string' ? raw : undefined, cwd); } } // Every value still a string is text, with a classified word's path in // place of its spelling. for (const [key, value] of Object.entries(flagKwargs)) { if (typeof value === 'string') { const match = scopeMap.get(value); if (match !== undefined) flagKwargs[key] = match.virtual; } } // Classify positional args: each operand takes its own word. The // spelling rides along for a word the classifier left as text (an // interpreter's bare script name under the shell's word policy), so // the handler still sees it as typed: CPython puts the operand // itself in argv[0]. const paths = []; const texts = []; parsed.args.forEach(([value, kind], index) => { if (kind === 'path') { paths.push(takeSpelling(spellings, scopeMap, value, parsed.rawOperands[index]?.[0], cwd)); } else { texts.push(value); } }); return { paths, texts, flagKwargs, warnings: parsed.warnings, invalidOptions: parsed.invalidOptions, ambiguousOptions: parsed.ambiguousOptions, optionErrorKinds: parsed.optionErrorKinds, needsValueOptions: parsed.needsValueOptions, invalidValueOptions: parsed.invalidValueOptions, ambiguousValueOptions: parsed.ambiguousValueOptions, invalidIntOptions: parsed.invalidIntOptions, invalidFloatOptions: parsed.invalidFloatOptions, missingRequiredOptions: parsed.missingRequiredOptions, oldOptionNeedsValue: parsed.oldOptionNeedsValue, missingRequiredOperands: parsed.missingRequiredOperands, typedDests: parsed.typedDests, builtin: parsed.builtin, }; } const paths = []; const texts = []; for (const item of parts) { if (item instanceof PathSpec) paths.push(item); else texts.push(item); } return { paths, texts, flagKwargs: {}, warnings: [], invalidOptions: [], ambiguousOptions: [], optionErrorKinds: [], needsValueOptions: [], invalidValueOptions: [], ambiguousValueOptions: [], invalidIntOptions: [], invalidFloatOptions: [], missingRequiredOptions: [], oldOptionNeedsValue: null, missingRequiredOperands: [], typedDests: [], builtin: false, }; } // GNU-shaped refusal for option errors the parser reported. The builtin // find is exempt: its expression tokens are validated by // parseFindExpression, which raises the GNU predicate error itself. A // command registered under a builtin's name is refused like any other // custom command, so every renderer takes the parse's `builtin` bit. // Takes the whole ParsedCommand, mirroring Python's // `option_error(cmd_name, parsed)`. export function optionError(cmdName, parsed) { const builtin = parsed.builtin; if (builtin && cmdName === 'find') return null; // An old-style cluster short of an argument outranks every scan error // below: tar counts the cluster's needs before argp validates a letter, // so `tar Qf` and `tar fQ` both name f, not Q. if (parsed.oldOptionNeedsValue !== null) { return oldOptionError(cmdName, parsed.oldOptionNeedsValue, builtin); } // The first refusal on the line, whichever check made it: GNU stops at // the first offending token, so `grep --c --bogus` reports the // ambiguity, the reversed line reports --bogus, and `numfmt --from=bad // --bogus` reports the value. The kinds tape holds one tag per refusal // in scan order and each list is in scan order too, so the first tag's // detail is the head of that tag's list. "invalid" and // "unexpected_value" share invalidOptions: a boolean long handed a value // is not an unrecognized option, and getopt_long words it differently // (`grep --byte-offset=2`). for (const kind of parsed.optionErrorKinds) { if (kind === 'ambiguous') { const ambiguous = parsed.ambiguousOptions[0]; if (ambiguous !== undefined) return ambiguousOptionError(cmdName, ...ambiguous, builtin); } else if (kind === 'unexpected_value') { return unexpectedValueError(cmdName, parsed.invalidOptions[0] ?? '', builtin); } else if (kind === 'invalid') { return unknownOptionError(cmdName, parsed.invalidOptions[0] ?? '', builtin); } else if (kind === 'needs_value') { return missingValueError(cmdName, parsed.needsValueOptions[0] ?? '', builtin); } else if (kind === 'int') { const badInt = parsed.invalidIntOptions[0]; if (badInt !== undefined) return invalidIntError(cmdName, ...badInt, builtin); } else if (kind === 'float') { const badFloat = parsed.invalidFloatOptions[0]; if (badFloat !== undefined) return invalidFloatError(cmdName, ...badFloat, builtin); } else if (kind === 'value') { const badValue = parsed.invalidValueOptions[0]; if (badValue !== undefined) { return invalidArgumentError(cmdName, ...badValue, undefined, 'invalid', builtin); } } else if (kind === 'ambiguous_value') { // GNU's other wording for the same refusal, reached only by a // word-choice table: the value is a prefix of two candidates or more. const badValue = parsed.ambiguousValueOptions[0]; if (badValue !== undefined) { return invalidArgumentError(cmdName, ...badValue, undefined, 'ambiguous', builtin); } } } if (parsed.missingRequiredOptions.length > 0) { return missingRequiredError(cmdName, parsed.missingRequiredOptions[0] ?? '', builtin); } return null; } //# sourceMappingURL=flags.js.map