routie dev init since i didn't adhere to any proper guidance up until now

This commit is contained in:
2026-04-29 22:27:29 -06:00
commit e1dabb71e2
15301 changed files with 3562618 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) Pooya Parsa <pooya@pi0.io>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+231
View File
@@ -0,0 +1,231 @@
# 🌆 citty
<!-- automd:badges color=yellow bundlephobia -->
[![npm version](https://img.shields.io/npm/v/citty?color=yellow)](https://npmjs.com/package/citty)
[![npm downloads](https://img.shields.io/npm/dm/citty?color=yellow)](https://npmjs.com/package/citty)
[![bundle size](https://img.shields.io/bundlephobia/minzip/citty?color=yellow)](https://bundlephobia.com/package/citty)
<!-- /automd -->
Elegant CLI Builder
- Zero dependency, fast and lightweight (based on native [`util.parseArgs`](https://nodejs.org/api/util.html#utilparseargsconfig))
- Smart value parsing with typecast and boolean shortcuts
- Nested sub-commands with lazy and async loading
- Pluggable and composable API with auto generated usage
## Usage
```sh
npx nypm add -D citty
```
```js
import { defineCommand, runMain } from "citty";
const main = defineCommand({
meta: {
name: "hello",
version: "1.0.0",
description: "My Awesome CLI App",
},
args: {
name: {
type: "positional",
description: "Your name",
required: true,
},
friendly: {
type: "boolean",
description: "Use friendly greeting",
},
},
setup({ args }) {
console.log(`now setup ${args.command}`);
},
cleanup({ args }) {
console.log(`now cleanup ${args.command}`);
},
run({ args }) {
console.log(`${args.friendly ? "Hi" : "Greetings"} ${args.name}!`);
},
});
runMain(main);
```
```sh
node index.mjs john
# Greetings john!
```
### Sub Commands
Sub commands can be nested recursively. Use lazy imports for large CLIs to avoid loading all commands at once.
```js
import { defineCommand, runMain } from "citty";
const sub = defineCommand({
meta: { name: "sub", description: "Sub command" },
args: {
name: { type: "positional", description: "Your name", required: true },
},
run({ args }) {
console.log(`Hello ${args.name}!`);
},
});
const main = defineCommand({
meta: { name: "hello", version: "1.0.0", description: "My Awesome CLI App" },
subCommands: { sub },
});
runMain(main);
```
Subcommands support `meta.alias` (e.g., `["i", "add"]`) and `meta.hidden: true` to hide from help output.
### Lazy Commands
For large CLIs, lazy load sub commands so only the executed command is imported:
```js
const main = defineCommand({
meta: { name: "hello", version: "1.0.0", description: "My Awesome CLI App" },
subCommands: {
sub: () => import("./sub.mjs").then((m) => m.default),
},
});
```
`meta`, `args`, and `subCommands` all accept `Resolvable<T>` values — a value, Promise, function, or async function — enabling lazy and dynamic resolution.
### Hooks
Commands support `setup` and `cleanup` functions called before and after `run()`. Only the executed command's hooks run. `cleanup` always runs, even if `run()` throws.
```js
const main = defineCommand({
meta: { name: "hello", version: "1.0.0", description: "My Awesome CLI App" },
setup() {
console.log("Setting up...");
},
cleanup() {
console.log("Cleaning up...");
},
run() {
console.log("Hello World!");
},
});
```
### Plugins
Plugins extend commands with reusable `setup` and `cleanup` hooks:
```js
import { defineCommand, defineCittyPlugin, runMain } from "citty";
const logger = defineCittyPlugin({
name: "logger",
setup({ args }) {
console.log("Logger setup, args:", args);
},
cleanup() {
console.log("Logger cleanup");
},
});
const main = defineCommand({
meta: { name: "hello", description: "My CLI App" },
plugins: [logger],
run() {
console.log("Hello!");
},
});
runMain(main);
```
Plugin `setup` hooks run before the command's `setup` (in order), `cleanup` hooks run after (in reverse). Plugins can be async or factory functions.
## Arguments
### Argument Types
| Type | Description | Example |
| ------------ | ---------------------------------------- | --------------------------- |
| `positional` | Unnamed positional args | `cli <name>` |
| `string` | Named string options | `--name value` |
| `boolean` | Boolean flags, supports `--no-` negation | `--verbose` |
| `enum` | Constrained to `options` array | `--level=info\|warn\|error` |
### Common Options
| Option | Description |
| ------------- | ------------------------------------------------------------- |
| `description` | Help text shown in usage output |
| `required` | Whether the argument is required |
| `default` | Default value when not provided |
| `alias` | Short aliases (e.g., `["f"]`). Not for `positional` |
| `valueHint` | Display hint in help (e.g., `"host"` renders `--name=<host>`) |
### Example
```js
const main = defineCommand({
args: {
name: { type: "positional", description: "Your name", required: true },
friendly: { type: "boolean", description: "Use friendly greeting", alias: ["f"] },
greeting: { type: "string", description: "Custom greeting", default: "Hello" },
level: {
type: "enum",
description: "Log level",
options: ["debug", "info", "warn", "error"],
default: "info",
},
},
run({ args }) {
console.log(`${args.greeting} ${args.name}! (level: ${args.level})`);
},
});
```
### Boolean Negation
Boolean args support `--no-` prefix. The negative variant appears in help when `default: true` or `negativeDescription` is set.
### Case-Agnostic Access
Kebab-case args can be accessed as camelCase: `args["output-dir"]` and `args.outputDir` both work.
## Built-in Flags
`--help` / `-h` and `--version` / `-v` are handled automatically. Disabled if your command defines args with the same names or aliases.
## API
| Function | Description |
| ----------------------------- | -------------------------------------------------------------------------- |
| `defineCommand(def)` | Type helper for defining commands |
| `runMain(cmd, opts?)` | Run a command with usage support and graceful error handling |
| `createMain(cmd)` | Create a wrapper that calls `runMain` when invoked |
| `runCommand(cmd, opts)` | Parse args and run command/sub-commands; access `result` from return value |
| `parseArgs(rawArgs, argsDef)` | Parse input arguments and apply defaults |
| `renderUsage(cmd, parent?)` | Render command usage to a string |
| `showUsage(cmd, parent?)` | Render usage and print to console |
| `defineCittyPlugin(def)` | Type helper for defining plugins |
## Development
- Clone this repository
- Install latest LTS version of [Node.js](https://nodejs.org/en/)
- Enable [Corepack](https://github.com/nodejs/corepack) using `corepack enable`
- Install dependencies using `pnpm install`
- Run interactive tests using `pnpm dev`
## License
Made with 💛 Published under [MIT License](./LICENSE).
+33
View File
@@ -0,0 +1,33 @@
# Licenses of Bundled Dependencies
The published artifact additionally contains code with the following licenses:
MIT
# Bundled Dependencies
## scule
License: MIT
Repository: https://github.com/unjs/scule
> MIT License
>
> Copyright (c) Pooya Parsa <pooya@pi0.io>
>
> Permission is hereby granted, free of charge, to any person obtaining a copy
> of this software and associated documentation files (the "Software"), to deal
> in the Software without restriction, including without limitation the rights
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
> copies of the Software, and to permit persons to whom the Software is
> furnished to do so, subject to the following conditions:
>
> The above copyright notice and this permission notice shall be included in all
> copies or substantial portions of the Software.
>
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
> SOFTWARE.
+70
View File
@@ -0,0 +1,70 @@
//#region node_modules/.pnpm/scule@1.3.0/node_modules/scule/dist/index.mjs
const NUMBER_CHAR_RE = /\d/;
const STR_SPLITTERS = [
"-",
"_",
"/",
"."
];
function isUppercase(char = "") {
if (NUMBER_CHAR_RE.test(char)) return;
return char !== char.toLowerCase();
}
function splitByCase(str, separators) {
const splitters = separators ?? STR_SPLITTERS;
const parts = [];
if (!str || typeof str !== "string") return parts;
let buff = "";
let previousUpper;
let previousSplitter;
for (const char of str) {
const isSplitter = splitters.includes(char);
if (isSplitter === true) {
parts.push(buff);
buff = "";
previousUpper = void 0;
continue;
}
const isUpper = isUppercase(char);
if (previousSplitter === false) {
if (previousUpper === false && isUpper === true) {
parts.push(buff);
buff = char;
previousUpper = isUpper;
continue;
}
if (previousUpper === true && isUpper === false && buff.length > 1) {
const lastChar = buff.at(-1);
parts.push(buff.slice(0, Math.max(0, buff.length - 1)));
buff = lastChar + char;
previousUpper = isUpper;
continue;
}
}
buff += char;
previousUpper = isUpper;
previousSplitter = isSplitter;
}
parts.push(buff);
return parts;
}
function upperFirst(str) {
return str ? str[0].toUpperCase() + str.slice(1) : "";
}
function lowerFirst(str) {
return str ? str[0].toLowerCase() + str.slice(1) : "";
}
function pascalCase(str, opts) {
return str ? (Array.isArray(str) ? str : splitByCase(str)).map((p) => upperFirst(opts?.normalize ? p.toLowerCase() : p)).join("") : "";
}
function camelCase(str, opts) {
return lowerFirst(pascalCase(str || "", opts));
}
function kebabCase(str, joiner) {
return str ? (Array.isArray(str) ? str : splitByCase(str)).map((p) => p.toLowerCase()).join(joiner ?? "-") : "";
}
function snakeCase(str) {
return kebabCase(str || "", "_");
}
//#endregion
export { kebabCase as n, snakeCase as r, camelCase as t };
+112
View File
@@ -0,0 +1,112 @@
//#region src/types.d.ts
type ArgType = "boolean" | "string" | "enum" | "positional" | undefined;
type _ArgDef<T extends ArgType, VT extends boolean | number | string> = {
type?: T;
description?: string;
valueHint?: string;
alias?: string | string[];
default?: VT;
required?: boolean;
options?: string[];
};
type BooleanArgDef = Omit<_ArgDef<"boolean", boolean>, "options"> & {
negativeDescription?: string;
};
type StringArgDef = Omit<_ArgDef<"string", string>, "options">;
type EnumArgDef = _ArgDef<"enum", string>;
type PositionalArgDef = Omit<_ArgDef<"positional", string>, "alias" | "options">;
type ArgDef = BooleanArgDef | StringArgDef | PositionalArgDef | EnumArgDef;
type ArgsDef = Record<string, ArgDef>;
type Arg = ArgDef & {
name: string;
alias: string[];
};
type ResolveParsedArgType<T extends ArgDef, VT> = T extends {
default?: any;
required?: boolean;
} ? T["default"] extends NonNullable<VT> ? VT : T["required"] extends true ? VT : VT | undefined : VT | undefined;
type ParsedPositionalArg<T extends ArgDef> = T extends {
type: "positional";
} ? ResolveParsedArgType<T, string> : never;
type ParsedStringArg<T extends ArgDef> = T extends {
type: "string";
} ? ResolveParsedArgType<T, string> : never;
type ParsedBooleanArg<T extends ArgDef> = T extends {
type: "boolean";
} ? ResolveParsedArgType<T, boolean> : never;
type ParsedEnumArg<T extends ArgDef> = T extends {
type: "enum";
options: infer U;
} ? U extends Array<any> ? ResolveParsedArgType<T, U[number]> : never : never;
type RawArgs = {
_: string[];
};
type ParsedArg<T extends ArgDef> = T["type"] extends "positional" ? ParsedPositionalArg<T> : T["type"] extends "boolean" ? ParsedBooleanArg<T> : T["type"] extends "string" ? ParsedStringArg<T> : T["type"] extends "enum" ? ParsedEnumArg<T> : never;
type ParsedArgs<T extends ArgsDef = ArgsDef> = RawArgs & { [K in keyof T]: ParsedArg<T[K]> } & { [K in keyof T as T[K] extends {
alias: string;
} ? T[K]["alias"] : never]: ParsedArg<T[K]> } & { [K in keyof T as T[K] extends {
alias: string[];
} ? T[K]["alias"][number] : never]: ParsedArg<T[K]> } & Record<string, string | number | boolean | string[]>;
interface CommandMeta {
name?: string;
version?: string;
description?: string;
hidden?: boolean;
alias?: string | string[];
}
type SubCommandsDef = Record<string, Resolvable<CommandDef<any>>>;
type CommandDef<T extends ArgsDef = ArgsDef> = {
meta?: Resolvable<CommandMeta>;
args?: Resolvable<T>;
default?: Resolvable<string>;
subCommands?: Resolvable<SubCommandsDef>;
plugins?: Resolvable<CittyPlugin>[];
setup?: (context: CommandContext<T>) => any | Promise<any>;
cleanup?: (context: CommandContext<T>) => any | Promise<any>;
run?: (context: CommandContext<T>) => any | Promise<any>;
};
type CommandContext<T extends ArgsDef = ArgsDef> = {
rawArgs: string[];
args: ParsedArgs<T>;
cmd: CommandDef<T>;
subCommand?: CommandDef<T>;
data?: any;
};
type CittyPlugin = {
name: string;
setup?(context: CommandContext<any>): void | Promise<void>;
cleanup?(context: CommandContext<any>): void | Promise<void>;
};
type Awaitable<T> = () => T | Promise<T>;
type Resolvable<T> = T | Promise<T> | (() => T) | (() => Promise<T>);
//#endregion
//#region src/command.d.ts
declare function defineCommand<const T extends ArgsDef = ArgsDef>(def: CommandDef<T>): CommandDef<T>;
interface RunCommandOptions {
rawArgs: string[];
data?: any;
showUsage?: boolean;
}
declare function runCommand<T extends ArgsDef = ArgsDef>(cmd: CommandDef<T>, opts: RunCommandOptions): Promise<{
result: unknown;
}>;
//#endregion
//#region src/usage.d.ts
declare function showUsage<T extends ArgsDef = ArgsDef>(cmd: CommandDef<T>, parent?: CommandDef<T>): Promise<void>;
declare function renderUsage<T extends ArgsDef = ArgsDef>(cmd: CommandDef<T>, parent?: CommandDef<T>): Promise<string>;
//#endregion
//#region src/main.d.ts
interface RunMainOptions {
rawArgs?: string[];
showUsage?: typeof showUsage;
}
declare function runMain<T extends ArgsDef = ArgsDef>(cmd: CommandDef<T>, opts?: RunMainOptions): Promise<void>;
declare function createMain<T extends ArgsDef = ArgsDef>(cmd: CommandDef<T>): (opts?: RunMainOptions) => Promise<void>;
//#endregion
//#region src/args.d.ts
declare function parseArgs<T extends ArgsDef = ArgsDef>(rawArgs: string[], argsDef: ArgsDef): ParsedArgs<T>;
//#endregion
//#region src/plugin.d.ts
declare function defineCittyPlugin(plugin: Resolvable<CittyPlugin>): Resolvable<CittyPlugin>;
//#endregion
export { Arg, ArgDef, ArgType, ArgsDef, Awaitable, BooleanArgDef, CittyPlugin, CommandContext, CommandDef, CommandMeta, EnumArgDef, ParsedArgs, PositionalArgDef, Resolvable, type RunCommandOptions, type RunMainOptions, StringArgDef, SubCommandsDef, _ArgDef, createMain, defineCittyPlugin, defineCommand, parseArgs, renderUsage, runCommand, runMain, showUsage };
+425
View File
@@ -0,0 +1,425 @@
import { n as kebabCase, r as snakeCase, t as camelCase } from "./_chunks/libs/scule.mjs";
import { parseArgs as parseArgs$1 } from "node:util";
//#region src/_utils.ts
function toArray(val) {
if (Array.isArray(val)) return val;
return val === void 0 ? [] : [val];
}
function formatLineColumns(lines, linePrefix = "") {
const maxLength = [];
for (const line of lines) for (const [i, element] of line.entries()) maxLength[i] = Math.max(maxLength[i] || 0, element.length);
return lines.map((l) => l.map((c, i) => linePrefix + c[i === 0 ? "padStart" : "padEnd"](maxLength[i])).join(" ")).join("\n");
}
function resolveValue(input) {
return typeof input === "function" ? input() : input;
}
var CLIError = class extends Error {
code;
constructor(message, code) {
super(message);
this.name = "CLIError";
this.code = code;
}
};
//#endregion
//#region src/_parser.ts
function parseRawArgs(args = [], opts = {}) {
const booleans = new Set(opts.boolean || []);
const strings = new Set(opts.string || []);
const aliasMap = opts.alias || {};
const defaults = opts.default || {};
const aliasToMain = /* @__PURE__ */ new Map();
const mainToAliases = /* @__PURE__ */ new Map();
for (const [key, value] of Object.entries(aliasMap)) {
const targets = value;
for (const target of targets) {
aliasToMain.set(key, target);
if (!mainToAliases.has(target)) mainToAliases.set(target, []);
mainToAliases.get(target).push(key);
aliasToMain.set(target, key);
if (!mainToAliases.has(key)) mainToAliases.set(key, []);
mainToAliases.get(key).push(target);
}
}
const options = {};
function getType(name) {
if (booleans.has(name)) return "boolean";
const aliases = mainToAliases.get(name) || [];
for (const alias of aliases) if (booleans.has(alias)) return "boolean";
return "string";
}
function isStringType(name) {
if (strings.has(name)) return true;
const aliases = mainToAliases.get(name) || [];
for (const alias of aliases) if (strings.has(alias)) return true;
return false;
}
const allOptions = new Set([
...booleans,
...strings,
...Object.keys(aliasMap),
...Object.values(aliasMap).flat(),
...Object.keys(defaults)
]);
for (const name of allOptions) if (!options[name]) options[name] = {
type: getType(name),
default: defaults[name]
};
for (const [alias, main] of aliasToMain.entries()) if (alias.length === 1 && options[main] && !options[main].short) options[main].short = alias;
const processedArgs = [];
const negatedFlags = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--") {
processedArgs.push(...args.slice(i));
break;
}
if (arg.startsWith("--no-")) {
const flagName = arg.slice(5);
negatedFlags[flagName] = true;
continue;
}
processedArgs.push(arg);
}
let parsed;
try {
parsed = parseArgs$1({
args: processedArgs,
options: Object.keys(options).length > 0 ? options : void 0,
allowPositionals: true,
strict: false
});
} catch {
parsed = {
values: {},
positionals: processedArgs
};
}
const out = { _: [] };
out._ = parsed.positionals;
for (const [key, value] of Object.entries(parsed.values)) {
let coerced = value;
if (getType(key) === "boolean" && typeof value === "string") coerced = value !== "false";
else if (isStringType(key) && typeof value === "boolean") coerced = "";
out[key] = coerced;
}
for (const [name] of Object.entries(negatedFlags)) {
out[name] = false;
const mainName = aliasToMain.get(name);
if (mainName) out[mainName] = false;
const aliases = mainToAliases.get(name);
if (aliases) for (const alias of aliases) out[alias] = false;
}
for (const [alias, main] of aliasToMain.entries()) {
if (out[alias] !== void 0 && out[main] === void 0) out[main] = out[alias];
if (out[main] !== void 0 && out[alias] === void 0) out[alias] = out[main];
if (out[alias] !== out[main] && defaults[main] === out[main]) out[main] = out[alias];
}
return out;
}
//#endregion
//#region src/_color.ts
const noColor = /* @__PURE__ */ (() => {
const env = globalThis.process?.env ?? {};
return env.NO_COLOR === "1" || env.TERM === "dumb" || env.TEST || env.CI;
})();
const _c = (c, r = 39) => (t) => noColor ? t : `\u001B[${c}m${t}\u001B[${r}m`;
const bold = /* @__PURE__ */ _c(1, 22);
const cyan = /* @__PURE__ */ _c(36);
const gray = /* @__PURE__ */ _c(90);
const underline = /* @__PURE__ */ _c(4, 24);
//#endregion
//#region src/args.ts
function parseArgs(rawArgs, argsDef) {
const parseOptions = {
boolean: [],
string: [],
alias: {},
default: {}
};
const args = resolveArgs(argsDef);
for (const arg of args) {
if (arg.type === "positional") continue;
if (arg.type === "string" || arg.type === "enum") parseOptions.string.push(arg.name);
else if (arg.type === "boolean") parseOptions.boolean.push(arg.name);
if (arg.default !== void 0) parseOptions.default[arg.name] = arg.default;
if (arg.alias) parseOptions.alias[arg.name] = arg.alias;
const camelName = camelCase(arg.name);
const kebabName = kebabCase(arg.name);
if (camelName !== arg.name || kebabName !== arg.name) {
const existingAliases = toArray(parseOptions.alias[arg.name] || []);
if (camelName !== arg.name && !existingAliases.includes(camelName)) existingAliases.push(camelName);
if (kebabName !== arg.name && !existingAliases.includes(kebabName)) existingAliases.push(kebabName);
if (existingAliases.length > 0) parseOptions.alias[arg.name] = existingAliases;
}
}
const parsed = parseRawArgs(rawArgs, parseOptions);
const [ ...positionalArguments] = parsed._;
const parsedArgsProxy = new Proxy(parsed, { get(target, prop) {
return target[prop] ?? target[camelCase(prop)] ?? target[kebabCase(prop)];
} });
for (const [, arg] of args.entries()) if (arg.type === "positional") {
const nextPositionalArgument = positionalArguments.shift();
if (nextPositionalArgument !== void 0) parsedArgsProxy[arg.name] = nextPositionalArgument;
else if (arg.default === void 0 && arg.required !== false) throw new CLIError(`Missing required positional argument: ${arg.name.toUpperCase()}`, "EARG");
else parsedArgsProxy[arg.name] = arg.default;
} else if (arg.type === "enum") {
const argument = parsedArgsProxy[arg.name];
const options = arg.options || [];
if (argument !== void 0 && options.length > 0 && !options.includes(argument)) throw new CLIError(`Invalid value for argument: ${cyan(`--${arg.name}`)} (${cyan(argument)}). Expected one of: ${options.map((o) => cyan(o)).join(", ")}.`, "EARG");
} else if (arg.required && parsedArgsProxy[arg.name] === void 0) throw new CLIError(`Missing required argument: --${arg.name}`, "EARG");
return parsedArgsProxy;
}
function resolveArgs(argsDef) {
const args = [];
for (const [name, argDef] of Object.entries(argsDef || {})) args.push({
...argDef,
name,
alias: toArray(argDef.alias)
});
return args;
}
//#endregion
//#region src/plugin.ts
function defineCittyPlugin(plugin) {
return plugin;
}
async function resolvePlugins(plugins) {
return Promise.all(plugins.map((p) => resolveValue(p)));
}
//#endregion
//#region src/command.ts
function defineCommand(def) {
return def;
}
async function runCommand(cmd, opts) {
const cmdArgs = await resolveValue(cmd.args || {});
const parsedArgs = parseArgs(opts.rawArgs, cmdArgs);
const context = {
rawArgs: opts.rawArgs,
args: parsedArgs,
data: opts.data,
cmd
};
const plugins = await resolvePlugins(cmd.plugins ?? []);
let result;
let runError;
try {
for (const plugin of plugins) await plugin.setup?.(context);
if (typeof cmd.setup === "function") await cmd.setup(context);
const subCommands = await resolveValue(cmd.subCommands);
if (subCommands && Object.keys(subCommands).length > 0) {
const subCommandArgIndex = findSubCommandIndex(opts.rawArgs, cmdArgs);
const explicitName = opts.rawArgs[subCommandArgIndex];
if (explicitName) {
const subCommand = await _findSubCommand(subCommands, explicitName);
if (!subCommand) throw new CLIError(`Unknown command ${cyan(explicitName)}`, "E_UNKNOWN_COMMAND");
await runCommand(subCommand, { rawArgs: opts.rawArgs.slice(subCommandArgIndex + 1) });
} else {
const defaultSubCommand = await resolveValue(cmd.default);
if (defaultSubCommand) {
if (cmd.run) throw new CLIError(`Cannot specify both 'run' and 'default' on the same command.`, "E_DEFAULT_CONFLICT");
const subCommand = await _findSubCommand(subCommands, defaultSubCommand);
if (!subCommand) throw new CLIError(`Default sub command ${cyan(defaultSubCommand)} not found in subCommands.`, "E_UNKNOWN_COMMAND");
await runCommand(subCommand, { rawArgs: opts.rawArgs });
} else if (!cmd.run) throw new CLIError(`No command specified.`, "E_NO_COMMAND");
}
}
if (typeof cmd.run === "function") result = await cmd.run(context);
} catch (error) {
runError = error;
}
const cleanupErrors = [];
if (typeof cmd.cleanup === "function") try {
await cmd.cleanup(context);
} catch (error) {
cleanupErrors.push(error);
}
for (const plugin of [...plugins].reverse()) try {
await plugin.cleanup?.(context);
} catch (error) {
cleanupErrors.push(error);
}
if (runError) throw runError;
if (cleanupErrors.length === 1) throw cleanupErrors[0];
if (cleanupErrors.length > 1) throw new Error("Multiple cleanup errors", { cause: cleanupErrors });
return { result };
}
async function resolveSubCommand(cmd, rawArgs, parent) {
const subCommands = await resolveValue(cmd.subCommands);
if (subCommands && Object.keys(subCommands).length > 0) {
const subCommandArgIndex = findSubCommandIndex(rawArgs, await resolveValue(cmd.args || {}));
const subCommandName = rawArgs[subCommandArgIndex];
const subCommand = await _findSubCommand(subCommands, subCommandName);
if (subCommand) return resolveSubCommand(subCommand, rawArgs.slice(subCommandArgIndex + 1), cmd);
}
return [cmd, parent];
}
async function _findSubCommand(subCommands, name) {
if (name in subCommands) return resolveValue(subCommands[name]);
for (const sub of Object.values(subCommands)) {
const resolved = await resolveValue(sub);
const meta = await resolveValue(resolved?.meta);
if (meta?.alias) {
if (toArray(meta.alias).includes(name)) return resolved;
}
}
}
function findSubCommandIndex(rawArgs, argsDef) {
for (let i = 0; i < rawArgs.length; i++) {
const arg = rawArgs[i];
if (arg === "--") return -1;
if (arg.startsWith("-")) {
if (!arg.includes("=") && _isValueFlag(arg, argsDef)) i++;
continue;
}
return i;
}
return -1;
}
function _isValueFlag(flag, argsDef) {
const name = flag.replace(/^-{1,2}/, "");
const normalized = camelCase(name);
for (const [key, def] of Object.entries(argsDef)) {
if (def.type !== "string" && def.type !== "enum") continue;
if (normalized === camelCase(key)) return true;
if ((Array.isArray(def.alias) ? def.alias : def.alias ? [def.alias] : []).includes(name)) return true;
}
return false;
}
//#endregion
//#region src/usage.ts
async function showUsage(cmd, parent) {
try {
console.log(await renderUsage(cmd, parent) + "\n");
} catch (error) {
console.error(error);
}
}
const negativePrefixRe = /^no[-A-Z]/;
async function renderUsage(cmd, parent) {
const cmdMeta = await resolveValue(cmd.meta || {});
const cmdArgs = resolveArgs(await resolveValue(cmd.args || {}));
const parentMeta = await resolveValue(parent?.meta || {});
const commandName = `${parentMeta.name ? `${parentMeta.name} ` : ""}` + (cmdMeta.name || process.argv[1]);
const argLines = [];
const posLines = [];
const commandsLines = [];
const usageLine = [];
for (const arg of cmdArgs) if (arg.type === "positional") {
const name = arg.name.toUpperCase();
const isRequired = arg.required !== false && arg.default === void 0;
posLines.push([cyan(name + renderValueHint(arg)), renderDescription(arg, isRequired)]);
usageLine.push(isRequired ? `<${name}>` : `[${name}]`);
} else {
const isRequired = arg.required === true && arg.default === void 0;
const argStr = [...(arg.alias || []).map((a) => `-${a}`), `--${arg.name}`].join(", ") + renderValueHint(arg);
argLines.push([cyan(argStr), renderDescription(arg, isRequired)]);
/**
* print negative boolean arg variant usage when
* - enabled by default or has `negativeDescription`
* - not prefixed with `no-` or `no[A-Z]`
*/
if (arg.type === "boolean" && (arg.default === true || arg.negativeDescription) && !negativePrefixRe.test(arg.name)) {
const negativeArgStr = [...(arg.alias || []).map((a) => `--no-${a}`), `--no-${arg.name}`].join(", ");
argLines.push([cyan(negativeArgStr), [arg.negativeDescription, isRequired ? gray("(Required)") : ""].filter(Boolean).join(" ")]);
}
if (isRequired) usageLine.push(`--${arg.name}` + renderValueHint(arg));
}
if (cmd.subCommands) {
const commandNames = [];
const subCommands = await resolveValue(cmd.subCommands);
for (const [name, sub] of Object.entries(subCommands)) {
const meta = await resolveValue((await resolveValue(sub))?.meta);
if (meta?.hidden) continue;
const aliases = toArray(meta?.alias);
const label = [name, ...aliases].join(", ");
commandsLines.push([cyan(label), meta?.description || ""]);
commandNames.push(name, ...aliases);
}
usageLine.push(commandNames.join("|"));
}
const usageLines = [];
const version = cmdMeta.version || parentMeta.version;
usageLines.push(gray(`${cmdMeta.description} (${commandName + (version ? ` v${version}` : "")})`), "");
const hasOptions = argLines.length > 0 || posLines.length > 0;
usageLines.push(`${underline(bold("USAGE"))} ${cyan(`${commandName}${hasOptions ? " [OPTIONS]" : ""} ${usageLine.join(" ")}`)}`, "");
if (posLines.length > 0) {
usageLines.push(underline(bold("ARGUMENTS")), "");
usageLines.push(formatLineColumns(posLines, " "));
usageLines.push("");
}
if (argLines.length > 0) {
usageLines.push(underline(bold("OPTIONS")), "");
usageLines.push(formatLineColumns(argLines, " "));
usageLines.push("");
}
if (commandsLines.length > 0) {
usageLines.push(underline(bold("COMMANDS")), "");
usageLines.push(formatLineColumns(commandsLines, " "));
usageLines.push("", `Use ${cyan(`${commandName} <command> --help`)} for more information about a command.`);
}
return usageLines.filter((l) => typeof l === "string").join("\n");
}
function renderValueHint(arg) {
const valueHint = arg.valueHint ? `=<${arg.valueHint}>` : "";
const fallbackValueHint = valueHint || `=<${snakeCase(arg.name)}>`;
if (!arg.type || arg.type === "positional" || arg.type === "boolean") return valueHint;
if (arg.type === "enum" && arg.options?.length) return `=<${arg.options.join("|")}>`;
return fallbackValueHint;
}
function renderDescription(arg, required) {
const requiredHint = required ? gray("(Required)") : "";
const defaultHint = arg.default === void 0 ? "" : gray(`(Default: ${arg.default})`);
return [
arg.description,
requiredHint,
defaultHint
].filter(Boolean).join(" ");
}
//#endregion
//#region src/main.ts
async function runMain(cmd, opts = {}) {
const rawArgs = opts.rawArgs || process.argv.slice(2);
const showUsage$1 = opts.showUsage || showUsage;
try {
const builtinFlags = await _resolveBuiltinFlags(cmd);
if (builtinFlags.help.length > 0 && rawArgs.some((arg) => builtinFlags.help.includes(arg))) {
await showUsage$1(...await resolveSubCommand(cmd, rawArgs));
process.exit(0);
} else if (rawArgs.length === 1 && builtinFlags.version.includes(rawArgs[0])) {
const meta = typeof cmd.meta === "function" ? await cmd.meta() : await cmd.meta;
if (!meta?.version) throw new CLIError("No version specified", "E_NO_VERSION");
console.log(meta.version);
} else await runCommand(cmd, { rawArgs });
} catch (error) {
if (error instanceof CLIError) {
await showUsage$1(...await resolveSubCommand(cmd, rawArgs));
console.error(error.message);
} else console.error(error, "\n");
process.exit(1);
}
}
function createMain(cmd) {
return (opts = {}) => runMain(cmd, opts);
}
async function _resolveBuiltinFlags(cmd) {
const argsDef = await resolveValue(cmd.args || {});
const userNames = /* @__PURE__ */ new Set();
const userAliases = /* @__PURE__ */ new Set();
for (const [name, def] of Object.entries(argsDef)) {
userNames.add(name);
for (const alias of toArray(def.alias)) userAliases.add(alias);
}
return {
help: _getBuiltinFlags("help", "h", userNames, userAliases),
version: _getBuiltinFlags("version", "v", userNames, userAliases)
};
}
function _getBuiltinFlags(long, short, userNames, userAliases) {
if (userNames.has(long) || userAliases.has(long)) return [];
if (userNames.has(short) || userAliases.has(short)) return [`--${long}`];
return [`--${long}`, `-${short}`];
}
//#endregion
export { createMain, defineCittyPlugin, defineCommand, parseArgs, renderUsage, runCommand, runMain, showUsage };
+42
View File
@@ -0,0 +1,42 @@
{
"name": "citty",
"version": "0.2.2",
"description": "Elegant CLI Builder",
"license": "MIT",
"repository": "unjs/citty",
"files": [
"dist"
],
"type": "module",
"sideEffects": false,
"types": "./dist/index.d.mts",
"exports": {
".": "./dist/index.mjs"
},
"scripts": {
"build": "obuild",
"dev": "vitest dev",
"lint": "oxlint . && oxfmt --check",
"fmt": "oxlint . --fix && oxfmt",
"prepack": "pnpm run build",
"play": "node ./playground/cli.ts",
"release": "pnpm test && pnpm build && changelogen --release --push && npm publish",
"test": "pnpm lint && pnpm test:types && vitest run --coverage",
"test:types": "tsgo --noEmit"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@typescript/native-preview": "^7.0.0-dev.20260401.1",
"@vitest/coverage-v8": "^4.1.2",
"automd": "^0.4.3",
"changelogen": "^0.6.2",
"eslint-config-unjs": "^0.6.2",
"obuild": "^0.4.32",
"oxfmt": "^0.43.0",
"oxlint": "^1.58.0",
"scule": "^1.3.0",
"typescript": "^6.0.2",
"vitest": "^4.1.2"
},
"packageManager": "pnpm@10.33.0"
}