Pastel

repository·main·Indexed 25 days ago

https://github.com/vadimdemedes/pastel

A Next.js-like framework for building command-line interfaces (CLIs) using React via Ink. Pastel leverages Zod for type-safe command options and arguments and uses Commander.js under the hood. It features automatic command discovery from a commands directory, support for subcommands, aliases, and a custom app wrapper via _app.tsx.

Tokens
4.2K
Snippets
15
Records
23
Agent score
32%

What's inside pastel

  1. How commands work in Pastel

    main

    Pastel treats every file in the commands folder as a command. The filename (excluding extension) becomes the command name. Each command file must export a React component that renders the command's output.

    To define type-safe options and arguments, export an options object created with zod. The component receives these options as props.

    Example command (commands/login.tsx):

    import React from 'react';
    import {Text} from 'ink';
    
    export default function Login() {
    	return <Text>Logging in</Text>;
    }
  2. Create a custom app wrapper with _app.tsx

    main

    Pastel wraps every command component with a component exported from commands/_app.tsx. If this file exists, you can use it to inject shared logic, context, or styling across all commands in your CLI.

    If _app.tsx is missing, Pastel uses a default component that simply renders your command with options and args props.

    import React from 'react';
    import type {AppProps} from 'pastel';
    
    export default function App({Component, commandProps}: AppProps) {
    	// Add shared logic here
    	return <Component {...commandProps} />;
    }
  3. Define and use command options with Zod

    main

    Pastel uses Zod to define, parse, and validate command options. To implement options for a command, export a variable named options containing a Zod object schema. Pastel will automatically parse this schema and pass the validated values to your component via the options prop. Help messages are automatically generated using the .describe() method on your Zod schema.

    import React from 'react';
    import {Text} from 'ink';
    import zod from 'zod';
    
    export const options = zod.object({
    	name: zod.string().describe('Server name'),
    	os: zod.enum(['Ubuntu', 'Debian']).describe('Operating system'),
    	memory: zod.number().describe('Memory size'),
    	region: zod.enum(['waw', 'lhr', 'nyc']).describe('Region'),
    });
    
    type Props = {
    	options: zod.infer<typeof options>;
    };
    
    export default function Deploy({options}: Props) {
    	return (
    		<Text>
    			Deploying a server named "{options.name}" running {options.os} with memory
    			size of {options.memory} MB in {options.region} region
    		</Text>
    	);
    }
  4. Use index commands as the default execution

    main

    Files named index.tsx inside the commands folder are treated as index commands. They are executed when the CLI is run without any specific command name.

    This is ideal for single-purpose CLIs.

    Example structure:

    commands/
    	index.tsx
    	login.tsx

    Running my-cli will execute index.tsx.

  5. Create subcommands using nested folders

    main

    To group related commands, create nested folders within the commands directory. The folder name becomes the first part of the command, and the filename becomes the second.

    Example structure:

    commands/
    	domains/
    		list.tsx
    		add.tsx

    This allows users to run my-cli domains list or my-cli domains add.

  6. Define positional arguments using Zod

    main

    Arguments in Pastel are positional values that do not require flags (e.g., --name). They are defined using Zod schemas and passed to the command component via the args prop.

    • Fixed number of arguments: Use zod.tuple([...]) when the exact number and order of arguments matter (e.g., a mv command).
    • Variable number of arguments: Use zod.array(...) when a command can accept any number of arguments (e.g., an rm command).
  7. Scaffold a Pastel app with create-pastel-app

    main

    The fastest way to get started is using the create-pastel-app scaffolding tool, which sets up a TypeScript project with a linter and tests pre-configured.

    npm create pastel-app hello-world
    cd hello-world
    npm create pastel-app hello-world
    hello-world
  8. Manual setup for Pastel

    main

    If you prefer to set up your project manually, follow these steps:

    1. Initialize project: mkdir hello-world && cd hello-world && npm init --yes
    2. Install dependencies: npm install pastel and npm install --save-dev typescript @sindresorhus/tsconfig
    3. Configure TypeScript: Create a tsconfig.json extending @sindresorhus/tsconfig with outDir: "build", sourceMap: true, and include: ["source"].
    4. Create source directory: mkdir source.
    5. Create CLI entrypoint: Create source/cli.ts:
      #!/usr/bin/env node
      import Pastel from 'pastel';
      
      const app = new Pastel({
        importMeta: import.meta,
      });
      
      await app.run();
    6. Create commands directory: mkdir source/commands.
    7. Define a command: Create source/commands/index.tsx (see 'How commands work' for details).
    8. Build: Run npx tsc.
    9. Configure executable: Add "bin": "build/cli.js" to your package.json and run npm link --global to make it available system-wide.
    #!/usr/bin/env node
    import Pastel from 'pastel';
    
    const app = new Pastel({
    	importMeta: import.meta,
    });
    
    await app.run();
  9. Set default values for arguments

    main

    You can define default values for arguments using Zod's .default() method. These values will be automatically used if the user does not provide the argument, and the default value will be displayed in the --help output.

    If you want the help message to show a formatted version of the default (e.g., adding commas to a large number), use the defaultValueDescription option within the argument() helper.

    export const args = zod.tuple([
    	zod
    		.number()
    		.default(1024)
    		.describe(
    			argument({
    				name: 'number',
    				description: 'Some number',
    				defaultValueDescription: '1,024',
    			}),
    		),
    ]);
  10. Configure boolean negated options

    main

    When a boolean option is configured with a default value of true, Pastel automatically supports a negated flag by adding the no- prefix to the option name. This allows users to explicitly set the value to false.

    import React from 'react';
    import {Text} from 'ink';
    import zod from 'zod';
    
    export const options = zod.object({
    	compress: zod.boolean().default(true).describe("Don't compress output"),
    });
    
    type Props = {
    	options: zod.infer<typeof options>;
    };
    
    export default function Example({options}: Props) {
    	return <Text>Compress = {String(options.compress)}</Text>;
    }

    Usage:

    $ my-cli --no-compress
    Compress = false
  11. Make options optional or required

    main

    By default, all options defined in a Zod schema are required. To make an option optional (so the command can run without it), use the .optional() method in your Zod schema.

    import React from 'react';
    import {Text} from 'ink';
    import zod from 'zod';
    
    export const options = zod.object({
    	os: zod.enum(['Ubuntu', 'Debian']).optional().describe('Operating system'),
    });
    
    type Props = {
    	options: zod.infer<typeof options>;
    };
    
    export default function Example({options}: Props) {
    	return <Text>Operating system = {options.os ?? 'unspecified'}</Text>;
    }