@crustjs/core 0.0.8 → 0.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,21 +13,20 @@ bun add @crustjs/core
13
13
  ## Quick Example
14
14
 
15
15
  ```ts
16
- import { defineCommand, runMain } from "@crustjs/core";
16
+ import { Crust } from "@crustjs/core";
17
17
 
18
- const main = defineCommand({
19
- meta: { name: "greet", description: "Say hello" },
20
- args: [{ name: "name", type: "string", default: "world" }],
21
- flags: {
18
+ const app = new Crust("greet")
19
+ .meta({ description: "Say hello" })
20
+ .args([{ name: "name", type: "string", default: "world" }] as const)
21
+ .flags({
22
22
  loud: { type: "boolean", description: "Shout it", alias: "l" },
23
- },
24
- run({ args, flags }) {
23
+ })
24
+ .run(({ args, flags }) => {
25
25
  const msg = `Hello, ${args.name}!`;
26
26
  console.log(flags.loud ? msg.toUpperCase() : msg);
27
- },
28
- });
27
+ });
29
28
 
30
- runMain(main);
29
+ app.execute();
31
30
  ```
32
31
 
33
32
  ## Documentation
package/dist/index.d.ts CHANGED
@@ -1,3 +1,37 @@
1
+ /**
2
+ * The result of resolving a command from an argv array.
3
+ *
4
+ * Contains the resolved (sub)command and argv after subcommand
5
+ * resolution, and the full command path for help text rendering.
6
+ */
7
+ interface CommandRoute {
8
+ /** The routed command (may be a subcommand of the original) */
9
+ command: CommandNode;
10
+ /** The argv after subcommand names have been consumed */
11
+ argv: string[];
12
+ /** The command path for help text (e.g. ["crust", "generate", "command"]) */
13
+ commandPath: string[];
14
+ }
15
+ /**
16
+ * Resolve a command from an argv array by walking the subcommand tree.
17
+ *
18
+ * Subcommand matching happens BEFORE flag parsing, so:
19
+ * `crust build --entry src/cli.ts` first resolves "build" as a subcommand,
20
+ * then passes `["--entry", "src/cli.ts"]` to the build command's parser.
21
+ *
22
+ * Resolution rules:
23
+ * 1. If `argv[0]` matches a subcommand key, recurse into that subcommand
24
+ * 2. If no match and the current command has `run()`, return it (args passed to parser)
25
+ * 3. If no match and the current command has NO `run()`, it signals the caller
26
+ * should show help (the `showHelp` flag is set in the result)
27
+ * 4. Unknown subcommands produce a structured COMMAND_NOT_FOUND error
28
+ *
29
+ * @param command - The root command to resolve from
30
+ * @param argv - The argv array to resolve against
31
+ * @returns The resolved command, argv, and the command path
32
+ * @throws {CrustError} COMMAND_NOT_FOUND when an unknown subcommand is given and the parent has no run()
33
+ */
34
+ declare function resolveCommand(command: CommandNode, argv: string[]): CommandRoute;
1
35
  /** Supported type literals for args and flags */
2
36
  type ValueType = "string" | "number" | "boolean";
3
37
  /**
@@ -59,10 +93,14 @@ type ArgsDef = readonly ArgDef[];
59
93
  interface FlagDefBase {
60
94
  /** Human-readable description for help text */
61
95
  description?: string;
62
- /** Short alias or array of aliases (e.g. `"v"` or `["v", "V"]`) */
63
- alias?: string | string[];
96
+ /** Single-character short alias (e.g. `"v"` → `-v`) */
97
+ short?: string;
98
+ /** Additional long aliases (e.g. `["out"]` → `--out`) */
99
+ aliases?: string[];
64
100
  /** When `true`, the parser throws if the flag is not provided */
65
101
  required?: true;
102
+ /** When `true`, the flag is inherited by subcommands */
103
+ inherit?: true;
66
104
  }
67
105
  /** Base for single-value flags — `multiple` must be omitted */
68
106
  interface SingleFlagBase extends FlagDefBase {
@@ -119,7 +157,7 @@ interface BooleanMultiFlagDef extends MultiFlagBase {
119
157
  * @example
120
158
  * ```ts
121
159
  * const flags = {
122
- * verbose: { type: "boolean", description: "Enable verbose logging", alias: "v" },
160
+ * verbose: { type: "boolean", description: "Enable verbose logging", short: "v" },
123
161
  * port: { type: "number", description: "Port number", default: 3000 },
124
162
  * files: { type: "string", multiple: true, default: ["index.ts"] },
125
163
  * } satisfies FlagsDef;
@@ -129,18 +167,32 @@ type FlagDef = StringFlagDef | NumberFlagDef | BooleanFlagDef | StringMultiFlagD
129
167
  /** Record mapping flag names to their definitions */
130
168
  type FlagsDef = Record<string, FlagDef>;
131
169
  /**
132
- * Extract alias string literals from any value that has an `alias` field.
170
+ * Extract the `short` alias literal from a flag definition.
171
+ * Resolves to `never` when no `short` field exists or when the type
172
+ * is the broad `string` (not a narrowed literal).
173
+ */
174
+ type ExtractShort<F> = F extends {
175
+ short: infer S;
176
+ } ? S extends string ? string extends S ? never : S : never : never;
177
+ /**
178
+ * Extract alias string literals from the `aliases` array of a flag definition.
179
+ * Resolves to `never` when no `aliases` field exists or when the element type
180
+ * is the broad `string` (not narrowed literals).
181
+ */
182
+ type ExtractLongAliases<F> = F extends {
183
+ aliases: infer A;
184
+ } ? A extends readonly string[] ? string extends A[number] ? never : A[number] : never : never;
185
+ /**
186
+ * Extract all alias identifiers (short + long) from a flag definition.
133
187
  *
134
188
  * Generalized to work with any shape (`FlagDef`, `FlagSpec`, etc.) —
135
- * values without an `alias` field resolve to `never`.
189
+ * values without `short`/`aliases` fields resolve to `never`.
136
190
  *
137
- * Includes a `string extends A` guard so non-narrowed aliases (e.g. the
191
+ * Includes `string extends ...` guards so non-narrowed types (e.g. the
138
192
  * broad `string` type from a default generic) resolve to `never` instead
139
193
  * of causing false-positive collisions.
140
194
  */
141
- type ExtractAliases<F> = F extends {
142
- alias: infer A;
143
- } ? A extends string ? string extends A ? never : A : A extends readonly string[] ? string extends A[number] ? never : A[number] : never : never;
195
+ type ExtractAllAliases<F> = ExtractShort<F> | ExtractLongAliases<F>;
144
196
  /**
145
197
  * Collects aliases from every flag *except* flag K.
146
198
  * Used to detect alias→alias duplicates across different flags.
@@ -148,7 +200,7 @@ type ExtractAliases<F> = F extends {
148
200
  type AliasesExcluding<
149
201
  F extends Record<string, unknown>,
150
202
  K extends keyof F & string
151
- > = { [J in Exclude<keyof F & string, K>] : ExtractAliases<F[J]> }[Exclude<keyof F & string, K>];
203
+ > = { [J in Exclude<keyof F & string, K>] : ExtractAllAliases<F[J]> }[Exclude<keyof F & string, K>];
152
204
  /**
153
205
  * Per-flag collision detection: resolves to the alias literal(s) of flag K
154
206
  * that collide with another flag's name or another flag's alias,
@@ -157,7 +209,7 @@ type AliasesExcluding<
157
209
  type CollidingAliases<
158
210
  F extends Record<string, unknown>,
159
211
  K extends keyof F & string
160
- > = (ExtractAliases<F[K]> & Exclude<keyof F & string, K>) | (ExtractAliases<F[K]> & AliasesExcluding<F, K>);
212
+ > = (ExtractAllAliases<F[K]> & Exclude<keyof F & string, K>) | (ExtractAllAliases<F[K]> & AliasesExcluding<F, K>);
161
213
  /**
162
214
  * Per-flag validation mapped type. Resolves to `F` when no collisions exist.
163
215
  * For flags with colliding aliases, adds a branded error property to the
@@ -167,30 +219,74 @@ type CollidingAliases<
167
219
  * it with `FlagsDef`, the validate package uses it with `FlagShape`, etc.
168
220
  *
169
221
  * ```
170
- * Property 'FIX_ALIAS_COLLISION' is missing in type '{ type: "string"; alias: "minify" }'
222
+ * Property 'FIX_ALIAS_COLLISION' is missing in type '{ type: "string"; short: "m" }'
171
223
  * but required in type
172
- * '{ readonly FIX_ALIAS_COLLISION: "Alias \"minify\" collides with another flag name or alias" }'.
224
+ * '{ readonly FIX_ALIAS_COLLISION: "Alias \"m\" collides with another flag name or alias" }'.
173
225
  * ```
174
226
  */
175
227
  type ValidateFlagAliases<F extends Record<string, unknown>> = { [K in keyof F & string] : CollidingAliases<F, K> extends never ? F[K] : F[K] & {
176
228
  readonly FIX_ALIAS_COLLISION: `Alias "${CollidingAliases<F, K>}" collides with another flag name or alias`;
177
229
  } };
178
230
  /**
231
+ * Collects aliases from inherited flags, excluding those whose keys the
232
+ * child overrides (intentional override — child redefines a flag by name).
233
+ */
234
+ type InheritedAliasesExcluding<
235
+ I extends Record<string, unknown>,
236
+ OverrideKeys extends string
237
+ > = { [K in Exclude<keyof I & string, OverrideKeys>] : ExtractAllAliases<I[K]> }[Exclude<keyof I & string, OverrideKeys>];
238
+ /**
239
+ * Per-flag cross-collision detection between a child flag K (from local
240
+ * flags F) and the inherited flag set I. Resolves to the colliding
241
+ * identifier, or `never` when no collision exists.
242
+ *
243
+ * Detects three collision classes:
244
+ * 1. Child alias → inherited flag name
245
+ * 2. Child alias → inherited flag alias
246
+ * 3. Child flag name → inherited flag alias
247
+ *
248
+ * Intentional name overrides (child defines a flag with the same key as
249
+ * an inherited flag) are excluded — those are handled by `MergeFlags`.
250
+ */
251
+ type CrossCollision<
252
+ I extends Record<string, unknown>,
253
+ F extends Record<string, unknown>,
254
+ K extends keyof F & string
255
+ > = (ExtractAllAliases<F[K]> & Exclude<keyof I & string, keyof F & string>) | (ExtractAllAliases<F[K]> & InheritedAliasesExcluding<I, keyof F & string>) | (K & InheritedAliasesExcluding<I, keyof F & string>);
256
+ /**
257
+ * Per-flag validation mapped type for cross-collisions between inherited
258
+ * and local flags. Resolves to `F` when no collisions exist.
259
+ *
260
+ * When `Inherited` is the wide `FlagsDef` type (root commands with no
261
+ * parent), the validation is skipped to avoid false positives since
262
+ * `keyof FlagsDef` is `string`.
263
+ *
264
+ * ```
265
+ * Property 'FIX_INHERITED_COLLISION' is missing in type '{ type: "string"; aliases: ["verbose"] }'
266
+ * but required in type
267
+ * '{ readonly FIX_INHERITED_COLLISION: "\"verbose\" collides with inherited flag" }'.
268
+ * ```
269
+ */
270
+ type ValidateCrossCollisions<
271
+ I extends Record<string, unknown>,
272
+ F extends Record<string, unknown>
273
+ > = string extends keyof I ? F : { [K in keyof F & string] : CrossCollision<I, F, K> extends never ? F[K] : F[K] & {
274
+ readonly FIX_INHERITED_COLLISION: `"${CrossCollision<I, F, K> & string}" collides with inherited flag`;
275
+ } };
276
+ /**
179
277
  * Detects whether a single alias literal starts with `"no-"`.
180
278
  * Resolves to the offending alias, or `never` when it is clean.
181
279
  */
182
280
  type NoPrefixedAlias<A> = A extends `no-${string}` ? A : never;
183
281
  /**
184
282
  * Collects all `"no-"`-prefixed alias literals from a flag definition.
185
- * Works with both `alias: "no-foo"` (string) and `alias: ["no-foo", "f"]` (array).
283
+ * Checks both `short` and `aliases` fields.
186
284
  * Non-narrowed `string` types resolve to `never` to avoid false positives.
187
285
  */
188
- type NoPrefixedAliases<F> = F extends {
189
- alias: infer A;
190
- } ? A extends string ? string extends A ? never : NoPrefixedAlias<A> : A extends readonly string[] ? string extends A[number] ? never : NoPrefixedAlias<A[number]> : never : never;
286
+ type NoPrefixedAliases<F> = NoPrefixedAlias<ExtractShort<F>> | NoPrefixedAlias<ExtractLongAliases<F>>;
191
287
  /**
192
288
  * Per-flag validation mapped type. Resolves to `F` when no `"no-"` prefixes
193
- * exist on flag names or aliases. For flags with offending names or aliases,
289
+ * exist on flag names, short aliases, or long aliases. For flags with offending values,
194
290
  * adds a branded error property causing a compile-time type error.
195
291
  *
196
292
  * The `"no-"` prefix is reserved for boolean flag negation (`--no-flag`).
@@ -229,6 +325,60 @@ type ValidateVariadicArgs<A extends readonly object[]> = A extends readonly [inf
229
325
  readonly FIX_VARIADIC_POSITION: "Only the last positional argument can be variadic";
230
326
  }, ...ValidateVariadicArgs<Tail>] : readonly [Head, ...ValidateVariadicArgs<Tail>] : readonly [Head] : A;
231
327
  /**
328
+ * Picks only the flags from `F` that have `inherit: true`.
329
+ *
330
+ * Flags without `inherit` (or with `inherit` omitted) are excluded.
331
+ *
332
+ * @example
333
+ * ```ts
334
+ * type Flags = {
335
+ * verbose: { type: "boolean"; inherit: true };
336
+ * port: { type: "number" };
337
+ * };
338
+ * type Result = InheritableFlags<Flags>;
339
+ * // Result = { verbose: { type: "boolean"; inherit: true } }
340
+ * ```
341
+ */
342
+ type InheritableFlags<F extends FlagsDef> = { [K in keyof F as F[K] extends {
343
+ inherit: true;
344
+ } ? K : never] : F[K] };
345
+ /**
346
+ * Merges parent flags with local flags, where local keys override parent keys.
347
+ *
348
+ * @example
349
+ * ```ts
350
+ * type Parent = { verbose: { type: "boolean" }; port: { type: "number" } };
351
+ * type Local = { port: { type: "string" } };
352
+ * type Result = MergeFlags<Parent, Local>;
353
+ * // Result = { verbose: { type: "boolean" }; port: { type: "string" } }
354
+ * ```
355
+ */
356
+ type MergeFlags<
357
+ Parent extends FlagsDef,
358
+ Local extends FlagsDef
359
+ > = Simplify<Omit<Parent, keyof Local> & Local>;
360
+ /**
361
+ * Computes the effective flags for a command by filtering the inherited flags
362
+ * (only those with `inherit: true`) and merging them with local flags.
363
+ *
364
+ * Local flags override inherited flags with the same key.
365
+ *
366
+ * @example
367
+ * ```ts
368
+ * type Inherited = {
369
+ * verbose: { type: "boolean"; inherit: true };
370
+ * port: { type: "number" };
371
+ * };
372
+ * type Local = { output: { type: "string" } };
373
+ * type Result = EffectiveFlags<Inherited, Local>;
374
+ * // Result = { verbose: { type: "boolean"; inherit: true }; output: { type: "string" } }
375
+ * ```
376
+ */
377
+ type EffectiveFlags<
378
+ Inherited extends FlagsDef,
379
+ Local extends FlagsDef
380
+ > = MergeFlags<InheritableFlags<Inherited>, Local>;
381
+ /**
232
382
  * Infer the resolved type for a single ArgDef:
233
383
  *
234
384
  * - **variadic** → `primitive[]`
@@ -322,109 +472,351 @@ interface ParseResult<
322
472
  /** Raw arguments that appeared after the `--` separator */
323
473
  rawArgs: string[];
324
474
  }
325
- /**
326
- * The runtime context object passed to `preRun()`, `run()`, and `postRun()` hooks.
327
- *
328
- * Extends {@link ParseResult} with a back-reference to the resolved command.
329
- */
330
- interface CommandContext<
331
- A extends ArgsDef = ArgsDef,
332
- F extends FlagsDef = FlagsDef
333
- > extends ParseResult<A, F> {
334
- /** The resolved command that is being executed */
335
- command: AnyCommand;
475
+ interface PluginState {
476
+ get<T = unknown>(key: string): T | undefined;
477
+ has(key: string): boolean;
478
+ set(key: string, value: unknown): void;
479
+ delete(key: string): boolean;
480
+ }
481
+ /** A command target accepted by SetupActions */
482
+ type CommandTarget = CommandNode;
483
+ interface SetupActions {
484
+ /**
485
+ * Inject a flag definition into a command's flags object.
486
+ *
487
+ * Use this in plugin `setup()` hooks to register plugin-specific flags
488
+ * (e.g. `--version`, `--help`) so they are recognized by the parser
489
+ * and rendered in help text.
490
+ *
491
+ * The flag is added to `effectiveFlags` only — `localFlags` is left
492
+ * unchanged to distinguish user-defined flags from plugin-injected ones.
493
+ *
494
+ * @param command - The command node to add the flag to
495
+ * @param name - The flag name (e.g. "version")
496
+ * @param def - The flag definition
497
+ */
498
+ addFlag(command: CommandTarget, name: string, def: FlagDef): void;
499
+ /**
500
+ * Inject a subcommand into a command's `subCommands` record.
501
+ *
502
+ * Use this in plugin `setup()` hooks to register plugin-provided commands
503
+ * (e.g. a "skill" management command). If the parent already has a
504
+ * subcommand with the same name (user-defined), the call is silently
505
+ * skipped — user definitions always take priority over plugin injections.
506
+ *
507
+ * @param parent - The parent command node to add the subcommand to
508
+ * @param name - The subcommand name (used for routing)
509
+ * @param command - The subcommand node to register
510
+ */
511
+ addSubCommand(parent: CommandTarget, name: string, command: CommandTarget): void;
512
+ }
513
+ /** Shared context fields available in both setup and middleware phases. */
514
+ interface BaseContext {
515
+ readonly argv: readonly string[];
516
+ readonly rootCommand: CommandNode;
517
+ readonly state: PluginState;
518
+ }
519
+ /** Context passed to plugin `setup()` hooks. */
520
+ interface SetupContext extends BaseContext {}
521
+ /** Context passed to plugin `middleware()` hooks. */
522
+ interface MiddlewareContext extends BaseContext {
523
+ route: Readonly<CommandRoute> | null;
524
+ input: ParseResult | null;
525
+ }
526
+ type Next = () => Promise<void>;
527
+ type PluginMiddleware = (context: MiddlewareContext, next: Next) => void | Promise<void>;
528
+ interface CrustPlugin {
529
+ name?: string;
530
+ setup?: (context: SetupContext, actions: SetupActions) => void | Promise<void>;
531
+ middleware?: PluginMiddleware;
336
532
  }
337
533
  /**
338
- * Configuration object accepted by `defineCommand()`.
534
+ * Internal representation of a single node in the command tree.
339
535
  *
340
- * Identical shape to {@link Command} but uses `NoInfer` on lifecycle-hook
341
- * parameters so TypeScript infers `A` and `F` solely from the `args` / `flags`
342
- * data properties — not from callbacks. This ensures full contextual typing
343
- * (e.g. `description` is `string`, not `any`) when writing command definitions.
344
- *
345
- * Compile-time validation for variadic args and flag alias collisions is
346
- * enforced via parameter-level intersection in `defineCommand()`.
536
+ * Built by the `Crust` builder class; not part of the public API.
537
+ * Each node carries its own local flags, the pre-computed effective
538
+ * (inherited + local merged) flags, positional args, subcommands,
539
+ * plugins, and lifecycle handlers.
347
540
  */
348
- interface CommandDef<
349
- A extends ArgsDef = ArgsDef,
350
- F extends FlagsDef = FlagsDef
351
- > {
541
+ interface CommandNode {
352
542
  /** Command metadata (name, description, usage) */
353
543
  meta: CommandMeta;
544
+ /** Flags defined directly on this command via `.flags()` */
545
+ localFlags: FlagsDef;
546
+ /** Inherited flags merged with local flags (used by the parser) */
547
+ effectiveFlags: FlagsDef;
354
548
  /** Positional argument definitions */
355
- args?: A;
356
- /** Flag definitions */
357
- flags?: F;
358
- /** Named subcommands */
359
- subCommands?: Record<string, AnyCommand>;
549
+ args: ArgsDef | undefined;
550
+ /** Named subcommands keyed by name */
551
+ subCommands: Record<string, CommandNode>;
552
+ /** Plugins registered via `.use()` */
553
+ plugins: CrustPlugin[];
360
554
  /** Called before `run()` — useful for initialization */
361
- preRun?(context: CommandContext<NoInfer<A>, NoInfer<F>>): void | Promise<void>;
555
+ preRun?: (ctx: unknown) => void | Promise<void>;
362
556
  /** The main command handler */
363
- run?(context: CommandContext<NoInfer<A>, NoInfer<F>>): void | Promise<void>;
557
+ run?: (ctx: unknown) => void | Promise<void>;
364
558
  /** Called after `run()` (even if it throws) — useful for teardown */
365
- postRun?(context: CommandContext<NoInfer<A>, NoInfer<F>>): void | Promise<void>;
559
+ postRun?: (ctx: unknown) => void | Promise<void>;
366
560
  }
367
- /** Frozen command object returned by `defineCommand()` and used at runtime. */
368
- interface Command<
561
+ /**
562
+ * Creates a new `CommandNode` with all fields initialized to defaults.
563
+ *
564
+ * @param name - The command name.
565
+ * @returns A fresh `CommandNode` with empty flags, no args, no subcommands,
566
+ * no plugins, and no lifecycle handlers.
567
+ */
568
+ declare function createCommandNode(name: string): CommandNode;
569
+ /**
570
+ * Merges inherited flags (only those with `inherit: true`) with local flags.
571
+ *
572
+ * Local flags override inherited flags with the same key. Non-inheritable
573
+ * flags from the parent are excluded entirely.
574
+ *
575
+ * This is the runtime counterpart of the `EffectiveFlags` utility type.
576
+ *
577
+ * @param inherited - The parent's effective flags (or any FlagsDef).
578
+ * @param local - The current command's locally-defined flags.
579
+ * @returns A new `FlagsDef` containing inherited+local merged flags.
580
+ */
581
+ declare function computeEffectiveFlags(inherited: FlagsDef, local: FlagsDef): FlagsDef;
582
+ /**
583
+ * The runtime context object passed to `preRun()`, `run()`, and `postRun()`
584
+ * lifecycle hooks on the `Crust` builder.
585
+ *
586
+ * Generic parameters:
587
+ * - `A` — positional argument definitions tuple
588
+ * - `F` — the effective (inherited + local merged) flag definitions
589
+ */
590
+ interface CrustCommandContext<
369
591
  A extends ArgsDef = ArgsDef,
370
592
  F extends FlagsDef = FlagsDef
371
593
  > {
372
- /** Command metadata (name, description, usage) */
373
- readonly meta: CommandMeta;
374
- /** Positional argument definitions */
375
- readonly args?: A;
376
- /** Flag definitions */
377
- readonly flags?: F;
378
- /** Named subcommands (always initialized, may be empty) */
379
- readonly subCommands: Record<string, AnyCommand>;
380
- /** Called before `run()` — useful for initialization */
381
- preRun?(context: CommandContext<A, F>): void | Promise<void>;
382
- /** The main command handler */
383
- run?(context: CommandContext<A, F>): void | Promise<void>;
384
- /** Called after `run()` (even if it throws) — useful for teardown */
385
- postRun?(context: CommandContext<A, F>): void | Promise<void>;
594
+ /** Resolved positional arguments, keyed by arg name */
595
+ args: InferArgs<A>;
596
+ /** Resolved flags, keyed by flag name */
597
+ flags: InferFlags<F>;
598
+ /** Raw arguments that appeared after the `--` separator */
599
+ rawArgs: string[];
600
+ /** The resolved command node that is being executed */
601
+ command: CommandNode;
386
602
  }
387
- type AnyCommand = Command<any, any>;
388
603
  /**
389
- * Define a CLI command with full type inference.
604
+ * Build-time validation protocol.
390
605
  *
391
- * The returned command object is frozen (immutable) and typed so that
392
- * `run()`, `preRun()`, and `postRun()` callbacks receive correctly-typed
393
- * `args` and `flags` based on the definitions provided.
606
+ * `crust build` spawns the user's entrypoint as a subprocess with
607
+ * `CRUST_INTERNAL_VALIDATE_ONLY=1`. When `.execute()` detects this env flag
608
+ * it runs validation, surfaces errors via stderr/exitCode, then force-exits.
609
+ */
610
+ declare const VALIDATION_MODE_ENV = "CRUST_INTERNAL_VALIDATE_ONLY";
611
+ /**
612
+ * Chainable builder for defining CLI commands with full type inference.
394
613
  *
395
- * @param config - The command definition config object
396
- * @returns A frozen, readonly Command object
397
- * @throws {CrustError} `DEFINITION` if `meta.name` is missing or empty
614
+ * Generic parameters:
615
+ * - `Inherited` — flags inherited from a parent command (populated by `.command()`)
616
+ * - `Local` — flags defined on this command via `.flags()`
617
+ * - `A` — positional argument definitions
398
618
  *
399
619
  * @example
400
620
  * ```ts
401
- * const cmd = defineCommand({
402
- * meta: { name: "serve", description: "Start dev server" },
403
- * args: [
404
- * { name: "port", type: "number", description: "Port number", default: 3000 },
405
- * ],
406
- * flags: {
407
- * verbose: { type: "boolean", description: "Enable verbose logging", alias: "v" },
408
- * },
409
- * run({ args, flags }) {
410
- * // args.port is typed as number, flags.verbose is typed as boolean | undefined
411
- * console.log(`Starting server on port ${args.port}`);
412
- * },
413
- * });
621
+ * const app = new Crust("my-cli")
622
+ * .flags({
623
+ * verbose: { type: "boolean", short: "v", inherit: true },
624
+ * })
625
+ * .args([{ name: "file", type: "string", required: true }])
626
+ * .run(({ args, flags }) => {
627
+ * console.log(args.file, flags.verbose);
628
+ * });
414
629
  * ```
415
630
  */
416
- declare function defineCommand<
417
- const A extends ArgsDef = ArgsDef,
418
- const F extends FlagsDef = FlagsDef
419
- >(config: CommandDef<A, F> & {
420
- args?: ValidateVariadicArgs<A>;
421
- flags?: ValidateNoPrefixedFlags<ValidateFlagAliases<F>>;
422
- }): Command<A, F>;
631
+ declare class Crust<
632
+ Inherited extends FlagsDef = FlagsDef,
633
+ Local extends FlagsDef = FlagsDef,
634
+ A extends ArgsDef = ArgsDef
635
+ > {
636
+ /** @internal — Phantom property exposing generic parameters for type-level testing */
637
+ readonly _types: {
638
+ inherited: Inherited;
639
+ local: Local;
640
+ args: A;
641
+ };
642
+ /** @internal */
643
+ readonly _node: CommandNode;
644
+ /** @internal — The inherited flags record (runtime counterpart of Inherited generic) */
645
+ readonly _inheritedFlags: FlagsDef;
646
+ /**
647
+ * Create a new root or standalone command builder.
648
+ *
649
+ * @param name - The command name.
650
+ * @throws {CrustError} `DEFINITION` if name is empty or whitespace-only
651
+ */
652
+ constructor(name: string);
653
+ /**
654
+ * @internal — Create a child builder with pre-populated inherited flags.
655
+ * Used by `.command()` to propagate parent flags to the child.
656
+ */
657
+ static _createChild<I extends FlagsDef>(name: string, inheritedFlags: FlagsDef): Crust<I, {}, []>;
658
+ /**
659
+ * @internal — Clone this builder with a new node, preserving generics.
660
+ */
661
+ private _clone;
662
+ /**
663
+ * Set metadata (description, usage) for this command.
664
+ *
665
+ * The command name is already set via the constructor or `.command()`,
666
+ * so only `description` and `usage` can be provided here.
667
+ *
668
+ * Returns a new builder with updated metadata. The original builder
669
+ * is not mutated.
670
+ *
671
+ * @param meta - Metadata fields to set (description, usage)
672
+ * @returns A new `Crust` instance with updated metadata
673
+ */
674
+ meta(meta: Omit<CommandMeta, "name">): Crust<Inherited, Local, A>;
675
+ /**
676
+ * Define local flags for this command.
677
+ *
678
+ * Returns a new builder with updated local flag types. The original
679
+ * builder is not mutated.
680
+ *
681
+ * @param defs - Flag definitions record
682
+ * @returns A new `Crust` instance with the given flags
683
+ * @throws {CrustError} `DEFINITION` if flag names/aliases violate constraints
684
+ */
685
+ flags<const F extends FlagsDef>(defs: F & ValidateNoPrefixedFlags<ValidateFlagAliases<F>> & ValidateCrossCollisions<Inherited, F>): Crust<Inherited, F, A>;
686
+ /**
687
+ * Define positional arguments for this command.
688
+ *
689
+ * Returns a new builder with updated args types. The original
690
+ * builder is not mutated.
691
+ *
692
+ * @param defs - Ordered tuple of positional argument definitions
693
+ * @returns A new `Crust` instance with the given args
694
+ */
695
+ args<const NewA extends ArgsDef>(defs: NewA & ValidateVariadicArgs<NewA>): Crust<Inherited, Local, NewA>;
696
+ /**
697
+ * Set the main command handler.
698
+ *
699
+ * The handler receives a {@link CrustCommandContext} with `args` typed from
700
+ * `.args()` and `flags` typed as `EffectiveFlags<Inherited, Local>` (inherited
701
+ * flags merged with local flags).
702
+ *
703
+ * Returns a new builder with the handler stored. The original builder is
704
+ * not mutated.
705
+ *
706
+ * @param handler - The main command handler function
707
+ * @returns A new `Crust` instance with the handler registered
708
+ */
709
+ run(handler: (ctx: NoInfer<CrustCommandContext<A, EffectiveFlags<Inherited, Local>>>) => void | Promise<void>): Crust<Inherited, Local, A>;
710
+ /**
711
+ * Set the pre-run lifecycle hook.
712
+ *
713
+ * Called before `run()` — useful for initialization and setup.
714
+ * Receives the same {@link CrustCommandContext} as `run()`.
715
+ *
716
+ * @param handler - The pre-run handler function
717
+ * @returns A new `Crust` instance with the preRun handler registered
718
+ */
719
+ preRun(handler: (ctx: NoInfer<CrustCommandContext<A, EffectiveFlags<Inherited, Local>>>) => void | Promise<void>): Crust<Inherited, Local, A>;
720
+ /**
721
+ * Set the post-run lifecycle hook.
722
+ *
723
+ * Called after `run()` (even if it throws) — useful for teardown and cleanup.
724
+ * Receives the same {@link CrustCommandContext} as `run()`.
725
+ *
726
+ * @param handler - The post-run handler function
727
+ * @returns A new `Crust` instance with the postRun handler registered
728
+ */
729
+ postRun(handler: (ctx: NoInfer<CrustCommandContext<A, EffectiveFlags<Inherited, Local>>>) => void | Promise<void>): Crust<Inherited, Local, A>;
730
+ /**
731
+ * Register a plugin on this command.
732
+ *
733
+ * Plugins are collected during `.execute()` and their `setup()` hooks
734
+ * receive `SetupContext` and `SetupActions`. Middleware hooks run in
735
+ * registration order.
736
+ *
737
+ * Returns a new builder with the plugin appended. The original builder
738
+ * is not mutated.
739
+ *
740
+ * @param plugin - The plugin to register
741
+ * @returns A new `Crust` instance with the plugin registered
742
+ */
743
+ use(plugin: CrustPlugin): Crust<Inherited, Local, A>;
744
+ /**
745
+ * Create a subcommand builder pre-typed with this command's inheritable flags.
746
+ *
747
+ * This is the factory method for the file-splitting pattern. The returned
748
+ * builder carries this command's effective flags (filtered for `inherit: true`)
749
+ * as its `Inherited` generic, enabling full type inference in split files
750
+ * without needing `Crust<any, any, any>`.
751
+ *
752
+ * Register the resulting builder with `.command(builder)` on the parent.
753
+ *
754
+ * @param name - Subcommand name (must be non-empty)
755
+ * @returns A new `Crust` builder pre-typed with inherited flags
756
+ * @throws {CrustError} `DEFINITION` if name is empty or whitespace-only
757
+ *
758
+ * @example
759
+ * ```ts
760
+ * // shared.ts
761
+ * const app = new Crust("my-cli")
762
+ * .flags({ verbose: { type: "boolean", inherit: true } });
763
+ *
764
+ * // commands/deploy.ts
765
+ * const deployCmd = app.sub("deploy")
766
+ * .flags({ env: { type: "string", required: true } })
767
+ * .run(({ flags }) => {
768
+ * flags.verbose; // boolean | undefined — typed!
769
+ * flags.env; // string — typed!
770
+ * });
771
+ *
772
+ * // cli.ts
773
+ * app.command(deployCmd).execute();
774
+ * ```
775
+ */
776
+ sub<N extends string>(name: N): Crust<EffectiveFlags<Inherited, Local>, {}, []>;
777
+ /**
778
+ * Register a named subcommand via inline callback.
779
+ *
780
+ * The callback receives a fresh `Crust` builder pre-typed with this
781
+ * command's effective inheritable flags, enabling TypeScript contextual
782
+ * typing to flow inherited flag types into subcommand definitions.
783
+ *
784
+ * @param name - Subcommand name (must be non-empty, unique among siblings)
785
+ * @param cb - Callback that receives a child builder and returns the configured builder
786
+ * @returns A new `Crust` instance with the subcommand registered
787
+ * @throws {CrustError} `DEFINITION` if name is empty or already registered
788
+ */
789
+ command<N extends string>(name: N, cb: (cmd: Crust<EffectiveFlags<Inherited, Local>, {}, []>) => Crust<any, any, any>): Crust<Inherited, Local, A>;
790
+ /**
791
+ * Register a pre-built subcommand builder (from `.sub()`).
792
+ *
793
+ * The builder's name (from its constructor or `.sub()`) is used as the
794
+ * subcommand name. This is the complement to `.sub()` for the
795
+ * file-splitting pattern.
796
+ *
797
+ * @param builder - A pre-configured `Crust` builder instance
798
+ * @returns A new `Crust` instance with the subcommand registered
799
+ * @throws {CrustError} `DEFINITION` if builder name is empty or already registered
800
+ */
801
+ command(builder: Crust<any, any, any>): Crust<Inherited, Local, A>;
802
+ /**
803
+ * Parse `process.argv`, resolve subcommands, run plugins and middleware,
804
+ * and execute the matched command handler.
805
+ *
806
+ * This is the entry point for CLI execution — call it on the root builder.
807
+ *
808
+ * @param options - Optional overrides (e.g. custom `argv` for testing)
809
+ * @returns A promise that resolves when execution completes
810
+ */
811
+ execute(options?: {
812
+ argv?: string[];
813
+ }): Promise<void>;
814
+ }
423
815
  interface CommandNotFoundErrorDetails {
424
816
  input: string;
425
817
  available: string[];
426
818
  commandPath: string[];
427
- parentCommand: AnyCommand;
819
+ parentCommand: CommandNode;
428
820
  }
429
821
  interface ValidationErrorDetails {
430
822
  issues: readonly {
@@ -513,98 +905,5 @@ declare class CrustError<C extends CrustErrorCode = CrustErrorCode> extends Erro
513
905
  declare function parseArgs<
514
906
  A extends ArgsDef = ArgsDef,
515
907
  F extends FlagsDef = FlagsDef
516
- >(command: Command<A, F>, argv: string[]): ParseResult<A, F>;
517
- /**
518
- * The result of resolving a command from an argv array.
519
- *
520
- * Contains the resolved (sub)command and argv after subcommand
521
- * resolution, and the full command path for help text rendering.
522
- */
523
- interface CommandRoute {
524
- /** The routed command (may be a subcommand of the original) */
525
- command: AnyCommand;
526
- /** The argv after subcommand names have been consumed */
527
- argv: string[];
528
- /** The command path for help text (e.g. ["crust", "generate", "command"]) */
529
- commandPath: string[];
530
- }
531
- /**
532
- * Resolve a command from an argv array by walking the subcommand tree.
533
- *
534
- * Subcommand matching happens BEFORE flag parsing, so:
535
- * `crust build --entry src/cli.ts` first resolves "build" as a subcommand,
536
- * then passes `["--entry", "src/cli.ts"]` to the build command's parser.
537
- *
538
- * Resolution rules:
539
- * 1. If `argv[0]` matches a subcommand key, recurse into that subcommand
540
- * 2. If no match and the current command has `run()`, return it (args passed to parser)
541
- * 3. If no match and the current command has NO `run()`, it signals the caller
542
- * should show help (the `showHelp` flag is set in the result)
543
- * 4. Unknown subcommands produce a structured COMMAND_NOT_FOUND error
544
- *
545
- * @param command - The root command to resolve from
546
- * @param argv - The argv array to resolve against
547
- * @returns The resolved command, argv, and the command path
548
- * @throws {CrustError} COMMAND_NOT_FOUND when an unknown subcommand is given and the parent has no run()
549
- */
550
- declare function resolveCommand(command: AnyCommand, argv: string[]): CommandRoute;
551
- interface PluginState {
552
- get<T = unknown>(key: string): T | undefined;
553
- has(key: string): boolean;
554
- set(key: string, value: unknown): void;
555
- delete(key: string): boolean;
556
- }
557
- interface SetupActions {
558
- /**
559
- * Inject a flag definition into a command's flags object.
560
- *
561
- * Use this in plugin `setup()` hooks to register plugin-specific flags
562
- * (e.g. `--version`, `--help`) so they are recognized by the parser
563
- * and rendered in help text.
564
- *
565
- * @param command - The command to add the flag to
566
- * @param name - The flag name (e.g. "version")
567
- * @param def - The flag definition
568
- */
569
- addFlag(command: AnyCommand, name: string, def: FlagDef): void;
570
- /**
571
- * Inject a subcommand into a command's `subCommands` record.
572
- *
573
- * Use this in plugin `setup()` hooks to register plugin-provided commands
574
- * (e.g. a "skill" management command). If the parent already has a
575
- * subcommand with the same name (user-defined), the call is silently
576
- * skipped — user definitions always take priority over plugin injections.
577
- *
578
- * @param parent - The parent command to add the subcommand to
579
- * @param name - The subcommand name (used for routing)
580
- * @param command - The subcommand to register
581
- */
582
- addSubCommand(parent: AnyCommand, name: string, command: AnyCommand): void;
583
- }
584
- /** Shared context fields available in both setup and middleware phases. */
585
- interface BaseContext {
586
- readonly argv: readonly string[];
587
- readonly rootCommand: AnyCommand;
588
- readonly state: PluginState;
589
- }
590
- /** Context passed to plugin `setup()` hooks. */
591
- interface SetupContext extends BaseContext {}
592
- /** Context passed to plugin `middleware()` hooks. */
593
- interface MiddlewareContext extends BaseContext {
594
- route: Readonly<CommandRoute> | null;
595
- input: ParseResult | null;
596
- }
597
- type Next = () => Promise<void>;
598
- type PluginMiddleware = (context: MiddlewareContext, next: Next) => void | Promise<void>;
599
- interface CrustPlugin {
600
- name?: string;
601
- setup?: (context: SetupContext, actions: SetupActions) => void | Promise<void>;
602
- middleware?: PluginMiddleware;
603
- }
604
- interface RunOptions {
605
- argv?: string[];
606
- plugins?: CrustPlugin[];
607
- }
608
- declare function runCommand(command: AnyCommand, options?: RunOptions): Promise<void>;
609
- declare function runMain(command: AnyCommand, options?: RunOptions): Promise<void>;
610
- export { runMain, runCommand, resolveCommand, parseArgs, defineCommand, ValueType, ValidateVariadicArgs, ValidateNoPrefixedFlags, ValidateFlagAliases, SetupContext, SetupActions, RunOptions, PluginMiddleware, ParseResult, MiddlewareContext, InferFlags, InferArgs, FlagsDef, FlagDef, CrustPlugin, CrustErrorCode, CrustError, CommandRoute, CommandMeta, CommandDef, CommandContext, Command, ArgsDef, ArgDef, AnyCommand };
908
+ >(command: CommandNode, argv: string[]): ParseResult<A, F>;
909
+ export { resolveCommand, parseArgs, createCommandNode, computeEffectiveFlags, ValueType, ValidateVariadicArgs, ValidateNoPrefixedFlags, ValidateFlagAliases, ValidateCrossCollisions, VALIDATION_MODE_ENV, SetupContext, SetupActions, PluginMiddleware, ParseResult, MiddlewareContext, MergeFlags, InheritableFlags, InferFlags, InferArgs, FlagsDef, FlagDef, EffectiveFlags, CrustPlugin, CrustErrorCode, CrustError, CrustCommandContext, Crust, CommandRoute, CommandNode, CommandMeta, ArgsDef, ArgDef };
package/dist/index.js CHANGED
@@ -1,459 +1,2 @@
1
1
  // @bun
2
- // src/errors.ts
3
- class CrustError extends Error {
4
- code;
5
- details;
6
- cause;
7
- constructor(code, message, ...details) {
8
- super(message);
9
- this.name = "CrustError";
10
- this.code = code;
11
- this.details = details[0];
12
- }
13
- is(code) {
14
- return this.code === code;
15
- }
16
- withCause(cause) {
17
- this.cause = cause;
18
- return this;
19
- }
20
- }
21
-
22
- // src/command.ts
23
- function defineCommand(config) {
24
- if (!config.meta.name.trim()) {
25
- throw new CrustError("DEFINITION", "defineCommand: meta.name is required and must be a non-empty string");
26
- }
27
- if (config.flags) {
28
- for (const [name, def] of Object.entries(config.flags)) {
29
- if (name.startsWith("no-")) {
30
- const base = name.slice(3);
31
- throw new CrustError("DEFINITION", `Flag name "--${name}" must not start with "no-"; define "${base}" instead and use "--no-${base}" at runtime`);
32
- }
33
- if (def.alias) {
34
- const aliases = Array.isArray(def.alias) ? def.alias : [def.alias];
35
- for (const alias of aliases) {
36
- if (alias.startsWith("no-")) {
37
- throw new CrustError("DEFINITION", `Alias "--${alias}" on flag "--${name}" must not start with "no-"; the "no-" prefix is reserved for boolean negation`);
38
- }
39
- }
40
- }
41
- }
42
- }
43
- const copy = {
44
- ...config,
45
- meta: { ...config.meta },
46
- ...config.args && {
47
- args: config.args.map((def) => ({ ...def }))
48
- },
49
- flags: config.flags ? Object.fromEntries(Object.entries(config.flags).map(([k, v]) => [k, { ...v }])) : {},
50
- subCommands: config.subCommands ? { ...config.subCommands } : {}
51
- };
52
- return Object.freeze(copy);
53
- }
54
- // src/parser.ts
55
- import {
56
- parseArgs as nodeParseArgs
57
- } from "util";
58
- function buildParseArgsOptionDescriptor(flagsDef) {
59
- const options = {};
60
- const aliasToName = {};
61
- if (!flagsDef)
62
- return { options, aliasToName };
63
- const aliasRegistry = new Map;
64
- for (const name of Object.keys(flagsDef)) {
65
- aliasRegistry.set(name, name);
66
- }
67
- for (const [name, def] of Object.entries(flagsDef)) {
68
- if (name.startsWith("no-")) {
69
- const base = name.slice(3);
70
- throw new CrustError("DEFINITION", `Flag name "--${name}" must not start with "no-"; define "${base}" instead and use "--no-${base}" at runtime`);
71
- }
72
- const parseType = def.type === "boolean" ? "boolean" : "string";
73
- const opt = { type: parseType };
74
- if (def.multiple) {
75
- opt.multiple = true;
76
- }
77
- if (def.alias) {
78
- const aliases = Array.isArray(def.alias) ? def.alias : [def.alias];
79
- for (const alias of aliases) {
80
- if (alias.startsWith("no-")) {
81
- throw new CrustError("DEFINITION", `Alias "--${alias}" on flag "--${name}" must not start with "no-"; the "no-" prefix is reserved for boolean negation`);
82
- }
83
- const existing = aliasRegistry.get(alias);
84
- if (existing) {
85
- throw new CrustError("DEFINITION", `Alias collision: "${alias.length === 1 ? "-" : "--"}${alias}" is used by both "--${existing}" and "--${name}"`);
86
- }
87
- aliasRegistry.set(alias, name);
88
- aliasToName[alias] = name;
89
- if (alias.length === 1 && !opt.short) {
90
- opt.short = alias;
91
- } else {
92
- const aliasOpt = { type: parseType };
93
- if (def.multiple) {
94
- aliasOpt.multiple = true;
95
- }
96
- options[alias] = aliasOpt;
97
- }
98
- }
99
- }
100
- options[name] = opt;
101
- }
102
- return { options, aliasToName };
103
- }
104
- function coerceValue(value, type, label) {
105
- if (type === "number") {
106
- const num = Number(value);
107
- if (Number.isNaN(num)) {
108
- throw new CrustError("PARSE", `Expected number for ${label}, got "${value}"`);
109
- }
110
- return num;
111
- }
112
- if (type === "boolean") {
113
- return value === "true" || value === "1";
114
- }
115
- return value;
116
- }
117
- function applyDefaultOrThrow(def, label) {
118
- if (def.default !== undefined)
119
- return def.default;
120
- if (def.required === true) {
121
- throw new CrustError("VALIDATION", `Missing required ${label}`);
122
- }
123
- return;
124
- }
125
- function coerceFlagValue(name, def, parsedValue) {
126
- const label = `--${name}`;
127
- if (def.multiple && Array.isArray(parsedValue)) {
128
- return def.type === "boolean" ? parsedValue.filter((v) => typeof v === "boolean") : parsedValue.map((v) => coerceValue(v, def.type, label));
129
- }
130
- if (def.type === "boolean") {
131
- if (typeof parsedValue === "boolean") {
132
- return parsedValue;
133
- }
134
- throw new CrustError("PARSE", `Expected boolean value for flag "${label}", got ${typeof parsedValue}`);
135
- }
136
- if (typeof parsedValue === "string") {
137
- return coerceValue(parsedValue, def.type, label);
138
- }
139
- if (parsedValue === true) {
140
- return def.default ?? undefined;
141
- }
142
- return parsedValue;
143
- }
144
- function resolveAliases(parsedValues, aliasToName, flagsDef) {
145
- const canonical = {};
146
- for (const key in parsedValues) {
147
- const canonicalName = aliasToName[key] ?? key;
148
- if (!(canonicalName in flagsDef))
149
- continue;
150
- const value = parsedValues[key];
151
- const existing = canonical[canonicalName];
152
- if (existing !== undefined && Array.isArray(existing) && Array.isArray(value)) {
153
- existing.push(...value);
154
- } else {
155
- canonical[canonicalName] = value;
156
- }
157
- }
158
- return canonical;
159
- }
160
- function resolveFlags(flagsDef, parsedValues, aliasToName) {
161
- if (!flagsDef)
162
- return {};
163
- const canonical = resolveAliases(parsedValues, aliasToName, flagsDef);
164
- const resolved = {};
165
- for (const [name, def] of Object.entries(flagsDef)) {
166
- const parsedValue = canonical[name];
167
- if (parsedValue !== undefined) {
168
- resolved[name] = coerceFlagValue(name, def, parsedValue);
169
- continue;
170
- }
171
- resolved[name] = def.default ?? undefined;
172
- }
173
- return resolved;
174
- }
175
- function validateRequiredFlags(flagsDef, resolvedFlags) {
176
- if (!flagsDef)
177
- return;
178
- for (const [name, def] of Object.entries(flagsDef)) {
179
- if (def.required === true && def.default === undefined) {
180
- if (resolvedFlags[name] === undefined) {
181
- throw new CrustError("VALIDATION", `Missing required flag "--${name}"`);
182
- }
183
- }
184
- }
185
- }
186
- function resolveArgs(argsDef, positionals) {
187
- if (!argsDef)
188
- return {};
189
- const resolved = {};
190
- let index = 0;
191
- for (const def of argsDef) {
192
- const { name } = def;
193
- const label = `argument "<${name}>"`;
194
- if (def.variadic) {
195
- const remaining = positionals.slice(index);
196
- if (def.required === true && remaining.length === 0) {
197
- throw new CrustError("VALIDATION", `Missing required ${label}`);
198
- }
199
- resolved[name] = def.type === "string" ? remaining : remaining.map((v) => coerceValue(v, def.type, `<${name}>`));
200
- index = positionals.length;
201
- } else if (index < positionals.length) {
202
- resolved[name] = coerceValue(positionals[index], def.type, `<${name}>`);
203
- index++;
204
- } else {
205
- resolved[name] = applyDefaultOrThrow(def, label);
206
- }
207
- }
208
- return resolved;
209
- }
210
- function validateCanonicalNegationUsage(argv, flagsDef, aliasToName) {
211
- if (!flagsDef)
212
- return;
213
- for (const arg of argv) {
214
- if (arg === "--")
215
- return;
216
- if (!arg.startsWith("--no-"))
217
- continue;
218
- const assignmentIndex = arg.indexOf("=");
219
- const rawName = assignmentIndex === -1 ? arg.slice("--no-".length) : arg.slice("--no-".length, assignmentIndex);
220
- if (!rawName)
221
- continue;
222
- const canonical = aliasToName[rawName];
223
- if (!canonical)
224
- continue;
225
- if (canonical === rawName)
226
- continue;
227
- const def = flagsDef[canonical];
228
- if (def?.type !== "boolean")
229
- continue;
230
- throw new CrustError("PARSE", `Cannot negate alias "--no-${rawName}"; use "--no-${canonical}" instead`);
231
- }
232
- }
233
- function parseArgs(command, argv) {
234
- const argsDef = command.args;
235
- const flagsDef = command.flags;
236
- const { options: parseOptions, aliasToName } = buildParseArgsOptionDescriptor(flagsDef);
237
- validateCanonicalNegationUsage(argv, flagsDef, aliasToName);
238
- let parsed;
239
- try {
240
- parsed = nodeParseArgs({
241
- args: argv,
242
- options: parseOptions,
243
- strict: true,
244
- allowPositionals: true,
245
- allowNegative: true,
246
- tokens: true
247
- });
248
- } catch (error) {
249
- if (error instanceof Error) {
250
- const unknownMatch = error.message.match(/Unknown option '(.+?)'/);
251
- if (unknownMatch) {
252
- throw new CrustError("PARSE", `Unknown flag "${unknownMatch[1]}"`).withCause(error);
253
- }
254
- }
255
- throw new CrustError("PARSE", "Failed to parse command arguments").withCause(error);
256
- }
257
- const rawArgs = [];
258
- const preSeparatorPositionals = [];
259
- if (parsed.tokens) {
260
- let afterSeparator = false;
261
- for (const token of parsed.tokens) {
262
- if (token.kind === "option-terminator") {
263
- afterSeparator = true;
264
- continue;
265
- }
266
- if (token.kind === "positional") {
267
- (afterSeparator ? rawArgs : preSeparatorPositionals).push(token.value ?? "");
268
- }
269
- }
270
- } else {
271
- preSeparatorPositionals.push(...parsed.positionals);
272
- }
273
- const resolvedFlags = resolveFlags(flagsDef, parsed.values, aliasToName);
274
- const resolvedArgs = resolveArgs(argsDef, preSeparatorPositionals);
275
- validateRequiredFlags(flagsDef, resolvedFlags);
276
- return {
277
- args: resolvedArgs,
278
- flags: resolvedFlags,
279
- rawArgs
280
- };
281
- }
282
- // src/router.ts
283
- function resolveCommand(command, argv) {
284
- const path = [command.meta.name];
285
- let current = command;
286
- let routedArgv = argv;
287
- while (routedArgv.length > 0) {
288
- const subCommands = current.subCommands;
289
- if (!subCommands || Object.keys(subCommands).length === 0) {
290
- break;
291
- }
292
- const candidate = routedArgv[0];
293
- if (!candidate || candidate.startsWith("-")) {
294
- break;
295
- }
296
- if (candidate in subCommands) {
297
- current = subCommands[candidate];
298
- path.push(candidate);
299
- routedArgv = routedArgv.slice(1);
300
- continue;
301
- }
302
- if (current.run) {
303
- break;
304
- }
305
- const available = Object.keys(subCommands);
306
- throw new CrustError("COMMAND_NOT_FOUND", `Unknown command "${candidate}".`, {
307
- input: candidate,
308
- available,
309
- commandPath: [...path],
310
- parentCommand: current
311
- });
312
- }
313
- return {
314
- command: current,
315
- argv: routedArgv,
316
- commandPath: path
317
- };
318
- }
319
- // src/run.ts
320
- function createPluginState() {
321
- const map = new Map;
322
- return {
323
- get(key) {
324
- return map.get(key);
325
- },
326
- has(key) {
327
- return map.has(key);
328
- },
329
- set(key, value) {
330
- map.set(key, value);
331
- },
332
- delete(key) {
333
- return map.delete(key);
334
- }
335
- };
336
- }
337
- async function runSetupHooks(plugins, context, actions) {
338
- for (const plugin of plugins) {
339
- if (!plugin.setup)
340
- continue;
341
- await plugin.setup(context, actions);
342
- }
343
- }
344
- async function executeCommand(command, parsed) {
345
- if (!command.run)
346
- return;
347
- const context = {
348
- args: parsed.args,
349
- flags: parsed.flags,
350
- rawArgs: parsed.rawArgs,
351
- command
352
- };
353
- try {
354
- if (command.preRun) {
355
- await command.preRun(context);
356
- }
357
- await command.run(context);
358
- } finally {
359
- if (command.postRun) {
360
- await command.postRun(context);
361
- }
362
- }
363
- }
364
- async function runMiddlewareChain(plugins, context, terminal) {
365
- const stack = plugins.map((plugin) => plugin.middleware).filter((middleware) => Boolean(middleware));
366
- let index = -1;
367
- const dispatch = async (i) => {
368
- if (i <= index) {
369
- throw new CrustError("DEFINITION", "Plugin middleware called next() multiple times");
370
- }
371
- index = i;
372
- if (i === stack.length) {
373
- await terminal();
374
- return;
375
- }
376
- const middleware = stack[i];
377
- if (!middleware) {
378
- throw new CrustError("DEFINITION", "Plugin middleware stack is invalid");
379
- }
380
- await middleware(context, () => dispatch(i + 1));
381
- };
382
- await dispatch(0);
383
- }
384
- async function runCommand(command, options) {
385
- const argv = options?.argv ?? process.argv.slice(2);
386
- const plugins = options?.plugins ?? [];
387
- const actions = {
388
- addFlag(target, name, def) {
389
- if (!target.flags) {
390
- throw new CrustError("DEFINITION", `Cannot add flag "${name}": command "${target.meta.name}" has no flags object.`);
391
- }
392
- target.flags[name] = def;
393
- },
394
- addSubCommand(parent, name, subCommand) {
395
- if (!name.trim()) {
396
- throw new CrustError("DEFINITION", "addSubCommand: name is required and must be a non-empty string");
397
- }
398
- if (parent.subCommands[name])
399
- return;
400
- parent.subCommands[name] = subCommand;
401
- }
402
- };
403
- const middlewareContext = {
404
- argv: [...argv],
405
- rootCommand: command,
406
- state: createPluginState(),
407
- route: null,
408
- input: null
409
- };
410
- try {
411
- await runSetupHooks(plugins, middlewareContext, actions);
412
- let parsed;
413
- let resolvedCommand;
414
- try {
415
- const resolved = resolveCommand(command, [...argv]);
416
- middlewareContext.route = resolved;
417
- parsed = parseArgs(resolved.command, resolved.argv);
418
- middlewareContext.input = {
419
- args: parsed.args,
420
- flags: parsed.flags,
421
- rawArgs: parsed.rawArgs
422
- };
423
- resolvedCommand = resolved.command;
424
- } catch (error) {
425
- await runMiddlewareChain(plugins, middlewareContext, async () => {
426
- throw error;
427
- });
428
- return;
429
- }
430
- await runMiddlewareChain(plugins, middlewareContext, async () => {
431
- await executeCommand(resolvedCommand, parsed);
432
- });
433
- } catch (error) {
434
- if (error instanceof CrustError) {
435
- throw error;
436
- }
437
- if (error instanceof Error) {
438
- throw new CrustError("EXECUTION", error.message).withCause(error);
439
- }
440
- throw new CrustError("EXECUTION", String(error)).withCause(error);
441
- }
442
- }
443
- async function runMain(command, options) {
444
- try {
445
- await runCommand(command, options);
446
- } catch (error) {
447
- const message = error instanceof Error ? error.message : String(error);
448
- console.error(`Error: ${message}`);
449
- process.exitCode = 1;
450
- }
451
- }
452
- export {
453
- runMain,
454
- runCommand,
455
- resolveCommand,
456
- parseArgs,
457
- defineCommand,
458
- CrustError
459
- };
2
+ import{a as P,b as Z,c as _}from"./shared/chunk-stffrp66.js";function I(j){return{meta:{name:j},localFlags:{},effectiveFlags:{},args:void 0,subCommands:{},plugins:[],preRun:void 0,run:void 0,postRun:void 0}}function z(j,q){let J={};for(let[Q,$]of Object.entries(j))if($.inherit===!0)J[Q]=$;for(let[Q,$]of Object.entries(q))J[Q]=$;return J}function R(j,q){let J=[j.meta.name],Q=j,$=q;while($.length>0){let G=Q.subCommands;if(!G||Object.keys(G).length===0)break;let X=$[0];if(!X||X.startsWith("-"))break;if(X in G&&G[X]){Q=G[X],J.push(X),$=$.slice(1);continue}if(Q.run)break;let U=Object.keys(G);throw new Z("COMMAND_NOT_FOUND",`Unknown command "${X}".`,{input:X,available:U,commandPath:[...J],parentCommand:Q})}return{command:Q,argv:$,commandPath:J}}function F(j){for(let[q,J]of Object.entries(j)){if(q.startsWith("no-")){let Q=q.slice(3);throw new Z("DEFINITION",`Flag "--${q}" must not use "no-" prefix; define "${Q}" and negate with "--no-${Q}"`)}if(J.short?.startsWith("no-"))throw new Z("DEFINITION",`Short alias "-${J.short}" on "--${q}" must not use "no-" prefix (reserved for negation)`);if(J.aliases){for(let Q of J.aliases)if(Q.startsWith("no-"))throw new Z("DEFINITION",`Alias "--${Q}" on "--${q}" must not use "no-" prefix (reserved for negation)`)}}}var S="CRUST_INTERNAL_VALIDATE_ONLY",O="__CRUST_VALIDATE_RESULT__";function y(){let j=new Map;return{get(q){return j.get(q)},has(q){return j.has(q)},set(q,J){j.set(q,J)},delete(q){return j.delete(q)}}}function A(j){return{addFlag(q,J,Q){if(J in q.effectiveFlags)j?.push(`Plugin flag "--${J}" on "${q.meta.name}" overrides existing flag`);q.effectiveFlags[J]=Q},addSubCommand(q,J,Q){if(!J.trim())throw new Z("DEFINITION","addSubCommand: name must be a non-empty string");if(q.subCommands[J]){j?.push(`Plugin subcommand "${J}" on "${q.meta.name}" skipped (already exists)`);return}q.subCommands[J]=Q}}}async function x(j,q,J){for(let Q of j){if(!Q.setup)continue;await Q.setup(q,J)}}async function L(j,q,J){let Q=j.map((X)=>X.middleware).filter((X)=>Boolean(X)),$=-1,G=async(X)=>{if(X<=$)throw new Z("DEFINITION","Plugin middleware called next() multiple times");if($=X,X===Q.length){await J();return}let U=Q[X];if(!U)throw new Z("DEFINITION","Plugin middleware stack is invalid");await U(q,()=>G(X+1))};await G(0)}function T(j){let q=[...j.plugins];for(let J of Object.values(j.subCommands))q.push(...T(J));return q}function B(j){if(Object.freeze(j),Object.freeze(j.localFlags),Object.freeze(j.effectiveFlags),Object.freeze(j.meta),Object.freeze(j.plugins),j.args)Object.freeze(j.args);for(let q of Object.values(j.subCommands))B(q);Object.freeze(j.subCommands)}class V{_node;_inheritedFlags;constructor(j){if(!j.trim())throw new Z("DEFINITION","meta.name must be a non-empty string");this._node=I(j),this._inheritedFlags={}}static _createChild(j,q){let J=new V(j);return J._inheritedFlags=q,J}_clone(j){let q=Object.create(Object.getPrototypeOf(this)),J={...this._node,localFlags:{...this._node.localFlags},effectiveFlags:{...this._node.effectiveFlags},subCommands:{...this._node.subCommands},plugins:[...this._node.plugins],meta:{...this._node.meta},args:this._node.args?[...this._node.args]:void 0,...j};return q._node=J,q._inheritedFlags=this._inheritedFlags,q}meta(j){return this._clone({meta:{...this._node.meta,...j}})}flags(j){F(j);let q={};for(let[J,Q]of Object.entries(j))q[J]={...Q};return this._clone({localFlags:q,effectiveFlags:z(this._inheritedFlags,q)})}args(j){let q=j.map((J)=>({...J}));return this._clone({args:q})}run(j){return this._clone({run:j})}preRun(j){return this._clone({preRun:j})}postRun(j){return this._clone({postRun:j})}use(j){return this._clone({plugins:[...this._node.plugins,j]})}sub(j){if(!j.trim())throw new Z("DEFINITION","Subcommand name must be a non-empty string");let q=z(this._inheritedFlags,this._node.localFlags);return V._createChild(j,q)}command(j,q){if(typeof j==="string"){let G=j;if(!q)throw new Z("DEFINITION","command(name, cb) requires a callback");if(!G.trim())throw new Z("DEFINITION","Subcommand name must be a non-empty string");if(this._node.subCommands[G])throw new Z("DEFINITION",`Subcommand "${G}" is already registered`);let X=z(this._inheritedFlags,this._node.localFlags),U=V._createChild(G,X),Y=q(U),W={...Y._node,effectiveFlags:z(Y._inheritedFlags,Y._node.localFlags)};return this._clone({subCommands:{...this._node.subCommands,[G]:W}})}let J=j,Q=J._node.meta.name;if(!Q.trim())throw new Z("DEFINITION","Subcommand name must be a non-empty string");if(this._node.subCommands[Q])throw new Z("DEFINITION",`Subcommand "${Q}" is already registered`);let $={...J._node,effectiveFlags:z(J._inheritedFlags,J._node.localFlags)};return this._clone({subCommands:{...this._node.subCommands,[Q]:$}})}async execute(j){let q=j?.argv??process.argv.slice(2),J=this._node,Q=T(J),$=[],G=y(),X={argv:[...q],rootCommand:J,state:G},U=A($);try{await x(Q,X,U)}catch(W){if(W instanceof Z){console.error(`Error: ${W.message}`),process.exitCode=1;return}let H=W instanceof Error?W.message:String(W);console.error(`Error: ${H}`),process.exitCode=1;return}if(B(J),process.env[S]==="1"){let W=(async()=>{try{let{validateCommandTree:H}=await import("./shared/chunk-hzhxdeqq.js");H(J);for(let K of $)console.warn(`Warning: ${K}`);return{ok:!0}}catch(H){let K=H instanceof Error?H.message:String(H);return console.error(K),process.exitCode=1,{ok:!1,error:H}}})();return globalThis[O]=W,await W,process.exit(process.exitCode??0)}for(let W of $)console.warn(`Warning: ${W}`);let Y={argv:[...q],rootCommand:J,state:G,route:null,input:null};try{let W,H;try{let K=R(J,[...q]);Y.route=K,W=K.command,H=_(W,K.argv),Y.input=H}catch(K){await L(Q,Y,async()=>{throw K});return}await L(Q,Y,async()=>{if(!W.run)return;let K={args:H.args,flags:H.flags,rawArgs:H.rawArgs,command:W},M;try{if(W.preRun)await W.preRun(K);await W.run(K)}catch(D){M=D}if(W.postRun)try{await W.postRun(K)}catch(D){if(!M)M=D;else console.error(`Error in postRun: ${D instanceof Error?D.message:String(D)}`)}if(M)throw M})}catch(W){if(W instanceof Z){console.error(`Error: ${W.message}`),process.exitCode=1;return}if(W instanceof Error){let H=new Z("EXECUTION",W.message).withCause(W);console.error(`Error: ${H.message}`),process.exitCode=1;return}console.error(`Error: ${String(W)}`),process.exitCode=1}}}export{R as resolveCommand,_ as parseArgs,I as createCommandNode,z as computeEffectiveFlags,S as VALIDATION_MODE_ENV,Z as CrustError,V as Crust};
@@ -0,0 +1,2 @@
1
+ // @bun
2
+ import{b as P,c as Q}from"./chunk-stffrp66.js";function M(B){switch(B.type){case"number":return"1";case"boolean":return"true";default:return"sample"}}function S(B){let z=[],K=B.effectiveFlags;for(let[j,G]of Object.entries(K)){if(G.required!==!0||G.default!==void 0)continue;if(z.push(`--${j}`),G.type!=="boolean")z.push(M(G))}let H=B.args;if(H)for(let j of H){if(j.required!==!0||j.default!==void 0)continue;z.push(M(j))}return z}function Y(B){let z=[{command:B,path:[B.meta.name]}],K=new Set;while(z.length>0){let H=z.pop();if(!H)break;let{command:j,path:G}=H;if(K.has(j))continue;K.add(j);try{Q(j,S(j))}catch(J){let L=J instanceof Error?J.message:"Unknown validation error";throw new P("DEFINITION",`Command "${G.join(" ")}" failed runtime validation: ${L}`).withCause(J)}for(let[J,L]of Object.entries(j.subCommands))z.push({command:L,path:[...G,J]})}}export{Y as validateCommandTree};
@@ -0,0 +1,3 @@
1
+ // @bun
2
+ var A=import.meta.require;class W extends Error{code;details;cause;constructor(z,H,...J){super(H);this.name="CrustError",this.code=z,this.details=J[0]}is(z){return this.code===z}withCause(z){return this.cause=z,this}}import{parseArgs as q}from"util";function U(z){let H={},J={};if(!z)return{options:H,aliasToName:J};let B=new Map;for(let j of Object.keys(z))B.set(j,j);for(let[j,G]of Object.entries(z)){if(j.startsWith("no-")){let L=j.slice(3);throw new W("DEFINITION",`Flag "--${j}" must not use "no-" prefix; define "${L}" and negate with "--no-${L}"`)}let K=G.type==="boolean"?"boolean":"string",Q={type:K};if(G.multiple)Q.multiple=!0;if(G.short){if(G.short.startsWith("no-"))throw new W("DEFINITION",`Short alias "-${G.short}" on "--${j}" must not use "no-" prefix (reserved for negation)`);let L=B.get(G.short);if(L)throw new W("DEFINITION",`Alias collision: "-${G.short}" is used by both "--${L}" and "--${j}"`);B.set(G.short,j),J[G.short]=j,Q.short=G.short}if(G.aliases)for(let L of G.aliases){if(L.startsWith("no-"))throw new W("DEFINITION",`Alias "--${L}" on "--${j}" must not use "no-" prefix (reserved for negation)`);let Z=B.get(L);if(Z)throw new W("DEFINITION",`Alias collision: "${L.length===1?"-":"--"}${L}" is used by both "--${Z}" and "--${j}"`);B.set(L,j),J[L]=j;let _={type:K};if(G.multiple)_.multiple=!0;H[L]=_}H[j]=Q}return{options:H,aliasToName:J}}function $(z,H,J){if(H==="number"){let B=Number(z);if(Number.isNaN(B))throw new W("PARSE",`Expected number for ${J}, got "${z}"`);return B}if(H==="boolean")return z==="true"||z==="1";return z}function I(z,H){if(z.default!==void 0)return z.default;if(z.required===!0)throw new W("VALIDATION",`Missing required ${H}`);return}function M(z,H,J){let B=`--${z}`;if(H.multiple&&Array.isArray(J))return H.type==="boolean"?J.filter((j)=>typeof j==="boolean"):J.map((j)=>$(j,H.type,B));if(H.type==="boolean"){if(typeof J==="boolean")return J;throw new W("PARSE",`Expected boolean value for flag "${B}", got ${typeof J}`)}if(typeof J==="string")return $(J,H.type,B);if(J===!0)return H.default??void 0;return J}function h(z,H,J){let B={};for(let j in z){let G=H[j]??j;if(!(G in J))continue;let K=z[j],Q=B[G];if(Q!==void 0&&Array.isArray(Q)&&Array.isArray(K))Q.push(...K);else B[G]=K}return B}function P(z,H,J){if(!z)return{};let B=h(H,J,z),j={};for(let[G,K]of Object.entries(z)){let Q=B[G];if(Q!==void 0){j[G]=M(G,K,Q);continue}j[G]=K.default??void 0}return j}function S(z,H){if(!z)return;for(let[J,B]of Object.entries(z))if(B.required===!0&&B.default===void 0){if(H[J]===void 0)throw new W("VALIDATION",`Missing required flag "--${J}"`)}}function O(z,H){if(!z)return{};let J={},B=0;for(let j of z){let{name:G}=j,K=`argument "<${G}>"`;if(j.variadic){let Q=H.slice(B);if(j.required===!0&&Q.length===0)throw new W("VALIDATION",`Missing required ${K}`);J[G]=j.type==="string"?Q:Q.map((L)=>$(L,j.type,`<${G}>`)),B=H.length}else if(B<H.length)J[G]=$(H[B],j.type,`<${G}>`),B++;else J[G]=I(j,K)}return J}function R(z,H,J){if(!H)return;for(let B of z){if(B==="--")return;if(!B.startsWith("--no-"))continue;let j=B.indexOf("="),G=j===-1?B.slice(5):B.slice(5,j);if(!G)continue;let K=J[G];if(!K)continue;if(K===G)continue;if(H[K]?.type!=="boolean")continue;throw new W("PARSE",`Cannot negate alias "--no-${G}"; use "--no-${K}" instead`)}}function E(z,H){let{args:J,effectiveFlags:B}=z,{options:j,aliasToName:G}=U(B);R(H,B,G);let K;try{K=q({args:H,options:j,strict:!0,allowPositionals:!0,allowNegative:!0,tokens:!0})}catch(X){if(X instanceof Error){let Y=X.message.match(/Unknown option '(.+?)'/);if(Y)throw new W("PARSE",`Unknown flag "${Y[1]}"`).withCause(X)}throw new W("PARSE","Failed to parse command arguments").withCause(X)}let Q=[],L=[];if(K.tokens){let X=!1;for(let Y of K.tokens){if(Y.kind==="option-terminator"){X=!0;continue}if(Y.kind==="positional")(X?Q:L).push(Y.value??"")}}else L.push(...K.positionals);let Z=P(B,K.values,G),_=O(J,L);return S(B,Z),{args:_,flags:Z,rawArgs:Q}}
3
+ export{A as a,W as b,E as c};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crustjs/core",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Core library for the Crust CLI framework",
5
5
  "type": "module",
6
6
  "license": "MIT",