Crust CLI Framework

repository·main·Indexed 19 days ago

https://github.com/chenxin-yan/crust

A TypeScript-first, Bun-native CLI framework for building modular and composable command-line tools. It provides high-level abstractions for command routing, argument parsing, interactive prompts, and typed persistence. The ecosystem includes @crustjs/core for the command engine, @crustjs/store for typed persistence, @crustjs/prompts for interactive terminals, and tooling for distributing CLIs as standalone binaries or Bun runtime packages.

Tokens
109.5K
Snippets
380
Records
487
Agent score
60%

What's inside Crust

  1. What is Crust?

    main
    Crust is a TypeScript-first, Bun-native framework designed for building command-line applications. It uses a chainable builder pattern to declare commands, providing end-to-end type safety through automatic type inference. The framework is modular, allowing you to opt into specific features (like help, versioning, or completions) via composable modules to keep your application lightweight.
  2. Overview of Crust packages

    main

    Crust is a modular ecosystem of packages designed for building TypeScript and Bun-native CLI tools. Key packages include:

    • @crustjs/crust: CLI tooling for building and distributing standalone executables.
    • @crustjs/core: The engine for command definition, argument parsing, routing, plugins, and error handling.
    • @crustjs/extensions: Official extensions providing help, version, and completion functionality.
    • @crustjs/style: The foundation for terminal styling.
    • @crustjs/progress: Progress indicators for managing async CLI tasks.
    • @crustjs/prompts: Tools for creating interactive terminal prompts.
    • @crustjs/store: Typed persistence with separation for config, data, state, and cache.
    • @crustjs/skills: Generates agent skills from Crust command definitions.
    • @crustjs/create: A headless scaffolding engine for building your own create-xxx tools.
    • create-crust: The primary project scaffolding tool.
  3. The RunnableApp interface

    main

    The testing helpers work with any object that implements the RunnableApp structural contract. An application does not need to extend Crust to be compatible, as long as it provides a run method with the following signature:

    interface RunnableApp {
      run(
        argv: readonly string[],
        io?: {
          stdout?: (text: string) => void;
          stderr?: (text: string) => void;
        },
      ): Promise<void>;
    }

    Note: An inert CommandDefinition does not implement run() and must be mounted into a runnable application before it can be used with these helpers.

  4. How multi-target builds and resolvers work

    main

    When running crust build without a specific --target, the tool generates a distribution package in the dist/ directory containing:

    1. Platform-specific binaries: Named dist/<name>-<target> (e.g., dist/my-cli-bun-darwin-arm64).
    2. A Shell Resolver: A file named dist/cli (customizable via --resolver) which is a #!/usr/bin/env bash script. It detects the host platform via uname and uses exec to run the correct binary with no child process overhead.
    3. A Windows Resolver: A dist/cli.cmd batch file for Windows support.

    This resolver is what you should point to in your package.json's bin field to allow users to run your CLI regardless of their OS.

    # Default multi-target build output structure
    dist/
    ├── cli                                  # Shell resolver (entry point)
    ├── cli.cmd                              # Windows batch resolver
    ├── my-cli-bun-linux-x64-baseline        # Linux x64 binary
    ├── my-cli-bun-linux-arm64               # Linux ARM64 binary
    ├── my-cli-bun-darwin-x64                # macOS Intel binary
    ├── my-cli-bun-darwin-arm64              # macOS Apple Silicon binary
    ├── my-cli-bun-windows-x64-baseline.exe  # Windows x64 binary
    └── my-cli-bun-windows-arm64.exe         # Windows ARM64 binary
  5. How the no-color flag affects environment variables

    main

    The noColor extension implements standard boolean negation for the color flag. It modifies the environment for the duration of the command execution and restores previous values once finished.

    Flag Behavior

    • --color: Sets FORCE_COLOR=3 and clears NO_COLOR. This forces ANSI truecolor output, overriding non-TTY detection. Any library sensitive to FORCE_COLOR will obey this.
    • --no-color: Sets NO_COLOR=1 and clears FORCE_COLOR. This suppresses colors following the no-color.org specification, while still allowing non-color modifiers and hyperlinks to follow standard TTY detection.

    CLI Usage Examples

    my-cli --color
    my-cli --no-color
    my-cli deploy --no-color

    Important Notes

    • No Default: If the flag is omitted, the extension does nothing. Color detection remains the responsibility of @crustjs/style (using standard NO_COLOR, FORCE_COLOR, and TTY detection).
    • Concurrency: In overlapping programmatic runs, the last-writer-wins policy applies while active. The ambient environment is restored once the last run finishes.
  6. Core Concepts of Crust

    main

    To use Crust effectively, understand these core architectural principles:

    • Inference over annotation: Types flow automatically from your command definitions (args and flags) to your command handlers. You do not need to use decorators, code generation, or manual generics.
    • Immutable Builders: Definitions and Extensions are treated as frozen data. Every method call on a builder returns a new builder instance.
    • One-way Pattern: The recommended pattern is to use defineCommand() for every command and instantiate new Crust() exactly once at the root of your application.
    • Composable Modules: The core (@crustjs/core) is zero-dependency. Features like help, versioning, and completions are provided via Extensions attached at the root, and build tooling is provided by @crustjs/crust.
    • Subcommands and Inheritance: You can build nested command trees. Subcommands can inherit flags from parent commands by setting inherit: true.
    • Contexts: These are named command dependencies that are constructed lazily only for the specific command path being resolved.
  7. Avoid multiple .flags() calls on a builder

    main

    When using a builder, calling .flags() multiple times will replace the previous local flag definitions rather than merging them. This behavior differs from the Commander or yargs convention where flags are additive. To ensure all flags are correctly defined and to maintain the best TypeScript type inference, use a single .flags() call containing all flag definitions.

    // ❌ INCORRECT: This will result in only the second set of flags being preserved.
    builder.flags(flagsA).flags(flagsB);
    
    // ✅ CORRECT: Use a single call to include all definitions.
    builder.flags({ ...flagsA, ...flagsB });
  8. Configure flag aliases and negation

    main

    You can define short forms and aliases for flags to provide multiple ways to invoke them.

    • short: A single-character form (e.g., short: "o").
    • aliases: An array of additional long or short forms (e.g., aliases: ["out"]).
    • Boolean Negation: Boolean flags automatically support --no-<spelling> for the canonical name and all long aliases (e.g., if the flag is output, --no-out works).
    • Disabling Negation: Set noNegate: true to reject any --no- prefix with a PARSE error.

    Note: Boolean flags do not accept the --name=true syntax.

    .flags({ name: "output", type: "path", short: "o", aliases: ["out"] })
  9. Configure build-time constants with PUBLIC_*

    main

    The crust build command uses a model similar to Bun's PUBLIC_* prefix. Only environment variables starting with PUBLIC_ are eligible to be embedded into the binary as build-time constants.

    Warning: Embedded PUBLIC_* values are visible in the binary. Never use the PUBLIC_ prefix for secrets like API keys or private tokens.

    | Value                                       | Location                        |
    | ------------------------------------------- | ------------------------------- |
    | API keys, tokens, private deployment config | Runtime environment             |
    | Public API origins, public build labels     | PUBLIC_* build-time constants |
  10. Understand the Crust invocation lifecycle

    main

    When an application is invoked, Crust follows a strict sequence of operations to ensure proper setup, validation, and cleanup:

    1. Prepare definitions: Apply Extensions and check for command/flag/alias collisions.
    2. Route: Resolve the command path.
    3. Parse syntax: Consume positional and flag tokens.
    4. Run preRun hooks: Invoke Extension hooks in .extend() order. Returning ctx.finish() from a hook skips remaining hooks, validation, and the Command Handler.
    5. Validate structure: Enforce required values and choices.
    6. Apply schemas: Validate and transform schema-backed inputs.
    7. Construct Contexts: Create Context values in topological order based on their requirements.
    8. Run the Command Handler: Execute the core logic.
    9. Dispose Contexts: Run Symbol.dispose or Symbol.asyncDispose in reverse construction order (even if a failure occurs).
    10. Run postRun hooks: Invoke Extension hooks in reverse .extend() order. This acts as the finally block for the invocation.
  11. Structure of a generated Crust project

    main

    Every project scaffolded by create-crust includes the following files:

    • src/cli.ts: The entry point containing a sample command.
    • package.json: Configured specifically for your chosen distribution mode (binary or runtime).
    • tsconfig.json: A strict TypeScript configuration.
    • README.md: Initial getting started instructions.
    • .gitignore: Default ignores for Node/Bun projects.