zlye
Version:
A type-safe CLI parser with a Zod-like schema validation.
931 lines (920 loc) • 26.4 kB
JavaScript
// src/index.ts
import pc from "picocolors";
// src/utils.ts
function joinWithAnd(items) {
return new Intl.ListFormat("en", { type: "conjunction" }).format(items);
}
function joinWithOr(items) {
return new Intl.ListFormat("en", { type: "disjunction" }).format(items);
}
function getOrdinalNumber(index) {
const ordinals = [
"first",
"second",
"third",
"fourth",
"fifth",
"sixth",
"seventh",
"eighth",
"ninth",
"tenth",
"eleventh",
"twelfth",
"thirteenth",
"fourteenth",
"fifteenth",
"sixteenth",
"seventeenth",
"eighteenth",
"nineteenth",
"twentieth"
];
if (index < ordinals.length) {
return ordinals[index];
}
const num = index + 1;
const suffix = num % 10 === 1 && num % 100 !== 11 ? "st" : num % 10 === 2 && num % 100 !== 12 ? "nd" : num % 10 === 3 && num % 100 !== 13 ? "rd" : "th";
return `${num}${suffix}`;
}
// src/index.ts
class StringSchemaImpl {
_type = "string";
_output;
_input;
_description;
_alias;
_example;
_isOptional;
_defaultValue;
_minLength;
_maxLength;
_regex;
_choices;
parse(value, path = "value") {
if (value === undefined && this._isOptional)
return;
if (value === undefined && this._defaultValue !== undefined)
return this._defaultValue;
if (value === undefined)
throw new CLIError(`${path} is required`);
if (typeof value !== "string") {
throw new CLIError(`${path} must be a string, received ${typeof value}`);
}
if (this._choices && !this._choices.includes(value)) {
throw new CLIError(`${path} must be one of ${joinWithOr(Array.from(this._choices))}`);
}
if (this._minLength !== undefined && value.length < this._minLength) {
throw new CLIError(`${path} must be at least ${this._minLength} characters`);
}
if (this._maxLength !== undefined && value.length > this._maxLength) {
throw new CLIError(`${path} must be at most ${this._maxLength} characters`);
}
if (this._regex && !this._regex.pattern.test(value)) {
throw new CLIError(this._regex.message || `${path} must match pattern ${this._regex.pattern}`);
}
return value;
}
optional() {
const clone = Object.create(this);
clone._isOptional = true;
return clone;
}
default(value) {
const clone = Object.create(this);
clone._defaultValue = value;
return clone;
}
transform(fn) {
const clone = Object.create(this);
const originalParse = clone.parse.bind(clone);
clone.parse = (value, path) => fn(originalParse(value, path));
return clone;
}
describe(description) {
this._description = description;
return this;
}
alias(alias) {
this._alias = alias;
return this;
}
example(example) {
this._example = example;
return this;
}
min(length) {
this._minLength = length;
return this;
}
max(length) {
this._maxLength = length;
return this;
}
regex(pattern, message) {
this._regex = { pattern, message };
return this;
}
choices(choices) {
const clone = Object.create(this);
clone._choices = choices;
return clone;
}
}
class NumberSchemaImpl {
_type = "number";
_output;
_input;
_description;
_alias;
_example;
_isOptional;
_defaultValue;
_min;
_max;
_isInt;
_isPositive;
_isNegative;
parse(value, path = "value") {
if (value === undefined && this._isOptional)
return;
if (value === undefined && this._defaultValue !== undefined)
return this._defaultValue;
if (value === undefined)
throw new CLIError(`${path} is required`);
const num = Number(value);
if (Number.isNaN(num)) {
throw new CLIError(`${path} must be a number, received ${typeof value}`);
}
if (this._isInt && !Number.isInteger(num)) {
throw new CLIError(`${path} must be an integer`);
}
if (this._isPositive && num <= 0) {
throw new CLIError(`${path} must be positive`);
}
if (this._isNegative && num >= 0) {
throw new CLIError(`${path} must be negative`);
}
if (this._min !== undefined && num < this._min) {
throw new CLIError(`${path} must be at least ${this._min}`);
}
if (this._max !== undefined && num > this._max) {
throw new CLIError(`${path} must be at most ${this._max}`);
}
return num;
}
optional() {
const clone = Object.create(this);
clone._isOptional = true;
return clone;
}
default(value) {
const clone = Object.create(this);
clone._defaultValue = value;
return clone;
}
transform(fn) {
const clone = Object.create(this);
const originalParse = clone.parse.bind(clone);
clone.parse = (value, path) => fn(originalParse(value, path));
return clone;
}
describe(description) {
this._description = description;
return this;
}
alias(alias) {
this._alias = alias;
return this;
}
example(example) {
this._example = example;
return this;
}
min(value) {
this._min = value;
return this;
}
max(value) {
this._max = value;
return this;
}
int() {
this._isInt = true;
return this;
}
positive() {
this._isPositive = true;
return this;
}
negative() {
this._isNegative = true;
return this;
}
}
class BooleanSchemaImpl {
_type = "boolean";
_output;
_input;
_description;
_alias;
_example;
_isOptional;
_defaultValue;
parse(value, path = "value") {
if (value === undefined && this._isOptional)
return;
if (value === undefined && this._defaultValue !== undefined)
return this._defaultValue;
if (value === undefined)
return false;
if (value === "true" || value === true || value === "1" || value === 1)
return true;
if (value === "false" || value === false || value === "0" || value === 0)
return false;
throw new CLIError(`${path} must be a boolean`);
}
optional() {
const clone = Object.create(this);
clone._isOptional = true;
return clone;
}
default(value) {
const clone = Object.create(this);
clone._defaultValue = value;
return clone;
}
transform(fn) {
const clone = Object.create(this);
const originalParse = clone.parse.bind(clone);
clone.parse = (value, path) => fn(originalParse(value, path));
return clone;
}
describe(description) {
this._description = description;
return this;
}
alias(alias) {
this._alias = alias;
return this;
}
example(example) {
this._example = example;
return this;
}
}
class ArraySchemaImpl {
_type = "array";
_output;
_input;
_itemSchema;
_description;
_alias;
_example;
_isOptional;
_defaultValue;
_minLength;
_maxLength;
constructor(itemSchema) {
this._itemSchema = itemSchema;
}
parse(value, path = "value") {
if (value === undefined && this._isOptional)
return;
if (value === undefined && this._defaultValue !== undefined)
return this._defaultValue;
if (value === undefined)
throw new CLIError(`${path} is required`);
let arr;
if (Array.isArray(value)) {
arr = value;
} else if (typeof value === "string" && value.includes(",")) {
arr = value.split(",").map((item) => item.trim()).filter((item) => item !== "");
} else {
arr = [value];
}
if (this._minLength !== undefined && arr.length < this._minLength) {
throw new CLIError(`${path} must have at least ${this._minLength} items`);
}
if (this._maxLength !== undefined && arr.length > this._maxLength) {
throw new CLIError(`${path} must have at most ${this._maxLength} items`);
}
return arr.map((item, i) => {
const ordinalPath = path.includes('"') || path.includes(" ") ? `${getOrdinalNumber(i)} value of ${path}` : `${path}: ${getOrdinalNumber(i)} value`;
return this._itemSchema.parse(item, ordinalPath);
});
}
optional() {
const clone = Object.create(this);
clone._isOptional = true;
return clone;
}
default(value) {
const clone = Object.create(this);
clone._defaultValue = value;
return clone;
}
transform(fn) {
const clone = Object.create(this);
const originalParse = clone.parse.bind(clone);
clone.parse = (value, path) => fn(originalParse(value, path));
return clone;
}
describe(description) {
this._description = description;
return this;
}
alias(alias) {
this._alias = alias;
return this;
}
example(example) {
this._example = example;
return this;
}
min(length) {
this._minLength = length;
return this;
}
max(length) {
this._maxLength = length;
return this;
}
}
class ObjectSchemaImpl {
_type = "object";
_output;
_input;
_shape;
_description;
_alias;
_example;
_isOptional;
_defaultValue;
constructor(shape) {
this._shape = shape;
}
parse(value, path = "value") {
if (value === undefined && this._isOptional)
return;
if (value === undefined && this._defaultValue !== undefined)
return this._defaultValue;
const objectValue = value === undefined ? {} : value;
if (typeof objectValue !== "object" || objectValue === null) {
throw new CLIError(`${path} must be an object`);
}
const result = {};
for (const [key, schema] of Object.entries(this._shape)) {
result[key] = schema.parse(objectValue[key], `${path}.${key}`);
}
return result;
}
optional() {
const clone = Object.create(this);
clone._isOptional = true;
return clone;
}
default(value) {
const clone = Object.create(this);
clone._defaultValue = value;
return clone;
}
transform(fn) {
const clone = Object.create(this);
const originalParse = clone.parse.bind(clone);
clone.parse = (value, path) => fn(originalParse(value, path));
return clone;
}
describe(description) {
this._description = description;
return this;
}
alias(alias) {
this._alias = alias;
return this;
}
example(example) {
this._example = example;
return this;
}
}
class PositionalSchemaImpl {
_type = "string";
_output;
_input;
_name;
_description;
_baseSchema;
constructor(name, schema) {
this._name = name;
this._baseSchema = schema || new StringSchemaImpl;
this._description = schema?._description;
}
parse(value, path) {
return this._baseSchema.parse(value, path || this._name);
}
optional() {
const clone = Object.create(this);
clone._baseSchema = this._baseSchema.optional();
return clone;
}
default(value) {
const clone = Object.create(this);
clone._baseSchema = this._baseSchema.default(value);
return clone;
}
transform(fn) {
const clone = Object.create(this);
clone._baseSchema = this._baseSchema.transform(fn);
return clone;
}
describe(description) {
this._description = description;
return this;
}
alias(alias) {
return this;
}
example(example) {
if ("example" in this._baseSchema) {
this._baseSchema.example(example);
}
return this;
}
}
class CommandBuilderImpl {
_name;
_options;
_description;
_usage;
_examples = [];
_positionals = [];
constructor(name, options) {
this._name = name;
this._options = options;
}
description(desc) {
this._description = desc;
return this;
}
usage(usage) {
this._usage = usage;
return this;
}
example(example) {
if (Array.isArray(example)) {
this._examples.push(...example);
} else {
this._examples.push(example);
}
return this;
}
positional(name, schema) {
this._positionals.push(new PositionalSchemaImpl(name, schema));
return this;
}
action(fn) {
return {
name: this._name,
description: this._description,
usage: this._usage,
example: this._examples.length > 0 ? this._examples : undefined,
options: this._options,
positionals: this._positionals,
action: fn
};
}
}
class CLIError extends Error {
constructor(message) {
super(message);
this.name = "CLIError";
}
}
var z = {
string: () => new StringSchemaImpl,
number: () => new NumberSchemaImpl,
boolean: () => new BooleanSchemaImpl,
array: (schema) => new ArraySchemaImpl(schema),
object: (shape) => new ObjectSchemaImpl(shape)
};
class CLIImpl {
_name;
_version;
_description;
_usage;
_examples = [];
_options = {};
_positionals = [];
_commands = [];
name(name) {
this._name = name;
return this;
}
version(version) {
this._version = version;
return this;
}
description(description) {
this._description = description;
return this;
}
usage(usage) {
this._usage = usage;
return this;
}
example(example) {
if (Array.isArray(example)) {
this._examples.push(...example);
} else {
this._examples.push(example);
}
return this;
}
option(name, schema) {
this._options[name] = schema;
return this;
}
positional(name, schema) {
this._positionals.push(new PositionalSchemaImpl(name, schema));
return this;
}
command(name, options) {
const builder = new CommandBuilderImpl(name, options || {});
return new Proxy(builder, {
get: (target, prop) => {
if (prop === "action") {
return (fn) => {
const cmd = target.action(fn);
this._commands.push(cmd);
return cmd;
};
}
return target[prop];
}
});
}
parse(argv = process.argv.slice(2)) {
try {
let args = [...argv];
if (args.includes("--version") || args.includes("-v")) {
if (this._version) {
console.log(this._version);
process.exit(0);
}
}
const helpIndex = args.findIndex((arg) => arg === "--help" || arg === "-h");
if (helpIndex !== -1) {
const commandBeforeHelp = helpIndex > 0 ? args[helpIndex - 1] : null;
const command = commandBeforeHelp && this._commands.find((c) => c.name === commandBeforeHelp);
if (command) {
this.showCommandHelp(command);
} else {
this.showHelp();
}
process.exit(0);
}
let commandName;
let commandOptions = this._options;
let commandAction;
let commandPositionals = this._positionals;
const positionalArgs = [];
if (args.length > 0 && !args[0].startsWith("-")) {
commandName = args[0];
const cmd = this._commands.find((c) => c.name === commandName);
if (cmd) {
commandOptions = cmd.options;
commandPositionals = cmd.positionals || [];
commandAction = cmd.action;
args = args.slice(1);
} else if (this._commands.length > 0) {
throw new CLIError(`Unknown command: ${commandName}`);
} else {
positionalArgs.push(args[0]);
args = args.slice(1);
}
}
const parsed = {};
const rawOptions = {};
for (let i = 0;i < args.length; i++) {
const arg = args[i];
if (arg.startsWith("--")) {
const [key, ...valueParts] = arg.slice(2).split("=");
let value;
if (valueParts.length > 0) {
value = valueParts.join("=");
} else {
const schema = commandOptions[key];
if (schema && schema._type === "boolean") {
value = true;
} else if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
value = args[++i];
} else {
value = undefined;
}
}
const keys = key.split(".");
let current = rawOptions;
for (let j = 0;j < keys.length - 1; j++) {
current[keys[j]] = current[keys[j]] || {};
current = current[keys[j]];
}
const lastKey = keys[keys.length - 1];
if (current[lastKey] !== undefined) {
if (!Array.isArray(current[lastKey])) {
current[lastKey] = [current[lastKey]];
}
current[lastKey].push(value);
} else {
current[lastKey] = value;
}
} else if (arg.startsWith("-")) {
const alias = arg.slice(1);
const optionName = Object.entries(commandOptions).find(([_, schema]) => schema._alias === alias)?.[0];
if (optionName) {
const schema = commandOptions[optionName];
let value;
if (schema && schema._type === "boolean") {
value = true;
} else if (i + 1 < args.length && !args[i + 1].startsWith("-")) {
value = args[++i];
} else {
value = undefined;
}
rawOptions[optionName] = value;
}
} else {
positionalArgs.push(arg);
}
}
for (const [key, schema] of Object.entries(commandOptions)) {
parsed[key] = schema.parse(rawOptions[key], `--${key}`);
}
const parsedPositionals = [];
if (commandPositionals.length > 0) {
for (let i = 0;i < commandPositionals.length; i++) {
const positional = commandPositionals[i];
const value = positionalArgs[i];
try {
const parsedValue = positional.parse(value, positional._name);
parsedPositionals.push(parsedValue);
} catch (error) {
throw new CLIError(`Argument "${positional._name}": ${error instanceof Error ? error.message.replace(`${positional._name} `, "").replace(`${positional._name}: `, "") : error}`);
}
}
if (positionalArgs.length > commandPositionals.length) {
const extra = positionalArgs.slice(commandPositionals.length);
throw new CLIError(`Unexpected argument${extra.length > 1 ? "s" : ""}: ${joinWithAnd(extra)}`);
}
} else {
parsedPositionals.push(...positionalArgs);
}
if (commandAction) {
const result = commandAction({
options: parsed,
positionals: positionalArgs
});
if (result instanceof Promise) {
result.catch((err) => {
this.showError(err);
process.exit(1);
});
}
return;
}
return {
options: parsed,
positionals: parsedPositionals
};
} catch (error) {
this.showError(error);
process.exit(1);
}
}
showHelp() {
console.log();
if (this._description && this._version) {
console.log(`${this._description} ${pc.dim(`(${this._version})`)}`);
console.log();
} else if (this._name) {
console.log(pc.bold(this._name));
if (this._version) {
console.log(pc.dim(`v${this._version}`));
}
if (this._description) {
console.log();
console.log(this._description);
}
console.log();
}
if (this._usage) {
console.log(`${pc.bold("Usage:")} ${this._usage}`);
} else {
const name = this._name || "cli";
const parts = [name];
if (this._commands.length > 0) {
parts.push(pc.blue("<command>"));
}
if (Object.keys(this._options).length > 0) {
parts.push(pc.blue("[...flags]"));
}
if (this._positionals.length > 0) {
parts.push(...this._positionals.map((p) => pc.dim(`<${p._name}>`)));
} else {
parts.push(pc.blue("[...args]"));
}
console.log(`${pc.bold("Usage:")} ${parts.join(" ")}`);
}
console.log();
if (this._positionals.length > 0) {
console.log(pc.bold("Arguments:"));
for (const pos of this._positionals) {
console.log(` ${pc.cyan(`<${pos._name}>`)} ${pos._description || ""}`);
}
console.log();
}
if (this._commands.length > 0) {
console.log(pc.bold("Commands:"));
const commandRows = [];
for (const cmd of this._commands) {
const example = Array.isArray(cmd.example) ? cmd.example[0] : cmd.example;
commandRows.push([cmd.name, example || "", cmd.description || ""]);
}
const nameWidth = Math.max(...commandRows.map((r) => r[0].length));
const exampleWidth = Math.max(...commandRows.map((r) => r[1].length));
for (const [name, example, description] of commandRows) {
const namePart = ` ${pc.cyan(name.padEnd(nameWidth))}`;
const examplePart = example ? ` ${pc.dim(example.padEnd(exampleWidth))}` : ` ${" ".repeat(exampleWidth)}`;
const descPart = description ? ` ${description}` : "";
console.log(`${namePart}${examplePart}${descPart}`);
}
console.log();
console.log(` ${pc.cyan("<command> --help".padEnd(nameWidth))}${exampleWidth > 0 ? ` ${" ".repeat(exampleWidth)}` : ""} ${pc.dim("Print help text for command.")}`);
console.log();
}
if (Object.keys(this._options).length > 0) {
console.log(pc.bold("Flags:"));
this.showOptionsHelp(this._options);
console.log();
}
if (this._examples.length > 0) {
console.log(pc.bold("Examples:"));
for (const example of this._examples) {
const lines = example.split(`
`);
if (lines.length > 1) {
console.log(` ${lines[0]}`);
console.log(` ${pc.green(lines.slice(1).join(`
`))}`);
} else {
console.log(` ${pc.green(example)}`);
}
console.log();
}
}
}
showCommandHelp(command) {
console.log();
const usage = command.usage || `${this._name || "cli"} ${command.name}${Object.keys(command.options).length > 0 ? ` ${pc.cyan("[...flags]")}` : ""}${command.positionals ? ` ${command.positionals.map((p) => pc.dim(`<${p._name}>`)).join(" ")}` : ""}`;
console.log(`${pc.bold("Usage:")} ${usage}`);
console.log();
if (command.description) {
console.log(` ${command.description}`);
console.log();
}
if (command.positionals && command.positionals.length > 0) {
console.log(pc.bold("Arguments:"));
for (const pos of command.positionals) {
console.log(` ${pc.cyan(`<${pos._name}>`)} ${pos._description || ""}`);
}
console.log();
}
if (Object.keys(command.options).length > 0) {
console.log(pc.bold("Flags:"));
this.showOptionsHelp(command.options);
console.log();
}
const examples = Array.isArray(command.example) ? command.example : command.example ? [command.example] : [];
if (examples.length > 0) {
console.log(pc.bold("Examples:"));
for (const example of examples) {
const lines = example.split(`
`);
if (lines.length > 1) {
console.log(` ${lines[0]}`);
console.log(` ${pc.green(lines.slice(1).join(`
`))}`);
} else {
console.log(` ${pc.green(example)}`);
}
console.log();
}
}
}
showOptionsHelp(options) {
const optionRows = [];
for (const [key, schema] of Object.entries(options)) {
const flags = schema._alias ? `-${schema._alias}, --${key}` : ` --${key}`;
const type = this.getOptionTypeString(key, schema);
const desc = this.getOptionDescription(schema);
optionRows.push({ flags, type, desc });
}
const flagsWidth = Math.max(...optionRows.map((r) => r.flags.length));
const typeWidth = Math.max(...optionRows.map((r) => r.type.length));
for (const { flags, type, desc } of optionRows) {
console.log(` ${pc.cyan(flags.padEnd(flagsWidth))}${type.padEnd(typeWidth)} ${desc}`);
}
console.log(` ${pc.cyan("-h, --help".padEnd(flagsWidth))}${pc.dim("").padEnd(typeWidth)} ${pc.dim("Display this menu and exit")}`);
}
getOptionTypeString(_, schema) {
if (schema._type === "boolean") {
return pc.dim("");
}
let valueType = "val";
if (schema._type === "string" && schema._choices) {
const choices = schema._choices;
if (choices && choices.length <= 3) {
valueType = choices.join("|");
}
} else if (schema._type === "number") {
valueType = "n";
} else if (schema._type === "array") {
valueType = "val,...";
}
return ` ${pc.dim(`<${valueType}>`)} `;
}
getOptionDescription(schema) {
const parts = [];
if (schema._description) {
parts.push(schema._description);
}
const constraints = this.getConstraintsString(schema);
if (constraints) {
parts.push(pc.dim(constraints));
}
return parts.join(" ");
}
getConstraintsString(schema) {
const constraints = [];
if (schema._defaultValue !== undefined) {
if (schema._type === "boolean") {
constraints.push(`default: ${schema._defaultValue}`);
} else {
constraints.push(`default: ${JSON.stringify(schema._defaultValue)}`);
}
}
if (schema._type === "string") {
const strSchema = schema;
if (strSchema._minLength !== undefined) {
constraints.push(`min: ${strSchema._minLength}`);
}
if (strSchema._maxLength !== undefined) {
constraints.push(`max: ${strSchema._maxLength}`);
}
if (strSchema._regex) {
constraints.push(strSchema._regex.message || `pattern: ${strSchema._regex.pattern}`);
}
}
if (schema._type === "number") {
const numSchema = schema;
if (numSchema._min !== undefined) {
constraints.push(`min: ${numSchema._min}`);
}
if (numSchema._max !== undefined) {
constraints.push(`max: ${numSchema._max}`);
}
if (numSchema._isInt) {
constraints.push("integer");
}
if (numSchema._isPositive) {
constraints.push("positive");
}
if (numSchema._isNegative) {
constraints.push("negative");
}
}
if (schema._type === "array") {
const arrSchema = schema;
if (arrSchema._minLength !== undefined) {
constraints.push(`min: ${arrSchema._minLength}`);
}
if (arrSchema._maxLength !== undefined) {
constraints.push(`max: ${arrSchema._maxLength}`);
}
}
return constraints.length > 0 ? `(${joinWithAnd(constraints)})` : "";
}
showError(error) {
console.error();
console.error(pc.red(pc.bold("Error:")), error instanceof Error ? error.message : String(error));
console.error();
console.error("Run with --help for usage information");
}
}
function cli() {
return new CLIImpl;
}
export {
z,
cli
};