
Createcli
- 115 installs
- 17.2k repo stars
- Updated August 1, 2026
- danielmiessler/personal_ai_infrastructure
Scaffold and extend CLI commands that expose PAI capabilities—scripts, flags, subcommands—for terminal-driven agent workflows.
About
createcli from danielmiessler/personal_ai_infrastructure guides building command-line interfaces atop Personal AI Infrastructure: naming commands, structuring subcommands, wiring agent tools to stdin/stdout, and publishing repeatable terminal workflows that complement interactive agent sessions.
- CLI scaffolding templates for PAI
- Subcommand and flag conventions
- Scriptable agent invocations
- Local dev and packaging guidance
- Integration with core skill layout
Createcli by the numbers
- 115 all-time installs (skills.sh)
- Ranked #239 of 550 CLI & Terminal skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/danielmiessler/personal_ai_infrastructure --skill createcliAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 115 |
|---|---|
| repo stars | ★ 17.2k |
| Last updated | August 1, 2026 |
| Repository | danielmiessler/personal_ai_infrastructure ↗ |
What it does
Scaffold and extend CLI commands that expose PAI capabilities—scripts, flags, subcommands—for terminal-driven agent workflows.
Files
Customization
Before executing, check for user customizations at: ~/.claude/PAI/USER/SKILLCUSTOMIZATIONS/CreateCLI/
If this directory exists, load and apply any PREFERENCES.md, configurations, or resources found there. These override default behavior. If the directory does not exist, proceed with skill defaults.
🚨 MANDATORY: Voice Notification (REQUIRED BEFORE ANY ACTION)
You MUST send this notification BEFORE doing anything else when this skill is invoked.
1. Send voice notification:
curl -s -X POST http://localhost:31337/notify \
-H "Content-Type: application/json" \
-d '{"message": "Running the WORKFLOWNAME workflow in the CreateCLI skill to ACTION"}' \
> /dev/null 2>&1 &2. Output text notification:
Running the **WorkflowName** workflow in the **CreateCLI** skill to ACTION...This is not optional. Execute this curl command immediately upon skill invocation.
CreateCLI
Automated CLI Generation System
Generate production-ready TypeScript CLIs with comprehensive documentation, type safety, error handling, and CLI-First Architecture principles.
---
Workflow Routing
Route to the appropriate workflow based on the request.
When executing a workflow, output this notification directly:
Running the **WorkflowName** workflow in the **CreateCLI** skill to ACTION...- Create a new CLI tool from scratch →
Workflows/CreateCli.md - Add a new command to existing CLI →
Workflows/AddCommand.md - Upgrade CLI to higher tier →
Workflows/UpgradeTier.md
---
🚀 WHEN TO ACTIVATE THIS SKILL
Activate when you see these patterns:
Direct Requests
- "Create a CLI for [API/service/tool]"
- "Build a command-line interface for X"
- "Make a CLI that does Y"
- "Generate a TypeScript CLI"
- "I need a CLI tool for Z"
Context Clues
- User describes repetitive API calls → Suggest CLI
- User mentions "I keep typing this command" → Suggest CLI wrapper
- User has bash script doing complex work → Suggest TypeScript CLI replacement
- User working with API that lacks official CLI → Suggest creating one
Examples
- ✅ "Create a CLI for the GitHub API"
- ✅ "Build a command-line tool to process CSV files"
- ✅ "Make a CLI for my database migrations"
- ✅ "Generate a CLI that wraps this API"
- ✅ "I need a tool like llcli but for Notion API"
---
💡 CORE CAPABILITIES
Three-Tier Template System
Tier 1: llcli-Style (DEFAULT - 80% of use cases)
- Manual argument parsing (process.argv)
- Zero framework dependencies
- Bun + TypeScript
- Type-safe interfaces
- ~300-400 lines total
- Perfect for: API clients, data transformers, simple automation
When to use Tier 1:
- ✅ 2-10 commands
- ✅ Simple arguments (flags, values)
- ✅ JSON output
- ✅ No subcommands
- ✅ Fast development
Tier 2: Commander.js (ESCALATION - 15% of use cases)
- Framework-based parsing
- Subcommands + nested options
- Auto-generated help
- Plugin-ready
- Perfect for: Complex multi-command tools
When to use Tier 2:
- ❌ 10+ commands needing grouping
- ❌ Complex nested options
- ❌ Plugin architecture
- ❌ Multiple output formats
Tier 3: oclif (REFERENCE ONLY - 5% of use cases)
- Documentation only (no templates)
- Enterprise-grade plugin systems
- Perfect for: Heroku CLI, Salesforce CLI scale (rare)
What Every Generated CLI Includes
1. Complete Implementation
- TypeScript source with full type safety
- All commands functional and tested
- Error handling with proper exit codes
- Configuration management
2. Comprehensive Documentation
- README.md with philosophy, usage, examples
- QUICKSTART.md for common patterns
- Inline help text (--help)
- API response documentation
3. Development Setup
- package.json (Bun configuration)
- tsconfig.json (strict mode)
- .env.example (configuration template)
- File permissions configured
4. Quality Standards
- Type-safe throughout
- Deterministic output (JSON)
- Composable (pipes to jq, grep)
- Error messages with context
- Exit code compliance
---
🏗️ INTEGRATION WITH PAI
Technology Stack Alignment
Generated CLIs follow PAI standards:
- ✅ Runtime: Bun (NOT Node.js)
- ✅ Language: TypeScript (NOT JavaScript or Python)
- ✅ Package Manager: Bun (NOT npm/yarn/pnpm)
- ✅ Testing: Vitest (when tests added)
- ✅ Output: Deterministic JSON (composable)
- ✅ Documentation: README + QUICKSTART (llcli pattern)
Repository Placement
Generated CLIs go to:
~/.claude/Bin/[cli-name]/- Personal CLIs (like llcli)~/Projects/[project-name]/- Project-specific CLIs${PROJECTS_DIR}/PAI/Examples/clis/- Example CLIs (PUBLIC repo)
SAFETY: Always verify repository location before git operations
CLI-First Architecture Principles
Every generated CLI follows: 1. Deterministic - Same input → Same output 2. Clean - Single responsibility 3. Composable - JSON output pipes to other tools 4. Documented - Comprehensive help and examples 5. Testable - Predictable behavior
---
📚 EXTENDED CONTEXT
For detailed information, read these files:
Workflow Documentation
Workflows/CreateCli.md- Main CLI generation workflow (decision tree, 10-step process)Workflows/AddCommand.md- Add commands to existing CLIsWorkflows/UpgradeTier.md- Migrate simple → complex
Reference Documentation
FrameworkComparison.md- Manual vs Commander vs oclif (with research)Patterns.md- Common CLI patterns (from llcli analysis)TypescriptPatterns.md- Type safety patterns (from tsx, vite, bun research)
---
📖 EXAMPLES
Example 1: API Client CLI (Tier 1)
User Request: "Create a CLI for the GitHub API that can list repos, create issues, and search code"
Generated Structure:
~/.claude/Bin/ghcli/
├── ghcli.ts # 350 lines, complete implementation
├── package.json # Bun + TypeScript
├── tsconfig.json # Strict mode
├── .env.example # GITHUB_TOKEN=your_token
├── README.md # Full documentation
└── QUICKSTART.md # Common use casesUsage:
ghcli repos --user exampleuser
ghcli issues create --repo pai --title "Bug fix"
ghcli search "typescript CLI"
ghcli --help---
Example 2: File Processor (Tier 1)
User Request: "Build a CLI to convert markdown files to HTML with frontmatter extraction"
Generated Structure:
~/.claude/Bin/md2html/
├── md2html.ts
├── package.json
├── README.md
└── QUICKSTART.mdUsage:
md2html convert input.md output.html
md2html batch *.md output/
md2html extract-frontmatter post.md---
Example 3: Data Pipeline (Tier 2)
User Request: "Create a CLI for data transformation with multiple formats, validation, and analysis commands"
Generated Structure:
~/.claude/Bin/data-cli/
├── data-cli.ts # Commander.js with subcommands
├── package.json
├── README.md
└── QUICKSTART.mdUsage:
data-cli convert json csv input.json
data-cli validate schema data.json
data-cli analyze stats data.csv
data-cli transform filter --column=status --value=active---
✅ QUALITY STANDARDS
Every generated CLI must pass these gates:
1. Compilation
- ✅ TypeScript compiles with zero errors
- ✅ Strict mode enabled
- ✅ No
anytypes except justified
2. Functionality
- ✅ All commands work as specified
- ✅ Error handling comprehensive
- ✅ Exit codes correct (0 success, 1 error)
3. Documentation
- ✅ README explains philosophy and usage
- ✅ QUICKSTART has common examples
- ✅ --help text comprehensive
- ✅ All flags/options documented
4. Code Quality
- ✅ Type-safe throughout
- ✅ Clean function separation
- ✅ Error messages actionable
- ✅ Configuration externalized
5. Integration
- ✅ Follows PAI tech stack (Bun, TypeScript)
- ✅ CLI-First Architecture principles
- ✅ Deterministic output (JSON)
- ✅ Composable with other tools
---
🎯 PHILOSOPHY
Why This Skill Exists
Developers repeatedly create CLIs for APIs and tools. Each time: 1. Starts with bash script 2. Realizes it needs error handling 3. Realizes it needs help text 4. Realizes it needs type safety 5. Rewrites in TypeScript 6. Adds documentation 7. Now has production CLI
This skill automates steps 1-7.
The llcli Pattern
The llcli CLI (Limitless.ai API) proves this pattern works:
- 327 lines of TypeScript
- Zero dependencies (no framework)
- Complete error handling
- Comprehensive documentation
- Production-ready immediately
This skill replicates that success.
Design Principles
1. Start Simple - Default to Tier 1 (llcli-style) 2. Escalate When Needed - Tier 2 only when justified 3. Complete, Not Scaffold - Every CLI is production-ready 4. Documentation First - README explains "why" not just "how" 5. Type Safety - TypeScript strict mode always
---
🔗 RELATED SKILLS
- development - For complex feature development (not CLI-specific)
- mcp - For web scraping CLIs (Bright Data, Apify wrappers)
- lifelog - Example of skill using llcli
---
This skill turns "I need a CLI for X" into production-ready tools in minutes, following proven patterns from llcli and CLI-First Architecture.
Gotchas
- Always use bun, never npm/npx. Zero exceptions per system prompt.
- TypeScript only. Never generate Python CLIs unless the user explicitly approves.
- 3-tier system: Start with the simplest tier that fits. Don't over-engineer a Tier 3 CLI when Tier 1 suffices.
Execution Log
After completing any workflow, append a single JSONL entry:
echo '{"ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","skill":"CreateCLI","workflow":"WORKFLOW_USED","input":"8_WORD_SUMMARY","status":"ok|error","duration_s":SECONDS}' >> ~/.claude/PAI/MEMORY/SKILLS/execution.jsonlReplace WORKFLOW_USED with the workflow executed, 8_WORD_SUMMARY with a brief input description, and SECONDS with approximate wall-clock time. Log status: "error" if the workflow failed.
CLI Framework Comparison
Comprehensive analysis of TypeScript CLI frameworks for informed tier selection
---
🎯 Quick Recommendation Matrix
| Use Case | Framework | Why |
|---|---|---|
| API Client (2-10 commands) | Manual Parsing (Tier 1) | Zero deps, 300 lines, production-ready |
| File Processor (simple args) | Manual Parsing (Tier 1) | Fast development, type-safe, composable |
| Multi-Tool (10+ commands) | Commander.js (Tier 2) | Subcommands, auto-help, proven |
| Plugin System (extensible) | oclif (Tier 3) | Enterprise-grade, reference only |
Rule: Default to Manual → escalate to Commander → reference oclif only
---
📊 Framework Comparison Table
| Framework | Stars | Bundle Size | TypeScript | Best For | Tier |
|---|---|---|---|---|---|
| Manual Parsing | N/A | 0 KB | Native | Simple CLIs (llcli) | Tier 1 ⭐ DEFAULT |
| Commander.js | 25K+ | ~100 KB | Built-in | General CLIs | Tier 2 |
| oclif | 12K+ | 22+ MB | First-class | Enterprise plugins | Tier 3 (ref only) |
| cleye | N/A | Small | Schema inference | Modern TS CLIs | Alternative |
| citty | N/A | Moderate | Discriminated unions | Complex type safety | Alternative |
| Yargs | 30K+ | Larger | @types | Config-heavy | Not recommended |
---
1️⃣ TIER 1: Manual Parsing (llcli Pattern)
Pattern
#!/usr/bin/env bun
async function main() {
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === '--help') {
showHelp();
return;
}
const command = args[0];
switch (command) {
case 'today':
await fetchToday();
break;
case 'date':
if (!args[1]) {
console.error('Error: date requires YYYY-MM-DD argument');
process.exit(1);
}
await fetchDate(args[1]);
break;
case 'search':
const keyword = args[1];
const limitIdx = args.indexOf('--limit');
const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1]) : 20;
await fetchSearch(keyword, limit);
break;
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
}
}
main().catch(error => {
console.error('Fatal:', error);
process.exit(1);
});Pros
- ✅ Zero dependencies (no node_modules bloat)
- ✅ Complete control over parsing logic
- ✅ Type-safe with TypeScript interfaces
- ✅ 300-400 lines total (easy to understand)
- ✅ Fast development (no framework learning curve)
- ✅ Proven pattern (llcli is production-ready)
- ✅ Perfect for Bun runtime
- ✅ Deterministic behavior
Cons
- ❌ Manual help text (but this ensures quality)
- ❌ Manual argument parsing (but simple)
- ❌ No built-in subcommand routing (use Tier 2 if needed)
- ❌ Repetitive for 20+ commands (escalate at that point)
When to Use (DEFAULT)
- ✅ 2-10 commands
- ✅ API client wrappers
- ✅ Data transformers
- ✅ File processors
- ✅ Simple automation tools
- ✅ JSON output only
- ✅ Fast development priority
Reference Implementation
Location: ~/.claude/Bin/llcli/llcli.ts (327 lines) Commands: today, date, search Pattern: Exactly what this tier generates
---
2️⃣ TIER 2: Commander.js
Pattern
#!/usr/bin/env bun
import { Command } from 'commander';
const program = new Command();
program
.name('mycli')
.description('Production CLI tool')
.version('1.0.0');
program
.command('convert <format> <input>')
.option('-o, --output <file>', 'output file')
.option('--verbose', 'verbose logging')
.action((format: string, input: string, options) => {
console.log(`Converting ${input} to ${format}`);
if (options.output) {
console.log(`Output: ${options.output}`);
}
});
program
.command('validate')
.argument('<file>', 'file to validate')
.option('--strict', 'strict mode')
.action((file: string, options) => {
console.log(`Validating ${file}`);
});
program.parse();Pros
- ✅ Auto-generated help (from command definitions)
- ✅ Subcommand routing built-in
- ✅ Fluent API (readable, chainable)
- ✅ TypeScript definitions included
- ✅ Large community (25K+ stars)
- ✅ Well-documented
- ✅ Option parsing automatic
- ✅ Lightweight (~100 KB, zero sub-dependencies)
Cons
- ❌ Framework dependency (not zero-dep like Tier 1)
- ❌ Learning curve (need to understand API)
- ❌ Opinionated structure
- ❌ Overkill for simple CLIs (use Tier 1 instead)
- ❌ Bun may prefer zero-dep approach
When to Use (ESCALATION)
- ❌ 10+ commands needing organization
- ❌ Subcommands (e.g.,
cli convert json csvvscli convert csv json) - ❌ Plugin architecture needed
- ❌ Complex option combinations
- ❌ Multiple output format engines
- ❌ Git-style command groups
Example Use Case
# Data transformation CLI with subcommands
data-cli convert json csv input.json --output data.csv
data-cli convert csv json input.csv
data-cli validate schema data.json --strict
data-cli analyze stats data.csv
data-cli analyze trends data.csv --window 7dPattern: Commands naturally group into categories (convert, validate, analyze)
---
3️⃣ TIER 3: oclif (Reference Only)
Pattern
import { Command, Flags, Args } from '@oclif/core';
export default class Hello extends Command {
static description = 'Say hello';
static examples = [
'<%= config.bin %> <%= command.id %> --name World',
];
static flags = {
name: Flags.string({
char: 'n',
description: 'name to greet',
required: true,
}),
verbose: Flags.boolean({ char: 'v' }),
};
static args = {
file: Args.string({ description: 'file to process' }),
};
async run() {
const { flags, args } = await this.parse(Hello);
this.log(`Hello ${flags.name}!`);
}
}Pros
- ✅ Enterprise-grade plugin system
- ✅ Code generation (
oclif generate command) - ✅ Topics for hierarchical commands
- ✅ Auto-updates mechanism
- ✅ Multi-command CLIs (Heroku, Salesforce scale)
- ✅ Class-based commands (OOP style)
- ✅ ES modules + CommonJS compatible
Cons
- ❌ Heavy bundle size (22+ MB)
- ❌ Steep learning curve
- ❌ Complex setup
- ❌ Overkill for 99% of CLIs
- ❌ Not aligned with PAI's minimal approach
When to Reference (RARE)
- Enterprise plugin systems (Heroku CLI scale)
- 50+ commands with complex organization
- Auto-update mechanisms critical
- Multi-tenant CLI platforms
Note: This skill does NOT generate oclif CLIs. Documentation only for reference.
---
🔬 RESEARCH FINDINGS: Type-Safe Frameworks
cleye (Schema-Driven Inference)
Pattern:
import { cli } from 'cleye';
const argv = cli({
name: 'mycli',
flags: {
noCache: {
type: Boolean,
description: 'Disable cache',
},
tsconfig: {
type: String,
description: 'Path to tsconfig',
},
},
parameters: ['<script path>'],
});
// argv.flags.noCache → boolean
// argv.flags.tsconfig → string | undefined
// argv._.scriptPath → string | undefinedKey Insight: TypeScript infers full shape from flag definitions (zero manual typing)
Use When:
- Zero boilerplate preference
- Modern TypeScript CLI
- Full type inference needed
Trade-off vs Tier 1:
- + Type inference automatic
- - Framework dependency
- - Less control over parsing
---
citty (Discriminated Unions)
Pattern:
import { defineCommand, runMain } from 'citty';
const convert = defineCommand({
meta: {
name: 'convert',
description: 'Convert files',
},
args: {
format: {
type: 'positional',
description: 'Output format',
required: true,
},
strict: {
type: 'boolean',
description: 'Strict mode',
},
},
async run({ args }) {
// args.format → string (required)
// args.strict → boolean | undefined
console.log(`Converting to ${args.format}`);
},
});
runMain(convert);Key Insight: Discriminated unions provide exhaustive type checking
Use When:
- Complex command trees
- Type safety critical
- Argument validation needed
Trade-off vs Tier 1:
- + Advanced type safety
- - Framework abstraction
- - Additional dependency
---
📈 DECISION CRITERIA
Choose Manual Parsing (Tier 1) If:
- [ ] CLI has 2-10 simple commands
- [ ] Commands take basic arguments (strings, numbers, flags)
- [ ] Output is JSON only
- [ ] No subcommand grouping needed
- [ ] Zero dependencies preferred
- [ ] Fast development critical
- [ ] Following llcli pattern
→ 80% of CLIs should use Tier 1
---
Choose Commander.js (Tier 2) If:
- [ ] CLI has 10+ commands needing organization
- [ ] Subcommands required (git-style:
cli category command) - [ ] Complex nested options
- [ ] Plugin architecture planned
- [ ] Multiple output formats (JSON, table, CSV)
- [ ] Auto-generated help essential
→ 15% of CLIs need Tier 2
---
Reference oclif (Tier 3) If:
- [ ] Enterprise plugin system (Heroku/Salesforce scale)
- [ ] 50+ commands with topics
- [ ] Auto-update mechanism
- [ ] Multi-tenant platform
→ 5% of CLIs (NOT generated by this skill)
---
🎯 llcli Pattern Analysis
Why Manual Parsing Works
llcli demonstrates: 1. 327 lines total - Complete CLI with docs 2. Zero dependencies - No node_modules needed 3. Type-safe - Full TypeScript interfaces 4. Production-ready - Error handling, help, validation 5. Composable - JSON output pipes everywhere 6. Documented - README explains philosophy
Key Insight: For API wrappers and simple tools, manual parsing is SUPERIOR to frameworks because:
- Complete control over behavior
- No framework magic to debug
- Easier to understand and modify
- Faster to develop (no API to learn)
- Deterministic (no framework updates breaking things)
When llcli Pattern Breaks Down
Indicators to escalate:
- 15+ commands making switch statement unwieldy
- Need for subcommand grouping (convert json csv vs convert csv json)
- Plugin/extension system required
- Complex option validation across commands
At that point → Tier 2 (Commander.js)
---
💡 Best Practices
1. Start Tier 1, Escalate When Proven
Don't guess complexity. Build simple first.
2. Frameworks Are Not Free
Every dependency is debt. Justify it.
3. Type Safety > Frameworks
Manual parsing with TypeScript beats framework without types.
4. Help Text Quality Matters
Auto-generated help is convenient but often poor quality. Manual help (like llcli) is better.
5. Composability > Features
JSON output + pipes > built-in table rendering.
6. Test Immediately
Run --help before declaring framework choice successful.
7. Read Real Code
Study llcli, not just framework docs.
8. Benchmark Size
Check dist/ folder size. Tier 1 CLIs are <100 KB.
---
📚 Additional Research
Yargs (NOT Recommended for PAI)
Why not recommended:
- Larger bundle size than Commander
- Less TypeScript-friendly
- Verbose syntax
- Async typing issues
Use Commander.js instead if escalating from Tier 1.
---
Ink (NOT Recommended for General CLIs)
Why not recommended:
- React-based (massive overhead)
- Interactive UIs (not deterministic)
- Large bundle size
- Overkill for data processing
Use for: Dashboard UIs, dev servers with live updates
Not for: API clients, file processors, automation
---
✅ Final Recommendation
For PAI createcli skill:
1. Default: Tier 1 (Manual Parsing / llcli pattern) 2. Escalation: Tier 2 (Commander.js) when decision tree indicates 3. Reference: Tier 3 (oclif) for documentation only
Philosophy: The best framework is no framework until proven otherwise.
---
Sources:
- llcli production implementation (~/.claude/Bin/llcli/)
- Commander.js 12.x documentation
- oclif core documentation
- Perplexity research (32 sub-queries on CLI frameworks)
- Codex research (tsx, vite, next, bun CLI analysis)
Common CLI Patterns
Reusable patterns for TypeScript CLIs based on llcli and production CLIs.
---
🎯 CORE PATTERNS
1. Configuration Loading
Pattern from llcli:
interface Config {
apiKey: string;
baseUrl: string;
}
const DEFAULTS = {
baseUrl: 'https://api.example.com',
timeout: 30000,
limit: 20,
} as const;
function loadConfig(): Config {
const envPath = join(homedir(), '.claude', '.env');
try {
const envContent = readFileSync(envPath, 'utf-8');
const apiKey = envContent
.split('\n')
.find(line => line.startsWith('API_KEY='))
?.split('=')[1]
?.trim();
if (!apiKey) {
console.error('Error: API_KEY not found in ${PAI_DIR}/.env');
console.error('Add: API_KEY=your_key_here');
process.exit(1);
}
return {
apiKey,
baseUrl: process.env.API_BASE_URL || DEFAULTS.baseUrl,
};
} catch (error) {
console.error('Error: Cannot read ${PAI_DIR}/.env');
console.error('Create file: touch ${PAI_DIR}/.env');
process.exit(1);
}
}Key principles:
- Load from ${PAI_DIR}/.env (PAI standard)
- Clear error messages with resolution steps
- Defaults for optional config
- Type-safe Config interface
---
2. API Client Pattern
Fetch wrapper with error handling:
async function apiRequest<T>(
config: Config,
endpoint: string,
params: Record<string, string> = {}
): Promise<T> {
const queryParams = new URLSearchParams(params);
const url = `${config.baseUrl}/${endpoint}?${queryParams}`;
try {
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${config.apiKey}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return await response.json() as T;
} catch (error) {
if (error instanceof Error) {
console.error('Request failed:', error.message);
} else {
console.error('Unknown error:', error);
}
process.exit(1);
}
}
// Usage
const data = await apiRequest<ResponseType>(config, 'endpoint', {
param: 'value',
});---
3. Command Function Pattern
One function per command:
/**
* Fetch items by date
*
* @param date - Date in YYYY-MM-DD format
* @param options - Command options
*/
async function fetchByDate(
date: string,
options: { limit?: number } = {}
): Promise<void> {
const config = loadConfig();
// Validate input
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
console.error('Error: Date must be YYYY-MM-DD format');
console.error('Example: 2025-11-17');
process.exit(1);
}
// Build params
const params = {
date,
limit: (options.limit || DEFAULTS.limit).toString(),
};
// Make request
const data = await apiRequest<ApiResponse>(config, 'items', params);
// Output JSON
console.log(JSON.stringify(data, null, 2));
}Principles:
- Clear function signature
- Input validation with helpful errors
- Use loadConfig() inside function
- JSON output to stdout
- Errors to stderr
---
4. Argument Parsing Pattern
Manual parsing with validation:
function parseArguments(args: string[]): {
command: string;
args: string[];
options: Record<string, string | boolean>;
} {
if (args.length === 0) {
return { command: 'help', args: [], options: {} };
}
const command = args[0];
const options: Record<string, string | boolean> = {};
const commandArgs: string[] = [];
for (let i = 1; i < args.length; i++) {
const arg = args[i];
if (arg.startsWith('--')) {
// Long option
const key = arg.slice(2);
const next = args[i + 1];
if (next && !next.startsWith('-')) {
options[key] = next;
i++; // Skip next
} else {
options[key] = true;
}
} else if (arg.startsWith('-')) {
// Short option
const key = arg.slice(1);
options[key] = true;
} else {
// Positional argument
commandArgs.push(arg);
}
}
return { command, args: commandArgs, options };
}---
5. Help Text Pattern
Comprehensive, actionable help:
function showHelp(): void {
console.log(`
${CLI_NAME} - ${DESCRIPTION}
${'='.repeat(CLI_NAME.length + DESCRIPTION.length + 3)}
USAGE:
${CLI_NAME} <command> [arguments] [options]
COMMANDS:
command1 <arg> Description of command1
command2 [optional] Description of command2
help, --help, -h Show this help
version, --version, -v Show version
OPTIONS:
--limit <n> Max results (default: 20)
--format <type> Output format (json, csv)
--verbose Verbose logging
EXAMPLES:
# Common use case 1
$ ${CLI_NAME} command1 value
# Common use case 2
$ ${CLI_NAME} command2 --limit 50
# Piping to jq
$ ${CLI_NAME} command1 value | jq '.data[]'
OUTPUT:
JSON to stdout (deterministic)
Errors to stderr
Exit code: 0 = success, 1 = error
CONFIGURATION:
API Key: ${PAI_DIR}/.env (API_KEY=your_key)
Base URL: ${DEFAULTS.baseUrl}
PHILOSOPHY:
${CLI_NAME} follows PAI's CLI-First Architecture:
- Deterministic: Same input → Same output
- Clean: Single responsibility
- Composable: Pipes to jq, grep, etc.
- Documented: This help + README
- Testable: Predictable behavior
For full documentation: ~/.claude/Bin/${CLI_NAME}/README.md
Version: ${VERSION}
`);
}---
6. Error Handling Pattern
Type-safe custom errors:
class CLIError extends Error {
constructor(
message: string,
public readonly code: string,
public readonly hint?: string,
public readonly exitCode: number = 1
) {
super(message);
this.name = 'CLIError';
}
}
function handleError(error: unknown): never {
if (error instanceof CLIError) {
console.error(`Error [${error.code}]: ${error.message}`);
if (error.hint) {
console.error(`Hint: ${error.hint}`);
}
process.exit(error.exitCode);
}
if (error instanceof Error) {
console.error('Unexpected error:', error.message);
if (program.opts().debug) {
console.error(error.stack);
}
} else {
console.error('Unknown error:', error);
}
process.exit(1);
}
// Usage
throw new CLIError(
'Configuration file not found',
'ERR_NO_CONFIG',
'Run: mycli init'
);
// Global handler
process.on('uncaughtException', handleError);
process.on('unhandledRejection', handleError);---
7. Main Entry Pattern
Structured main with routing:
async function main(): Promise<void> {
const args = process.argv.slice(2);
// Help/version shortcuts
if (!args.length || args[0] === 'help' || args[0] === '--help') {
showHelp();
return;
}
if (args[0] === 'version' || args[0] === '--version') {
console.log(`${CLI_NAME} version ${VERSION}`);
return;
}
// Parse arguments
const { command, args: cmdArgs, options } = parseArguments(args);
// Route to command
switch (command) {
case 'command1':
await command1(cmdArgs[0], options);
break;
case 'command2':
await command2(cmdArgs, options);
break;
default:
throw new CLIError(
`Unknown command: ${command}`,
'ERR_UNKNOWN_COMMAND',
`Run "${CLI_NAME} --help" for usage`
);
}
}
// Execute
main().catch(handleError);---
8. File I/O Pattern
Safe file operations:
import { readFile, writeFile, access } from 'fs/promises';
import { constants } from 'fs';
async function readJsonFile<T>(path: string): Promise<T> {
try {
// Check file exists and is readable
await access(path, constants.R_OK);
const content = await readFile(path, 'utf-8');
return JSON.parse(content) as T;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new CLIError(
`File not found: ${path}`,
'ERR_FILE_NOT_FOUND',
'Check the file path'
);
}
throw new CLIError(
`Cannot read file: ${path}`,
'ERR_FILE_READ',
error instanceof Error ? error.message : 'Unknown error'
);
}
}
async function writeJsonFile<T>(path: string, data: T): Promise<void> {
try {
const json = JSON.stringify(data, null, 2);
await writeFile(path, json, 'utf-8');
} catch (error) {
throw new CLIError(
`Cannot write file: ${path}`,
'ERR_FILE_WRITE',
error instanceof Error ? error.message : 'Unknown error'
);
}
}---
9. Progress Indicator Pattern
For long operations:
import ora from 'ora';
async function processMany(items: string[]): Promise<void> {
const spinner = ora({
text: `Processing ${items.length} items...`,
spinner: 'dots',
}).start();
try {
for (let i = 0; i < items.length; i++) {
spinner.text = `Processing ${i + 1}/${items.length}: ${items[i]}`;
await processItem(items[i]);
}
spinner.succeed(`Processed ${items.length} items`);
} catch (error) {
spinner.fail('Processing failed');
throw error;
}
}---
10. Testing Pattern
Vitest for CLIs:
import { describe, it, expect } from 'vitest';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
describe('CLI', () => {
it('shows help', async () => {
const { stdout } = await execAsync('./cli.ts --help');
expect(stdout).toContain('USAGE:');
expect(stdout).toContain('COMMANDS:');
});
it('handles errors gracefully', async () => {
await expect(
execAsync('./cli.ts invalid-command')
).rejects.toThrow();
});
it('outputs valid JSON', async () => {
const { stdout } = await execAsync('./cli.ts command test');
const data = JSON.parse(stdout);
expect(data).toHaveProperty('result');
});
});---
✅ PATTERN CHECKLIST
When building a CLI, use these patterns:
- [ ] Configuration loading (from ${PAI_DIR}/.env)
- [ ] API client with error handling
- [ ] One function per command
- [ ] Manual argument parsing (Tier 1) or Commander (Tier 2)
- [ ] Comprehensive help text
- [ ] Custom CLIError class
- [ ] Main entry with routing
- [ ] Safe file I/O (if needed)
- [ ] Progress indicators (if long operations)
- [ ] Tests (Vitest integration)
---
All patterns battle-tested in llcli and production CLIs.
TypeScript Patterns for CLI Development
Production patterns from tsx, vite, turbo, bun, and other modern TypeScript CLIs
---
🎯 Overview
This document captures type safety patterns, modern TypeScript features, and error handling strategies from real-world production CLIs.
Research Sources:
- tsx (privatenumber/tsx)
- Vite (vitejs/vite)
- Next.js (vercel/next)
- Turbo (vercel/turbo)
- Bun installer (oven-sh/bun)
- pnpm (pnpm/pnpm)
- Shopify CLI (Shopify/cli)
---
1️⃣ TYPE-SAFE ARGUMENT PARSING
Pattern 1: Schema-Driven Inference (cleye library - used by tsx)
import { cli } from 'cleye';
const argv = cli({
name: 'tsx',
flags: {
noCache: {
type: Boolean,
description: 'Disable cache',
default: false,
},
tsconfig: {
type: String,
description: 'Path to tsconfig.json',
},
},
parameters: ['<script path>'],
});
// TypeScript infers:
// argv.flags.noCache → boolean
// argv.flags.tsconfig → string | undefined
// argv._.scriptPath → string | undefinedKey Insight: Flags defined as const objects enable full type inference.
---
Pattern 2: Manual Type Annotations (Vite - cac library)
interface GlobalCLIOptions {
'--'?: string[];
debug?: boolean | string;
filter?: string;
config?: string;
}
interface ServerOptions extends GlobalCLIOptions {
host?: string;
port?: number;
open?: boolean | string;
cors?: boolean;
strictPort?: boolean;
}
cli
.command('[root]', 'start dev server')
.option('--host <host>', 'specify hostname')
.option('--port <port>', 'specify port', { type: [Number] })
.option('--open [path]', 'open browser on startup')
.action(async (root: string, options: ServerOptions) => {
// options fully typed here
const server = await createServer({ ...options });
});Key Insight: Explicit interfaces + intersections for type narrowing.
---
Pattern 3: Discriminated Unions (citty library)
type ArgDef =
| { type: 'boolean'; default?: boolean }
| { type: 'string'; default?: string }
| { type: 'number'; default?: number };
type ParsedArgs<T> = {
[K in keyof T]: T[K] extends { type: 'boolean' }
? boolean
: T[K] extends { type: 'number' }
? number
: string;
};
// Command discriminants for exhaustive switching
type ParsedCLI<Commands> = {
command: Commands; // literal union type
flags: Record<string, unknown>;
};
const argv = parse() as ParsedCLI<'build' | 'dev' | 'test'>;
switch (argv.command) {
case 'build': /* ... */; break;
case 'dev': /* ... */; break;
case 'test': /* ... */; break;
default:
argv.command satisfies never; // exhaustiveness check
}Key Insight: Discriminated unions enable exhaustive compile-time checking.
---
2️⃣ MODERN TYPESCRIPT FEATURES (5.x)
Pattern 1: satisfies Operator (Vite/Turbo)
Exhaustive switch validation (Vite build.ts:1037):
type LogLevel = 'info' | 'warn' | 'error' | 'debug';
function handleLog(level: LogLevel) {
switch (level) {
case 'info': /* ... */; break;
case 'warn': /* ... */; break;
case 'error': /* ... */; break;
case 'debug': /* ... */; break;
default:
level satisfies never; // new levels = compile error
throw new Error(`Unknown log level: ${level}`);
}
}Typed URL mechanisms (Vite build.ts:1356-1362):
const urlMechanisms = {
es: (path: string) => `import.meta.url + '${path}'`,
iife: (path: string) => `document.baseURI + '${path}'`,
system: (path: string) => `module.meta.url + '${path}'`,
} as const satisfies Record<string, (path: string) => string>;
type Format = keyof typeof urlMechanisms; // 'es' | 'iife' | 'system'Config typing (Turbo jest.config.ts):
import type { Config } from '@jest/types';
const config = {
preset: 'ts-jest/presets/js-with-ts',
testEnvironment: 'node',
verbose: process.env.RUNNER_DEBUG === '1',
} as const satisfies Config;
export default config;Benefits:
- ✅ Literal types preserved
- ✅ Type-checked against interface
- ✅ IntelliSense on config object
- ✅ Compile error on typos
---
Pattern 2: Template Literal Types (Vite)
Debug scope validation (Vite utils.ts:181-207):
export type ViteDebugScope = `vite:${string}`;
function createDebugger(namespace: ViteDebugScope): void {
const log = debug(namespace);
// ...
}
createDebugger('vite:server'); // ✅ Valid
createDebugger('vite:config'); // ✅ Valid
createDebugger('app:server'); // ❌ Type errorCLI command validation:
type CommandName = `${'build' | 'dev' | 'test'}:${string}`;
function runCommand<const T extends CommandName>(name: T) {
// T preserves literal type
console.log(`Running: ${name}`);
}
runCommand('build:production'); // ✅
runCommand('dev:local'); // ✅
runCommand('deploy:prod'); // ❌ Type error---
Pattern 3: Const Type Parameters (TS 5.0)
Preserve literal types in flag definitions:
function defineFlags<const T extends Record<string, string | boolean>>(
flags: T
): T {
return flags;
}
const flags = defineFlags({
env: 'production',
watch: false,
verbose: true,
});
// T is inferred as:
// {
// readonly env: "production";
// readonly watch: false;
// readonly verbose: true;
// }
// Not widened to:
// { env: string; watch: boolean; verbose: boolean }---
3️⃣ ERROR HANDLING PATTERNS
Pattern 1: Custom Error Classes (pnpm)
Structured errors with metadata (pnpm error/index.ts:3-44):
export class PnpmError extends Error {
public readonly code: string;
public readonly hint?: string;
public attempts?: number;
public prefix?: string;
public pkgsStack?: Array<{
id: string;
name: string;
version: string;
}>;
constructor(
code: string,
message: string,
opts?: { hint?: string; attempts?: number }
) {
super(message);
this.code = code.startsWith('ERR_PNPM_') ? code : `ERR_PNPM_${code}`;
this.hint = opts?.hint;
this.attempts = opts?.attempts;
this.name = 'PnpmError';
}
}
// Usage with typed error codes
throw new PnpmError('NO_LOCKFILE', 'Missing pnpm-lock.yaml', {
hint: 'Run `pnpm install` to generate lockfile'
});Benefits:
- Machine-readable error codes
- Actionable hints for users
- Structured metadata (attempts, stack)
- Type-safe error construction
---
Pattern 2: Discriminated Fatal Errors (Shopify CLI)
Fatal error types (Shopify cli-kit/error.ts:9-92):
export enum FatalErrorType {
Abort, // User-facing error (bad input)
AbortSilent, // Abort without output
Bug, // Unexpected error (our fault)
}
export class FatalError extends ExtendableError {
public type: FatalErrorType;
public tryMessage?: TokenizedString;
}
export class AbortError extends FatalError {
constructor(message: string, tryMessage?: string) {
super(message);
this.type = FatalErrorType.Abort;
this.tryMessage = tryMessage;
}
}
export class BugError extends FatalError {
constructor(message: string) {
super(message);
this.type = FatalErrorType.Bug;
}
}
// Global error mapper
export function errorMapper(error: unknown): FatalError {
if (error instanceof FatalError) return error;
if (error instanceof Error) return new BugError(error.message);
return new BugError(String(error));
}
// Central handler
export function handler(error: unknown): void {
const fatalError = errorMapper(error);
renderFatalError(fatalError);
process.exitCode = fatalError.type === FatalErrorType.Bug ? 2 : 1;
}Benefits:
- Distinguish user errors from bugs
- Different exit codes (1 = user, 2 = bug)
- Centralized error handling
- Type-safe error classification
---
Pattern 3: Result Type (Shopify CLI)
Result/Either monad pattern (Shopify cli-kit/result.ts:3-113):
export type Result<TValue, TError> = Ok<TValue, TError> | Err<TValue, TError>;
export const ok = <TValue>(value: TValue) => new Ok(value);
export const err = <TError>(error: TError) => new Err(error);
class Ok<TValue, TError> {
constructor(readonly value: TValue) {}
isOk(): this is Ok<TValue, TError> { return true; }
isErr(): this is Err<TValue, TError> { return false; }
map<U>(fn: (value: TValue) => U): Result<U, TError> {
return ok(fn(this.value));
}
mapError<E>(_fn: (error: TError) => E): Result<TValue, E> {
return ok(this.value);
}
valueOrAbort(): TValue {
return this.value;
}
}
class Err<TValue, TError> {
constructor(readonly error: TError) {}
isOk(): this is Ok<TValue, TError> { return false; }
isErr(): this is Err<TValue, TError> { return true; }
map<U>(_fn: (value: TValue) => U): Result<U, TError> {
return err(this.error);
}
mapError<E>(fn: (error: TError) => E): Result<TValue, E> {
return err(fn(this.error));
}
valueOrAbort(): never {
throw new AbortError(String(this.error));
}
}
// Usage
async function loadConfig(): Promise<Result<Config, string>> {
try {
const raw = await readFile('config.json', 'utf-8');
const config = JSON.parse(raw);
return ok(config);
} catch (e) {
return err('Failed to load config');
}
}
// Expression-oriented error handling
const config = (await loadConfig())
.mapError(e => `Config error: ${e}`)
.valueOrAbort();Benefits:
- No try/catch needed
- Explicit error handling in types
- Composable with map/mapError
- Railway-oriented programming
---
Pattern 4: Signal Handling (tsx)
Relay signals to child process (tsx cli.ts:24-120):
let waitForSignal: Promise<void> | undefined;
const relaySignalToChild = async (signal: NodeJS.Signals) => {
if (waitForSignal) {
return waitForSignal;
}
childProcess.kill(signal);
waitForSignal = new Promise((resolve) => {
setTimeout(() => {
const exitCode = osConstants.signals[signal];
process.exit(128 + exitCode); // POSIX-compliant
}, 5000); // 5s grace period
childProcess.on('exit', () => {
resolve();
});
});
// Force kill after grace period
setTimeout(() => {
childProcess.kill('SIGKILL');
}, 5000);
};
process.on('SIGINT', relaySignalToChild);
process.on('SIGTERM', relaySignalToChild);Exit code mapping (tsx cli.ts:254):
childProcess.on('close', (code, signal) => {
if (code !== null) {
process.exit(code);
} else if (signal) {
const exitCode = osConstants.signals[signal];
process.exit(128 + exitCode); // 130 for SIGINT, 143 for SIGTERM
}
});Benefits:
- Graceful shutdown
- Proper signal propagation
- POSIX-compliant exit codes
- Timeout for hung processes
---
4️⃣ TYPE-SAFE CONFIGURATION
Pattern 1: Zod Validation with Transform
Parse → Transform → Enrich pattern:
import { z } from 'zod';
import path from 'node:path';
const RawConfigSchema = z.object({
root: z.string().default('.'),
mode: z.enum(['dev', 'prod']).default('dev'),
targets: z.array(z.string()).nonempty(),
deploy: z.discriminatedUnion('provider', [
z.object({
provider: z.literal('s3'),
bucket: z.string(),
region: z.string().default('us-east-1'),
}),
z.object({
provider: z.literal('gcs'),
bucket: z.string(),
projectId: z.string(),
}),
]),
});
const ConfigSchema = RawConfigSchema.transform((cfg) => ({
...cfg,
root: path.resolve(cfg.root),
isProd: cfg.mode === 'prod',
timestamp: Date.now(),
}));
type Config = z.infer<typeof ConfigSchema>;
// Safe parsing with formatted errors
const res = ConfigSchema.safeParse(rawInput);
if (!res.success) {
const formatted = res.error.issues
.map(issue => `${issue.path.join('.')}: ${issue.message}`)
.join('\n');
throw new Error(`Invalid config:\n${formatted}`);
}
const config = res.data;---
Pattern 2: Environment Variable Validation
import { z } from 'zod';
import dotenv from 'dotenv';
dotenv.config();
const EnvSchema = z.object({
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
DATABASE_URL: z.string().url(),
DEBUG: z.preprocess(
(v) => (typeof v === 'string' ? v === 'true' : v),
z.boolean().default(false)
),
API_KEY: z.string().min(10),
});
export const env = EnvSchema.parse(process.env);
export type Env = typeof env;
// Usage: env.PORT is guaranteed to be number 1-65535---
5️⃣ ASYNC/AWAIT BEST PRACTICES
Pattern 1: Top-Level Await (Prisma Style)
async function main() {
const args = process.argv.slice(2);
const result = await runCommand(args);
return result;
}
void main().catch((err) => {
console.error('❌', err instanceof Error ? err.message : err);
process.exitCode = 1;
});Pure ESM (tsx/bun-friendly):
const args = process.argv.slice(2);
const code = await runCommand(args);
process.exit(code ?? 0);---
Pattern 2: Parallel Operations
Fail-fast (Promise.all):
const [config, user, data] = await Promise.all([
loadConfig(),
fetchUser(),
fetchData(),
]);Best effort (Promise.allSettled):
const results = await Promise.allSettled(
tasks.map(task => processTask(task))
);
const failures = results.filter(r => r.status === 'rejected');
if (failures.length > 0) {
failures.forEach(f => console.error(f.reason));
process.exitCode = 1;
}Concurrency limiting (p-limit):
import pLimit from 'p-limit';
const limit = pLimit(4); // max 4 concurrent
await Promise.all(
items.map(item => limit(() => processItem(item)))
);---
Pattern 3: Cleanup (Prisma Pattern)
async function main() {
const client = await createClient();
try {
await client.connect();
await doWork(client);
} finally {
await client.close().catch(() => {}); // swallow close errors
}
}Signal-aware teardown (Vercel pattern):
import { onExit } from 'signal-exit';
const cleanup = async () => {
spinner?.stop();
await client?.close();
};
onExit(() => { void cleanup(); });
await main().catch(async (err) => {
await cleanup();
console.error(err);
process.exit(1);
});---
6️⃣ RECOMMENDED PATTERNS FOR KAI CLIS
For Tier 1 (llcli-style):
#!/usr/bin/env bun
interface Config {
apiKey: string;
baseUrl: string;
}
const DEFAULTS = {
baseUrl: 'https://api.example.com',
limit: 20,
} as const;
class CLIError extends Error {
constructor(
message: string,
public code: string,
public exitCode: number = 1
) {
super(message);
this.name = 'CLIError';
}
}
function loadConfig(): Config {
// ... load from ${PAI_DIR}/.env
throw new CLIError('API_KEY not found', 'ERR_NO_API_KEY');
}
async function main() {
const args = process.argv.slice(2);
if (!args.length || args[0] === '--help') {
showHelp();
return;
}
const command = args[0];
switch (command) {
case 'fetch':
await fetchData(args[1]);
break;
default:
throw new CLIError(`Unknown command: ${command}`, 'ERR_UNKNOWN_CMD');
}
}
main().catch(error => {
if (error instanceof CLIError) {
console.error(`Error [${error.code}]: ${error.message}`);
process.exit(error.exitCode);
}
console.error('Fatal error:', error);
process.exit(1);
});---
For Tier 2 (Commander.js):
#!/usr/bin/env bun
import { Command } from 'commander';
const program = new Command();
program
.name('mycli')
.description('My CLI tool')
.version('1.0.0');
program
.command('process <file>')
.option('-o, --output <path>', 'output path')
.option('--format <type>', 'output format', 'json')
.action(async (file: string, options) => {
try {
await processFile(file, options);
} catch (error) {
console.error('Processing failed:', error);
process.exit(1);
}
});
program.parse();---
✅ QUICK REFERENCE CHECKLIST
Type Safety:
- [ ] Use strict mode in tsconfig.json
- [ ] Define interfaces for all data structures
- [ ] Avoid
anytypes (useunknownif needed) - [ ] Use template literal types for string patterns
- [ ] Use discriminated unions for commands
- [ ] Prefer
as const satisfiesfor configs
Error Handling:
- [ ] Custom error class with
codefield - [ ] Actionable error messages with hints
- [ ] Exit code 0 = success, 1 = error, 2 = bug
- [ ] Catch at top level (main().catch())
- [ ] Handle signals (SIGINT, SIGTERM)
Modern TypeScript:
- [ ] Use
satisfiesfor exhaustiveness checks - [ ] Use template literals for validation
- [ ] Use const type parameters for literals
- [ ] Prefer
as constfor config objects
Async/Await:
- [ ] Top-level await in ESM or void main().catch()
- [ ] Use Promise.allSettled for best-effort parallel
- [ ] Cleanup in finally blocks
- [ ] Set process.exitCode instead of process.exit
---
Sources:
- tsx (privatenumber/tsx) - Type safety, signal handling
- Vite (vitejs/vite) - Modern TS features, validation
- Turbo (vercel/turbo) - Config patterns
- Bun (oven-sh/bun) - Async patterns
- pnpm (pnpm/pnpm) - Custom errors
- Shopify CLI (Shopify/cli) - Result types, fatal errors
- Codex research (actual repository analysis)
Add Command Workflow
Extend existing CLI with new commands while maintaining code quality and consistency.
Voice Notification
curl -s -X POST http://localhost:31337/notify \
-H "Content-Type: application/json" \
-d '{"message": "Running the AddCommand workflow in the CreateCLI skill to add CLI command"}' \
> /dev/null 2>&1 &Running the AddCommand workflow in the CreateCLI skill to add CLI command...
---
🎯 PURPOSE
Add one or more commands to an existing CLI without breaking existing functionality.
---
📍 WHEN TO USE
- User requests: "Add [command] to [CLI]"
- "Extend [CLI] with [feature]"
- "My CLI needs to do [X] too"
---
📋 STEPS
1. Locate Existing CLI
# Find CLI location
ls -la ~/.claude/Bin/[cli-name]/
# or
ls -la ~/Projects/[project]/2. Read Current Structure
// Identify:
// - Existing commands (in switch statement)
// - Interface definitions
// - Help text structure3. Add Interface (if needed)
// Add response interface
interface NewCommandResponse {
// ... based on API/data
}4. Implement Command Function
/**
* [Command description]
*/
async function newCommand(
arg: string,
options: { flag?: boolean } = {}
): Promise<void> {
const config = loadConfig();
// Validation
if (!arg) {
console.error('Error: argument required');
process.exit(1);
}
// Implementation
const result = await fetchData(config, arg);
// Output
console.log(JSON.stringify(result, null, 2));
}5. Add to Switch Statement
switch (command) {
// ... existing cases
case 'newcommand':
await newCommand(args[1], options);
break;
default:
console.error(`Unknown command: ${command}`);
process.exit(1);
}6. Update Help Text
COMMANDS:
existing-cmd Description
newcommand <arg> New command description // ← Add
help, --help, -h Show help
EXAMPLES:
# New command examples // ← Add
$ mycli newcommand value
$ mycli newcommand value --flag7. Update README
Add to command list and examples section.
8. Test
./cli.ts newcommand test-value
./cli.ts --help # Verify new command listed---
✅ QUALITY CHECKLIST
- [ ] Command function implemented
- [ ] Added to switch statement
- [ ] Help text updated
- [ ] README updated
- [ ] Tested and working
- [ ] Error handling added
- [ ] TypeScript compiles
---
Example: Adding "search" to existing "list/create" CLI
// Before: list, create
// After: list, create, search ← new
async function search(keyword: string, limit: number = 20): Promise<void> {
// Implementation
}
// Add to switch
case 'search':
await search(args[1], parseLimit(args));
break;Create CLI Workflow
Generate production-quality TypeScript command-line interfaces following llcli pattern and CLI-First Architecture.
Voice Notification
curl -s -X POST http://localhost:31337/notify \
-H "Content-Type: application/json" \
-d '{"message": "Running the CreateCli workflow in the CreateCLI skill to generate new CLI"}' \
> /dev/null 2>&1 &Running the CreateCli workflow in the CreateCLI skill to generate new CLI...
---
🎯 PURPOSE
This workflow generates a complete, immediately usable TypeScript CLI tool with:
- Full type safety and error handling
- Comprehensive documentation (README + QUICKSTART)
- Clean architecture (following llcli pattern)
- Production-ready code
- Quality validation gates
---
📍 WHEN TO USE
Activate this workflow when user requests:
- "Create a CLI for [API/service/tool]"
- "Build a command-line interface"
- "Make a CLI that does X"
- "Generate a CLI tool"
- "I need something like llcli but for Y"
---
🔀 TIER DECISION TREE
Use this deterministic decision tree to select complexity tier:
START: User describes CLI requirements
│
├─ Does it need 10+ commands with grouping? ─ YES → Tier 2 (Commander.js)
│ NO ↓
│
├─ Does it need plugin architecture? ──────── YES → Tier 2 (Commander.js)
│ NO ↓
│
├─ Does it need subcommands (git-style)? ──── YES → Tier 2 (Commander.js)
│ NO ↓
│
├─ Does it need complex nested options? ────── YES → Tier 2 (Commander.js)
│ NO ↓
│
└─ Use Tier 1 (llcli-style) ← DEFAULT
↑
└─ 80% of CLIs end up hereTier 1 Indicators (DEFAULT):
- ✅ 2-10 simple commands
- ✅ API client wrapper
- ✅ Data transformer
- ✅ File processor
- ✅ Simple automation
- ✅ JSON output
- ✅ Fast development needed
Tier 2 Indicators (ESCALATION):
- ❌ 10+ commands needing organization
- ❌ Plugin/extension system
- ❌ Subcommands (convert json csv, convert csv json)
- ❌ Multiple output format engines
- ❌ Complex option combinations
Rule of Thumb: If user doesn't explicitly need Tier 2 features, use Tier 1.
---
📋 WORKFLOW STEPS
Step 1: Gather Requirements
Extract from user request:
- CLI name (kebab-case, e.g.,
ghcli,md2html) - Purpose (one-sentence description)
- Commands needed (list each command with arguments)
- API/service being wrapped (if applicable)
- Authentication method (API key, Bearer token, OAuth)
- Environment variables needed
- Output format (usually JSON)
- Configuration flags needed (behavioral variants - see Step 5a)
Questions to ask user if unclear:
- "What API or service does this wrap?"
- "What are the main commands you need?"
- "How should it authenticate?"
- "Where should the CLI be installed?" (personal bin, project-specific, etc.)
Example extraction:
User: "Create a CLI for the GitHub API"
Extracted:
- Name: ghcli
- Purpose: GitHub API Command-Line Interface
- Commands: repos (list), issues (create, list), search
- API: api.github.com
- Auth: Bearer token (GITHUB_TOKEN)
- Env vars: GITHUB_TOKEN
- Output: JSON---
Step 2: Determine Complexity Tier
Apply decision tree from above.
For most requests → Tier 1
Example decision:
User: "CLI for GitHub API with repos, issues, search commands"
→ 3 commands (< 10) ✓
→ No subcommands ✓
→ Simple arguments ✓
→ API wrapper ✓
= TIER 1---
Step 3: Generate TypeScript Interface Definitions
Based on API responses/data structures:
// For API client CLIs
interface ApiResponse {
data: {
items: Item[];
};
}
interface Item {
id: string;
name: string;
created_at: string;
// ... fields from API docs
}
interface Config {
apiKey: string;
baseUrl: string;
// ... configuration fields
}For file processing CLIs:
interface ProcessResult {
input: string;
output: string;
status: 'success' | 'error';
error?: string;
}
interface Config {
outputDir: string;
format: string;
}---
Step 4: Generate Configuration Section
Pattern from llcli:
// ============================================================================
// Configuration
// ============================================================================
const DEFAULTS = {
baseUrl: '{{API_BASE_URL}}',
limit: 20,
{{ADDITIONAL_DEFAULTS}}
} as const;
/**
* Load configuration from environment
*/
function loadConfig(): Config {
const envPath = process.env.PAI_CONFIG_DIR ? join(process.env.PAI_CONFIG_DIR, '.env') : join(homedir(), '.claude', 'PAI', '.env');
try {
const envContent = readFileSync(envPath, 'utf-8');
const apiKey = envContent
.split('\n')
.find(line => line.startsWith('{{ENV_VAR_NAME}}='))
?.split('=')[1]
?.trim();
if (!apiKey) {
console.error('Error: {{ENV_VAR_NAME}} not found in ${PAI_CONFIG_DIR}/.env');
process.exit(1);
}
return {
apiKey,
baseUrl: DEFAULTS.baseUrl,
{{ADDITIONAL_CONFIG}}
};
} catch (error) {
console.error(`Error: Cannot read ${PAI_CONFIG_DIR}/.env file`);
console.error('Make sure {{ENV_VAR_NAME}} is set in ${PAI_CONFIG_DIR}/.env');
process.exit(1);
}
}---
Step 5a: Design Configuration Flags (REQUIRED)
Every CLI should expose behavioral configuration via flags, not hardcoded values.
This enables workflows and users to adapt CLI behavior without code changes.
Standard Flag Categories:
| Category | Examples | Purpose |
|---|---|---|
| Mode flags | --fast, --thorough, --dry-run | Execution behavior |
| Output flags | --format json, --quiet, --verbose | Output control |
| Resource flags | --model haiku, --model opus | Model/resource selection |
| Post-process flags | --thumbnail, --remove-bg | Additional processing |
Design Checklist: 1. What execution modes does this CLI need? (fast vs thorough, dry-run) 2. What output formats are useful? (json, table, quiet, verbose) 3. Are there resource/model selections? (cheap vs expensive, fast vs accurate) 4. Are there optional post-processing steps?
Example flag design for an API CLI:
// Mode flags
const dryRun = args.includes('--dry-run');
const verbose = args.includes('--verbose');
const quiet = args.includes('--quiet');
// Resource flags
const modelIdx = args.indexOf('--model');
const model = modelIdx !== -1 ? args[modelIdx + 1] : 'default';
// Output flags
const formatIdx = args.indexOf('--format');
const format = formatIdx !== -1 ? args[formatIdx + 1] : 'json';Flag Design Principles: 1. Sensible defaults: CLI works without flags for common case 2. Explicit overrides: Flags modify default behavior 3. Boolean flags: --flag enables (no --no-flag needed) 4. Value flags: --flag <value> for choices 5. Composable: Flags should combine logically
Reference: ~/.claude/PAI/DOCUMENTATION/Tools/CliFirstArchitecture.md (Configuration Flags section)
---
Step 5: Generate Command Functions
One function per command (incorporating configuration flags from Step 5a):
// ============================================================================
// CLI Commands
// ============================================================================
/**
* {{COMMAND_DESCRIPTION}}
*/
async function {{commandName}}(
{{ARGUMENTS}}: string,
options: { limit?: number } = {}
): Promise<void> {
const config = loadConfig();
// Validate inputs
if (!{{ARGUMENTS}} || {{ARGUMENTS}}.trim() === '') {
console.error('Error: {{ARGUMENT_NAME}} is required');
process.exit(1);
}
// Build request
const params = {
{{PARAM_MAPPING}},
limit: options.limit?.toString() ?? DEFAULTS.limit.toString(),
};
// Make API call
const data = await {{fetchFunction}}(config, params);
// Output JSON
console.log(JSON.stringify(data, null, 2));
}Repeat for each command.
---
Step 6: Generate Help Documentation
Comprehensive help text following llcli pattern:
// ============================================================================
// Help Documentation
// ============================================================================
function showHelp(): void {
console.log(`
{{CLI_NAME}} - {{CLI_DESCRIPTION}}
${'='.repeat(CLI_NAME.length + CLI_DESCRIPTION.length + 3)}
A clean, deterministic CLI for {{PURPOSE}}.
USAGE:
{{CLI_NAME}} <command> [options]
COMMANDS:
{{COMMAND_LIST}}
help, --help, -h Show this help message
version, --version, -v Show version information
OPTIONS:
{{OPTIONS_LIST}}
EXAMPLES:
{{EXAMPLE_LIST}}
OUTPUT:
All commands return JSON to stdout
Errors and messages go to stderr
Exit code 0 on success, 1 on error
CONFIGURATION:
{{CONFIGURATION_DETAILS}}
RESPONSE FORMAT:
{{JSON_STRUCTURE_EXAMPLE}}
PHILOSOPHY:
{{CLI_NAME}} follows CLI-First Architecture:
- Deterministic: Same input → Same output
- Clean: Single responsibility ({{PURPOSE}} only)
- Composable: JSON output pipes to jq, grep, etc.
- Documented: Full help and examples
- Testable: Predictable behavior
For more information, see ~/.claude/Bin/{{CLI_NAME}}/README.md
Version: 1.0.0
`);
}
function showVersion(): void {
console.log('{{CLI_NAME}} version 1.0.0');
}---
Step 7: Generate Main Entry Point
Argument parsing and command routing:
// ============================================================================
// Main CLI Entry Point
// ============================================================================
async function main() {
const args = process.argv.slice(2);
// Handle help/version
if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') {
showHelp();
return;
}
if (args[0] === 'version' || args[0] === '--version' || args[0] === '-v') {
showVersion();
return;
}
const command = args[0];
// Parse common options (e.g., --limit)
const limitIndex = args.indexOf('--limit');
const limit = limitIndex !== -1 && args[limitIndex + 1]
? parseInt(args[limitIndex + 1], 10)
: undefined;
if (limitIndex !== -1 && (isNaN(limit!) || limit! <= 0)) {
console.error('Error: --limit must be a positive number');
process.exit(1);
}
// Route to commands
switch (command) {
{{COMMAND_CASES}}
default:
console.error(`Error: Unknown command '${command}'`);
console.error('Run "{{CLI_NAME}} --help" for usage information');
process.exit(1);
}
}
// Run CLI
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});---
Step 8: Generate Documentation Files
README.md structure (following llcli):
# {{CLI_NAME}} - {{CLI_DESCRIPTION}}
**Version:** 1.0.0
**Last Updated:** {{TODAY_DATE}}
---
## Overview
{{CLI_NAME}} is a clean, deterministic command-line interface for {{PURPOSE}}. It provides simple access to {{SERVICE}} with a focus on reliability, composability, and documentation.
### Philosophy
{{CLI_NAME}} follows **CLI-First Architecture**:
1. **Deterministic** - Same input always produces same output
2. **Clean** - Single responsibility
3. **Composable** - JSON output pipes to jq, grep, other tools
4. **Documented** - Comprehensive help and examples
5. **Testable** - Predictable, verifiable behavior
---
## Installation
[Setup instructions]
---
## Usage
[Command documentation]
---
## Examples
[Real-world examples with jq, grep, etc.]
---
## Configuration
[Environment variables, defaults]
---
## API Reference
[API endpoint documentation]
---
## Philosophy
### Why This CLI Exists
[Explain the problem it solves]
### Design Principles
[Key decisions and why]
---
[Additional sections: Troubleshooting, Integration, Best Practices]QUICKSTART.md:
# {{CLI_NAME}} Quick Start
**The 30-second guide to using {{CLI_NAME}}**
## Installation
[Quick setup]
## Usage
[3-5 most common commands]
## Piping to jq
[Common jq patterns]
## Configuration
[Minimal config info]
## Full Documentation
See: ~/.claude/Bin/{{CLI_NAME}}/README.md---
Step 9: Generate Supporting Files
package.json:
{
"name": "{{CLI_NAME}}",
"version": "1.0.0",
"description": "{{CLI_DESCRIPTION}}",
"type": "module",
"bin": {
"{{CLI_NAME}}": "./{{CLI_NAME}}.ts"
},
"scripts": {
"help": "bun run {{CLI_NAME}}.ts --help"
},
"keywords": [{{KEYWORDS}}],
"author": "",
"license": "MIT",
"dependencies": {}
}tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"lib": ["ES2022"],
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["*.ts"],
"exclude": ["node_modules"]
}.env.example:
# {{CLI_NAME}} Configuration
{{ENV_VAR_NAME}}=your_{{TOKEN_TYPE}}_here---
Step 10: Validate and Report
Quality Gates: 1. ✅ TypeScript compiles without errors 2. ✅ All commands work with test inputs 3. ✅ Help text displays correctly 4. ✅ README is comprehensive 5. ✅ File permissions set (chmod +x)
Validation Commands:
cd ~/.claude/Bin/{{CLI_NAME}}/
chmod +x {{CLI_NAME}}.ts
./{{CLI_NAME}}.ts --help
./{{CLI_NAME}}.ts --versionReport to user:
✅ CLI Created: ~/.claude/Bin/{{CLI_NAME}}/
Files generated:
- {{CLI_NAME}}.ts ({{LINE_COUNT}} lines)
- package.json
- tsconfig.json
- .env.example
- README.md
- QUICKSTART.md
Next steps:
1. Configure: Add {{ENV_VAR_NAME}} to ${PAI_CONFIG_DIR}/.env
2. Test: ./{{CLI_NAME}}.ts --help
3. Use: ./{{CLI_NAME}}.ts {{EXAMPLE_COMMAND}}
Documentation: ~/.claude/Bin/{{CLI_NAME}}/README.md---
📤 OUTPUT EXAMPLE
User Request: "Create a CLI for the Notion API to list databases and create pages"
Generated Output:
✅ CLI Created: ~/.claude/Bin/notioncli/
Files generated:
- notioncli.ts (342 lines)
- package.json
- tsconfig.json
- .env.example (NOTION_API_KEY)
- README.md (with philosophy and examples)
- QUICKSTART.md
Commands available:
- notioncli databases # List all databases
- notioncli pages create <db-id> # Create page in database
- notioncli search <query> # Search workspace
- notioncli --help # Show full help
Next steps:
1. Add NOTION_API_KEY=your_key to ${PAI_CONFIG_DIR}/.env
2. Test: notioncli databases
3. Read: ~/.claude/Bin/notioncli/README.md
The CLI follows llcli pattern with type safety, error handling,
and comprehensive documentation.---
🔗 RELATED WORKFLOWS
After creating CLI:
add-command.md- Add more commands to existing CLIadd-testing.md- Generate test suitesetup-distribution.md- Setup npm publishing or binary distribution
Escalation:
upgrade-tier.md- Migrate from Tier 1 → Tier 2 if CLI grows complex
---
📖 REAL-WORLD EXAMPLES
Example 1: API Client
Request: "CLI for Stripe API"
Decision: Tier 1 (API wrapper, simple commands)
Generated Commands:
stripecli customers list
stripecli customers create --email user@example.com
stripecli payments list --customer cus_123
stripecli balance---
Example 2: File Processor
Request: "CLI to convert markdown to various formats"
Decision: Tier 1 (file I/O, simple transformations)
Generated Commands:
md-convert html input.md output.html
md-convert pdf input.md output.pdf
md-convert extract-links input.md
md-convert stats input.md---
Example 3: Database Tool
Request: "CLI for database migrations with rollback, status, and generate commands"
Decision: Tier 2 (complex workflow, subcommands)
Generated Commands:
db-migrate up # Run pending migrations
db-migrate down --steps 1 # Rollback
db-migrate status # Show migration status
db-migrate create --name users # Generate new migration---
✅ BEST PRACTICES
1. Default to Tier 1
Start simple. 80% of CLIs don't need a framework.
2. Complete Documentation
README explains "why" not just "how". Include philosophy section.
3. Type Safety First
All interfaces, strict mode, no any types.
4. Deterministic Output
JSON to stdout, errors to stderr. Consistent every time.
5. Error Context
Don't just say "Error". Explain what failed and how to fix it.
6. Examples in Help
Show real usage examples, not just flag descriptions.
7. Test Immediately
Run --help and version command before reporting success.
8. Follow llcli Pattern
Use proven structure from ~/.claude/Bin/llcli/ as reference.
---
🐛 TROUBLESHOOTING
"Should I use Tier 1 or Tier 2?" → Follow decision tree. If uncertain, use Tier 1. You can upgrade later.
"User wants 15 commands" → Tier 1 can handle this if commands are simple. Use Tier 2 only if they need grouping/subcommands.
"CLI needs both JSON and table output" → Tier 2 (multiple output engines). Tier 1 is JSON-only.
"User didn't specify commands" → Ask: "What are the main commands you need?"
"Don't know API structure" → Ask: "Do you have API documentation? What does a typical response look like?"
---
📊 QUALITY CHECKLIST
Before reporting CLI as complete, verify:
Core Functionality
- [ ] TypeScript compiles (run bun check)
- [ ] File permissions set (chmod +x)
- [ ] --help displays correctly
- [ ] --version shows version
- [ ] All commands in help are implemented
Configuration Flags Standard
- [ ] Configuration exposed via flags, not hardcoded
- [ ] Mode flags present where applicable (--fast, --thorough, --dry-run)
- [ ] Output flags present (--format, --quiet, --verbose)
- [ ] Resource flags present if applicable (--model, etc.)
- [ ] Sensible defaults work without flags
- [ ] Flags documented in --help output
Documentation & Output
- [ ] README has philosophy section
- [ ] QUICKSTART has common examples
- [ ] .env.example lists all required vars
- [ ] Error messages are actionable
- [ ] Exit codes correct (0 = success, 1 = error)
- [ ] JSON output valid (test with
| jq empty) - [ ] Configuration loaded from expected location
- [ ] CLI name is kebab-case
- [ ] Follows llcli structure pattern
Workflow Integration
- [ ] If this CLI will be called by workflows, document the intent-to-flag mapping pattern
- [ ] Flag names match standard conventions (see CliFirstArchitecture.md)
---
This workflow generates production-ready CLIs that work immediately, following the proven llcli pattern and CLI-First Architecture principles.
Upgrade Tier Workflow
Migrate from manual parsing to Commander.js when CLI grows complex.
Voice Notification
curl -s -X POST http://localhost:31337/notify \
-H "Content-Type: application/json" \
-d '{"message": "Running the UpgradeTier workflow in the CreateCLI skill to upgrade CLI tier"}' \
> /dev/null 2>&1 &Running the UpgradeTier workflow in the CreateCLI skill to upgrade CLI tier...
---
🎯 PURPOSE
Convert Tier 1 CLI (llcli-style) to Tier 2 (Commander.js) when complexity demands it.
---
📍 WHEN TO USE
Indicators to upgrade:
- 15+ commands (switch statement unwieldy)
- Need subcommands (git-style:
cli convert json csv) - Plugin architecture needed
- Complex option combinations
- Multiple output formats
Rule: Don't upgrade prematurely. Tier 1 handles 10-15 commands fine.
---
📋 MIGRATION STEPS
1. Install Commander.js
cd ~/.claude/Bin/[cli-name]/
bun add commander2. Create Commander Structure
#!/usr/bin/env bun
import { Command } from 'commander';
const program = new Command();
program
.name('[cli-name]')
.description('[description from old CLI]')
.version('2.0.0'); // Bump major version3. Convert Commands
Before (Tier 1):
async function fetchData(arg: string, limit: number): Promise<void> {
// ...
}
switch (command) {
case 'fetch':
await fetchData(args[1], limit);
break;
}After (Tier 2):
program
.command('fetch <arg>')
.option('-l, --limit <number>', 'limit results', '20')
.description('Fetch data')
.action(async (arg: string, options) => {
const limit = parseInt(options.limit, 10);
await fetchData(arg, limit);
});4. Preserve Help Quality
Don't let auto-generated help be worse than manual help.
program
.command('fetch <query>')
.description('Search and fetch data')
.option('-l, --limit <n>', 'max results', '20')
.addHelpText('after', `
Examples:
$ ${program.name()} fetch "keyword" --limit 50
$ ${program.name()} fetch "api query"
Output: JSON to stdout
`);5. Test All Commands
./cli.ts --help
./cli.ts fetch test
./cli.ts [each-command]6. Update Documentation
# Breaking Changes (v2.0.0)
Now uses Commander.js for better command organization.
**Migration:**
- All commands work the same
- Help text improved
- Added subcommand support
No API changes - drop-in replacement.---
🔄 BEFORE/AFTER COMPARISON
Before (Tier 1)
// Manual parsing, ~350 lines
async function main() {
const args = process.argv.slice(2);
const command = args[0];
switch (command) {
case 'fetch': /* ... */
case 'create': /* ... */
// ... 15 more cases
}
}After (Tier 2)
// Commander.js, ~250 lines (cleaner)
program
.command('fetch <query>').action(fetchCommand)
.command('create <name>').action(createCommand);
// ... 15 more commands
program.parse();---
✅ CHECKLIST
- [ ] Commander.js installed
- [ ] All commands converted
- [ ] Help text quality maintained
- [ ] All tests pass
- [ ] README updated (breaking changes)
- [ ] Version bumped to 2.0.0
- [ ] Users notified if published
---
Note: Most CLIs NEVER need this upgrade. Tier 1 is production-ready indefinitely.