@struktoai/mirage-core
Version:
A Unified Virtual Filesystem For AI Agents
264 lines • 12.3 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 { compileSpec, argumentDest, optionSpellings, positionalRequired } from "../spec/compile.js";
import { CLAP_EXIT, GIT_LONG_OPTIONS, GIT_SYNOPSES, GIT_USAGE_GAP, GIT_USAGE_WIDTH, HELP_EXIT, USAGE_EXIT, } from "./constants.js";
import { operandSlot, optionMetavar } from "../spec/help.js";
import { UsageStyle } from "../spec/types.js";
import { encodeText } from "../../shell/bytes.js";
import { posixPhrase } from "../../errors/posix.js";
export const ARGPARSE_EXIT = 2;
const LONG_PREFIX = '--';
const NEGATION = '--no-';
export const HELP_SWITCH = '-h';
const ENC = new TextEncoder();
/**
* git's usage block for one verb, as parse-options prints it.
*
* The synopsis lines come first, the first after `usage: ` and each next one
* after ` or: `, then a blank line, one row per option and a closing blank
* line. The lines are git's own (`GIT_SYNOPSES`); the rows are the leaf's spec
* in git's layout, so the block lists exactly the options mirage takes: four
* spaces, the short and long spellings, `--[no-]` where the spec declares a
* long's negation beside it, the value's name, then the description from
* column 26, or under that column on a line of its own when the spellings run
* past it. Pinned against git 2.47.3.
*
* @param path the verb's path under git ('branch', 'stash list')
* @param spec the leaf's grammar
*/
export function gitUsage(path, spec) {
const [first, ...rest] = GIT_SYNOPSES.get(path) ?? [`git ${path}`];
let text = `usage: ${first ?? ''}\n` + rest.map((line) => ` or: ${line}\n`).join('');
const rows = gitRows(path, spec);
if (rows.length > 0)
text += '\n' + rows.join('');
return text + '\n';
}
/**
* One usage row per option, a negation folded where git folds it.
*
* git spells a long `--[no-]name` where its own table does
* (`GIT_LONG_OPTIONS`), and the row then stands for the plain boolean
* `--no-name` mirage declares beside it too. A `--no-` option git lists apart
* (`--no-merges` filters rather than negates) keeps a row of its own.
*
* @param path the verb's path under git, for its table
* @param spec the leaf's grammar
*/
function gitRows(path, spec) {
const table = GIT_LONG_OPTIONS.get(path) ?? [];
const options = compileSpec(spec).options;
const negations = new Set();
for (const opt of options) {
const [short, long] = optionSpellings(opt);
if (long?.startsWith(NEGATION) &&
short === null &&
(opt.action === 'store_true' || opt.action === 'count') &&
table.includes(`[no-]${long.slice(NEGATION.length)}`))
negations.add(long);
}
const rows = [];
for (const opt of options) {
const [short, originalLong] = optionSpellings(opt);
let long = originalLong;
if (long !== null && negations.has(long))
continue;
if (long !== null && negations.has(`${NEGATION}${long.slice(2)}`)) {
long = `--[no-]${long.slice(2)}`;
}
let spelled = [short, long].filter((name) => name !== null).join(', ');
if (opt.action !== 'store_true' && opt.action !== 'count') {
const named = argumentDest(opt).replace(/^-+/, '');
const value = `<${opt.metavar ?? named}>`;
if (opt.nargs !== '?')
spelled += ` ${value}`;
else
spelled += long !== null ? `[=${value}]` : `[${value}]`;
}
const left = ` ${spelled}`;
const gap = left.length <= GIT_USAGE_WIDTH + 1
? ' '.repeat(GIT_USAGE_WIDTH + GIT_USAGE_GAP - left.length)
: '\n' + ' '.repeat(GIT_USAGE_WIDTH + GIT_USAGE_GAP);
rows.push(`${left}${gap}${opt.help ?? ''}\n`);
}
return rows;
}
/**
* parse-options' answer to a word the verb does not take.
*
* `-h` asks for the usage block, which goes to stdout and exits 0 (git 2.56);
* everything else here exits 129. A boolean long handed a value is refused on
* one line, and so is an option that needs a value and ends
* the line without one (it reaches the operands when the verb takes free text).
* Anything else is an option the verb does not have: a long one is an "option"
* and a short one a "switch", both named without their dashes and quoted with a
* backquote-apostrophe pair, and the usage block follows on stderr. Pinned
* against git 2.50.1 and 2.47.3.
*
* @param word the offending word with its dashes ('--nosuch', '-Z',
* '--quiet=1')
* @param path the verb's path under git, for its synopsis
* @param spec the leaf's grammar, for its rows
* @returns the refusal's stdout and its stderr
*/
export function gitOptionRefusal(word, path, spec) {
const usage = gitUsage(path, spec);
if (word === HELP_SWITCH)
return [usage, ''];
const eq = word.indexOf('=');
const name = eq === -1 ? word : word.slice(0, eq);
if (eq !== -1 &&
name.startsWith(LONG_PREFIX) &&
compileSpec(spec).options.some((opt) => opt.names.includes(name) && (opt.action === 'store_true' || opt.action === 'count'))) {
return ['', `error: option \`${name.slice(2)}' takes no value\n`];
}
const noun = word.startsWith(LONG_PREFIX) ? 'option' : 'switch';
const last = word.startsWith(LONG_PREFIX) ? word : `-${word.slice(-1)}`;
if (eq === -1 &&
compileSpec(spec).options.some((opt) => opt.names.includes(last) &&
opt.action !== 'store_true' &&
opt.action !== 'count' &&
opt.nargs !== '?')) {
return ['', `error: ${noun} \`${last.replace(/^-+/, '')}' requires a value\n`];
}
return ['', `error: unknown ${noun} \`${word.replace(/^-+/, '')}'\n${usage}`];
}
/**
* The options a clap usage line echoes back, in clap's order.
*
* clap reprints the options the line carried, in the order they were typed,
* then the ones an environment variable supplied. A *defaulted* option is not
* among them: pinned against ntn 0.21.9, whose --limit declares `[default: 25]`
* and never appears unless it was typed.
*
* @param spec the leaf's grammar, for spellings and value names
* @param typed dests the line carried, in scan order (canonical dashed
* spellings, the key space the parser records flags under)
* @param env the session environment, read for the options that declare one
*/
export function clapSupplied(spec, typed, env) {
const byDest = new Map(compileSpec(spec).options.map((opt) => [argumentDest(opt), opt]));
const bits = [];
for (const dest of typed) {
const opt = byDest.get(dest);
if (opt === undefined)
continue;
bits.push(opt.action === 'store_true' || opt.action === 'count'
? dest
: `${dest} <${optionMetavar(opt)}>`);
}
for (const [dest, opt] of byDest) {
if (opt.env === null || typed.includes(dest) || !(opt.env in env))
continue;
bits.push(`${dest} <${optionMetavar(opt)}>`);
}
return bits;
}
/** Every operand slot of a leaf, as a clap usage line spells them. */
function clapOperands(spec) {
const compiled = compileSpec(spec);
const slots = compiled.positional.map((operand) => operandSlot(operand));
const rest = compiled.rest;
if (rest !== null)
slots.push(operandSlot(rest, !positionalRequired(rest)));
return slots;
}
/**
* clap's refusal for required operands the line did not supply.
*
* Pinned against ntn 0.21.9: the empty slots are listed one per line under a
* fixed heading, then a usage line that carries the options the line supplied
* and every operand slot, then the "try --help" footer. The usage line names
* only what was supplied, which is why it is rebuilt here rather than taken
* from the help page.
*
* @param prog the full display path of the leaf ("ntn pages get")
* @param spec the leaf's grammar
* @param missing bare names of the empty required slots
* @param typed dests the line carried, in scan order
* @param env the session environment
*/
export function clapMissingOperands(prog, spec, missing, typed, env) {
const named = missing.map((name) => ` <${name}>`).join('\n');
const usage = [prog, ...clapSupplied(spec, typed, env), ...clapOperands(spec)].join(' ');
return ENC.encode('error: the following required arguments were not provided:\n' +
`${named}\n\nUsage: ${usage}\n\n` +
"For more information, try '--help'.\n");
}
/**
* The message and exit code a leaf answers a bad option with.
*
* A leaf usage error exits 2 under argparse's style regardless of the GNU
* USAGE_EXIT table, because an installed CLI name is never a GNU tool with its
* own pinned exit. git exits 129 for the same mistake, which is neither that
* nor its own 128 for a fatal. clap exits 2, agreeing with argparse by
* coincidence rather than by lineage.
*
* git answers in parse-options' words, and some of them print the verb's
* usage block: after an unknown option or an ambiguous abbreviation on
* stderr, and on stdout for `-h`, which exits 0 (git 2.56). A missing value is
* one line, a long named an "option" and a short one a "switch" (pinned
* against git 2.50.1).
*
* @param style the dialect the CLI's root declares
* @param argparseMessage the message the spec machinery built, used as-is for
* argparse and for anything another style words the same
* @param parsed the parse, read for the offending tokens when the style
* rewrites the message
* @param path the leaf's path under its head word, for git's synopsis
* @param spec the leaf's grammar, for git's option rows
* @returns the stderr, the exit code and the stdout, null when the refusal
* writes nothing there
*/
export function leafRefusal(style, argparseMessage, parsed, path, spec) {
if (style === UsageStyle.CLAP)
return [argparseMessage, CLAP_EXIT, null];
if (style !== UsageStyle.GIT)
return [argparseMessage, ARGPARSE_EXIT, null];
const kind = parsed.optionErrorKinds[0];
const ambiguous = parsed.ambiguousOptions[0];
if (kind === 'ambiguous' && ambiguous !== undefined) {
const [token, [first = '', second = '']] = ambiguous;
const line = `error: ambiguous option: ${token.slice(2)} (could be ${first} or ${second})\n`;
return [encodeText(line + gitUsage(path, spec)), USAGE_EXIT, null];
}
const needy = parsed.needsValueOptions[0];
if (kind === 'needs_value' && needy !== undefined) {
const named = needy.startsWith(LONG_PREFIX)
? `option \`${needy.slice(2)}'`
: `switch \`${needy.replace(/^-+/, '')}'`;
return [encodeText(`error: ${named} requires a value\n`), USAGE_EXIT, null];
}
const token = parsed.invalidOptions[0];
if ((kind === 'invalid' || kind === 'unexpected_value') && token !== undefined) {
const word = token.startsWith('-') ? token : `-${token}`;
const [shown, refused] = gitOptionRefusal(word, path, spec);
return [
encodeText(refused),
refused !== '' ? USAGE_EXIT : HELP_EXIT,
shown !== '' ? encodeText(shown) : null,
];
}
return [argparseMessage, USAGE_EXIT, null];
}
/** Render failure to enter a CLI's declared operand base. */
export function directoryRefusal(prog, path, reason, style) {
return style === UsageStyle.GIT
? [new TextEncoder().encode(`fatal: cannot change to '${path}': ${posixPhrase(reason)}\n`), 128]
: [
new TextEncoder().encode(`${prog}: cannot change directory to '${path}': ${posixPhrase(reason)}\n`),
1,
];
}
//# sourceMappingURL=refusal.js.map