@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
464 lines • 21.9 kB
JavaScript
// ========= 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 { UsageError } from "../errors.js";
import { quoteText } from "../quote.js";
import { ERRNO_ARMS } from "../../errors/constants.js";
import { posixPhrase } from "../../errors/posix.js";
import { IN_ORDER_OPERANDS, NOT_GETOPT_LONG, OLD_OPTION_EXIT, OPERAND_EXIT, PYTHON_NAMES, PYTHON_USAGE, READ_FAIL_EXIT, READ_FAIL_EXIT_ISDIR, RG_FLAG_NAMES, USAGE_EXIT, USAGE_HINT_PREFIX, ZGREP_UNSUPPORTED_LONG, ZGREP_UNSUPPORTED_SHORT, } from "./constants.js";
import { CommandName } from "./types.js";
import { decodeText, encodeText } from "../../shell/bytes.js";
/**
* GNU usage-error exit code for a command.
*
* `builtin` says whether the line was parsed against the builtin's own
* grammar. The per-program voices and codes describe the real program, so a
* command registered under its name answers like any other custom command.
*/
export function usageExitCode(cmdName, builtin = true) {
return builtin ? (USAGE_EXIT[cmdName] ?? 1) : 1;
}
/** Exit code of a command refused on one operand before it ran. */
export function operandExitCode(cmdName) {
return OPERAND_EXIT[cmdName] ?? 1;
}
/**
* The exit code for a command that could not read an operand.
*
* Read off the command, not off the errno, because that is how GNU's own
* codes fall; the errno is consulted only for the four commands that do
* answer a directory and a missing file differently. Mirrors the python
* `read_fail_exit`.
*
* Gated on READ_FAIL_ERRORS, and nothing wider: the tables are keyed by
* command and the executor's chokepoints catch everything a command can
* throw, so a loose gate makes them answer in the wrong voice. Two cases
* set the width. A bad script is not a filesystem error at all (`sed
* 's/o/O/0'` is exit 1, not sed's 2). And EACCES is as often a WRITE
* refusal as a read one (`sed -i` on a read-only backend is exit 1, not
* 4), which the chokepoint cannot tell apart. EACCES on a genuine read is
* the one case this leaves at 1 where GNU would answer the command's
* code; that is the safe side, and it is what the executor already did
* before the tables existed.
*/
const READ_FAIL_ERRORS = new Set([
'ENOENT',
'EISDIR',
'ENOTDIR',
'EFBIG',
'ELOOP',
]);
function readFailCode(cmdName, isDir) {
if (isDir) {
const isdir = READ_FAIL_EXIT_ISDIR[cmdName];
if (isdir !== undefined)
return isdir;
}
return READ_FAIL_EXIT[cmdName] ?? 1;
}
export function readFailExitCode(cmdName, err) {
const code = err.code;
const condition = code === undefined ? undefined : ERRNO_ARMS[code];
if (condition === undefined || !READ_FAIL_ERRORS.has(condition))
return 1;
return readFailCode(cmdName, condition === 'EISDIR');
}
/**
* The code one rendered stderr line's terminal errno asks for.
*
* Read off the LAST field, not searched for anywhere in the line: the
* renderer writes `<cmd>: <path>: <strerror>` and a path is free to spell
* a strerror itself, so a directory named `No such file or directory` read
* as ENOENT under a global scan and sed answered 2 where GNU answers 4.
* Null when the terminal field is not a strerror this family knows, which
* is what a line that is not a failed read looks like.
*/
function lineReadFailCode(cmdName, line) {
const cut = line.lastIndexOf(': ');
const terminal = cut === -1 ? line : line.slice(cut + 2);
for (const code of READ_FAIL_ERRORS) {
if (posixPhrase(code) === terminal)
return readFailCode(cmdName, code === 'EISDIR');
}
return null;
}
/**
* The same code, for a read failure known only as a rendered line.
*
* The cross-mount stream path fetches each operand with a native `cat`
* sub-run, so a failed operand arrives as cat's rendered stderr rather
* than as an error. That line is already respelled into the real
* command's voice, and the exit code has to follow it or `sort a
* /other/missing` answers 1 while `sort missing` answers 2, a split GNU
* does not have. Classified against the very strerrors the renderer
* wrote, so the forward and backward directions cannot drift; a blob that
* carries no failed-read line keeps the catch-all 1.
*
* One fetch can render several lines, because one operand can be a glob
* the owning mount expanded, and the most severe code is the answer: sed
* is the only stream command whose code depends on the errno, and its rule
* is the most severe (4 beats 2), which is also how the caller folds one
* operand's code into the next.
*
* Mirrors the python `read_fail_exit_line`.
*/
export function readFailExitCodeFromLine(cmdName, rendered) {
let code = 0;
for (const line of rendered.split('\n')) {
const one = lineReadFailCode(cmdName, line);
if (one !== null)
code = Math.max(code, one);
}
return code || 1;
}
/**
* GNU-shaped error for an option the spec does not declare.
*
* Shapes pinned against real GNU: long options report the full token
* (`cat: unrecognized option '--bogus=x'`), short options report the
* offending character (`cat: invalid option -- 'Y'`), and find uses its
* predicate wording with backquote quoting. GNU's per-tool usage dumps
* are deliberately omitted; the `--help` hint line is kept because every
* registered command serves `--help`.
*/
function pythonOptionError(cmdName, line) {
return [encodeText(line + PYTHON_USAGE.replace('{name}', cmdName)), usageExitCode(cmdName)];
}
/**
* curl's option refusal: one message line, then its own help hint.
*
* Pinned on curl 8.14.1 (debian:stable-slim). One divergence: curl names
* a whole cluster with a bad letter (`option -sW: is unknown`) where the
* parser reports the letter, so mirage says `option -W`.
*/
export function curlOptionError(line) {
const hint = "curl: try 'curl --help' or 'curl --manual' for more information\n";
return [encodeText(line + hint), usageExitCode('curl')];
}
export function unknownOptionError(cmdName, token, builtin = true) {
if (builtin && cmdName === 'curl') {
const dashed = token.startsWith('-') ? token : `-${token}`;
return curlOptionError(`curl: option ${dashed}: is unknown\n`);
}
if (builtin && cmdName === CommandName.FIND) {
const dashed = token.startsWith('-') ? token : `-${token}`;
return [encodeText(`find: unknown predicate \`${dashed}'\n`), usageExitCode(cmdName)];
}
if (builtin && cmdName === 'rg')
return rgUnknownFlag(token);
if (builtin && cmdName === 'zgrep' && zgrepUnsupported(token)) {
const dashed = token.startsWith('-') ? token : `-${token}`;
return [encodeText(`zgrep: ${dashed}: option not supported\n`), usageExitCode(cmdName)];
}
if (builtin && PYTHON_NAMES.has(cmdName)) {
// CPython names the whole typed token, long or short (pinned on 3.14.7;
// 3.12 still spelled a long one `unknown option`).
const dashed = token.startsWith('-') ? token : `-${token}`;
return pythonOptionError(cmdName, `Unknown option: ${dashed}\n`);
}
const line = token.startsWith('--')
? `${cmdName}: unrecognized option '${token}'\n`
: `${cmdName}: invalid option -- '${token}'\n`;
const hint = usageHint(cmdName, builtin) + '\n';
return [encodeText(line + hint), usageExitCode(cmdName, builtin)];
}
/** Whether zgrep refuses a grep option itself (gzip 1.13). */
export function zgrepUnsupported(token) {
if (!token.startsWith('--'))
return ZGREP_UNSUPPORTED_SHORT.has(token);
const name = token.slice(2).split('=', 1)[0] ?? '';
for (const [full, shortest] of ZGREP_UNSUPPORTED_LONG) {
if (name.length >= shortest && full.startsWith(name))
return true;
}
return false;
}
// ripgrep's `find_similar_names` threshold: the share of 3-grams a flag name
// must have in common with an unknown one to be suggested.
const RG_SUGGEST_THRESHOLD = 0.4;
/**
* ripgrep's refusal of a flag it does not have (14.1.1): the flag as typed
* without its value, then the similar flags its own table holds, if any:
* `rg --colo` suggests `--color, --colors`, and `rg --pcr` suggests nothing.
* No usage hint follows.
*/
export function rgUnknownFlag(token) {
const dashed = token.startsWith('-') ? token : `-${token}`;
const name = dashed.split('=', 1)[0] ?? dashed;
let line = `rg: unrecognized flag ${name}\n`;
if (name.startsWith('--')) {
const similar = similarRgFlags(name.slice(2)).filter((n) => n !== name.slice(2));
if (similar.length > 0) {
line += `\nsimilar flags that are available: ${similar.map((n) => `--${n}`).join(', ')}\n`;
}
}
return [encodeText(line), usageExitCode('rg')];
}
// ripgrep's `find_similar_names`: its flags whose 3-grams overlap enough.
export function similarRgFlags(unrecognized) {
const given = trigrams(unrecognized);
return RG_FLAG_NAMES.filter((name) => {
const grams = trigrams(name);
let shared = 0;
for (const gram of given)
if (grams.has(gram))
shared += 1;
return shared / (given.size + grams.size - shared) >= RG_SUGGEST_THRESHOLD;
});
}
// The 3-grams of a flag name, padded with `!` when shorter.
function trigrams(name) {
if (name.length < 3)
return new Set([(name + '!!!').slice(0, 3)]);
const out = new Set();
for (let i = 0; i + 3 <= name.length; i++)
out.add(name.slice(i, i + 3));
return out;
}
/**
* getopt_long refusal for a BOOLEAN long option handed a value.
*
* `grep --byte-offset=2` is not an unrecognized option -- getopt_long
* recognized it perfectly well and refused the `=2`, so the message names the
* option and drops the value, where the unrecognized-option message quotes the
* whole token including it. It also names the CANONICAL spelling, not the one
* that was typed: `grep --byte=2` answers for `--byte-offset`. Shape pinned
* against GNU grep 3.11 and coreutils 9.4 (`grep --byte-offset=2`,
* `grep --line-buffered=2`, `nl --help=2`, `cut --complement=2`,
* `sed --debug=2`), all exit 2 for grep and sort and 1 for the coreutils.
*
* GNU's per-tool usage dump is deliberately omitted, exactly as
* unknownOptionError omits it; grep and sed print theirs between the message
* and the hint, coreutils print none at all.
*
* `token` is the option's canonical long spelling and the value that was typed
* on it ('--byte-offset=2'). It is carried whole because the programs in
* NOT_GETOPT_LONG quote the value along with the option and getopt_long drops
* it.
*/
export function unexpectedValueError(cmdName, token, builtin = true) {
if (builtin && NOT_GETOPT_LONG.has(cmdName))
return unknownOptionError(cmdName, token);
const option = token.split('=', 1)[0] ?? token;
const line = `${cmdName}: option '${option}' doesn't allow an argument\n`;
const hint = usageHint(cmdName, builtin) + '\n';
return [encodeText(line + hint), usageExitCode(cmdName, builtin)];
}
/**
* getopt_long refusal for an abbreviated long matching several options.
*
* Shape pinned against real GNU (`grep --c`): the typed spelling, then
* every possibility quoted in declaration order on one line. The
* per-tool usage dump GNU appends is deliberately omitted, like
* unknownOptionError.
*/
export function ambiguousOptionError(cmdName, token, candidates, builtin = true) {
const listed = candidates.map((c) => `'${c}'`).join(' ');
const line = `${cmdName}: option '${token}' is ambiguous; possibilities: ${listed}\n`;
const hint = usageHint(cmdName, builtin) + '\n';
return [encodeText(line + hint), usageExitCode(cmdName, builtin)];
}
/**
* Refusal for a non-integer value on an int-typed option.
*
* No GNU tool declares types through getopt (each words its own refusal,
* e.g. `head: invalid number of lines`), so this mirrors argparse's
* `invalid int value: 'abc'` with the option attributed the way
* invalidArgumentError does.
*/
export function invalidIntError(cmdName, option, value, builtin = true) {
const line = `${cmdName}: invalid int value: '${value}' for '${option}'\n`;
const hint = usageHint(cmdName, builtin) + '\n';
return [encodeText(line + hint), usageExitCode(cmdName, builtin)];
}
/**
* Refusal for a non-number value on a float-typed option. Mirrors
* argparse's `invalid float value: '5x'` the same way invalidIntError
* mirrors the int wording.
*/
export function invalidFloatError(cmdName, option, value, builtin = true) {
if (builtin && cmdName === 'curl') {
return curlOptionError(`curl: option ${option}: expected a proper numerical parameter\n`);
}
const line = `${cmdName}: invalid float value: '${value}' for '${option}'\n`;
const hint = usageHint(cmdName, builtin) + '\n';
return [encodeText(line + hint), usageExitCode(cmdName, builtin)];
}
/** GNU-shaped error for a declared value flag with no argument left. */
export function missingValueError(cmdName, token, builtin = true) {
if (builtin && PYTHON_NAMES.has(cmdName)) {
const dashed = token.startsWith('-') ? token : `-${token}`;
return pythonOptionError(cmdName, `Argument expected for the ${dashed} option\n`);
}
if (builtin && cmdName === 'curl') {
const dashed = token.startsWith('-') ? token : `-${token}`;
return curlOptionError(`curl: option ${dashed}: requires parameter\n`);
}
const line = token.startsWith('--')
? `${cmdName}: option '${token}' requires an argument\n`
: `${cmdName}: option requires an argument -- '${token}'\n`;
const hint = usageHint(cmdName, builtin) + '\n';
return [encodeText(line + hint), usageExitCode(cmdName, builtin)];
}
/**
* GNU tar refusal for an old-style cluster letter with no argument.
*
* First line and exit pinned against GNU tar 1.35 (`tar xzf` with
* nothing after it, and `tar cfC a.tar`, which names C). tar's own
* wording, capital and full stop included, because it counts the
* cluster's argument needs before argp sees the line at all.
*
* The hint line is deliberately mirage's, not GNU's: GNU offers
* `Try 'tar --help' or 'tar --usage' for more information.` because argp
* gives every argp program a `--usage`, and mirage's tar serves only
* `--help`. Naming a flag that does not exist would be worse than the
* shorter hint, and every other refusal here words it this way, so tar's
* two refusals stay consistent with each other.
*/
export function oldOptionError(cmdName, letter, builtin = true) {
const line = `${cmdName}: Old option '${letter}' requires an argument.\n`;
const hint = usageHint(cmdName, builtin) + '\n';
return [encodeText(line + hint), builtin ? OLD_OPTION_EXIT : 1];
}
/**
* The first line of GNU's word-choice refusal, without its newline.
*
* Two wordings, and the CALLER's match result picks between them: the
* wording is a property of how `matchWord` refused the value, not of the
* value itself, so it arrives from there rather than being re-derived
* here. There is deliberately no `value === ''` branch: the
* empty word is ambiguous because it is a prefix of every candidate and
* those candidates span two or more values, which is the same rule every
* other word goes through and is what `tail --follow=`, `sort --check=`,
* `wc --total=`, `uniq --all-repeated=`, `uniq --group=`, `ls --format=`,
* `ls -l --time-style=`, `cp --update=` and `tee --output-error=` were
* all measured answering `ambiguous argument ''` for. Re-adding the
* special case would get those right and a one-candidate slot wrong.
* `du --max-depth=` is NOT a word choice and says `invalid maximum depth ''`,
* which is why that one is worded in du.
*
* The word is rendered through `quoteText`, quoted as GNU quotes it:
* `tee --output-error=xé` is `invalid argument 'x\303\251'`. Callers must
* therefore pass the value as typed and never pre-escape it.
*
* `word_refusal_line` in usage.py is the twin.
*/
export function wordRefusalLine(cmdName, option, value, kind = 'invalid') {
return `${cmdName}: ${kind} argument '${quoteText(value)}' for '${option}'`;
}
/** GNU's `Valid arguments are:` block, without a trailing newline. */
export function validWordsBlock(choices) {
const rows = choices.map((choice) => {
const group = typeof choice === 'string' ? [choice] : choice;
return ' - ' + group.map((c) => `'${c}'`).join(', ');
});
return 'Valid arguments are:\n' + rows.join('\n');
}
/**
* GNU's refusal of a value outside a declared choices set.
*
* Shape pinned against real GNU (`tee --output-error=bogus`): the
* offending value, the option's canonical long spelling, then every valid
* argument in declaration order, aliases of one value on one line, then
* the `Try '--help'` hint.
*
* `exitCode` undefined takes the command's own usage code, which is 1 for
* every command that reaches this renderer through the executor. ls and
* sort pass 1 explicitly: GNU exits 1 on a refused word even though their
* other usage errors are 2.
*/
export function invalidArgumentError(cmdName, option, value, choices, exitCode, kind = 'invalid', builtin = true) {
const line = `${wordRefusalLine(cmdName, option, value, kind)}\n${validWordsBlock(choices)}\n`;
const hint = usageHint(cmdName, builtin) + '\n';
const code = exitCode ?? usageExitCode(cmdName, builtin);
return [encodeText(line + hint), code];
}
/**
* `invalidArgumentError` as the error a command throws.
*
* The commands that validate a word-choice value themselves (`sort`, `wc`,
* `uniq`, `ls`, `cp`, `tail`) hold the value long after the parser is done
* with it, so they render through the same function the executor does
* rather than wording a second copy.
*
* `word_refusal_error` in usage.py is the twin.
*/
export function wordRefusalError(cmdName, option, value, choices, exitCode, kind = 'invalid') {
const [message, code] = invalidArgumentError(cmdName, option, value, choices, exitCode, kind);
return new UsageError(decodeText(message).replace(/\n+$/, ''), code);
}
/**
* Refusal for a declared required option absent from the line.
*
* No GNU tool declares required options through getopt, so there is no
* GNU shape to pin; this follows the unrecognized-option pattern (click
* reports the same condition as "Missing option").
*/
export function missingRequiredError(cmdName, option, builtin = true) {
const line = `${cmdName}: option '${option}' is required\n`;
const hint = usageHint(cmdName, builtin) + '\n';
return [encodeText(line + hint), usageExitCode(cmdName, builtin)];
}
/**
* The `Try '<cmd> --help'` line as that command prints it.
*
* coreutils writes the hint bare; diffutils routes it through `error()`,
* so cmp and diff carry the command prefix on the hint line too.
*/
export function usageHint(cmdName, builtin = true) {
const prefix = builtin && USAGE_HINT_PREFIX.has(cmdName) ? `${cmdName}: ` : '';
return `${prefix}Try '${cmdName} --help' for more information.`;
}
/**
* GNU-shaped usage error for an operand past a command's arity.
*
* Shapes pinned against real GNU: `<cmd>: extra operand '<arg>'` with the
* `Try '--help'` hint (diff and cmp prefix the hint line with the command
* name; mktemp says `too many templates` with no operand). The operand must
* be the as-typed spelling (`rawPath`), never the resolved path.
*/
export function extraOperandError(cmdName, operand) {
// mktemp says `too many templates` with no operand, and patch names the
// operand first, bare (`patch: x: extra operand`).
const line = cmdName === CommandName.MKTEMP
? 'mktemp: too many templates'
: cmdName === CommandName.PATCH
? `patch: ${operand}: extra operand`
: `${cmdName}: extra operand '${operand}'`;
return new UsageError(`${line}\n${usageHint(cmdName)}`, usageExitCode(cmdName));
}
/**
* GNU-shaped usage error for an operand short of a command's arity.
*
* Shapes pinned against real GNU: `<cmd>: missing operand after '<arg>'`
* names `argv[argc - 1]` once getopt has moved the operands behind the
* options, which is the last operand given, or the line's last word for a
* program that reads its operands in order (join). With none given,
* coreutils says a bare `missing operand`, while diffutils still names the
* line's last word, an option or its value included (`cmp: missing operand
* after '-s'`, `diff -U 3` names `3`), and the program itself on a bare line
* (`cmp: missing operand after 'cmp'`; diffutils 3.10). `argv` is the
* line's words after the command name. Mirrors Python's
* missing_operand_error.
*/
export function missingOperandError(cmdName, last, argv = []) {
let after = last;
const lastWord = argv[argv.length - 1];
if (after !== null && IN_ORDER_OPERANDS.has(cmdName) && lastWord !== undefined)
after = lastWord;
if (after === null && USAGE_HINT_PREFIX.has(cmdName))
after = lastWord ?? cmdName;
const line = after === null ? `${cmdName}: missing operand` : `${cmdName}: missing operand after '${after}'`;
return new UsageError(`${line}\n${usageHint(cmdName)}`, usageExitCode(cmdName));
}
//# sourceMappingURL=usage.js.map