Files
openclaw/scripts/lib/arg-utils.runtime.mjs
T
Peter Steinberger c23d66e3b5 refactor: consolidate coercion ownership (#122692)
* refactor: consolidate coercion ownership

* test: align shard check with weighted planning

* chore: refresh plugin SDK API baseline
2026-08-12 09:25:28 -07:00

432 lines
12 KiB
JavaScript

// Shared argument parsing helpers for repository scripts.
/**
* @template {Record<string, unknown>} T
* @typedef {{ bivarianceHack(target: T): void }["bivarianceHack"]} ApplyFlag
*/
/**
* @template {Record<string, unknown>} T
* @typedef {{
* flag: string,
* nextIndex: number,
* repeatable?: boolean,
* apply: ApplyFlag<T>,
* }} ConsumedFlag
*/
/**
* @template {Record<string, unknown>} T
* @typedef {{
* bivarianceHack(
* argv: readonly string[],
* index: number,
* args: T,
* ): ConsumedFlag<T> | null,
* }["bivarianceHack"]} ConsumeFlag
*/
/**
* @template {Record<string, unknown>} T
* @typedef {{
* consume: ConsumeFlag<T>,
* }} FlagSpec
*/
/**
* @typedef {{
* allowEmpty?: boolean,
* allowInline?: boolean,
* missingValueMessage?: string,
* rejectShortOptions?: boolean,
* repeatable?: boolean,
* transform?: (value: string) => unknown,
* }} StringOptions
*/
/**
* @template {Record<string, unknown>} T
* @typedef {{
* allowUnknownOptions?: boolean,
* duplicateOptionMessage?: (flag: string) => string,
* ignoreDoubleDash?: boolean,
* onUnhandledArg?: (arg: string, args: T) => "handled" | void,
* }} ParseOptions
*/
/**
* @typedef {{ kind: "syntax" } | { kind: "below" } | { kind: "above" } | { kind: "value", value: number }} BoundedUnsignedDecimalResult
*/
/** @param {string} message */
function failFlagParse(message) {
throw new Error(message);
}
/**
* @param {Record<string, unknown>} target
* @param {string} key
* @param {unknown} value
*/
function assignFlag(target, key, value) {
target[key] = value;
}
/**
* Read a flag value from `--flag value` or `--flag=value` arguments.
* @internal Shared repository-script contract.
* @param {readonly string[]} args
* @param {string} name
* @returns {string | undefined}
*/
export function readFlagValue(args, name) {
for (let index = args.length - 1; index >= 0; index -= 1) {
const arg = args[index];
if (arg === undefined) {
continue;
}
if (arg === name) {
return args[index + 1];
}
if (arg.startsWith(`${name}=`)) {
return arg.slice(name.length + 1);
}
}
return undefined;
}
/**
* Remove the leading `--` separator inserted by package-manager script invocations.
* @internal Shared repository-script contract.
* @param {string[]} argv
* @returns {string[]}
*/
export function stripLeadingPackageManagerSeparator(argv) {
return argv[0] === "--" ? argv.slice(1) : argv;
}
/**
* @param {string} value
* @param {StringOptions} options
*/
function isMissingStringFlagValue(value, options) {
if (!value && options.allowEmpty !== true) {
return true;
}
if (value.startsWith("--")) {
return true;
}
return options.rejectShortOptions === true && value.startsWith("-");
}
/**
* @param {readonly string[]} argv
* @param {number} index
* @param {string} flag
* @param {StringOptions} options
*/
function consumeStringFlag(argv, index, flag, options) {
const arg = argv[index];
if (arg === undefined) {
return null;
}
const inlineValue = options.allowInline === false ? null : readInlineFlagValue(arg, flag);
if (inlineValue !== null) {
if (isMissingStringFlagValue(inlineValue, options)) {
failFlagParse(options.missingValueMessage ?? `${flag} requires a value`);
}
return {
nextIndex: index,
value: inlineValue,
};
}
if (argv[index] !== flag) {
return null;
}
const value = argv[index + 1];
if (value === undefined || isMissingStringFlagValue(value, options)) {
failFlagParse(options.missingValueMessage ?? `${flag} requires a value`);
}
return {
nextIndex: index + 1,
value,
};
}
/**
* @param {string} arg
* @param {string} flag
*/
function readInlineFlagValue(arg, flag) {
const prefix = `${flag}=`;
return arg.startsWith(prefix) ? arg.slice(prefix.length) : null;
}
/**
* @param {readonly string[]} argv
* @param {number} index
* @param {string} flag
*/
function readFlagOptionValue(argv, index, flag) {
const arg = argv[index];
if (arg === undefined) {
return null;
}
const inlineValue = readInlineFlagValue(arg, flag);
if (inlineValue !== null) {
if (!inlineValue) {
failFlagParse(`${flag} requires a value`);
}
return { nextIndex: index, value: inlineValue };
}
if (argv[index] !== flag) {
return null;
}
const value = argv[index + 1];
if (!value || value.startsWith("--")) {
failFlagParse(`${flag} requires a value`);
}
return { nextIndex: index + 1, value };
}
/**
* Parse the exact lowercase Boolean language used by strict script arguments.
* @param {unknown} value
* @param {string} label
*/
export function parseStrictBooleanArg(value, label) {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
throw new Error(`${label} must be true or false.`);
}
/**
* Classify an ASCII unsigned-decimal token against inclusive bounds.
* @param {unknown} value
* @param {number} min
* @param {number} max
* @returns {BoundedUnsignedDecimalResult}
*/
export function classifyBoundedUnsignedDecimal(value, min, max) {
if (typeof value !== "string" || !/^\d+$/u.test(value)) {
return { kind: "syntax" };
}
const parsed = Number(value);
if (parsed < min) {
return { kind: "below" };
}
if (parsed > max) {
return { kind: "above" };
}
return { kind: "value", value: parsed };
}
const PERMISSIVE_BOOLEAN_TRUE_TOKENS = new Set(["1", "on", "true", "yes"]);
const PERMISSIVE_BOOLEAN_FALSE_TOKENS = new Set(["0", "false", "no", "off"]);
/**
* Parse the normalized Boolean token language shared by repository scripts.
* @param {unknown} value
* @returns {boolean | undefined}
*/
export function parsePermissiveBooleanToken(value) {
const normalized = typeof value === "string" ? value.trim().toLowerCase() : "";
if (!normalized) {
return undefined;
}
if (PERMISSIVE_BOOLEAN_TRUE_TOKENS.has(normalized)) {
return true;
}
return PERMISSIVE_BOOLEAN_FALSE_TOKENS.has(normalized) ? false : undefined;
}
const OPEN_ENDED_FALSE_TOKENS = new Set(["", "0", "false", "no"]);
/**
* Treat every non-empty token except the explicit false language as enabled.
* @param {string | undefined} value
*/
export function isOpenEndedTruthyValue(value) {
return !OPEN_ENDED_FALSE_TOKENS.has((value ?? "").trim().toLowerCase());
}
const STRICT_AFFIRMATIVE_TOKENS = new Set(["1", "true", "yes"]);
/**
* Accept only the narrow affirmative token language used by script environment flags.
* @param {string | undefined} value
*/
export function isStrictAffirmativeValue(value) {
return STRICT_AFFIRMATIVE_TOKENS.has(value?.trim().toLowerCase() ?? "");
}
/**
* @param {string} raw
* @param {string} flag
*/
function parseIntegerFlagValue(raw, flag) {
const text = raw.trim();
if (!/^-?\d+$/u.test(text)) {
failFlagParse(`${flag} must be an integer`);
}
const parsed = Number(text);
if (!Number.isSafeInteger(parsed)) {
failFlagParse(`${flag} must be a safe integer`);
}
return parsed;
}
/**
* Create a flag spec that assigns one string value to the parsed args object.
* @template {Record<string, unknown>} T
* @param {string} flag
* @param {string} key
* @param {StringOptions} [options]
* @returns {FlagSpec<T>}
*/
export function stringFlag(flag, key, options = {}) {
return {
consume(argv, index) {
const option = consumeStringFlag(argv, index, flag, options);
if (!option) {
return null;
}
return {
flag,
nextIndex: option.nextIndex,
repeatable: options.repeatable === true,
apply(target) {
const value = options.transform ? options.transform(option.value) : option.value;
assignFlag(target, key, value);
},
};
},
};
}
/**
* Create a flag spec that appends repeated string values to an array field.
* @internal Shared repository-script contract.
* @template {Record<string, unknown>} T
* @param {string} flag
* @param {string} key
* @param {Omit<StringOptions, "repeatable" | "transform">} [options]
* @returns {FlagSpec<T>}
*/
export function stringListFlag(flag, key, options = {}) {
return {
consume(argv, index) {
const option = consumeStringFlag(argv, index, flag, options);
if (!option) {
return null;
}
return {
flag,
nextIndex: option.nextIndex,
repeatable: true,
apply(target) {
const current = target[key];
if (current == null) {
assignFlag(target, key, [option.value]);
return;
}
if (!Array.isArray(current)) {
throw new TypeError(`${key} must be an array`);
}
current.push(option.value);
},
};
},
};
}
/**
* Create a flag spec that parses and assigns a safe integer value.
* @internal Shared repository-script contract.
* @template {Record<string, unknown>} T
* @param {string} flag
* @param {string} key
* @param {{ min?: number }} [options]
* @returns {FlagSpec<T>}
*/
export function intFlag(flag, key, options) {
return {
consume(argv, index) {
const raw = readFlagOptionValue(argv, index, flag);
if (!raw) {
return null;
}
const value = parseIntegerFlagValue(raw.value, flag);
const min = options?.min ?? Number.NEGATIVE_INFINITY;
if (value < min) {
failFlagParse(`${flag} must be at least ${min}`);
}
return {
flag,
nextIndex: raw.nextIndex,
repeatable: false,
apply(target) {
assignFlag(target, key, value);
},
};
},
};
}
/**
* Create a flag spec that assigns a fixed boolean-like value when present.
* @template {Record<string, unknown>} T
* @param {string} flag
* @param {string} key
* @param {unknown} [value]
* @param {{ repeatable?: boolean }} [options]
* @returns {FlagSpec<T>}
*/
export function booleanFlag(flag, key, value = true, options = {}) {
return {
consume(argv, index) {
if (argv[index] !== flag) {
return null;
}
return {
flag,
nextIndex: index,
repeatable: options.repeatable === true,
apply(target) {
assignFlag(target, key, value);
},
};
},
};
}
/**
* Apply flag specs to argv and return the mutated parsed args object.
* @template {Record<string, unknown>} T
* @param {readonly string[]} argv
* @param {T} args
* @param {readonly FlagSpec<T>[]} specs
* @param {ParseOptions<T>} [options]
* @returns {T}
*/
export function parseFlagArgs(argv, args, specs, options = {}) {
const ignoreDoubleDash = options.ignoreDoubleDash ?? true;
const seenFlags = new Set();
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === undefined) {
continue;
}
if (arg === "--" && ignoreDoubleDash) {
continue;
}
let handled = false;
for (const spec of specs) {
const option = spec.consume(argv, i, args);
if (!option) {
continue;
}
if (option.repeatable !== true) {
if (seenFlags.has(option.flag)) {
failFlagParse(
options.duplicateOptionMessage?.(option.flag) ??
`${option.flag} was provided more than once`,
);
}
seenFlags.add(option.flag);
}
option.apply(args);
i = option.nextIndex;
handled = true;
break;
}
if (handled) {
continue;
}
const fallbackResult = options.onUnhandledArg?.(arg, args);
if (fallbackResult === "handled") {
continue;
}
if (!options.allowUnknownOptions && arg.startsWith("-")) {
failFlagParse(`Unknown option: ${arg}`);
}
}
return args;
}