A TypeScript-first CLI builder inspired by Next.js App Router's file-based routing system. Create powerful command-line interfaces with zero configuration using familiar file-based conventions and pre-validated, type-safe command contexts.
- π Simplified ParamsHandler: No more
type
field needed! The system automatically detects whether you're usingmappings
,schema
, or both - π― Context-based architecture: All handlers now receive a consistent context object with environment variables, args, and options
- πͺ Enhanced validation patterns: Support for three validation approaches:
- Mappings-only (with automatic valibot schema generation)
- Schema-only (for complex validation rules)
- Combined (schema + mappings for maximum flexibility)
- π§ Improved error handling: Error handlers now receive full context including the error object
- π Better TypeScript support: Enhanced type inference and discriminated unions
- π File-based routing: Commands defined in
app/
directory with intuitive folder structure - π§ TypeScript-first: Full TypeScript support with proper type definitions
- β‘ Pre-validated data: Commands receive type-safe, pre-validated data from
params.ts
- π AST parsing: TypeScript AST parsing for automatic command metadata extraction
- π‘οΈ Integrated validation: Built-in validation with valibot, no separate
validate.ts
needed - π― Function-based commands: Clean function-based command definitions with dependency injection
- π Real-time development: Changes reflect instantly with mise watch tasks
- π¦ Zero configuration: Works out of the box with sensible defaults
- β‘ Dynamic imports: Generated CLIs use dynamic imports for instant command loading
- π·οΈ Command aliases: Support for command aliases (e.g.,
hi
βhello
,add
βuser create
) - π Middleware support: Global middleware for authentication, logging, and cross-cutting concerns
- π€ Auto-generated types: Environment variable types automatically generated from schema definitions
npm i -D decopin-cli
- Initialize project structure:
mkdir my-cli && cd my-cli
npm init -y
npm install decopin-cli
- Create app directory and your first command:
mkdir -p app/hello
- Create
app/hello/command.ts
:
import type { CommandContext } from '../../dist/types/index.js';
import type { HelloData } from './params.js';
export default async function createCommand(context: CommandContext<HelloData>) {
const { name } = context.validatedData;
console.log(`Hello, ${name}!!!`);
}
- Create
app/hello/params.ts
for type-safe argument validation:
import type { ParamsHandler } from 'decopin-cli';
export type HelloData = {
name: string;
};
export default function createParams(): ParamsHandler {
return {
mappings: [
{
field: 'name',
type: 'string',
option: 'name',
argIndex: 0,
defaultValue: 'World',
description: 'Name to greet'
},
],
};
}
- Generate your CLI:
npx decopin-cli build
- Test your CLI:
node dist/cli.js hello Alice
# Output: Hello, Alice!
node dist/cli.js hello --name Bob
# Output: Hello, Bob!
# Using aliases
node dist/cli.js hi Alice
# Output: Hello, Alice!
app/
βββ version.ts # Version configuration
βββ hello/ # Simple hello command
β βββ command.ts
β βββ params.ts
β βββ help.ts
βββ user/ # Nested user command group
βββ create/ # user create - Create a user
β βββ command.ts
β βββ params.ts
β βββ error.ts
β βββ help.ts
βββ list/ # user list - List users
βββ command.ts
βββ params.ts
βββ error.ts
βββ help.ts
decopin-cli uses a simple function pattern where commands are async functions that receive pre-validated contexts:
// decopin-cli approach
export default async function createCommand(context: CommandContext<HelloData>) {
const { name } = context.validatedData; // Already validated and typed!
console.log(`Hello, ${name}!!!`);
}
Defines the main command logic. Exports an async function by default and receives a type-safe context.
import type { CommandContext } from '../../dist/types/index.js';
import type { UserData } from './params.js';
export default async function createCommand(context: CommandContext<UserData>) {
const { name, email } = context.validatedData; // Pre-validated data
// Main command logic
console.log(`Creating user: ${name} (${email})`);
}
Requirements:
- Provide async function as default export
- Accept
CommandContext<T>
orBaseCommandContext
- When
params.ts
exists, validated data is available viacontext.validatedData
Defines command argument types, validation schemas, and mapping configurations. The system automatically determines which pattern you're using based on the properties you provide - no explicit type
field needed!
You can choose between three approaches:
Automatically generates valibot schemas from mappings with built-in type coercion for CLI inputs:
import type { ParamsHandler, Context } from 'decopin-cli';
export default function createParams(context: Context<typeof process.env>): ParamsHandler {
return {
mappings: [
{
field: 'name',
type: 'string',
option: 'name', // --name option
argIndex: 0, // 1st positional argument
required: true,
description: 'User name',
},
{
field: 'age',
type: 'number', // Automatically converts string to number
option: 'age', // --age option
argIndex: 1, // 2nd positional argument
defaultValue: 18,
},
{
field: 'active',
type: 'boolean', // Converts "true", "1", "yes" to true
option: 'active',
defaultValue: true,
},
],
};
}
Type coercion rules:
number
: Converts strings to numbers (e.g., "123" β 123)boolean
: Converts "true", "1", "yes" to true, others to falsearray
: Splits comma-separated strings (e.g., "a,b,c" β ["a", "b", "c"])object
: Parses JSON strings (e.g., '{"key":"value"}' β {key: "value"})
Use valibot schemas directly for detailed validation rules:
import * as v from 'valibot';
import type { ParamsHandler, Context } from 'decopin-cli';
const UserSchema = v.object({
arg0: v.pipe(
v.string(),
v.email('Invalid email format'),
v.endsWith('@company.com', 'Must be a company email')
),
arg1: v.pipe(
v.string(),
v.minLength(8, 'Password must be at least 8 characters'),
v.regex(/[A-Z]/, 'Must contain uppercase letter'),
v.regex(/[0-9]/, 'Must contain number')
),
role: v.optional(
v.picklist(['admin', 'user', 'guest'], 'Invalid role'),
'user'
)
});
export type UserData = v.InferInput<typeof UserSchema>;
export default function createParams(context: Context<typeof process.env>): ParamsHandler {
return {
schema: UserSchema,
};
}
For complex validation scenarios where you need both custom validation rules and automatic argument mapping:
import * as v from 'valibot';
import type { ParamsHandler } from 'decopin-cli';
const UserSchema = v.object({
name: v.pipe(v.string(), v.minLength(1, 'Name is required')),
email: v.pipe(v.string(), v.email('Invalid email format')),
age: v.optional(v.pipe(v.number(), v.minValue(0), v.maxValue(150)), 25),
});
export type UserData = v.InferInput<typeof UserSchema>;
export default function createParams(): ParamsHandler {
return {
schema: UserSchema,
mappings: [
{
field: 'name',
option: 'name',
argIndex: 0,
},
{
field: 'email',
option: 'email',
argIndex: 1,
},
{
field: 'age',
option: 'age',
},
],
};
}
Features:
- Validation Options: Valibot schema for powerful validation or simple mappings
- Argument Mapping: Flexible mapping between positional and option arguments
- Default Values: Default value configuration within schema
- Priority: Positional arguments β Option arguments β Default values
- Type Safety: Full TypeScript support with both patterns
When to use each pattern:
- Mappings: Best for simple CLIs with straightforward argument handling
- Valibot Schema: Best for complex validation, advanced type transformations, nested objects
Defines custom error handlers for validation errors and command execution errors.
import type { ValiError } from 'valibot';
export default function createErrorHandler() {
return async function handleError(error: ValiError<any>) {
console.error('π« Input error occurred:');
for (const issue of error.issues) {
const field = issue.path?.join('.') || 'unknown';
console.error(` β’ ${field}: ${issue.message}`);
}
console.error('\nπ‘ Correct format: my-cli user create <name> <email>');
process.exit(1);
};
}
Use Cases:
- Customize validation error display
- User-friendly error messages
- Provide additional help information
Defines a global error handler that catches errors from commands without custom error handlers. Place this file in the root app/
directory.
import type { GlobalErrorHandler, CLIError } from 'decopin-cli';
import { isValidationError, isModuleError, hasStackTrace } from 'decopin-cli';
export default function createGlobalErrorHandler(): GlobalErrorHandler {
return async (error: CLIError) => {
console.error('\nβ An error occurred\n');
// Type-safe error handling
if (isValidationError(error)) {
// Valibot validation error
console.error('π Validation Error:');
error.issues.forEach((issue) => {
const path = issue.path?.map(p => p.key).join('.') || 'value';
console.error(` β’ ${path}: ${issue.message}`);
});
} else if (isModuleError(error)) {
// Module loading error
console.error('π¦ Module Error:');
console.error(` ${error.message}`);
} else {
// Runtime error
console.error('π₯ Error Details:');
console.error(` ${error.message}`);
}
// Show stack trace in debug mode
if (process.env.DEBUG && hasStackTrace(error)) {
console.error('\nπ Stack Trace:');
console.error(error.stack);
}
process.exit(1);
};
}
Features:
- Catches unhandled errors from any command
- Fallback when no command-specific error.ts exists
- Supports debug mode with stack traces
- Type-safe error handling with proper TypeScript types
Error Types:
ValidationError
- Valibot validation errors withissues
arrayModuleError
- Node.js module loading errors with errorcode
Error
- Standard JavaScript/runtime errors- Type guards available:
isValidationError()
,isModuleError()
,hasStackTrace()
Defines detailed command help information, usage examples, aliases, etc.
import type { HelpHandler } from 'decopin-cli';
export default function createHelp(): HelpHandler {
return {
name: 'user create',
description: 'Create a new user',
examples: [
'user create "John Doe" "john@example.com"',
'user create --name "Alice" --email "alice@example.com"',
'user create "Bob" --email "bob@test.com" --age 30'
],
aliases: ['add-user', 'new-user'],
additionalHelp: `
This command creates a new user.
Name and email address are required. Age is optional with a default value of 25.
`.trim()
};
}
Provided Information:
- Command description
- List of usage examples
- Command aliases
- Additional help text
Defines CLI version information and metadata (place in root app/version.ts
).
import type { VersionHandler } from '../dist/index.js';
export default function createVersion(): VersionHandler {
return {
version: "1.2.0",
metadata: {
name: "my-awesome-cli",
version: "1.2.0",
description: "My awesome CLI tool",
author: "Developer Name <dev@example.com>",
homepage: "https://github.com/username/my-cli",
license: "MIT"
}
};
}
Configuration Items:
- version: Version string
- metadata: CLI-wide metadata
- name: CLI name
- version: CLI version
- description: CLI description
- author: Author information
- homepage: Project homepage (optional)
- license: License information (optional)
Minimal Configuration:
app/simple/
βββ command.ts # Basic command
Complete Configuration:
app/complex/
βββ command.ts # Main logic
βββ params.ts # Argument definition
βββ error.ts # Error handling
βββ help.ts # Help information
With Middleware:
app/
βββ middleware.ts # Global middleware (optional)
βββ user/
βββ create/
βββ command.ts # Main logic
βββ params.ts # Argument definition
With Global Error Handler:
app/
βββ global-error.ts # Global error handler (optional)
βββ middleware.ts # Global middleware (optional)
βββ commands/
βββ command.ts # Commands without error.ts use global handler
βββ params.ts
Validation is integrated into params.ts
, providing type-safe parameter handling using valibot schemas:
app/hello/
βββ params.ts # β
Types + valibot schema + mappings
βββ command.ts # β
Command logic (receives validated data)
decopin-cli automatically handles argument validation and type conversion based on your params.ts
configuration:
my-cli user create "John Doe" "john@example.com"
my-cli user create --name "John Doe" --email "john@example.com"
my-cli user create "Jane" --email "jane@example.com"
# name will be "Jane" (from position 0), not from --name option
For development, use the built-in mise configuration for automatic CLI regeneration:
# Install mise if you haven't already
curl https://mise.run | sh
# Start development mode with auto-regeneration
npm run dev
npm run build
npx decopin-cli build --app-dir app --output-dir examples
decopin-cli build [options]
Options:
--output-dir <dir>
: Output directory (default:dist
)--output-file <file>
: Output file name (default:cli.js
)--app-dir <dir>
: App directory path (default:app
)--cli-name <name>
: CLI name for generated files
decopin-cli --help
Shows available commands and options.
decopin-cli --version
Shows the current version of decopin-cli.
Create a global-error.ts
in your app root for centralized error handling:
// app/global-error.ts
import type { GlobalErrorHandler, CLIError } from 'decopin-cli';
import { isValidationError, isModuleError } from 'decopin-cli';
export default function createGlobalErrorHandler(): GlobalErrorHandler {
return async (error: CLIError) => {
// Log errors to file for debugging
const errorLog = `[${new Date().toISOString()}] ${error.message}\n`;
await fs.appendFile('cli-errors.log', errorLog).catch(() => {});
// User-friendly error display
if (isValidationError(error)) {
console.error('β Invalid input provided:');
error.issues.forEach(issue => {
console.error(` - ${issue.message}`);
});
console.error('\nRun with --help for usage information.');
} else {
console.error('β An unexpected error occurred.');
if (process.env.DEBUG) {
console.error(error);
}
}
process.exit(1);
};
}
Commands with parameters receive a CommandContext<T>
with pre-validated data:
interface CommandContext<T> {
validatedData: T; // Pre-validated typed data from params.ts
args: string[]; // Positional arguments
options: Record<string, string | boolean>; // Named options
params: Record<string, string>; // Dynamic route parameters
showHelp: () => void; // Function to show command help
}
For commands that don't need parameters, simply omit the params.ts
file:
// app/status/command.ts
import type { BaseCommandContext } from 'decopin-cli';
export default async function createCommand(context: BaseCommandContext) {
console.log('β
Application is running');
}
Create help.ts
to provide detailed command documentation:
// app/hello/help.ts
import type { HelpHandler } from 'decopin-cli';
export default function createHelp(): HelpHandler {
return {
name: 'hello',
description: 'Say hello to someone',
examples: [
'hello Alice',
'hello --name Bob',
'hello "Alice Smith"'
],
aliases: ['hi', 'greet'],
additionalHelp: 'This command greets a person with a friendly hello message.'
};
}
export default async function createCommand(context: CommandContext<UserData>) {
const { name, email } = context.validatedData;
try {
await createUser(name, email);
console.log('β
User created successfully!');
} catch (error) {
console.error('β Error:', error.message);
process.exit(1);
}
}
All handlers in decopin-cli follow a consistent context-based pattern, providing a unified interface for accessing command execution information:
-
Command Handler (
command.ts
)export default async function createCommand(context: CommandContext<T, E>) { const { validatedData, args, env, options } = context; // Direct command implementation }
-
Params Handler (
params.ts
)export default function createParams(context: Context<E>): ParamsHandler { // Can access environment during initialization const { env } = context; return { mappings: [...] // or schema: ... }; }
-
Error Handler (
error.ts
)export default async function createErrorHandler(context: ErrorContext<T, E>) { const { error, validatedData, args, env } = context; // Custom error handling with full context }
-
Middleware Factory (
middleware.ts
)export default function createMiddleware(context: Context<E>) { return async (middlewareContext: MiddlewareContext, next: NextFunction) => { // Middleware logic with access to factory context }; }
-
Global Error Handler (
global-error.ts
)export default function createGlobalErrorHandler(context: Context<E>) { return async (error: unknown) => { // Global error handling with factory context }; }
- Context: Basic context with args, env, command, and options
- CommandContext: Extends Context with validatedData
- ErrorContext: Extends CommandContext with error property
- MiddlewareContext: Used within middleware execution
decopin-cli provides robust environment variable handling with automatic type generation:
Create app/env.ts
to define your environment schema:
import type { EnvHandler } from '../dist/types/index.js';
import { SCHEMA_TYPE } from '../dist/types/index.js';
const envSchema = {
NODE_ENV: {
type: SCHEMA_TYPE.STRING,
required: false,
default: 'development',
errorMessage: 'NODE_ENV must be development, production, or test',
},
API_KEY: {
type: SCHEMA_TYPE.STRING,
required: true,
minLength: 10,
errorMessage: 'API_KEY is required and must be at least 10 characters',
},
PORT: {
type: SCHEMA_TYPE.NUMBER,
required: false,
default: 3000,
min: 1000,
max: 65535,
errorMessage: 'PORT must be between 1000 and 65535',
},
} as const;
export default function createEnv(): EnvHandler {
return envSchema;
}
Types are automatically generated during build:
npm run build # Types auto-generated to app/generated/env-types.ts
npm run generate:env-types # Manually generate types
The generated app/generated/env-types.ts
:
// This file is auto-generated. Do not edit manually.
export interface AppEnv {
NODE_ENV: string;
API_KEY: string;
PORT: number;
}
Import the generated type for type-safe environment access:
import type { CommandContext } from '../../../dist/types/index.js';
import type { AppEnv } from '../../generated/env-types.js';
export default async function createCommand(context: CommandContext<UserData, AppEnv>) {
const { API_KEY, NODE_ENV } = context.env; // Type-safe access
console.log(`Environment: ${NODE_ENV}`);
// Use API_KEY for authentication...
}
Benefits:
- Single source of truth: Schema defines both validation and types
- Type safety: Full TypeScript support for environment variables
- Auto-sync: Types always match your schema definition
- Build-time generation: No runtime overhead
Note: The app/generated/
directory is automatically excluded from file watching to prevent build loops during development.
decopin-cli supports global middleware for cross-cutting concerns like authentication, logging, and error handling. Create app/middleware.ts
to define middleware that runs before every command:
// app/middleware.ts
import type { MiddlewareFactory, MiddlewareContext, NextFunction } from '../dist/types/middleware.js';
const createMiddleware: MiddlewareFactory = () => {
return async (context: MiddlewareContext<typeof process.env>, next: NextFunction) => {
// Pre-execution logic
console.log(`Executing command: ${context.command}`);
const startTime = Date.now();
try {
// Call the next middleware or command
await next();
// Post-execution logic
const duration = Date.now() - startTime;
console.log(`Command completed in ${duration}ms`);
} catch (error) {
// Global error handling
console.error('Command failed:', error);
throw error;
}
};
};
export default createMiddleware;
Middleware Context:
interface MiddlewareContext<Env> {
command: string; // Command path (e.g., 'user/create')
args: string[]; // Positional arguments
options: Record<string, string | boolean>; // CLI options
env: Env; // Environment variables
}
Common Middleware Use Cases:
- Authentication: Check auth tokens before command execution
- Logging: Log command execution for debugging
- Performance Monitoring: Measure command execution time
- Error Handling: Centralized error handling and reporting
- Environment Setup: Initialize services or configurations
Example: Authentication Middleware
export default function createMiddleware(): MiddlewareFactory {
return async (context, next) => {
// Check for auth flag
if (context.options.auth) {
const token = context.env.AUTH_TOKEN;
if (!token) {
console.error('β Authentication required. Set AUTH_TOKEN environment variable.');
process.exit(1);
}
console.log('β
Authenticated');
}
await next();
};
}
- Pre/Post action hooks: Execute logic before and after command execution
- Global and command-specific hooks: Support both CLI-wide and per-command hooks
- Error handling hooks: Custom error processing hooks
// Planned API
// app/hooks.ts - Global hooks
export const hooks = {
preAction: async (context) => {
console.log(`About to execute: ${context.command.name}`);
},
postAction: async (context, result) => {
console.log(`Completed: ${context.command.name}`);
},
};
// app/user/create/hooks.ts - Command-specific hooks
export default {
preAction: async (context) => {
// Validate user permissions before creating
},
};
- Multi-shell support: Generate completion scripts for bash, zsh, fish, PowerShell
- Dynamic completion: Context-aware completion based on current command state
- Custom completion functions: User-defined completion logic
# Planned CLI options
decopin-cli build --completion=bash > my-cli-completion.bash
decopin-cli build --completion=zsh > _my-cli
decopin-cli build --completion=fish > my-cli.fish
# Auto-install completions
decopin-cli build --install-completion=bash
- Option choices: Restrict option values to predefined sets
- Option conflicts/implies: Define option dependencies and conflicts
- Variadic options: Support for multiple values per option
- Option groups: Group related options in help output
- Shell Autocompletion - High priority, essential for production CLIs
- Lifecycle Hooks - Medium priority, useful for complex workflows
- Advanced Option Features - Lower priority, nice-to-have features
We welcome contributions! If you'd like to work on any of these features, please:
- Open an issue to discuss the implementation approach
- Check existing issues to avoid duplicate work
- Follow our coding standards and testing practices
MIT License - see LICENSE for details.
- Inspired by Next.js App Router's file-based routing
- Built with TypeScript and modern Node.js features
- Uses valibot for type-safe validation
decopin-cli - Build CLIs like you build Next.js apps! π