Initial commit

This commit is contained in:
Zhongwei Li
2025-11-30 09:04:14 +08:00
commit 70c36b5eff
248 changed files with 47482 additions and 0 deletions

View File

@@ -0,0 +1,201 @@
---
name: argparse-patterns
description: Standard library Python argparse examples with subparsers, choices, actions, and nested command patterns. Use when building Python CLIs without external dependencies, implementing argument parsing, creating subcommands, or when user mentions argparse, standard library CLI, subparsers, argument validation, or nested commands.
allowed-tools: Read, Write, Edit, Bash
---
# argparse-patterns
Python's built-in argparse module for CLI argument parsing - no external dependencies required.
## Overview
Provides comprehensive argparse patterns using only Python standard library. Includes subparsers for nested commands, choices for validation, custom actions, argument groups, and mutually exclusive options.
## Instructions
### Basic Parser Setup
1. Import argparse and create parser with description
2. Add version info with `action='version'`
3. Set formatter_class for better help formatting
4. Parse arguments with `parser.parse_args()`
### Subparsers (Nested Commands)
1. Use `parser.add_subparsers(dest='command')` to create command groups
2. Add individual commands with `subparsers.add_parser('command-name')`
3. Each subparser can have its own arguments and options
4. Nest subparsers for multi-level commands (e.g., `mycli config get key`)
### Choices and Validation
1. Use `choices=['opt1', 'opt2']` to restrict values
2. Implement custom validation with type functions
3. Add validators using argparse types
4. Set defaults with `default=value`
### Actions
1. `store_true/store_false` - Boolean flags
2. `store_const` - Store constant value
3. `append` - Collect multiple values
4. `count` - Count flag occurrences
5. `version` - Display version and exit
6. Custom actions with Action subclass
### Argument Types
1. Positional arguments - Required by default
2. Optional arguments - Prefix with `--` or `-`
3. Type coercion - `type=int`, `type=float`, `type=pathlib.Path`
4. Nargs - `'?'` (optional), `'*'` (zero or more), `'+'` (one or more)
## Available Templates
### Python Templates
- **basic-parser.py** - Simple parser with arguments and options
- **subparser-pattern.py** - Single-level subcommands
- **nested-subparser.py** - Multi-level nested commands (e.g., git config get)
- **choices-validation.py** - Argument choices and validation
- **boolean-flags.py** - Boolean flag patterns
- **custom-actions.py** - Custom action classes
- **mutually-exclusive.py** - Mutually exclusive groups
- **argument-groups.py** - Organizing related arguments
- **type-coercion.py** - Custom type converters
- **variadic-args.py** - Variable argument patterns
### TypeScript Templates
- **argparse-to-commander.ts** - argparse patterns translated to commander.js
- **argparse-to-yargs.ts** - argparse patterns translated to yargs
- **parser-comparison.ts** - Side-by-side argparse vs Node.js patterns
## Available Scripts
- **generate-parser.sh** - Generate argparse parser from specifications
- **validate-parser.sh** - Validate parser structure and completeness
- **test-parser.sh** - Test parser with various argument combinations
- **convert-to-click.sh** - Convert argparse code to Click decorators
## Examples
See `examples/` directory for comprehensive patterns:
- **basic-usage.md** - Simple CLI with arguments
- **subcommands.md** - Multi-command CLI (like git, docker)
- **nested-commands.md** - Deep command hierarchies
- **validation-patterns.md** - Argument validation strategies
- **advanced-parsing.md** - Complex parsing scenarios
## Common Patterns
### Pattern 1: Simple CLI with Options
```python
parser = argparse.ArgumentParser(description='Deploy application')
parser.add_argument('--env', choices=['dev', 'staging', 'prod'], default='dev')
parser.add_argument('--force', action='store_true')
args = parser.parse_args()
```
### Pattern 2: Subcommands (git-like)
```python
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='command')
deploy_cmd = subparsers.add_parser('deploy')
deploy_cmd.add_argument('environment')
config_cmd = subparsers.add_parser('config')
```
### Pattern 3: Nested Subcommands (git config get/set)
```python
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='command')
config = subparsers.add_parser('config')
config_subs = config.add_subparsers(dest='config_command')
config_get = config_subs.add_parser('get')
config_get.add_argument('key')
config_set = config_subs.add_parser('set')
config_set.add_argument('key')
config_set.add_argument('value')
```
### Pattern 4: Mutually Exclusive Options
```python
group = parser.add_mutually_exclusive_group()
group.add_argument('--json', action='store_true')
group.add_argument('--yaml', action='store_true')
```
### Pattern 5: Custom Validation
```python
def validate_port(value):
ivalue = int(value)
if ivalue < 1 or ivalue > 65535:
raise argparse.ArgumentTypeError(f"{value} is not a valid port")
return ivalue
parser.add_argument('--port', type=validate_port, default=8080)
```
## Best Practices
1. **Always provide help text** - Use `help=` for every argument
2. **Set sensible defaults** - Use `default=` to avoid None values
3. **Use choices for fixed options** - Better than manual validation
4. **Group related arguments** - Use `add_argument_group()` for clarity
5. **Handle missing subcommands** - Check if `args.command` is None
6. **Use type coercion** - Prefer `type=int` over manual conversion
7. **Provide examples** - Use `epilog=` for usage examples
## Advantages Over External Libraries
- **No dependencies** - Built into Python standard library
- **Stable API** - Won't break with updates
- **Universal** - Works everywhere Python works
- **Well documented** - Extensive official documentation
- **Lightweight** - No installation or import overhead
## When to Use argparse
Use argparse when:
- Building simple to medium complexity CLIs
- Avoiding external dependencies is important
- Working in restricted environments
- Learning CLI patterns (clear, explicit API)
Consider alternatives when:
- Need decorator-based syntax (use Click/Typer)
- Want type safety and auto-completion (use Typer)
- Rapid prototyping from existing code (use Fire)
## Integration
This skill integrates with:
- `cli-setup` agent - Initialize Python CLI projects
- `cli-feature-impl` agent - Implement command logic
- `cli-verifier-python` agent - Validate argparse structure
- `click-patterns` skill - Compare with Click patterns
- `typer-patterns` skill - Compare with Typer patterns
## Requirements
- Python 3.7+ (argparse included in standard library)
- No external dependencies required
- Works on all platforms (Windows, macOS, Linux)
---
**Purpose**: Standard library Python CLI argument parsing patterns
**Used by**: Python CLI projects prioritizing zero dependencies

View File

@@ -0,0 +1,473 @@
# Advanced argparse Patterns
Complex argument parsing scenarios and advanced techniques.
## Templates Reference
- `templates/custom-actions.py`
- `templates/mutually-exclusive.py`
- `templates/argument-groups.py`
- `templates/variadic-args.py`
## Overview
Advanced patterns:
- Custom action classes
- Mutually exclusive groups
- Argument groups (organization)
- Variadic arguments (nargs)
- Environment variable fallback
- Config file integration
- Subparser inheritance
## 1. Custom Actions
Create custom argument processing logic.
### Simple Custom Action
```python
class UpperCaseAction(argparse.Action):
"""Convert value to uppercase."""
def __call__(self, parser, namespace, values, option_string=None):
setattr(namespace, self.dest, values.upper())
parser.add_argument('--name', action=UpperCaseAction)
```
### Key-Value Action
```python
class KeyValueAction(argparse.Action):
"""Parse key=value pairs."""
def __call__(self, parser, namespace, values, option_string=None):
if '=' not in values:
parser.error(f"Must be key=value format: {values}")
key, value = values.split('=', 1)
items = getattr(namespace, self.dest, {}) or {}
items[key] = value
setattr(namespace, self.dest, items)
parser.add_argument(
'--env', '-e',
action=KeyValueAction,
help='Environment variable (key=value)'
)
# Usage: --env API_KEY=abc123 --env DB_URL=postgres://...
```
### Load File Action
```python
class LoadFileAction(argparse.Action):
"""Load and parse file content."""
def __call__(self, parser, namespace, values, option_string=None):
try:
with open(values, 'r') as f:
content = f.read()
setattr(namespace, self.dest, content)
except Exception as e:
parser.error(f"Cannot load file {values}: {e}")
parser.add_argument('--config', action=LoadFileAction)
```
## 2. Mutually Exclusive Groups
Ensure only one option from a group is used.
### Basic Exclusivity
```python
group = parser.add_mutually_exclusive_group()
group.add_argument('--json', help='Output as JSON')
group.add_argument('--yaml', help='Output as YAML')
group.add_argument('--xml', help='Output as XML')
# Valid: --json output.json
# Valid: --yaml output.yaml
# Invalid: --json output.json --yaml output.yaml
```
### Required Exclusive Group
```python
mode_group = parser.add_mutually_exclusive_group(required=True)
mode_group.add_argument('--create', metavar='NAME')
mode_group.add_argument('--update', metavar='NAME')
mode_group.add_argument('--delete', metavar='NAME')
mode_group.add_argument('--list', action='store_true')
# Must specify exactly one: create, update, delete, or list
```
### Multiple Exclusive Groups
```python
# Output format group
output = parser.add_mutually_exclusive_group()
output.add_argument('--json', action='store_true')
output.add_argument('--yaml', action='store_true')
# Verbosity group
verbosity = parser.add_mutually_exclusive_group()
verbosity.add_argument('--verbose', action='store_true')
verbosity.add_argument('--quiet', action='store_true')
# Can use one from each group:
# Valid: --json --verbose
# Invalid: --json --yaml
```
## 3. Argument Groups
Organize arguments for better help display.
```python
parser = argparse.ArgumentParser()
# Server configuration
server_group = parser.add_argument_group(
'server configuration',
'Options for configuring the web server'
)
server_group.add_argument('--host', default='127.0.0.1')
server_group.add_argument('--port', type=int, default=8080)
server_group.add_argument('--workers', type=int, default=4)
# Database configuration
db_group = parser.add_argument_group(
'database configuration',
'Options for database connection'
)
db_group.add_argument('--db-host', default='localhost')
db_group.add_argument('--db-port', type=int, default=5432)
db_group.add_argument('--db-name', required=True)
# Logging configuration
log_group = parser.add_argument_group(
'logging configuration',
'Options for logging and monitoring'
)
log_group.add_argument('--log-level',
choices=['debug', 'info', 'warning', 'error'],
default='info')
log_group.add_argument('--log-file', help='Log to file')
```
**Help output groups arguments logically.**
## 4. Variadic Arguments (nargs)
Handle variable number of arguments.
### Optional Single Argument (?)
```python
parser.add_argument(
'--output',
nargs='?',
const='default.json', # Used if flag present, no value
default=None, # Used if flag not present
help='Output file'
)
# --output → 'default.json'
# --output file.json → 'file.json'
# (no flag) → None
```
### Zero or More (*)
```python
parser.add_argument(
'--include',
nargs='*',
default=[],
help='Include patterns'
)
# --include → []
# --include *.py → ['*.py']
# --include *.py *.md → ['*.py', '*.md']
```
### One or More (+)
```python
parser.add_argument(
'files',
nargs='+',
help='Input files (at least one required)'
)
# file1.txt → ['file1.txt']
# file1.txt file2.txt → ['file1.txt', 'file2.txt']
# (no files) → Error: required
```
### Exact Number
```python
parser.add_argument(
'--range',
nargs=2,
type=int,
metavar=('START', 'END'),
help='Range as start end'
)
# --range 1 10 → [1, 10]
# --range 1 → Error: expected 2
```
### Remainder
```python
parser.add_argument(
'--command',
nargs=argparse.REMAINDER,
help='Pass-through command and args'
)
# mycli --command python script.py --arg1 --arg2
# → command = ['python', 'script.py', '--arg1', '--arg2']
```
## 5. Environment Variable Fallback
```python
import os
parser = argparse.ArgumentParser()
parser.add_argument(
'--api-key',
default=os.environ.get('API_KEY'),
help='API key (default: $API_KEY)'
)
parser.add_argument(
'--db-url',
default=os.environ.get('DATABASE_URL'),
help='Database URL (default: $DATABASE_URL)'
)
# Precedence: CLI arg > Environment variable > Default
```
## 6. Config File Integration
```python
import configparser
def load_config(config_file):
"""Load configuration from INI file."""
config = configparser.ConfigParser()
config.read(config_file)
return config
parser = argparse.ArgumentParser()
parser.add_argument('--config', help='Config file')
parser.add_argument('--host', help='Server host')
parser.add_argument('--port', type=int, help='Server port')
args = parser.parse_args()
# Load config file if specified
if args.config:
config = load_config(args.config)
# Use config values as defaults if not specified on CLI
if not args.host:
args.host = config.get('server', 'host', fallback='127.0.0.1')
if not args.port:
args.port = config.getint('server', 'port', fallback=8080)
```
## 7. Parent Parsers (Inheritance)
Share common arguments across subcommands.
```python
# Parent parser with common arguments
parent_parser = argparse.ArgumentParser(add_help=False)
parent_parser.add_argument('--verbose', action='store_true')
parent_parser.add_argument('--config', help='Config file')
# Main parser
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers(dest='command')
# Subcommands inherit from parent
deploy_parser = subparsers.add_parser(
'deploy',
parents=[parent_parser],
help='Deploy application'
)
deploy_parser.add_argument('environment')
build_parser = subparsers.add_parser(
'build',
parents=[parent_parser],
help='Build application'
)
build_parser.add_argument('--target')
# Both subcommands have --verbose and --config
```
## 8. Argument Defaults from Dict
```python
defaults = {
'host': '127.0.0.1',
'port': 8080,
'workers': 4,
'timeout': 30.0
}
parser = argparse.ArgumentParser()
parser.add_argument('--host')
parser.add_argument('--port', type=int)
parser.add_argument('--workers', type=int)
parser.add_argument('--timeout', type=float)
# Set all defaults at once
parser.set_defaults(**defaults)
```
## 9. Namespace Manipulation
```python
# Pre-populate namespace
defaults = argparse.Namespace(
host='127.0.0.1',
port=8080,
debug=False
)
args = parser.parse_args(namespace=defaults)
# Or modify after parsing
args = parser.parse_args()
args.computed_value = args.value1 + args.value2
```
## 10. Conditional Arguments
```python
args = parser.parse_args()
# Add conditional validation
if args.ssl and not (args.cert and args.key):
parser.error("--ssl requires both --cert and --key")
# Add computed values
if args.workers == 'auto':
import os
args.workers = os.cpu_count()
```
## Complete Advanced Example
```python
#!/usr/bin/env python3
import argparse
import os
import sys
class KeyValueAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None):
key, value = values.split('=', 1)
items = getattr(namespace, self.dest, {}) or {}
items[key] = value
setattr(namespace, self.dest, items)
def main():
# Parent parser for common args
parent = argparse.ArgumentParser(add_help=False)
parent.add_argument('--verbose', action='store_true')
parent.add_argument('--config', help='Config file')
# Main parser
parser = argparse.ArgumentParser(
description='Advanced argparse patterns'
)
subparsers = parser.add_subparsers(dest='command', required=True)
# Deploy command
deploy = subparsers.add_parser(
'deploy',
parents=[parent],
help='Deploy application'
)
# Mutually exclusive group
format_group = deploy.add_mutually_exclusive_group()
format_group.add_argument('--json', action='store_true')
format_group.add_argument('--yaml', action='store_true')
# Custom action
deploy.add_argument(
'--env', '-e',
action=KeyValueAction,
help='Environment variable'
)
# Variadic arguments
deploy.add_argument(
'targets',
nargs='+',
help='Deployment targets'
)
# Environment fallback
deploy.add_argument(
'--api-key',
default=os.environ.get('API_KEY'),
help='API key'
)
args = parser.parse_args()
# Post-parse validation
if args.command == 'deploy':
if not args.api_key:
parser.error("API key required (use --api-key or $API_KEY)")
return 0
if __name__ == '__main__':
sys.exit(main())
```
## Best Practices
1. **Use parent parsers** for shared arguments
2. **Use argument groups** for organization
3. **Use mutually exclusive groups** when appropriate
4. **Validate after parsing** for complex logic
5. **Provide environment fallbacks** for sensitive data
6. **Use custom actions** for complex transformations
7. **Document nargs behavior** in help text
## Next Steps
- Review template files for complete implementations
- Test patterns with `scripts/test-parser.sh`
- Compare with Click/Typer alternatives

View File

@@ -0,0 +1,230 @@
# Basic argparse Usage
Simple CLI with positional and optional arguments using Python's standard library.
## Template Reference
`templates/basic-parser.py`
## Overview
Demonstrates fundamental argparse patterns:
- Positional arguments (required)
- Optional arguments with flags
- Boolean flags
- Type coercion
- Default values
- Help text generation
## Quick Start
```bash
# View help
python basic-parser.py --help
# Basic usage
python basic-parser.py deploy my-app
# With optional arguments
python basic-parser.py deploy my-app --env staging --timeout 60
# Boolean flags
python basic-parser.py deploy my-app --force
# Verbose mode (count occurrences)
python basic-parser.py deploy my-app -vvv
```
## Key Patterns
### 1. Create Parser
```python
import argparse
parser = argparse.ArgumentParser(
description='Deploy application to specified environment',
formatter_class=argparse.RawDescriptionHelpFormatter
)
```
**Why `RawDescriptionHelpFormatter`?**
- Preserves formatting in epilog (usage examples)
- Better control over help text layout
### 2. Add Version
```python
parser.add_argument(
'--version',
action='version',
version='%(prog)s 1.0.0'
)
```
**Usage:** `python mycli.py --version`
### 3. Positional Arguments
```python
parser.add_argument(
'app_name',
help='Name of the application to deploy'
)
```
**Required by default** - no flag needed, just the value.
### 4. Optional Arguments
```python
parser.add_argument(
'--env', '-e',
default='development',
help='Deployment environment (default: %(default)s)'
)
```
**Note:** `%(default)s` automatically shows default value in help.
### 5. Type Coercion
```python
parser.add_argument(
'--timeout', '-t',
type=int,
default=30,
help='Timeout in seconds'
)
```
**Automatic validation** - argparse will error if non-integer provided.
### 6. Boolean Flags
```python
parser.add_argument(
'--force', '-f',
action='store_true',
help='Force deployment without confirmation'
)
```
**Result:**
- Present: `args.force = True`
- Absent: `args.force = False`
### 7. Count Action
```python
parser.add_argument(
'--verbose', '-v',
action='count',
default=0,
help='Increase verbosity (-v, -vv, -vvv)'
)
```
**Usage:**
- `-v`: verbosity = 1
- `-vv`: verbosity = 2
- `-vvv`: verbosity = 3
## Complete Example
```python
#!/usr/bin/env python3
import argparse
import sys
def main():
parser = argparse.ArgumentParser(
description='Simple deployment tool'
)
parser.add_argument('--version', action='version', version='1.0.0')
parser.add_argument('app_name', help='Application name')
parser.add_argument('--env', default='dev', help='Environment')
parser.add_argument('--timeout', type=int, default=30, help='Timeout')
parser.add_argument('--force', action='store_true', help='Force')
args = parser.parse_args()
print(f"Deploying {args.app_name} to {args.env}")
print(f"Timeout: {args.timeout}s")
print(f"Force: {args.force}")
return 0
if __name__ == '__main__':
sys.exit(main())
```
## Help Output
```
usage: basic-parser.py [-h] [--version] [--env ENV] [--timeout TIMEOUT]
[--force] [--verbose]
action app_name
Deploy application to specified environment
positional arguments:
action Action to perform
app_name Name of the application to deploy
optional arguments:
-h, --help show this help message and exit
--version show program's version number and exit
--env ENV, -e ENV Deployment environment (default: development)
--timeout TIMEOUT, -t TIMEOUT
Timeout in seconds (default: 30)
--force, -f Force deployment without confirmation
--verbose, -v Increase verbosity (-v, -vv, -vvv)
```
## Common Mistakes
### ❌ Wrong: Accessing before parsing
```python
args = parser.parse_args()
print(args.env) # ✓ Correct
```
```python
print(args.env) # ✗ Wrong - args doesn't exist yet
args = parser.parse_args()
```
### ❌ Wrong: Not checking boolean flags
```python
if args.force: # ✓ Correct
print("Force mode")
```
```python
if args.force == True: # ✗ Unnecessary comparison
print("Force mode")
```
### ❌ Wrong: Manual type conversion
```python
parser.add_argument('--port', type=int) # ✓ Let argparse handle it
```
```python
parser.add_argument('--port')
port = int(args.port) # ✗ Manual conversion (error-prone)
```
## Next Steps
- **Subcommands:** See `subcommands.md`
- **Validation:** See `validation-patterns.md`
- **Advanced:** See `advanced-parsing.md`

View File

@@ -0,0 +1,370 @@
# Nested Subcommands
Multi-level command hierarchies like `git config get` or `kubectl config view`.
## Template Reference
`templates/nested-subparser.py`
## Overview
Create deep command structures:
- `mycli config get key`
- `mycli config set key value`
- `mycli deploy start production`
- `mycli deploy stop production`
## Quick Start
```bash
# Two-level commands
python nested-subparser.py config get database_url
python nested-subparser.py config set api_key abc123
python nested-subparser.py config list
# Deploy subcommands
python nested-subparser.py deploy start production --replicas 3
python nested-subparser.py deploy stop staging
```
## Architecture
```
mycli
├── config
│ ├── get <key>
│ ├── set <key> <value>
│ ├── list
│ └── delete <key>
└── deploy
├── start <environment>
├── stop <environment>
└── restart <environment>
```
## Implementation Pattern
### 1. Main Parser
```python
parser = argparse.ArgumentParser(description='Multi-level CLI')
subparsers = parser.add_subparsers(dest='command', required=True)
```
### 2. First-Level Subcommand
```python
# Create 'config' command group
config_parser = subparsers.add_parser(
'config',
help='Manage configuration'
)
# Create second-level subparsers under 'config'
config_subparsers = config_parser.add_subparsers(
dest='config_command',
required=True
)
```
### 3. Second-Level Subcommands
```python
# config get
config_get = config_subparsers.add_parser('get', help='Get value')
config_get.add_argument('key', help='Configuration key')
config_get.set_defaults(func=config_get_handler)
# config set
config_set = config_subparsers.add_parser('set', help='Set value')
config_set.add_argument('key', help='Configuration key')
config_set.add_argument('value', help='Configuration value')
config_set.add_argument('--force', action='store_true')
config_set.set_defaults(func=config_set_handler)
```
## Complete Example
```python
#!/usr/bin/env python3
import argparse
import sys
# Config handlers
def config_get(args):
print(f"Getting: {args.key}")
return 0
def config_set(args):
print(f"Setting: {args.key} = {args.value}")
return 0
# Deploy handlers
def deploy_start(args):
print(f"Starting deployment to {args.environment}")
return 0
def main():
parser = argparse.ArgumentParser(description='Nested CLI')
subparsers = parser.add_subparsers(dest='command', required=True)
# === Config group ===
config_parser = subparsers.add_parser('config', help='Configuration')
config_subs = config_parser.add_subparsers(
dest='config_command',
required=True
)
# config get
get_parser = config_subs.add_parser('get')
get_parser.add_argument('key')
get_parser.set_defaults(func=config_get)
# config set
set_parser = config_subs.add_parser('set')
set_parser.add_argument('key')
set_parser.add_argument('value')
set_parser.set_defaults(func=config_set)
# === Deploy group ===
deploy_parser = subparsers.add_parser('deploy', help='Deployment')
deploy_subs = deploy_parser.add_subparsers(
dest='deploy_command',
required=True
)
# deploy start
start_parser = deploy_subs.add_parser('start')
start_parser.add_argument('environment')
start_parser.set_defaults(func=deploy_start)
# Parse and dispatch
args = parser.parse_args()
return args.func(args)
if __name__ == '__main__':
sys.exit(main())
```
## Accessing Nested Commands
```python
args = parser.parse_args()
# Top-level command
print(args.command) # 'config' or 'deploy'
# Second-level command
if args.command == 'config':
print(args.config_command) # 'get', 'set', 'list', 'delete'
elif args.command == 'deploy':
print(args.deploy_command) # 'start', 'stop', 'restart'
```
## Help Output
### Top-Level Help
```
usage: mycli [-h] {config,deploy} ...
positional arguments:
{config,deploy}
config Manage configuration
deploy Manage deployments
```
### Second-Level Help
```bash
$ python mycli.py config --help
usage: mycli config [-h] {get,set,list,delete} ...
positional arguments:
{get,set,list,delete}
get Get configuration value
set Set configuration value
list List all configuration
delete Delete configuration value
```
### Third-Level Help
```bash
$ python mycli.py config set --help
usage: mycli config set [-h] [-f] key value
positional arguments:
key Configuration key
value Configuration value
optional arguments:
-h, --help show this help message and exit
-f, --force Overwrite existing value
```
## Dispatch Pattern
### Option 1: Manual Switch
```python
args = parser.parse_args()
if args.command == 'config':
if args.config_command == 'get':
config_get(args)
elif args.config_command == 'set':
config_set(args)
elif args.command == 'deploy':
if args.deploy_command == 'start':
deploy_start(args)
```
### Option 2: Function Dispatch (Recommended)
```python
# Set handlers when creating parsers
config_get.set_defaults(func=config_get_handler)
config_set.set_defaults(func=config_set_handler)
deploy_start.set_defaults(func=deploy_start_handler)
# Simple dispatch
args = parser.parse_args()
return args.func(args)
```
## Best Practices
### 1. Consistent Naming
```python
# ✓ Good - consistent dest naming
config_parser.add_subparsers(dest='config_command')
deploy_parser.add_subparsers(dest='deploy_command')
```
### 2. Set Required
```python
# ✓ Good - require subcommand
config_subs = config_parser.add_subparsers(
dest='config_command',
required=True
)
```
### 3. Provide Help
```python
# ✓ Good - descriptive help at each level
config_parser = subparsers.add_parser(
'config',
help='Manage configuration',
description='Configuration management commands'
)
```
### 4. Use set_defaults
```python
# ✓ Good - easy dispatch
get_parser.set_defaults(func=config_get)
```
## How Deep Should You Go?
### ✓ Good: 2-3 Levels
```
mycli config get key
mycli deploy start production
```
### ⚠️ Consider alternatives: 4+ Levels
```
mycli server database config get key # Too deep
```
**Alternatives:**
- Flatten: `mycli db-config-get key`
- Split: Separate CLI tools
- Use flags: `mycli config get key --scope=server --type=database`
## Common Mistakes
### ❌ Wrong: Same dest name
```python
# Both use 'command' - second overwrites first
config_subs = config_parser.add_subparsers(dest='command')
deploy_subs = deploy_parser.add_subparsers(dest='command')
```
```python
# ✓ Correct - unique dest names
config_subs = config_parser.add_subparsers(dest='config_command')
deploy_subs = deploy_parser.add_subparsers(dest='deploy_command')
```
### ❌ Wrong: Accessing wrong level
```python
args = parser.parse_args(['config', 'get', 'key'])
print(args.command) # ✓ 'config'
print(args.config_command) # ✓ 'get'
print(args.deploy_command) # ✗ Error - not set
```
### ❌ Wrong: Not checking hierarchy
```python
# ✗ Assumes deploy command
print(args.deploy_command)
```
```python
# ✓ Check first
if args.command == 'deploy':
print(args.deploy_command)
```
## Real-World Examples
### Git-style
```
git config --global user.name "Name"
git remote add origin url
git branch --list
```
### Kubectl-style
```
kubectl config view
kubectl get pods --namespace default
kubectl logs pod-name --follow
```
### Docker-style
```
docker container ls
docker image build -t name .
docker network create name
```
## Next Steps
- **Validation:** See `validation-patterns.md`
- **Advanced:** See `advanced-parsing.md`
- **Compare frameworks:** See templates for Click/Typer equivalents

View File

@@ -0,0 +1,283 @@
# Subcommands with argparse
Multi-command CLI like `git`, `docker`, or `kubectl` using subparsers.
## Template Reference
`templates/subparser-pattern.py`
## Overview
Create CLIs with multiple commands:
- `mycli init` - Initialize project
- `mycli deploy production` - Deploy to environment
- `mycli status` - Show status
Each subcommand has its own arguments and options.
## Quick Start
```bash
# View main help
python subparser-pattern.py --help
# View subcommand help
python subparser-pattern.py init --help
python subparser-pattern.py deploy --help
# Execute subcommands
python subparser-pattern.py init --template react
python subparser-pattern.py deploy production --force
python subparser-pattern.py status --format json
```
## Key Patterns
### 1. Create Subparsers
```python
parser = argparse.ArgumentParser(description='Multi-command CLI')
subparsers = parser.add_subparsers(
dest='command', # Store command name in args.command
help='Available commands',
required=True # At least one command required (Python 3.7+)
)
```
**Important:** Set `dest='command'` to access which command was used.
### 2. Add Subcommand
```python
init_parser = subparsers.add_parser(
'init',
help='Initialize a new project',
description='Initialize a new project with specified template'
)
init_parser.add_argument('--template', default='basic')
init_parser.add_argument('--path', default='.')
```
Each subcommand is a separate parser with its own arguments.
### 3. Set Command Handler
```python
def cmd_init(args):
"""Initialize project."""
print(f"Initializing with {args.template} template...")
init_parser.set_defaults(func=cmd_init)
```
**Dispatch pattern:**
```python
args = parser.parse_args()
return args.func(args) # Call the appropriate handler
```
### 4. Subcommand with Choices
```python
deploy_parser = subparsers.add_parser('deploy')
deploy_parser.add_argument(
'environment',
choices=['development', 'staging', 'production'],
help='Target environment'
)
deploy_parser.add_argument(
'--mode',
choices=['fast', 'safe', 'rollback'],
default='safe'
)
```
## Complete Example
```python
#!/usr/bin/env python3
import argparse
import sys
def cmd_init(args):
print(f"Initializing with {args.template} template")
return 0
def cmd_deploy(args):
print(f"Deploying to {args.environment}")
return 0
def main():
parser = argparse.ArgumentParser(description='My CLI Tool')
parser.add_argument('--version', action='version', version='1.0.0')
subparsers = parser.add_subparsers(dest='command', required=True)
# Init command
init_parser = subparsers.add_parser('init', help='Initialize project')
init_parser.add_argument('--template', default='basic')
init_parser.set_defaults(func=cmd_init)
# Deploy command
deploy_parser = subparsers.add_parser('deploy', help='Deploy app')
deploy_parser.add_argument(
'environment',
choices=['dev', 'staging', 'prod']
)
deploy_parser.set_defaults(func=cmd_deploy)
# Parse and dispatch
args = parser.parse_args()
return args.func(args)
if __name__ == '__main__':
sys.exit(main())
```
## Help Output
### Main Help
```
usage: mycli [-h] [--version] {init,deploy,status} ...
Multi-command CLI tool
positional arguments:
{init,deploy,status} Available commands
init Initialize a new project
deploy Deploy application to environment
status Show deployment status
optional arguments:
-h, --help show this help message and exit
--version show program's version number and exit
```
### Subcommand Help
```bash
$ python mycli.py deploy --help
usage: mycli deploy [-h] [-f] [-m {fast,safe,rollback}]
{development,staging,production}
positional arguments:
{development,staging,production}
Target environment
optional arguments:
-h, --help show this help message and exit
-f, --force Force deployment without confirmation
-m {fast,safe,rollback}, --mode {fast,safe,rollback}
Deployment mode (default: safe)
```
## Accessing Parsed Values
```python
args = parser.parse_args()
# Which command was used?
print(args.command) # 'init', 'deploy', or 'status'
# Command-specific arguments
if args.command == 'deploy':
print(args.environment) # 'production'
print(args.force) # True/False
print(args.mode) # 'safe'
```
## Common Patterns
### Pattern 1: Switch on Command
```python
args = parser.parse_args()
if args.command == 'init':
init_project(args)
elif args.command == 'deploy':
deploy_app(args)
elif args.command == 'status':
show_status(args)
```
### Pattern 2: Function Dispatch (Better)
```python
# Set handlers
init_parser.set_defaults(func=cmd_init)
deploy_parser.set_defaults(func=cmd_deploy)
# Dispatch
args = parser.parse_args()
return args.func(args)
```
### Pattern 3: Check if Command Provided
```python
args = parser.parse_args()
if not args.command:
parser.print_help()
sys.exit(1)
```
**Note:** Use `required=True` in `add_subparsers()` to make this automatic.
## Common Mistakes
### ❌ Wrong: Forgetting dest
```python
subparsers = parser.add_subparsers(dest='command') # ✓ Can check args.command
```
```python
subparsers = parser.add_subparsers() # ✗ Can't access which command
```
### ❌ Wrong: Accessing wrong argument
```python
# deploy_parser defines 'environment'
# init_parser defines 'template'
args = parser.parse_args(['deploy', 'prod'])
print(args.environment) # ✓ Correct
print(args.template) # ✗ Error - not defined for deploy
```
### ❌ Wrong: No required=True (Python 3.7+)
```python
subparsers = parser.add_subparsers(dest='command', required=True) # ✓
```
```python
subparsers = parser.add_subparsers(dest='command') # ✗ Command optional
# User can run: python mycli.py (no command)
```
## Nested Subcommands
For multi-level commands like `git config get`, see:
- `nested-commands.md`
- `templates/nested-subparser.py`
## Next Steps
- **Nested Commands:** See `nested-commands.md`
- **Validation:** See `validation-patterns.md`
- **Complex CLIs:** See `advanced-parsing.md`

View File

@@ -0,0 +1,424 @@
# Validation Patterns with argparse
Custom validators, type checking, and error handling.
## Template Reference
`templates/choices-validation.py`
## Overview
Robust argument validation:
- Built-in choices validation
- Custom type validators
- Range validation
- Pattern matching (regex)
- File/path validation
## Quick Start
```bash
# Valid inputs
python choices-validation.py --log-level debug --port 8080
python choices-validation.py --region us-east-1 --email user@example.com
# Invalid inputs (will error)
python choices-validation.py --log-level invalid # Not in choices
python choices-validation.py --port 99999 # Out of range
python choices-validation.py --email invalid # Invalid format
```
## Validation Methods
### 1. Choices (Built-in)
```python
parser.add_argument(
'--log-level',
choices=['debug', 'info', 'warning', 'error', 'critical'],
default='info',
help='Logging level'
)
```
**Automatic validation** - argparse rejects invalid values.
### 2. Custom Type Validator
```python
def validate_port(value):
"""Validate port number is 1-65535."""
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"{value} is not a valid integer")
if ivalue < 1 or ivalue > 65535:
raise argparse.ArgumentTypeError(
f"{value} is not a valid port (must be 1-65535)"
)
return ivalue
parser.add_argument(
'--port',
type=validate_port,
default=8080,
help='Server port (1-65535)'
)
```
### 3. Regex Pattern Validation
```python
import re
def validate_email(value):
"""Validate email address format."""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, value):
raise argparse.ArgumentTypeError(
f"{value} is not a valid email address"
)
return value
parser.add_argument('--email', type=validate_email)
```
### 4. IP Address Validation
```python
def validate_ip(value):
"""Validate IPv4 address."""
pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
if not re.match(pattern, value):
raise argparse.ArgumentTypeError(
f"{value} is not a valid IP address"
)
# Check each octet is 0-255
octets = [int(x) for x in value.split('.')]
if any(o < 0 or o > 255 for o in octets):
raise argparse.ArgumentTypeError(
f"{value} contains invalid octets"
)
return value
parser.add_argument('--host', type=validate_ip)
```
### 5. Path Validation
```python
from pathlib import Path
def validate_path_exists(value):
"""Validate path exists."""
path = Path(value)
if not path.exists():
raise argparse.ArgumentTypeError(
f"Path does not exist: {value}"
)
return path
parser.add_argument('--config', type=validate_path_exists)
```
### 6. Range Validation Factory
```python
def validate_range(min_val, max_val):
"""Factory function for range validators."""
def validator(value):
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(
f"{value} is not a valid integer"
)
if ivalue < min_val or ivalue > max_val:
raise argparse.ArgumentTypeError(
f"{value} must be between {min_val} and {max_val}"
)
return ivalue
return validator
# Usage
parser.add_argument(
'--workers',
type=validate_range(1, 32),
default=4,
help='Number of workers (1-32)'
)
```
## Complete Validation Example
```python
#!/usr/bin/env python3
import argparse
import re
import sys
from pathlib import Path
def validate_port(value):
ivalue = int(value)
if not (1 <= ivalue <= 65535):
raise argparse.ArgumentTypeError(
f"Port must be 1-65535, got {value}"
)
return ivalue
def validate_email(value):
if not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', value):
raise argparse.ArgumentTypeError(
f"Invalid email: {value}"
)
return value
def main():
parser = argparse.ArgumentParser(
description='Validation examples'
)
# Choices
parser.add_argument(
'--env',
choices=['dev', 'staging', 'prod'],
required=True,
help='Environment (required)'
)
# Custom validators
parser.add_argument('--port', type=validate_port, default=8080)
parser.add_argument('--email', type=validate_email)
# Path validation
parser.add_argument(
'--config',
type=lambda x: Path(x) if Path(x).exists() else
parser.error(f"File not found: {x}"),
help='Config file (must exist)'
)
args = parser.parse_args()
print(f"Environment: {args.env}")
print(f"Port: {args.port}")
if args.email:
print(f"Email: {args.email}")
return 0
if __name__ == '__main__':
sys.exit(main())
```
## Post-Parse Validation
Sometimes you need to validate relationships between arguments:
```python
args = parser.parse_args()
# Validate argument combinations
if args.ssl and not (args.cert and args.key):
parser.error("--ssl requires both --cert and --key")
if args.output and args.output.exists() and not args.force:
parser.error(f"Output file exists: {args.output}. Use --force to overwrite")
# Validate argument ranges
if args.start_date > args.end_date:
parser.error("Start date must be before end date")
```
## Error Messages
### Built-in Error Format
```bash
$ python mycli.py --env invalid
usage: mycli.py [-h] --env {dev,staging,prod}
mycli.py: error: argument --env: invalid choice: 'invalid'
(choose from 'dev', 'staging', 'prod')
```
### Custom Error Format
```python
def validate_port(value):
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(
f"Port must be an integer (got '{value}')"
)
if ivalue < 1 or ivalue > 65535:
raise argparse.ArgumentTypeError(
f"Port {ivalue} is out of range (valid: 1-65535)"
)
return ivalue
```
```bash
$ python mycli.py --port 99999
usage: mycli.py [-h] [--port PORT]
mycli.py: error: argument --port: Port 99999 is out of range (valid: 1-65535)
```
## Common Validation Patterns
### URL Validation
```python
import re
def validate_url(value):
pattern = r'^https?://[\w\.-]+\.\w+(:\d+)?(/.*)?$'
if not re.match(pattern, value):
raise argparse.ArgumentTypeError(f"Invalid URL: {value}")
return value
```
### Date Validation
```python
from datetime import datetime
def validate_date(value):
try:
return datetime.strptime(value, '%Y-%m-%d').date()
except ValueError:
raise argparse.ArgumentTypeError(
f"Invalid date: {value} (expected YYYY-MM-DD)"
)
```
### File Extension Validation
```python
def validate_json_file(value):
path = Path(value)
if path.suffix != '.json':
raise argparse.ArgumentTypeError(
f"File must have .json extension: {value}"
)
return path
```
### Percentage Validation
```python
def validate_percentage(value):
try:
pct = float(value.rstrip('%'))
except ValueError:
raise argparse.ArgumentTypeError(f"Invalid percentage: {value}")
if not (0 <= pct <= 100):
raise argparse.ArgumentTypeError(
f"Percentage must be 0-100: {value}"
)
return pct
```
## Best Practices
### ✓ Do: Fail Early
```python
# Validate during parsing
parser.add_argument('--port', type=validate_port)
# Not after parsing
args = parser.parse_args()
if not valid_port(args.port): # ✗ Too late
sys.exit(1)
```
### ✓ Do: Provide Clear Messages
```python
# ✓ Clear, actionable error
raise argparse.ArgumentTypeError(
f"Port {value} is out of range (valid: 1-65535)"
)
# ✗ Vague error
raise argparse.ArgumentTypeError("Invalid port")
```
### ✓ Do: Use Choices When Possible
```python
# ✓ Let argparse handle it
parser.add_argument('--env', choices=['dev', 'staging', 'prod'])
# ✗ Manual validation
parser.add_argument('--env')
if args.env not in ['dev', 'staging', 'prod']:
parser.error("Invalid environment")
```
### ✓ Do: Validate Type Before Range
```python
def validate_port(value):
# First ensure it's an integer
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"Not an integer: {value}")
# Then check range
if not (1 <= ivalue <= 65535):
raise argparse.ArgumentTypeError(f"Out of range: {ivalue}")
return ivalue
```
## Testing Validation
```python
import pytest
from io import StringIO
import sys
def test_valid_port():
"""Test valid port number."""
parser = create_parser()
args = parser.parse_args(['--port', '8080'])
assert args.port == 8080
def test_invalid_port():
"""Test invalid port number."""
parser = create_parser()
with pytest.raises(SystemExit):
parser.parse_args(['--port', '99999'])
def test_invalid_choice():
"""Test invalid choice."""
parser = create_parser()
with pytest.raises(SystemExit):
parser.parse_args(['--env', 'invalid'])
```
## Next Steps
- **Advanced Patterns:** See `advanced-parsing.md`
- **Type Coercion:** See `templates/type-coercion.py`
- **Custom Actions:** See `templates/custom-actions.py`

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env bash
# Convert argparse code to Click decorators
set -euo pipefail
usage() {
cat <<EOF
Convert argparse parser to Click decorators
Usage: $(basename "$0") ARGPARSE_FILE [OUTPUT_FILE]
Performs basic conversion from argparse to Click:
- ArgumentParser → @click.group() or @click.command()
- add_argument() → @click.option() or @click.argument()
- add_subparsers() → @group.command()
- choices=[] → type=click.Choice([])
- action='store_true' → is_flag=True
Note: This is a basic converter. Manual refinement may be needed.
Examples:
$(basename "$0") mycli.py mycli_click.py
$(basename "$0") basic-parser.py
EOF
exit 1
}
if [ $# -eq 0 ]; then
usage
fi
ARGPARSE_FILE="$1"
OUTPUT_FILE="${2:-}"
if [ ! -f "$ARGPARSE_FILE" ]; then
echo "Error: File not found: $ARGPARSE_FILE"
exit 1
fi
echo "Converting argparse to Click: $ARGPARSE_FILE"
convert_to_click() {
cat <<'EOF'
#!/usr/bin/env python3
"""
Converted from argparse to Click
This is a basic conversion. You may need to adjust:
- Argument order and grouping
- Type conversions
- Custom validators
- Error handling
"""
import click
@click.group()
@click.version_option(version='1.0.0')
@click.pass_context
def cli(ctx):
"""CLI tool converted from argparse"""
ctx.ensure_object(dict)
# Convert your subcommands here
# Example pattern:
#
# @cli.command()
# @click.argument('target')
# @click.option('--env', type=click.Choice(['dev', 'staging', 'prod']), default='dev')
# @click.option('--force', is_flag=True, help='Force operation')
# def deploy(target, env, force):
# """Deploy to environment"""
# click.echo(f"Deploying {target} to {env}")
# if force:
# click.echo("Force mode enabled")
if __name__ == '__main__':
cli()
EOF
echo ""
echo "# Detected argparse patterns:"
echo ""
# Detect subcommands
if grep -q "add_subparsers(" "$ARGPARSE_FILE"; then
echo "# Subcommands found:"
grep -oP "add_parser\('\K[^']+(?=')" "$ARGPARSE_FILE" | while read -r cmd; do
echo "# - $cmd"
done
echo ""
fi
# Detect arguments
if grep -q "add_argument(" "$ARGPARSE_FILE"; then
echo "# Arguments found:"
grep "add_argument(" "$ARGPARSE_FILE" | grep -oP "'[^']+'" | head -n1 | while read -r arg; do
echo "# $arg"
done
echo ""
fi
# Detect choices
if grep -q "choices=" "$ARGPARSE_FILE"; then
echo "# Choices found (convert to click.Choice):"
grep -oP "choices=\[\K[^\]]+(?=\])" "$ARGPARSE_FILE" | while read -r choices; do
echo "# [$choices]"
done
echo ""
fi
# Provide conversion hints
cat <<'EOF'
# Conversion Guide:
#
# argparse → Click
# ----------------------------------|--------------------------------
# parser.add_argument('arg') → @click.argument('arg')
# parser.add_argument('--opt') → @click.option('--opt')
# action='store_true' → is_flag=True
# choices=['a', 'b'] → type=click.Choice(['a', 'b'])
# type=int → type=int
# required=True → required=True
# default='value' → default='value'
# help='...' → help='...'
#
# For nested subcommands:
# Use @group.command() decorator
#
# For more info: https://click.palletsprojects.com/
EOF
}
# Output
if [ -n "$OUTPUT_FILE" ]; then
convert_to_click > "$OUTPUT_FILE"
chmod +x "$OUTPUT_FILE"
echo "Converted to Click: $OUTPUT_FILE"
echo ""
echo "Next steps:"
echo " 1. Review the generated file"
echo " 2. Add your command implementations"
echo " 3. Install Click: pip install click"
echo " 4. Test: python $OUTPUT_FILE --help"
else
convert_to_click
fi

View File

@@ -0,0 +1,213 @@
#!/usr/bin/env bash
# Generate argparse parser from specification
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEMPLATES_DIR="$(dirname "$SCRIPT_DIR")/templates"
usage() {
cat <<EOF
Generate argparse parser from specification
Usage: $(basename "$0") [OPTIONS]
Options:
-n, --name NAME Parser name (required)
-d, --description DESC Parser description
-s, --subcommands Include subcommands
-c, --choices Include choice validation
-g, --groups Include argument groups
-o, --output FILE Output file (default: stdout)
-h, --help Show this help
Examples:
$(basename "$0") -n mycli -d "My CLI tool" -o mycli.py
$(basename "$0") -n deploy -s -c -o deploy.py
EOF
exit 1
}
# Parse arguments
NAME=""
DESCRIPTION=""
SUBCOMMANDS=false
CHOICES=false
GROUPS=false
OUTPUT=""
while [[ $# -gt 0 ]]; do
case $1 in
-n|--name)
NAME="$2"
shift 2
;;
-d|--description)
DESCRIPTION="$2"
shift 2
;;
-s|--subcommands)
SUBCOMMANDS=true
shift
;;
-c|--choices)
CHOICES=true
shift
;;
-g|--groups)
GROUPS=true
shift
;;
-o|--output)
OUTPUT="$2"
shift 2
;;
-h|--help)
usage
;;
*)
echo "Error: Unknown option $1"
usage
;;
esac
done
if [ -z "$NAME" ]; then
echo "Error: --name is required"
usage
fi
# Set defaults
DESCRIPTION="${DESCRIPTION:-$NAME CLI tool}"
# Generate parser
generate_parser() {
cat <<EOF
#!/usr/bin/env python3
"""
$DESCRIPTION
Generated by generate-parser.sh
"""
import argparse
import sys
def main():
parser = argparse.ArgumentParser(
description='$DESCRIPTION',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
'--version',
action='version',
version='1.0.0'
)
parser.add_argument(
'--verbose', '-v',
action='store_true',
help='Enable verbose output'
)
EOF
if [ "$GROUPS" = true ]; then
cat <<EOF
# Configuration group
config_group = parser.add_argument_group(
'configuration',
'Configuration options'
)
config_group.add_argument(
'--config',
help='Configuration file'
)
EOF
fi
if [ "$SUBCOMMANDS" = true ]; then
cat <<EOF
# Create subparsers
subparsers = parser.add_subparsers(
dest='command',
help='Available commands',
required=True
)
# Example subcommand
cmd_parser = subparsers.add_parser(
'run',
help='Run the application'
)
cmd_parser.add_argument(
'target',
help='Target to run'
)
EOF
if [ "$CHOICES" = true ]; then
cat <<EOF
cmd_parser.add_argument(
'--env',
choices=['development', 'staging', 'production'],
default='development',
help='Environment (default: %(default)s)'
)
EOF
fi
else
cat <<EOF
# Arguments
parser.add_argument(
'target',
help='Target to process'
)
EOF
if [ "$CHOICES" = true ]; then
cat <<EOF
parser.add_argument(
'--env',
choices=['development', 'staging', 'production'],
default='development',
help='Environment (default: %(default)s)'
)
EOF
fi
fi
cat <<EOF
# Parse arguments
args = parser.parse_args()
# Display configuration
if args.verbose:
print("Verbose mode enabled")
print(f"Arguments: {args}")
return 0
if __name__ == '__main__':
sys.exit(main())
EOF
}
# Output
if [ -n "$OUTPUT" ]; then
generate_parser > "$OUTPUT"
chmod +x "$OUTPUT"
echo "Generated parser: $OUTPUT"
else
generate_parser
fi

View File

@@ -0,0 +1,149 @@
#!/usr/bin/env bash
# Test argparse parser with various argument combinations
set -euo pipefail
usage() {
cat <<EOF
Test argparse parser with various arguments
Usage: $(basename "$0") PARSER_FILE
Tests:
- Help display (--help)
- Version display (--version)
- Missing required arguments
- Invalid choices
- Type validation
- Subcommands (if present)
Examples:
$(basename "$0") mycli.py
$(basename "$0") ../templates/basic-parser.py
EOF
exit 1
}
if [ $# -eq 0 ]; then
usage
fi
PARSER_FILE="$1"
if [ ! -f "$PARSER_FILE" ]; then
echo "Error: File not found: $PARSER_FILE"
exit 1
fi
# Make executable if needed
if [ ! -x "$PARSER_FILE" ]; then
chmod +x "$PARSER_FILE"
fi
echo "Testing argparse parser: $PARSER_FILE"
echo ""
PASSED=0
FAILED=0
run_test() {
local description="$1"
shift
local expected_result="$1"
shift
echo -n "Testing: $description ... "
if "$PARSER_FILE" "$@" >/dev/null 2>&1; then
result="success"
else
result="failure"
fi
if [ "$result" = "$expected_result" ]; then
echo "✓ PASS"
((PASSED++))
else
echo "✗ FAIL (expected $expected_result, got $result)"
((FAILED++))
fi
}
# Test --help
run_test "Help display" "success" --help
# Test --version
if grep -q "action='version'" "$PARSER_FILE"; then
run_test "Version display" "success" --version
fi
# Test with no arguments
run_test "No arguments" "failure"
# Test invalid option
run_test "Invalid option" "failure" --invalid-option
# Detect and test subcommands
if grep -q "add_subparsers(" "$PARSER_FILE"; then
echo ""
echo "Subcommands detected, testing subcommand patterns..."
# Try to extract subcommand names
subcommands=$(grep -oP "add_parser\('\K[^']+(?=')" "$PARSER_FILE" || true)
if [ -n "$subcommands" ]; then
for cmd in $subcommands; do
run_test "Subcommand: $cmd --help" "success" "$cmd" --help
done
fi
fi
# Test choices if present
if grep -q "choices=\[" "$PARSER_FILE"; then
echo ""
echo "Choices validation detected, testing..."
# Extract valid and invalid choices
valid_choice=$(grep -oP "choices=\[\s*'([^']+)" "$PARSER_FILE" | head -n1 | grep -oP "'[^']+'" | tr -d "'" || echo "valid")
invalid_choice="invalid_choice_12345"
if grep -q "add_subparsers(" "$PARSER_FILE" && [ -n "$subcommands" ]; then
first_cmd=$(echo "$subcommands" | head -n1)
run_test "Valid choice" "success" "$first_cmd" target --env "$valid_choice" 2>/dev/null || true
run_test "Invalid choice" "failure" "$first_cmd" target --env "$invalid_choice" 2>/dev/null || true
fi
fi
# Test type validation if present
if grep -q "type=int" "$PARSER_FILE"; then
echo ""
echo "Type validation detected, testing..."
run_test "Valid integer" "success" --port 8080 2>/dev/null || true
run_test "Invalid integer" "failure" --port invalid 2>/dev/null || true
fi
# Test boolean flags if present
if grep -q "action='store_true'" "$PARSER_FILE"; then
echo ""
echo "Boolean flags detected, testing..."
run_test "Boolean flag present" "success" --verbose 2>/dev/null || true
fi
# Summary
echo ""
echo "Test Summary:"
echo " Passed: $PASSED"
echo " Failed: $FAILED"
echo " Total: $((PASSED + FAILED))"
if [ $FAILED -eq 0 ]; then
echo ""
echo "✓ All tests passed"
exit 0
else
echo ""
echo "✗ Some tests failed"
exit 1
fi

View File

@@ -0,0 +1,173 @@
#!/usr/bin/env bash
# Validate argparse parser structure and completeness
set -euo pipefail
usage() {
cat <<EOF
Validate argparse parser structure
Usage: $(basename "$0") PARSER_FILE
Checks:
- Valid Python syntax
- Imports argparse
- Creates ArgumentParser
- Has main() function
- Calls parse_args()
- Has proper shebang
- Has help text
- Has version info
Examples:
$(basename "$0") mycli.py
$(basename "$0") ../templates/basic-parser.py
EOF
exit 1
}
if [ $# -eq 0 ]; then
usage
fi
PARSER_FILE="$1"
if [ ! -f "$PARSER_FILE" ]; then
echo "Error: File not found: $PARSER_FILE"
exit 1
fi
echo "Validating argparse parser: $PARSER_FILE"
echo ""
ERRORS=0
WARNINGS=0
# Check shebang
if head -n1 "$PARSER_FILE" | grep -q '^#!/usr/bin/env python'; then
echo "✓ Has proper Python shebang"
else
echo "✗ Missing or invalid shebang"
((ERRORS++))
fi
# Check syntax
if python3 -m py_compile "$PARSER_FILE" 2>/dev/null; then
echo "✓ Valid Python syntax"
else
echo "✗ Invalid Python syntax"
((ERRORS++))
fi
# Check imports
if grep -q "import argparse" "$PARSER_FILE"; then
echo "✓ Imports argparse"
else
echo "✗ Does not import argparse"
((ERRORS++))
fi
# Check ArgumentParser creation
if grep -q "ArgumentParser(" "$PARSER_FILE"; then
echo "✓ Creates ArgumentParser"
else
echo "✗ Does not create ArgumentParser"
((ERRORS++))
fi
# Check main function
if grep -q "^def main(" "$PARSER_FILE"; then
echo "✓ Has main() function"
else
echo "⚠ No main() function found"
((WARNINGS++))
fi
# Check parse_args call
if grep -q "\.parse_args()" "$PARSER_FILE"; then
echo "✓ Calls parse_args()"
else
echo "✗ Does not call parse_args()"
((ERRORS++))
fi
# Check version
if grep -q "action='version'" "$PARSER_FILE"; then
echo "✓ Has version info"
else
echo "⚠ No version info found"
((WARNINGS++))
fi
# Check help text
if grep -q "help=" "$PARSER_FILE"; then
echo "✓ Has help text for arguments"
else
echo "⚠ No help text found"
((WARNINGS++))
fi
# Check description
if grep -q "description=" "$PARSER_FILE"; then
echo "✓ Has parser description"
else
echo "⚠ No parser description"
((WARNINGS++))
fi
# Check if executable
if [ -x "$PARSER_FILE" ]; then
echo "✓ File is executable"
else
echo "⚠ File is not executable (run: chmod +x $PARSER_FILE)"
((WARNINGS++))
fi
# Check subparsers if present
if grep -q "add_subparsers(" "$PARSER_FILE"; then
echo "✓ Has subparsers"
# Check if dest is set
if grep -q "add_subparsers(.*dest=" "$PARSER_FILE"; then
echo " ✓ Subparsers have dest set"
else
echo " ⚠ Subparsers missing dest parameter"
((WARNINGS++))
fi
fi
# Check for choices
if grep -q "choices=" "$PARSER_FILE"; then
echo "✓ Uses choices for validation"
fi
# Check for type coercion
if grep -q "type=" "$PARSER_FILE"; then
echo "✓ Uses type coercion"
fi
# Check for argument groups
if grep -q "add_argument_group(" "$PARSER_FILE"; then
echo "✓ Uses argument groups"
fi
# Check for mutually exclusive groups
if grep -q "add_mutually_exclusive_group(" "$PARSER_FILE"; then
echo "✓ Uses mutually exclusive groups"
fi
# Summary
echo ""
echo "Validation Summary:"
echo " Errors: $ERRORS"
echo " Warnings: $WARNINGS"
if [ $ERRORS -eq 0 ]; then
echo ""
echo "✓ Parser validation passed"
exit 0
else
echo ""
echo "✗ Parser validation failed"
exit 1
fi

View File

@@ -0,0 +1,201 @@
#!/usr/bin/env node
/**
* argparse patterns translated to commander.js
*
* Shows equivalent patterns between Python argparse and Node.js commander
*
* Usage:
* npm install commander
* node argparse-to-commander.ts deploy production --force
*/
import { Command, Option } from 'commander';
const program = new Command();
// ===== Basic Configuration (like ArgumentParser) =====
program
.name('mycli')
.description('A powerful CLI tool')
.version('1.0.0');
// ===== Subcommands (like add_subparsers) =====
// Init command (like subparsers.add_parser('init'))
program
.command('init')
.description('Initialize a new project')
.option('-t, --template <type>', 'project template', 'basic')
.option('-p, --path <path>', 'project path', '.')
.action((options) => {
console.log(`Initializing project with ${options.template} template...`);
console.log(`Path: ${options.path}`);
});
// Deploy command with choices (like choices=[...])
program
.command('deploy <environment>')
.description('Deploy to specified environment')
.addOption(
new Option('-m, --mode <mode>', 'deployment mode')
.choices(['fast', 'safe', 'rollback'])
.default('safe')
)
.option('-f, --force', 'force deployment', false)
.action((environment, options) => {
console.log(`Deploying to ${environment} in ${options.mode} mode`);
if (options.force) {
console.log('Warning: Force mode enabled');
}
});
// ===== Nested Subcommands (like nested add_subparsers) =====
const config = program
.command('config')
.description('Manage configuration');
config
.command('get <key>')
.description('Get configuration value')
.action((key) => {
console.log(`Getting config: ${key}`);
});
config
.command('set <key> <value>')
.description('Set configuration value')
.option('-f, --force', 'overwrite existing value')
.action((key, value, options) => {
console.log(`Setting ${key} = ${value}`);
if (options.force) {
console.log('(Overwriting existing value)');
}
});
config
.command('list')
.description('List all configuration values')
.option('--format <format>', 'output format', 'text')
.action((options) => {
console.log(`Listing configuration (format: ${options.format})`);
});
// ===== Boolean Flags (like action='store_true') =====
program
.command('build')
.description('Build the project')
.option('--verbose', 'enable verbose output')
.option('--debug', 'enable debug mode')
.option('--no-cache', 'disable cache (enabled by default)')
.action((options) => {
console.log('Building project...');
console.log(`Verbose: ${options.verbose || false}`);
console.log(`Debug: ${options.debug || false}`);
console.log(`Cache: ${options.cache}`);
});
// ===== Type Coercion (like type=int, type=float) =====
program
.command('server')
.description('Start server')
.option('-p, --port <number>', 'server port', parseInt, 8080)
.option('-t, --timeout <seconds>', 'timeout in seconds', parseFloat, 30.0)
.option('-w, --workers <number>', 'number of workers', parseInt, 4)
.action((options) => {
console.log(`Starting server on port ${options.port}`);
console.log(`Timeout: ${options.timeout}s`);
console.log(`Workers: ${options.workers}`);
});
// ===== Variadic Arguments (like nargs='+') =====
program
.command('process <files...>')
.description('Process multiple files')
.option('--format <format>', 'output format', 'json')
.action((files, options) => {
console.log(`Processing ${files.length} file(s):`);
files.forEach((file) => console.log(` - ${file}`));
console.log(`Output format: ${options.format}`);
});
// ===== Mutually Exclusive Options =====
// Note: Commander doesn't have built-in mutually exclusive groups
// You need to validate manually
program
.command('export')
.description('Export data')
.option('--json <file>', 'export as JSON')
.option('--yaml <file>', 'export as YAML')
.option('--xml <file>', 'export as XML')
.action((options) => {
const formats = [options.json, options.yaml, options.xml].filter(Boolean);
if (formats.length > 1) {
console.error('Error: --json, --yaml, and --xml are mutually exclusive');
process.exit(1);
}
if (options.json) {
console.log(`Exporting as JSON to ${options.json}`);
} else if (options.yaml) {
console.log(`Exporting as YAML to ${options.yaml}`);
} else if (options.xml) {
console.log(`Exporting as XML to ${options.xml}`);
}
});
// ===== Required Options (like required=True) =====
program
.command('login')
.description('Login to service')
.requiredOption('--username <username>', 'username for authentication')
.requiredOption('--password <password>', 'password for authentication')
.option('--token <token>', 'authentication token (alternative to password)')
.action((options) => {
console.log(`Logging in as ${options.username}`);
});
// ===== Custom Validation =====
function validatePort(value: string): number {
const port = parseInt(value, 10);
if (isNaN(port) || port < 1 || port > 65535) {
throw new Error(`Invalid port: ${value} (must be 1-65535)`);
}
return port;
}
program
.command('connect')
.description('Connect to server')
.option('-p, --port <number>', 'server port', validatePort, 8080)
.action((options) => {
console.log(`Connecting to port ${options.port}`);
});
// ===== Argument Groups (display organization) =====
// Note: Commander doesn't have argument groups for help display
// You can organize with comments or separate commands
// ===== Parse Arguments =====
program.parse();
/**
* COMPARISON SUMMARY:
*
* argparse Pattern | commander.js Equivalent
* ---------------------------------|--------------------------------
* ArgumentParser() | new Command()
* add_argument() | .option() or .argument()
* add_subparsers() | .command()
* choices=[...] | .choices([...])
* action='store_true' | .option('--flag')
* action='store_false' | .option('--no-flag')
* type=int | parseInt
* type=float | parseFloat
* nargs='+' | <arg...>
* nargs='*' | [arg...]
* required=True | .requiredOption()
* default=value | option(..., default)
* help='...' | .description('...')
* mutually_exclusive_group() | Manual validation
* add_argument_group() | Organize with subcommands
*/

View File

@@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""
Argument groups for better organization and help output.
Usage:
python argument-groups.py --host 192.168.1.1 --port 8080 --ssl
python argument-groups.py --db-host localhost --db-port 5432 --db-name mydb
python argument-groups.py --log-level debug --log-file app.log
"""
import argparse
import sys
def main():
parser = argparse.ArgumentParser(
description='Organized arguments with groups',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# ===== Server Configuration Group =====
server_group = parser.add_argument_group(
'server configuration',
'Options for configuring the web server'
)
server_group.add_argument(
'--host',
default='127.0.0.1',
help='Server host address (default: %(default)s)'
)
server_group.add_argument(
'--port', '-p',
type=int,
default=8080,
help='Server port (default: %(default)s)'
)
server_group.add_argument(
'--workers',
type=int,
default=4,
help='Number of worker processes (default: %(default)s)'
)
server_group.add_argument(
'--ssl',
action='store_true',
help='Enable SSL/TLS'
)
server_group.add_argument(
'--cert',
help='Path to SSL certificate (required if --ssl is set)'
)
server_group.add_argument(
'--key',
help='Path to SSL private key (required if --ssl is set)'
)
# ===== Database Configuration Group =====
db_group = parser.add_argument_group(
'database configuration',
'Options for database connection'
)
db_group.add_argument(
'--db-host',
default='localhost',
help='Database host (default: %(default)s)'
)
db_group.add_argument(
'--db-port',
type=int,
default=5432,
help='Database port (default: %(default)s)'
)
db_group.add_argument(
'--db-name',
required=True,
help='Database name (required)'
)
db_group.add_argument(
'--db-user',
help='Database username'
)
db_group.add_argument(
'--db-password',
help='Database password'
)
db_group.add_argument(
'--db-pool-size',
type=int,
default=10,
help='Database connection pool size (default: %(default)s)'
)
# ===== Logging Configuration Group =====
log_group = parser.add_argument_group(
'logging configuration',
'Options for logging and monitoring'
)
log_group.add_argument(
'--log-level',
choices=['debug', 'info', 'warning', 'error', 'critical'],
default='info',
help='Logging level (default: %(default)s)'
)
log_group.add_argument(
'--log-file',
help='Log to file instead of stdout'
)
log_group.add_argument(
'--log-format',
choices=['text', 'json'],
default='text',
help='Log format (default: %(default)s)'
)
log_group.add_argument(
'--access-log',
action='store_true',
help='Enable access logging'
)
# ===== Cache Configuration Group =====
cache_group = parser.add_argument_group(
'cache configuration',
'Options for caching layer'
)
cache_group.add_argument(
'--cache-backend',
choices=['redis', 'memcached', 'memory'],
default='memory',
help='Cache backend (default: %(default)s)'
)
cache_group.add_argument(
'--cache-host',
default='localhost',
help='Cache server host (default: %(default)s)'
)
cache_group.add_argument(
'--cache-port',
type=int,
default=6379,
help='Cache server port (default: %(default)s)'
)
cache_group.add_argument(
'--cache-ttl',
type=int,
default=300,
help='Default cache TTL in seconds (default: %(default)s)'
)
# ===== Security Configuration Group =====
security_group = parser.add_argument_group(
'security configuration',
'Security and authentication options'
)
security_group.add_argument(
'--auth-required',
action='store_true',
help='Require authentication for all requests'
)
security_group.add_argument(
'--jwt-secret',
help='JWT secret key'
)
security_group.add_argument(
'--cors-origins',
nargs='+',
help='Allowed CORS origins'
)
security_group.add_argument(
'--rate-limit',
type=int,
default=100,
help='Rate limit (requests per minute, default: %(default)s)'
)
# Parse arguments
args = parser.parse_args()
# Validate SSL configuration
if args.ssl and (not args.cert or not args.key):
parser.error("--cert and --key are required when --ssl is enabled")
# Display configuration
print("Configuration Summary:")
print("\nServer:")
print(f" Host: {args.host}:{args.port}")
print(f" Workers: {args.workers}")
print(f" SSL: {'Enabled' if args.ssl else 'Disabled'}")
if args.ssl:
print(f" Certificate: {args.cert}")
print(f" Key: {args.key}")
print("\nDatabase:")
print(f" Host: {args.db_host}:{args.db_port}")
print(f" Database: {args.db_name}")
print(f" User: {args.db_user or '(not set)'}")
print(f" Pool Size: {args.db_pool_size}")
print("\nLogging:")
print(f" Level: {args.log_level}")
print(f" File: {args.log_file or 'stdout'}")
print(f" Format: {args.log_format}")
print(f" Access Log: {'Enabled' if args.access_log else 'Disabled'}")
print("\nCache:")
print(f" Backend: {args.cache_backend}")
print(f" Host: {args.cache_host}:{args.cache_port}")
print(f" TTL: {args.cache_ttl}s")
print("\nSecurity:")
print(f" Auth Required: {'Yes' if args.auth_required else 'No'}")
print(f" CORS Origins: {args.cors_origins or '(not set)'}")
print(f" Rate Limit: {args.rate_limit} req/min")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""
Basic argparse parser with common argument types.
Usage:
python basic-parser.py --help
python basic-parser.py deploy app1 --env production --force
python basic-parser.py deploy app2 --env staging --timeout 60
"""
import argparse
import sys
def main():
parser = argparse.ArgumentParser(
description='Deploy application to specified environment',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog='''
Examples:
%(prog)s deploy my-app --env production
%(prog)s deploy my-app --env staging --force
%(prog)s deploy my-app --env dev --timeout 120
'''
)
# Version info
parser.add_argument(
'--version',
action='version',
version='%(prog)s 1.0.0'
)
# Required positional argument
parser.add_argument(
'action',
help='Action to perform'
)
parser.add_argument(
'app_name',
help='Name of the application to deploy'
)
# Optional arguments with different types
parser.add_argument(
'--env', '-e',
default='development',
help='Deployment environment (default: %(default)s)'
)
parser.add_argument(
'--timeout', '-t',
type=int,
default=30,
help='Timeout in seconds (default: %(default)s)'
)
# Boolean flag
parser.add_argument(
'--force', '-f',
action='store_true',
help='Force deployment without confirmation'
)
# Verbose flag (count occurrences)
parser.add_argument(
'--verbose', '-v',
action='count',
default=0,
help='Increase verbosity (-v, -vv, -vvv)'
)
# Parse arguments
args = parser.parse_args()
# Use parsed arguments
print(f"Action: {args.action}")
print(f"App Name: {args.app_name}")
print(f"Environment: {args.env}")
print(f"Timeout: {args.timeout}s")
print(f"Force: {args.force}")
print(f"Verbosity Level: {args.verbose}")
# Example validation
if args.timeout < 1:
parser.error("Timeout must be at least 1 second")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""
Boolean flag patterns with store_true, store_false, and count actions.
Usage:
python boolean-flags.py --verbose
python boolean-flags.py -vvv --debug --force
python boolean-flags.py --no-cache --quiet
"""
import argparse
import sys
def main():
parser = argparse.ArgumentParser(
description='Boolean flag patterns',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# ===== store_true (False by default) =====
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose output'
)
parser.add_argument(
'--debug',
action='store_true',
help='Enable debug mode'
)
parser.add_argument(
'--force', '-f',
action='store_true',
help='Force operation without confirmation'
)
parser.add_argument(
'--dry-run',
action='store_true',
help='Perform a dry run without making changes'
)
# ===== store_false (True by default) =====
parser.add_argument(
'--no-cache',
action='store_false',
dest='cache',
help='Disable caching (enabled by default)'
)
parser.add_argument(
'--no-color',
action='store_false',
dest='color',
help='Disable colored output (enabled by default)'
)
# ===== count action (count occurrences) =====
parser.add_argument(
'-v',
action='count',
default=0,
dest='verbosity',
help='Increase verbosity (-v, -vv, -vvv)'
)
parser.add_argument(
'-q', '--quiet',
action='count',
default=0,
help='Decrease verbosity (-q, -qq, -qqq)'
)
# ===== store_const action =====
parser.add_argument(
'--fast',
action='store_const',
const='fast',
dest='mode',
help='Use fast mode'
)
parser.add_argument(
'--safe',
action='store_const',
const='safe',
dest='mode',
help='Use safe mode (default)'
)
parser.set_defaults(mode='safe')
# ===== Combined short flags =====
parser.add_argument(
'-a', '--all',
action='store_true',
help='Process all items'
)
parser.add_argument(
'-r', '--recursive',
action='store_true',
help='Process recursively'
)
parser.add_argument(
'-i', '--interactive',
action='store_true',
help='Run in interactive mode'
)
# Parse arguments
args = parser.parse_args()
# Calculate effective verbosity
effective_verbosity = args.verbosity - args.quiet
# Display configuration
print("Boolean Flags Configuration:")
print(f" Verbose: {args.verbose}")
print(f" Debug: {args.debug}")
print(f" Force: {args.force}")
print(f" Dry Run: {args.dry_run}")
print(f" Cache: {args.cache}")
print(f" Color: {args.color}")
print(f" Verbosity Level: {effective_verbosity}")
print(f" Mode: {args.mode}")
print(f" All: {args.all}")
print(f" Recursive: {args.recursive}")
print(f" Interactive: {args.interactive}")
# Example usage based on flags
if args.debug:
print("\nDebug mode enabled - showing detailed information")
if args.dry_run:
print("\nDry run mode - no changes will be made")
if effective_verbosity > 0:
print(f"\nVerbosity level: {effective_verbosity}")
if effective_verbosity >= 3:
print("Maximum verbosity - showing everything")
elif effective_verbosity < 0:
print(f"\nQuiet level: {abs(effective_verbosity)}")
if args.force:
print("\nForce mode - skipping confirmations")
if not args.cache:
print("\nCache disabled")
if not args.color:
print("\nColor output disabled")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,197 @@
#!/usr/bin/env python3
"""
Argument choices and custom validation patterns.
Usage:
python choices-validation.py --log-level debug
python choices-validation.py --port 8080 --host 192.168.1.1
python choices-validation.py --region us-east-1 --instance-type t2.micro
"""
import argparse
import re
import sys
from pathlib import Path
def validate_port(value):
"""Custom validator for port numbers."""
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"{value} is not a valid integer")
if ivalue < 1 or ivalue > 65535:
raise argparse.ArgumentTypeError(
f"{value} is not a valid port (must be 1-65535)"
)
return ivalue
def validate_ip(value):
"""Custom validator for IP addresses."""
pattern = r'^(\d{1,3}\.){3}\d{1,3}$'
if not re.match(pattern, value):
raise argparse.ArgumentTypeError(f"{value} is not a valid IP address")
# Check each octet is 0-255
octets = [int(x) for x in value.split('.')]
if any(o < 0 or o > 255 for o in octets):
raise argparse.ArgumentTypeError(
f"{value} contains invalid octets (must be 0-255)"
)
return value
def validate_email(value):
"""Custom validator for email addresses."""
pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
if not re.match(pattern, value):
raise argparse.ArgumentTypeError(f"{value} is not a valid email address")
return value
def validate_path_exists(value):
"""Custom validator to check if path exists."""
path = Path(value)
if not path.exists():
raise argparse.ArgumentTypeError(f"Path does not exist: {value}")
return path
def validate_range(min_val, max_val):
"""Factory function for range validators."""
def validator(value):
try:
ivalue = int(value)
except ValueError:
raise argparse.ArgumentTypeError(f"{value} is not a valid integer")
if ivalue < min_val or ivalue > max_val:
raise argparse.ArgumentTypeError(
f"{value} must be between {min_val} and {max_val}"
)
return ivalue
return validator
def main():
parser = argparse.ArgumentParser(
description='Demonstrate choices and validation patterns',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# ===== String Choices =====
parser.add_argument(
'--log-level',
choices=['debug', 'info', 'warning', 'error', 'critical'],
default='info',
help='Logging level (default: %(default)s)'
)
parser.add_argument(
'--region',
choices=[
'us-east-1', 'us-west-1', 'us-west-2',
'eu-west-1', 'eu-central-1',
'ap-southeast-1', 'ap-northeast-1'
],
default='us-east-1',
help='AWS region (default: %(default)s)'
)
parser.add_argument(
'--format',
choices=['json', 'yaml', 'toml', 'xml'],
default='json',
help='Output format (default: %(default)s)'
)
# ===== Custom Validators =====
parser.add_argument(
'--port',
type=validate_port,
default=8080,
help='Server port (1-65535, default: %(default)s)'
)
parser.add_argument(
'--host',
type=validate_ip,
default='127.0.0.1',
help='Server host IP (default: %(default)s)'
)
parser.add_argument(
'--email',
type=validate_email,
help='Email address for notifications'
)
parser.add_argument(
'--config',
type=validate_path_exists,
help='Path to configuration file (must exist)'
)
# ===== Range Validators =====
parser.add_argument(
'--workers',
type=validate_range(1, 32),
default=4,
help='Number of worker processes (1-32, default: %(default)s)'
)
parser.add_argument(
'--timeout',
type=validate_range(1, 3600),
default=30,
help='Request timeout in seconds (1-3600, default: %(default)s)'
)
# ===== Integer Choices =====
parser.add_argument(
'--instance-type',
choices=['t2.micro', 't2.small', 't2.medium', 't3.large'],
default='t2.micro',
help='EC2 instance type (default: %(default)s)'
)
# ===== Type Coercion =====
parser.add_argument(
'--memory',
type=float,
default=1.0,
help='Memory limit in GB (default: %(default)s)'
)
parser.add_argument(
'--retry-count',
type=int,
default=3,
help='Number of retries (default: %(default)s)'
)
# Parse arguments
args = parser.parse_args()
# Display parsed values
print("Configuration:")
print(f" Log Level: {args.log_level}")
print(f" Region: {args.region}")
print(f" Format: {args.format}")
print(f" Port: {args.port}")
print(f" Host: {args.host}")
print(f" Email: {args.email}")
print(f" Config: {args.config}")
print(f" Workers: {args.workers}")
print(f" Timeout: {args.timeout}s")
print(f" Instance Type: {args.instance_type}")
print(f" Memory: {args.memory}GB")
print(f" Retry Count: {args.retry_count}")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
Custom action classes for advanced argument processing.
Usage:
python custom-actions.py --env-file .env
python custom-actions.py --key API_KEY --key DB_URL
python custom-actions.py --range 1-10 --range 20-30
"""
import argparse
import sys
from pathlib import Path
class LoadEnvFileAction(argparse.Action):
"""Custom action to load environment variables from file."""
def __call__(self, parser, namespace, values, option_string=None):
env_file = Path(values)
if not env_file.exists():
parser.error(f"Environment file does not exist: {values}")
env_vars = {}
with open(env_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
if '=' in line:
key, value = line.split('=', 1)
env_vars[key.strip()] = value.strip()
setattr(namespace, self.dest, env_vars)
class KeyValueAction(argparse.Action):
"""Custom action to parse key=value pairs."""
def __call__(self, parser, namespace, values, option_string=None):
if '=' not in values:
parser.error(f"Argument must be in key=value format: {values}")
key, value = values.split('=', 1)
items = getattr(namespace, self.dest, None) or {}
items[key] = value
setattr(namespace, self.dest, items)
class RangeAction(argparse.Action):
"""Custom action to parse ranges like 1-10."""
def __call__(self, parser, namespace, values, option_string=None):
if '-' not in values:
parser.error(f"Range must be in format start-end: {values}")
try:
start, end = values.split('-')
start = int(start)
end = int(end)
except ValueError:
parser.error(f"Invalid range format: {values}")
if start > end:
parser.error(f"Start must be less than or equal to end: {values}")
ranges = getattr(namespace, self.dest, None) or []
ranges.append((start, end))
setattr(namespace, self.dest, ranges)
class AppendUniqueAction(argparse.Action):
"""Custom action to append unique values only."""
def __call__(self, parser, namespace, values, option_string=None):
items = getattr(namespace, self.dest, None) or []
if values not in items:
items.append(values)
setattr(namespace, self.dest, items)
class ValidateAndStoreAction(argparse.Action):
"""Custom action that validates before storing."""
def __call__(self, parser, namespace, values, option_string=None):
# Custom validation logic
if values.startswith('test-'):
print(f"Warning: Using test value: {values}")
# Transform value
transformed = values.upper()
setattr(namespace, self.dest, transformed)
class IncrementAction(argparse.Action):
"""Custom action to increment a value."""
def __init__(self, option_strings, dest, default=0, **kwargs):
super().__init__(option_strings, dest, nargs=0, default=default, **kwargs)
def __call__(self, parser, namespace, values, option_string=None):
current = getattr(namespace, self.dest, self.default)
setattr(namespace, self.dest, current + 1)
def main():
parser = argparse.ArgumentParser(
description='Custom action demonstrations',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# Load environment file
parser.add_argument(
'--env-file',
action=LoadEnvFileAction,
help='Load environment variables from file'
)
# Key-value pairs
parser.add_argument(
'--config', '-c',
action=KeyValueAction,
help='Configuration in key=value format (can be used multiple times)'
)
# Range parsing
parser.add_argument(
'--range', '-r',
action=RangeAction,
help='Range in start-end format (e.g., 1-10)'
)
# Unique append
parser.add_argument(
'--tag',
action=AppendUniqueAction,
help='Add unique tag (duplicates ignored)'
)
# Validate and transform
parser.add_argument(
'--key',
action=ValidateAndStoreAction,
help='Key to transform to uppercase'
)
# Custom increment
parser.add_argument(
'--increment',
action=IncrementAction,
help='Increment counter'
)
# Parse arguments
args = parser.parse_args()
# Display results
print("Custom Actions Results:")
if args.env_file:
print(f"\nEnvironment Variables:")
for key, value in args.env_file.items():
print(f" {key}={value}")
if args.config:
print(f"\nConfiguration:")
for key, value in args.config.items():
print(f" {key}={value}")
if args.range:
print(f"\nRanges:")
for start, end in args.range:
print(f" {start}-{end} (includes {end - start + 1} values)")
if args.tag:
print(f"\nUnique Tags: {', '.join(args.tag)}")
if args.key:
print(f"\nTransformed Key: {args.key}")
if args.increment:
print(f"\nIncrement Count: {args.increment}")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""
Mutually exclusive argument groups.
Usage:
python mutually-exclusive.py --json output.json
python mutually-exclusive.py --yaml output.yaml
python mutually-exclusive.py --verbose
python mutually-exclusive.py --quiet
python mutually-exclusive.py --create resource
python mutually-exclusive.py --delete resource
"""
import argparse
import sys
def main():
parser = argparse.ArgumentParser(
description='Mutually exclusive argument groups',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# ===== Output Format (mutually exclusive) =====
output_group = parser.add_mutually_exclusive_group()
output_group.add_argument(
'--json',
metavar='FILE',
help='Output in JSON format'
)
output_group.add_argument(
'--yaml',
metavar='FILE',
help='Output in YAML format'
)
output_group.add_argument(
'--xml',
metavar='FILE',
help='Output in XML format'
)
# ===== Verbosity (mutually exclusive) =====
verbosity_group = parser.add_mutually_exclusive_group()
verbosity_group.add_argument(
'--verbose', '-v',
action='store_true',
help='Increase verbosity'
)
verbosity_group.add_argument(
'--quiet', '-q',
action='store_true',
help='Suppress output'
)
# ===== Operation Mode (mutually exclusive, required) =====
operation_group = parser.add_mutually_exclusive_group(required=True)
operation_group.add_argument(
'--create',
metavar='RESOURCE',
help='Create a resource'
)
operation_group.add_argument(
'--update',
metavar='RESOURCE',
help='Update a resource'
)
operation_group.add_argument(
'--delete',
metavar='RESOURCE',
help='Delete a resource'
)
operation_group.add_argument(
'--list',
action='store_true',
help='List all resources'
)
# ===== Authentication Method (mutually exclusive) =====
auth_group = parser.add_mutually_exclusive_group()
auth_group.add_argument(
'--token',
metavar='TOKEN',
help='Authenticate with token'
)
auth_group.add_argument(
'--api-key',
metavar='KEY',
help='Authenticate with API key'
)
auth_group.add_argument(
'--credentials',
metavar='FILE',
help='Authenticate with credentials file'
)
# ===== Deployment Strategy (mutually exclusive with default) =====
strategy_group = parser.add_mutually_exclusive_group()
strategy_group.add_argument(
'--rolling',
action='store_true',
help='Use rolling deployment'
)
strategy_group.add_argument(
'--blue-green',
action='store_true',
help='Use blue-green deployment'
)
strategy_group.add_argument(
'--canary',
action='store_true',
help='Use canary deployment'
)
# Set default strategy if none specified
parser.set_defaults(rolling=False, blue_green=False, canary=False)
# Parse arguments
args = parser.parse_args()
# Display configuration
print("Mutually Exclusive Groups Configuration:")
# Output format
if args.json:
print(f" Output Format: JSON to {args.json}")
elif args.yaml:
print(f" Output Format: YAML to {args.yaml}")
elif args.xml:
print(f" Output Format: XML to {args.xml}")
else:
print(" Output Format: None (default stdout)")
# Verbosity
if args.verbose:
print(" Verbosity: Verbose")
elif args.quiet:
print(" Verbosity: Quiet")
else:
print(" Verbosity: Normal")
# Operation
if args.create:
print(f" Operation: Create {args.create}")
elif args.update:
print(f" Operation: Update {args.update}")
elif args.delete:
print(f" Operation: Delete {args.delete}")
elif args.list:
print(" Operation: List resources")
# Authentication
if args.token:
print(f" Auth Method: Token")
elif args.api_key:
print(f" Auth Method: API Key")
elif args.credentials:
print(f" Auth Method: Credentials file ({args.credentials})")
else:
print(" Auth Method: None")
# Deployment strategy
if args.rolling:
print(" Deployment: Rolling")
elif args.blue_green:
print(" Deployment: Blue-Green")
elif args.canary:
print(" Deployment: Canary")
else:
print(" Deployment: Default")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
Nested subcommands pattern (like git config get/set, kubectl config view).
Usage:
python nested-subparser.py config get database_url
python nested-subparser.py config set api_key abc123
python nested-subparser.py config list
python nested-subparser.py deploy start production --replicas 3
python nested-subparser.py deploy stop production
"""
import argparse
import sys
# Config command handlers
def config_get(args):
"""Get configuration value."""
print(f"Getting config: {args.key}")
# Simulate getting config
print(f"{args.key} = example_value")
def config_set(args):
"""Set configuration value."""
print(f"Setting config: {args.key} = {args.value}")
if args.force:
print("(Overwriting existing value)")
def config_list(args):
"""List all configuration values."""
print(f"Listing all configuration (format: {args.format})")
def config_delete(args):
"""Delete configuration value."""
if not args.force:
response = input(f"Delete {args.key}? (y/n): ")
if response.lower() != 'y':
print("Cancelled")
return 1
print(f"Deleted: {args.key}")
# Deploy command handlers
def deploy_start(args):
"""Start deployment."""
print(f"Starting deployment to {args.environment}")
print(f"Replicas: {args.replicas}")
print(f"Wait: {args.wait}")
def deploy_stop(args):
"""Stop deployment."""
print(f"Stopping deployment in {args.environment}")
def deploy_restart(args):
"""Restart deployment."""
print(f"Restarting deployment in {args.environment}")
if args.hard:
print("(Hard restart)")
def main():
# Main parser
parser = argparse.ArgumentParser(
description='Multi-level CLI tool with nested subcommands',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument('--version', action='version', version='1.0.0')
# Top-level subparsers
subparsers = parser.add_subparsers(
dest='command',
help='Top-level commands',
required=True
)
# ===== Config command group =====
config_parser = subparsers.add_parser(
'config',
help='Manage configuration',
description='Configuration management commands'
)
# Config subcommands
config_subparsers = config_parser.add_subparsers(
dest='config_command',
help='Config operations',
required=True
)
# config get
config_get_parser = config_subparsers.add_parser(
'get',
help='Get configuration value'
)
config_get_parser.add_argument('key', help='Configuration key')
config_get_parser.set_defaults(func=config_get)
# config set
config_set_parser = config_subparsers.add_parser(
'set',
help='Set configuration value'
)
config_set_parser.add_argument('key', help='Configuration key')
config_set_parser.add_argument('value', help='Configuration value')
config_set_parser.add_argument(
'--force', '-f',
action='store_true',
help='Overwrite existing value'
)
config_set_parser.set_defaults(func=config_set)
# config list
config_list_parser = config_subparsers.add_parser(
'list',
help='List all configuration values'
)
config_list_parser.add_argument(
'--format',
choices=['text', 'json', 'yaml'],
default='text',
help='Output format (default: %(default)s)'
)
config_list_parser.set_defaults(func=config_list)
# config delete
config_delete_parser = config_subparsers.add_parser(
'delete',
help='Delete configuration value'
)
config_delete_parser.add_argument('key', help='Configuration key')
config_delete_parser.add_argument(
'--force', '-f',
action='store_true',
help='Delete without confirmation'
)
config_delete_parser.set_defaults(func=config_delete)
# ===== Deploy command group =====
deploy_parser = subparsers.add_parser(
'deploy',
help='Manage deployments',
description='Deployment management commands'
)
# Deploy subcommands
deploy_subparsers = deploy_parser.add_subparsers(
dest='deploy_command',
help='Deploy operations',
required=True
)
# deploy start
deploy_start_parser = deploy_subparsers.add_parser(
'start',
help='Start deployment'
)
deploy_start_parser.add_argument(
'environment',
choices=['development', 'staging', 'production'],
help='Target environment'
)
deploy_start_parser.add_argument(
'--replicas', '-r',
type=int,
default=1,
help='Number of replicas (default: %(default)s)'
)
deploy_start_parser.add_argument(
'--wait',
action='store_true',
help='Wait for deployment to complete'
)
deploy_start_parser.set_defaults(func=deploy_start)
# deploy stop
deploy_stop_parser = deploy_subparsers.add_parser(
'stop',
help='Stop deployment'
)
deploy_stop_parser.add_argument(
'environment',
choices=['development', 'staging', 'production'],
help='Target environment'
)
deploy_stop_parser.set_defaults(func=deploy_stop)
# deploy restart
deploy_restart_parser = deploy_subparsers.add_parser(
'restart',
help='Restart deployment'
)
deploy_restart_parser.add_argument(
'environment',
choices=['development', 'staging', 'production'],
help='Target environment'
)
deploy_restart_parser.add_argument(
'--hard',
action='store_true',
help='Perform hard restart'
)
deploy_restart_parser.set_defaults(func=deploy_restart)
# Parse arguments
args = parser.parse_args()
# Call the appropriate command function
return args.func(args)
if __name__ == '__main__':
sys.exit(main() or 0)

View File

@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""
Single-level subcommands pattern (like docker, kubectl).
Usage:
python subparser-pattern.py init --template react
python subparser-pattern.py deploy production --force
python subparser-pattern.py status --format json
"""
import argparse
import sys
def cmd_init(args):
"""Initialize a new project."""
print(f"Initializing project with {args.template} template...")
print(f"Path: {args.path}")
def cmd_deploy(args):
"""Deploy application."""
print(f"Deploying to {args.environment} in {args.mode} mode")
if args.force:
print("Warning: Force mode enabled")
def cmd_status(args):
"""Show deployment status."""
print(f"Status format: {args.format}")
print("Fetching status...")
def main():
# Main parser
parser = argparse.ArgumentParser(
description='Multi-command CLI tool',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
'--version',
action='version',
version='1.0.0'
)
# Create subparsers
subparsers = parser.add_subparsers(
dest='command',
help='Available commands',
required=True # Python 3.7+
)
# Init command
init_parser = subparsers.add_parser(
'init',
help='Initialize a new project',
description='Initialize a new project with specified template'
)
init_parser.add_argument(
'--template', '-t',
default='basic',
help='Project template (default: %(default)s)'
)
init_parser.add_argument(
'--path', '-p',
default='.',
help='Project path (default: %(default)s)'
)
init_parser.set_defaults(func=cmd_init)
# Deploy command
deploy_parser = subparsers.add_parser(
'deploy',
help='Deploy application to environment',
description='Deploy application to specified environment'
)
deploy_parser.add_argument(
'environment',
choices=['development', 'staging', 'production'],
help='Target environment'
)
deploy_parser.add_argument(
'--force', '-f',
action='store_true',
help='Force deployment without confirmation'
)
deploy_parser.add_argument(
'--mode', '-m',
choices=['fast', 'safe', 'rollback'],
default='safe',
help='Deployment mode (default: %(default)s)'
)
deploy_parser.set_defaults(func=cmd_deploy)
# Status command
status_parser = subparsers.add_parser(
'status',
help='Show deployment status',
description='Display current deployment status'
)
status_parser.add_argument(
'--format',
choices=['text', 'json', 'yaml'],
default='text',
help='Output format (default: %(default)s)'
)
status_parser.add_argument(
'--service',
action='append',
help='Filter by service (can be used multiple times)'
)
status_parser.set_defaults(func=cmd_status)
# Parse arguments
args = parser.parse_args()
# Call the appropriate command function
return args.func(args)
if __name__ == '__main__':
sys.exit(main() or 0)

View File

@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""
Type coercion and custom type converters.
Usage:
python type-coercion.py --port 8080 --timeout 30.5 --date 2024-01-15
python type-coercion.py --url https://api.example.com --size 1.5GB
"""
import argparse
import sys
from datetime import datetime
from pathlib import Path
import re
def parse_date(value):
"""Parse date in YYYY-MM-DD format."""
try:
return datetime.strptime(value, '%Y-%m-%d').date()
except ValueError:
raise argparse.ArgumentTypeError(
f"Invalid date format: {value} (expected YYYY-MM-DD)"
)
def parse_datetime(value):
"""Parse datetime in ISO format."""
try:
return datetime.fromisoformat(value)
except ValueError:
raise argparse.ArgumentTypeError(
f"Invalid datetime format: {value} (expected ISO format)"
)
def parse_url(value):
"""Parse and validate URL."""
pattern = r'^https?://[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(/.*)?$'
if not re.match(pattern, value):
raise argparse.ArgumentTypeError(f"Invalid URL: {value}")
return value
def parse_size(value):
"""Parse size with units (e.g., 1.5GB, 500MB)."""
pattern = r'^(\d+\.?\d*)(B|KB|MB|GB|TB)$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
raise argparse.ArgumentTypeError(
f"Invalid size format: {value} (expected number with unit)"
)
size, unit = match.groups()
size = float(size)
units = {'B': 1, 'KB': 1024, 'MB': 1024**2, 'GB': 1024**3, 'TB': 1024**4}
return int(size * units[unit.upper()])
def parse_duration(value):
"""Parse duration (e.g., 1h, 30m, 90s)."""
pattern = r'^(\d+)(s|m|h|d)$'
match = re.match(pattern, value, re.IGNORECASE)
if not match:
raise argparse.ArgumentTypeError(
f"Invalid duration format: {value} (expected number with s/m/h/d)"
)
amount, unit = match.groups()
amount = int(amount)
units = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}
return amount * units[unit.lower()]
def parse_percentage(value):
"""Parse percentage (0-100)."""
try:
pct = float(value.rstrip('%'))
except ValueError:
raise argparse.ArgumentTypeError(f"Invalid percentage: {value}")
if pct < 0 or pct > 100:
raise argparse.ArgumentTypeError(
f"Percentage must be between 0 and 100: {value}"
)
return pct
def parse_comma_separated(value):
"""Parse comma-separated list."""
return [item.strip() for item in value.split(',') if item.strip()]
def parse_key_value_pairs(value):
"""Parse semicolon-separated key=value pairs."""
pairs = {}
for pair in value.split(';'):
if '=' in pair:
key, val = pair.split('=', 1)
pairs[key.strip()] = val.strip()
return pairs
def main():
parser = argparse.ArgumentParser(
description='Type coercion demonstrations',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# ===== Built-in Types =====
parser.add_argument(
'--port',
type=int,
default=8080,
help='Port number (integer)'
)
parser.add_argument(
'--timeout',
type=float,
default=30.0,
help='Timeout in seconds (float)'
)
parser.add_argument(
'--config',
type=Path,
help='Configuration file path'
)
parser.add_argument(
'--output',
type=argparse.FileType('w'),
help='Output file (opened for writing)'
)
parser.add_argument(
'--input',
type=argparse.FileType('r'),
help='Input file (opened for reading)'
)
# ===== Custom Types =====
parser.add_argument(
'--date',
type=parse_date,
help='Date in YYYY-MM-DD format'
)
parser.add_argument(
'--datetime',
type=parse_datetime,
help='Datetime in ISO format'
)
parser.add_argument(
'--url',
type=parse_url,
help='URL to connect to'
)
parser.add_argument(
'--size',
type=parse_size,
help='Size with unit (e.g., 1.5GB, 500MB)'
)
parser.add_argument(
'--duration',
type=parse_duration,
help='Duration (e.g., 1h, 30m, 90s)'
)
parser.add_argument(
'--percentage',
type=parse_percentage,
help='Percentage (0-100)'
)
parser.add_argument(
'--tags',
type=parse_comma_separated,
help='Comma-separated tags'
)
parser.add_argument(
'--env',
type=parse_key_value_pairs,
help='Environment variables as key=value;key2=value2'
)
# ===== List Types =====
parser.add_argument(
'--ids',
type=int,
nargs='+',
help='List of integer IDs'
)
parser.add_argument(
'--ratios',
type=float,
nargs='*',
help='List of float ratios'
)
# Parse arguments
args = parser.parse_args()
# Display parsed values
print("Type Coercion Results:")
print("\nBuilt-in Types:")
print(f" Port (int): {args.port} - type: {type(args.port).__name__}")
print(f" Timeout (float): {args.timeout} - type: {type(args.timeout).__name__}")
if args.config:
print(f" Config (Path): {args.config} - type: {type(args.config).__name__}")
print("\nCustom Types:")
if args.date:
print(f" Date: {args.date} - type: {type(args.date).__name__}")
if args.datetime:
print(f" Datetime: {args.datetime}")
if args.url:
print(f" URL: {args.url}")
if args.size:
print(f" Size: {args.size} bytes ({args.size / (1024**3):.2f} GB)")
if args.duration:
print(f" Duration: {args.duration} seconds ({args.duration / 3600:.2f} hours)")
if args.percentage is not None:
print(f" Percentage: {args.percentage}%")
if args.tags:
print(f" Tags: {args.tags}")
if args.env:
print(f" Environment:")
for key, value in args.env.items():
print(f" {key} = {value}")
print("\nList Types:")
if args.ids:
print(f" IDs: {args.ids}")
if args.ratios:
print(f" Ratios: {args.ratios}")
# Clean up file handles
if args.output:
args.output.close()
if args.input:
args.input.close()
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
Variadic argument patterns (nargs: ?, *, +, number).
Usage:
python variadic-args.py file1.txt file2.txt file3.txt
python variadic-args.py --output result.json file1.txt file2.txt
python variadic-args.py --include *.py --exclude test_*.py
"""
import argparse
import sys
from pathlib import Path
def main():
parser = argparse.ArgumentParser(
description='Variadic argument patterns',
formatter_class=argparse.RawDescriptionHelpFormatter
)
# ===== nargs='?' (optional, 0 or 1) =====
parser.add_argument(
'--output',
nargs='?',
const='default.json', # Used if flag present but no value
default=None, # Used if flag not present
help='Output file (default: stdout, or default.json if flag present)'
)
parser.add_argument(
'--config',
nargs='?',
const='config.yaml',
help='Configuration file (default: config.yaml if flag present)'
)
# ===== nargs='*' (zero or more) =====
parser.add_argument(
'--include',
nargs='*',
default=[],
help='Include patterns (zero or more)'
)
parser.add_argument(
'--exclude',
nargs='*',
default=[],
help='Exclude patterns (zero or more)'
)
parser.add_argument(
'--tags',
nargs='*',
metavar='TAG',
help='Tags to apply'
)
# ===== nargs='+' (one or more, required) =====
parser.add_argument(
'files',
nargs='+',
type=Path,
help='Input files (at least one required)'
)
parser.add_argument(
'--servers',
nargs='+',
metavar='SERVER',
help='Server addresses (at least one required if specified)'
)
# ===== nargs=N (exact number) =====
parser.add_argument(
'--coordinates',
nargs=2,
type=float,
metavar=('LAT', 'LON'),
help='Coordinates as latitude longitude'
)
parser.add_argument(
'--range',
nargs=2,
type=int,
metavar=('START', 'END'),
help='Range as start end'
)
parser.add_argument(
'--rgb',
nargs=3,
type=int,
metavar=('R', 'G', 'B'),
help='RGB color values (0-255)'
)
# ===== Remainder arguments =====
parser.add_argument(
'--command',
nargs=argparse.REMAINDER,
help='Command and arguments to pass through'
)
# Parse arguments
args = parser.parse_args()
# Display results
print("Variadic Arguments Results:")
print("\nnargs='?' (optional):")
print(f" Output: {args.output}")
print(f" Config: {args.config}")
print("\nnargs='*' (zero or more):")
print(f" Include Patterns: {args.include if args.include else '(none)'}")
print(f" Exclude Patterns: {args.exclude if args.exclude else '(none)'}")
print(f" Tags: {args.tags if args.tags else '(none)'}")
print("\nnargs='+' (one or more):")
print(f" Files ({len(args.files)}):")
for f in args.files:
print(f" - {f}")
if args.servers:
print(f" Servers ({len(args.servers)}):")
for s in args.servers:
print(f" - {s}")
print("\nnargs=N (exact number):")
if args.coordinates:
lat, lon = args.coordinates
print(f" Coordinates: {lat}, {lon}")
if args.range:
start, end = args.range
print(f" Range: {start} to {end}")
if args.rgb:
r, g, b = args.rgb
print(f" RGB Color: rgb({r}, {g}, {b})")
print("\nRemaining arguments:")
if args.command:
print(f" Command: {' '.join(args.command)}")
# Example usage
print("\nExample Processing:")
print(f"Processing {len(args.files)} file(s)...")
if args.include:
print(f"Including patterns: {', '.join(args.include)}")
if args.exclude:
print(f"Excluding patterns: {', '.join(args.exclude)}")
if args.output:
print(f"Output will be written to: {args.output}")
else:
print("Output will be written to: stdout")
return 0
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,249 @@
---
name: clap-patterns
description: Modern type-safe Rust CLI patterns with Clap derive macros, Parser trait, Subcommand enums, validation, and value parsers. Use when building CLI applications, creating Clap commands, implementing type-safe Rust CLIs, or when user mentions Clap, CLI patterns, Rust command-line, derive macros, Parser trait, Subcommands, or command-line interfaces.
allowed-tools: Read, Write, Edit, Bash
---
# clap-patterns
Provides modern type-safe Rust CLI patterns using Clap 4.x with derive macros, Parser trait, Subcommand enums, custom validation, value parsers, and environment variable integration for building maintainable command-line applications.
## Core Patterns
### 1. Basic Parser with Derive Macros
Use derive macros for automatic CLI parsing with type safety:
```rust
use clap::Parser;
#[derive(Parser)]
#[command(name = "myapp")]
#[command(author, version, about, long_about = None)]
struct Cli {
/// Input file path
#[arg(short, long, value_name = "FILE")]
input: std::path::PathBuf,
/// Optional output file
#[arg(short, long)]
output: Option<std::path::PathBuf>,
/// Verbose mode
#[arg(short, long)]
verbose: bool,
/// Number of items to process
#[arg(short, long, default_value_t = 10)]
count: usize,
}
fn main() {
let cli = Cli::parse();
if cli.verbose {
println!("Processing: {:?}", cli.input);
}
}
```
### 2. Subcommand Enums
Organize complex CLIs with nested subcommands:
```rust
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "git")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Add files to staging
Add {
/// Files to add
#[arg(value_name = "FILE")]
files: Vec<String>,
},
/// Commit changes
Commit {
/// Commit message
#[arg(short, long)]
message: String,
},
}
```
### 3. Value Parsers and Validation
Implement custom parsing and validation:
```rust
use clap::Parser;
use std::ops::RangeInclusive;
const PORT_RANGE: RangeInclusive<usize> = 1..=65535;
fn port_in_range(s: &str) -> Result<u16, String> {
let port: usize = s
.parse()
.map_err(|_| format!("`{s}` isn't a valid port number"))?;
if PORT_RANGE.contains(&port) {
Ok(port as u16)
} else {
Err(format!("port not in range {}-{}", PORT_RANGE.start(), PORT_RANGE.end()))
}
}
#[derive(Parser)]
struct Cli {
/// Port to listen on
#[arg(short, long, value_parser = port_in_range)]
port: u16,
}
```
### 4. Environment Variable Integration
Support environment variables with fallback:
```rust
use clap::Parser;
#[derive(Parser)]
struct Cli {
/// API key (or set API_KEY env var)
#[arg(long, env = "API_KEY")]
api_key: String,
/// Database URL
#[arg(long, env = "DATABASE_URL")]
database_url: String,
/// Optional log level
#[arg(long, env = "LOG_LEVEL", default_value = "info")]
log_level: String,
}
```
### 5. ValueEnum for Constrained Choices
Use ValueEnum for type-safe option selection:
```rust
use clap::{Parser, ValueEnum};
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Format {
Json,
Yaml,
Toml,
}
#[derive(Parser)]
struct Cli {
/// Output format
#[arg(value_enum, short, long, default_value_t = Format::Json)]
format: Format,
}
```
## Available Templates
The following Rust templates demonstrate Clap patterns:
- **basic-parser.rs**: Simple CLI with Parser derive macro
- **subcommands.rs**: Multi-level subcommand structure
- **value-parser.rs**: Custom validation with value parsers
- **env-variables.rs**: Environment variable integration
- **value-enum.rs**: Type-safe enums for options
- **builder-pattern.rs**: Manual builder API (for complex cases)
- **full-featured-cli.rs**: Complete CLI with all patterns
## Available Scripts
Helper scripts for Clap development:
- **generate-completions.sh**: Generate shell completions (bash, zsh, fish)
- **validate-cargo.sh**: Check Cargo.toml for correct Clap dependencies
- **test-cli.sh**: Test CLI with various argument combinations
## Usage Instructions
1. **Choose the appropriate template** based on your CLI complexity:
- Simple single-command → `basic-parser.rs`
- Multiple subcommands → `subcommands.rs`
- Need validation → `value-parser.rs`
- Environment config → `env-variables.rs`
2. **Add Clap to Cargo.toml**:
```toml
[dependencies]
clap = { version = "4.5", features = ["derive", "env"] }
```
3. **Implement your CLI** using the selected template as a starting point
4. **Generate completions** using the provided script for better UX
## Best Practices
- Use derive macros for most cases (cleaner, less boilerplate)
- Add help text with doc comments (shows in `--help`)
- Validate early with value parsers
- Use ValueEnum for constrained choices
- Support environment variables for sensitive data
- Provide sensible defaults with `default_value_t`
- Use PathBuf for file/directory arguments
- Add version and author metadata
## Common Patterns
### Multiple Values
```rust
#[arg(short, long, num_args = 1..)]
files: Vec<PathBuf>,
```
### Required Unless Present
```rust
#[arg(long, required_unless_present = "config")]
database_url: Option<String>,
```
### Conflicting Arguments
```rust
#[arg(long, conflicts_with = "json")]
yaml: bool,
```
### Global Arguments (for subcommands)
```rust
#[arg(global = true, short, long)]
verbose: bool,
```
## Testing Your CLI
Run the test script to validate your CLI:
```bash
bash scripts/test-cli.sh your-binary
```
This tests:
- Help output (`--help`)
- Version flag (`--version`)
- Invalid arguments
- Subcommand routing
- Environment variable precedence
## References
- Templates: `skills/clap-patterns/templates/`
- Scripts: `skills/clap-patterns/scripts/`
- Examples: `skills/clap-patterns/examples/`
- Clap Documentation: https://docs.rs/clap/latest/clap/

View File

@@ -0,0 +1,164 @@
# Clap Quick Start Guide
This guide will help you build your first Clap CLI application in minutes.
## Prerequisites
- Rust installed (1.70.0 or newer)
- Cargo (comes with Rust)
## Step 1: Create a New Project
```bash
cargo new my-cli
cd my-cli
```
## Step 2: Add Clap Dependency
Edit `Cargo.toml`:
```toml
[dependencies]
clap = { version = "4.5", features = ["derive"] }
```
## Step 3: Write Your First CLI
Replace `src/main.rs` with:
```rust
use clap::Parser;
/// Simple program to greet a person
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
/// Name of the person to greet
#[arg(short, long)]
name: String,
/// Number of times to greet
#[arg(short, long, default_value_t = 1)]
count: u8,
}
fn main() {
let args = Args::parse();
for _ in 0..args.count {
println!("Hello {}!", args.name)
}
}
```
## Step 4: Build and Run
```bash
# Build the project
cargo build --release
# Run with arguments
./target/release/my-cli --name Alice --count 3
# Check help output
./target/release/my-cli --help
```
## Expected Output
```
$ ./target/release/my-cli --name Alice --count 3
Hello Alice!
Hello Alice!
Hello Alice!
```
## Help Output
```
$ ./target/release/my-cli --help
Simple program to greet a person
Usage: my-cli --name <NAME> [--count <COUNT>]
Options:
-n, --name <NAME> Name of the person to greet
-c, --count <COUNT> Number of times to greet [default: 1]
-h, --help Print help
-V, --version Print version
```
## Next Steps
1. **Add Subcommands**: See `subcommands.rs` template
2. **Add Validation**: See `value-parser.rs` template
3. **Environment Variables**: See `env-variables.rs` template
4. **Type-Safe Options**: See `value-enum.rs` template
## Common Patterns
### Optional Arguments
```rust
#[arg(short, long)]
output: Option<String>,
```
### Multiple Values
```rust
#[arg(short, long, num_args = 1..)]
files: Vec<PathBuf>,
```
### Boolean Flags
```rust
#[arg(short, long)]
verbose: bool,
```
### With Default Value
```rust
#[arg(short, long, default_value = "config.toml")]
config: String,
```
### Required Unless Present
```rust
#[arg(long, required_unless_present = "config")]
database_url: Option<String>,
```
## Troubleshooting
### "Parser trait not found"
Add the import:
```rust
use clap::Parser;
```
### "derive feature not enabled"
Update `Cargo.toml`:
```toml
clap = { version = "4.5", features = ["derive"] }
```
### Help text not showing
Add doc comments above fields:
```rust
/// This shows up in --help output
#[arg(short, long)]
```
## Resources
- Full templates: `skills/clap-patterns/templates/`
- Helper scripts: `skills/clap-patterns/scripts/`
- Official docs: https://docs.rs/clap/latest/clap/

View File

@@ -0,0 +1,474 @@
# Real-World Clap CLI Example
A complete, production-ready CLI application demonstrating best practices.
## Project Structure
```
my-tool/
├── Cargo.toml
├── src/
│ ├── main.rs # CLI definition and entry point
│ ├── commands/ # Command implementations
│ │ ├── mod.rs
│ │ ├── init.rs
│ │ ├── build.rs
│ │ └── deploy.rs
│ ├── config.rs # Configuration management
│ └── utils.rs # Helper functions
├── tests/
│ └── cli_tests.rs # Integration tests
└── completions/ # Generated shell completions
```
## Cargo.toml
```toml
[package]
name = "my-tool"
version = "1.0.0"
edition = "2021"
[dependencies]
clap = { version = "4.5", features = ["derive", "env", "cargo"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
anyhow = "1.0"
tokio = { version = "1", features = ["full"] }
colored = "2.0"
[dev-dependencies]
assert_cmd = "2.0"
predicates = "3.0"
```
## main.rs - Complete CLI Definition
```rust
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
mod commands;
mod config;
mod utils;
#[derive(Parser)]
#[command(name = "my-tool")]
#[command(author, version, about = "A production-ready CLI tool", long_about = None)]
#[command(propagate_version = true)]
struct Cli {
/// Configuration file
#[arg(
short,
long,
env = "MY_TOOL_CONFIG",
global = true,
default_value = "config.json"
)]
config: PathBuf,
/// Enable verbose output
#[arg(short, long, global = true)]
verbose: bool,
/// Output format
#[arg(short = 'F', long, value_enum, global = true, default_value_t = OutputFormat::Text)]
format: OutputFormat,
/// Log file path
#[arg(long, env = "MY_TOOL_LOG", global = true)]
log_file: Option<PathBuf>,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize a new project
Init {
/// Project directory
#[arg(default_value = ".")]
path: PathBuf,
/// Project name
#[arg(short, long)]
name: Option<String>,
/// Project template
#[arg(short, long, value_enum, default_value_t = Template::Default)]
template: Template,
/// Skip interactive prompts
#[arg(short = 'y', long)]
yes: bool,
/// Git repository URL
#[arg(short, long)]
git: Option<String>,
},
/// Build the project
Build {
/// Build profile
#[arg(short, long, value_enum, default_value_t = Profile::Debug)]
profile: Profile,
/// Number of parallel jobs
#[arg(short, long, value_parser = clap::value_parser!(u8).range(1..=32), default_value_t = 4)]
jobs: u8,
/// Target directory
#[arg(short, long, default_value = "target")]
target: PathBuf,
/// Clean before building
#[arg(long)]
clean: bool,
/// Watch for changes
#[arg(short, long)]
watch: bool,
},
/// Deploy to environment
Deploy {
/// Target environment
#[arg(value_enum)]
environment: Environment,
/// Deployment version/tag
#[arg(short, long)]
version: String,
/// Dry run (don't actually deploy)
#[arg(short = 'n', long)]
dry_run: bool,
/// Skip pre-deployment checks
#[arg(long)]
skip_checks: bool,
/// Deployment timeout in seconds
#[arg(short, long, default_value_t = 300)]
timeout: u64,
/// Rollback on failure
#[arg(long)]
rollback: bool,
},
/// Manage configuration
Config {
#[command(subcommand)]
action: ConfigAction,
},
/// Generate shell completions
Completions {
/// Shell type
#[arg(value_enum)]
shell: Shell,
/// Output directory
#[arg(short, long, default_value = "completions")]
output: PathBuf,
},
}
#[derive(Subcommand)]
enum ConfigAction {
/// Show current configuration
Show,
/// Set a configuration value
Set {
/// Configuration key
key: String,
/// Configuration value
value: String,
},
/// Get a configuration value
Get {
/// Configuration key
key: String,
},
/// Reset configuration to defaults
Reset {
/// Confirm reset
#[arg(short = 'y', long)]
yes: bool,
},
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum OutputFormat {
Text,
Json,
Yaml,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Template {
Default,
Minimal,
Full,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Profile {
Debug,
Release,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Environment {
Dev,
Staging,
Prod,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Shell {
Bash,
Zsh,
Fish,
PowerShell,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
// Initialize logging
if let Some(log_file) = &cli.log_file {
utils::init_file_logging(log_file, cli.verbose)?;
} else {
utils::init_console_logging(cli.verbose);
}
// Load configuration
let config = config::load(&cli.config)?;
// Execute command
match &cli.command {
Commands::Init {
path,
name,
template,
yes,
git,
} => {
commands::init::execute(path, name.as_deref(), *template, *yes, git.as_deref()).await?;
}
Commands::Build {
profile,
jobs,
target,
clean,
watch,
} => {
commands::build::execute(*profile, *jobs, target, *clean, *watch).await?;
}
Commands::Deploy {
environment,
version,
dry_run,
skip_checks,
timeout,
rollback,
} => {
commands::deploy::execute(
*environment,
version,
*dry_run,
*skip_checks,
*timeout,
*rollback,
)
.await?;
}
Commands::Config { action } => match action {
ConfigAction::Show => config::show(&config, cli.format),
ConfigAction::Set { key, value } => config::set(&cli.config, key, value)?,
ConfigAction::Get { key } => config::get(&config, key, cli.format)?,
ConfigAction::Reset { yes } => config::reset(&cli.config, *yes)?,
},
Commands::Completions { shell, output } => {
commands::completions::generate(*shell, output)?;
}
}
Ok(())
}
```
## Key Features Demonstrated
### 1. Global Arguments
Arguments available to all subcommands:
```rust
#[arg(short, long, global = true)]
verbose: bool,
```
### 2. Environment Variables
Fallback to environment variables:
```rust
#[arg(short, long, env = "MY_TOOL_CONFIG")]
config: PathBuf,
```
### 3. Validation
Numeric range validation:
```rust
#[arg(short, long, value_parser = clap::value_parser!(u8).range(1..=32))]
jobs: u8,
```
### 4. Type-Safe Enums
Constrained choices with ValueEnum:
```rust
#[derive(ValueEnum)]
enum Environment {
Dev,
Staging,
Prod,
}
```
### 5. Nested Subcommands
Multi-level command structure:
```rust
Config {
#[command(subcommand)]
action: ConfigAction,
}
```
### 6. Default Values
Sensible defaults for all options:
```rust
#[arg(short, long, default_value = "config.json")]
config: PathBuf,
```
## Integration Tests
`tests/cli_tests.rs`:
```rust
use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn test_help() {
let mut cmd = Command::cargo_bin("my-tool").unwrap();
cmd.arg("--help")
.assert()
.success()
.stdout(predicate::str::contains("A production-ready CLI tool"));
}
#[test]
fn test_version() {
let mut cmd = Command::cargo_bin("my-tool").unwrap();
cmd.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains("1.0.0"));
}
#[test]
fn test_init_command() {
let mut cmd = Command::cargo_bin("my-tool").unwrap();
cmd.arg("init")
.arg("--name")
.arg("test-project")
.arg("--yes")
.assert()
.success();
}
```
## Building for Production
```bash
# Build release binary
cargo build --release
# Run tests
cargo test
# Generate completions
./target/release/my-tool completions bash
./target/release/my-tool completions zsh
./target/release/my-tool completions fish
# Install locally
cargo install --path .
```
## Distribution
### Cross-Platform Binaries
Use `cross` for cross-compilation:
```bash
cargo install cross
cross build --release --target x86_64-unknown-linux-gnu
cross build --release --target x86_64-pc-windows-gnu
cross build --release --target x86_64-apple-darwin
```
### Package for Distribution
```bash
# Linux/macOS tar.gz
tar czf my-tool-linux-x64.tar.gz -C target/release my-tool
# Windows zip
zip my-tool-windows-x64.zip target/release/my-tool.exe
```
## Best Practices Checklist
- ✓ Clear, descriptive help text
- ✓ Sensible default values
- ✓ Environment variable support
- ✓ Input validation
- ✓ Type-safe options (ValueEnum)
- ✓ Global arguments for common options
- ✓ Proper error handling (anyhow)
- ✓ Integration tests
- ✓ Shell completion generation
- ✓ Version information
- ✓ Verbose/quiet modes
- ✓ Configuration file support
- ✓ Dry-run mode for destructive operations
## Resources
- Full templates: `skills/clap-patterns/templates/`
- Validation examples: `examples/validation-examples.md`
- Test scripts: `scripts/test-cli.sh`

View File

@@ -0,0 +1,300 @@
# Clap Validation Examples
Comprehensive examples for validating CLI input with Clap value parsers.
## 1. Port Number Validation
Validate port numbers are in the valid range (1-65535):
```rust
use std::ops::RangeInclusive;
const PORT_RANGE: RangeInclusive<usize> = 1..=65535;
fn port_in_range(s: &str) -> Result<u16, String> {
let port: usize = s
.parse()
.map_err(|_| format!("`{}` isn't a valid port number", s))?;
if PORT_RANGE.contains(&port) {
Ok(port as u16)
} else {
Err(format!(
"port not in range {}-{}",
PORT_RANGE.start(),
PORT_RANGE.end()
))
}
}
#[derive(Parser)]
struct Cli {
#[arg(short, long, value_parser = port_in_range)]
port: u16,
}
```
**Usage:**
```bash
$ my-cli --port 8080 # ✓ Valid
$ my-cli --port 80000 # ❌ Error: port not in range 1-65535
$ my-cli --port abc # ❌ Error: `abc` isn't a valid port number
```
## 2. Email Validation
Basic email format validation:
```rust
fn validate_email(s: &str) -> Result<String, String> {
if s.contains('@') && s.contains('.') && s.len() > 5 {
Ok(s.to_string())
} else {
Err(format!("`{}` is not a valid email address", s))
}
}
#[derive(Parser)]
struct Cli {
#[arg(short, long, value_parser = validate_email)]
email: String,
}
```
## 3. File/Directory Existence
Validate that files or directories exist:
```rust
fn file_exists(s: &str) -> Result<PathBuf, String> {
let path = PathBuf::from(s);
if path.exists() && path.is_file() {
Ok(path)
} else {
Err(format!("file does not exist: {}", s))
}
}
fn dir_exists(s: &str) -> Result<PathBuf, String> {
let path = PathBuf::from(s);
if path.exists() && path.is_dir() {
Ok(path)
} else {
Err(format!("directory does not exist: {}", s))
}
}
#[derive(Parser)]
struct Cli {
#[arg(short, long, value_parser = file_exists)]
input: PathBuf,
#[arg(short, long, value_parser = dir_exists)]
output_dir: PathBuf,
}
```
## 4. URL Validation
Validate URL format:
```rust
fn validate_url(s: &str) -> Result<String, String> {
if s.starts_with("http://") || s.starts_with("https://") {
Ok(s.to_string())
} else {
Err(format!("`{}` must start with http:// or https://", s))
}
}
#[derive(Parser)]
struct Cli {
#[arg(short, long, value_parser = validate_url)]
endpoint: String,
}
```
## 5. Numeric Range Validation
Use built-in range validation:
```rust
#[derive(Parser)]
struct Cli {
/// Port (1-65535)
#[arg(long, value_parser = clap::value_parser!(u16).range(1..=65535))]
port: u16,
/// Threads (1-32)
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=32))]
threads: u8,
/// Percentage (0-100)
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=100))]
percentage: u8,
}
```
## 6. Regex Pattern Validation
Validate against regex patterns:
```rust
use regex::Regex;
fn validate_version(s: &str) -> Result<String, String> {
let re = Regex::new(r"^\d+\.\d+\.\d+$").unwrap();
if re.is_match(s) {
Ok(s.to_string())
} else {
Err(format!("`{}` is not a valid semantic version (e.g., 1.2.3)", s))
}
}
#[derive(Parser)]
struct Cli {
#[arg(short, long, value_parser = validate_version)]
version: String,
}
```
**Note:** Add `regex = "1"` to `Cargo.toml` for this example.
## 7. Multiple Validation Rules
Combine multiple validation rules:
```rust
fn validate_username(s: &str) -> Result<String, String> {
// Must be 3-20 characters
if s.len() < 3 || s.len() > 20 {
return Err("username must be 3-20 characters".to_string());
}
// Must start with letter
if !s.chars().next().unwrap().is_alphabetic() {
return Err("username must start with a letter".to_string());
}
// Only alphanumeric and underscore
if !s.chars().all(|c| c.is_alphanumeric() || c == '_') {
return Err("username can only contain letters, numbers, and underscores".to_string());
}
Ok(s.to_string())
}
#[derive(Parser)]
struct Cli {
#[arg(short, long, value_parser = validate_username)]
username: String,
}
```
## 8. Conditional Validation
Validate based on other arguments:
```rust
#[derive(Parser)]
struct Cli {
/// Enable SSL
#[arg(long)]
ssl: bool,
/// SSL certificate (required if --ssl is set)
#[arg(long, required_if_eq("ssl", "true"))]
cert: Option<PathBuf>,
/// SSL key (required if --ssl is set)
#[arg(long, required_if_eq("ssl", "true"))]
key: Option<PathBuf>,
}
```
## 9. Mutually Exclusive Arguments
Ensure only one option is provided:
```rust
#[derive(Parser)]
struct Cli {
/// Use JSON format
#[arg(long, conflicts_with = "yaml")]
json: bool,
/// Use YAML format
#[arg(long, conflicts_with = "json")]
yaml: bool,
}
```
## 10. Custom Type with FromStr
Implement `FromStr` for automatic parsing:
```rust
use std::str::FromStr;
struct IpPort {
ip: std::net::IpAddr,
port: u16,
}
impl FromStr for IpPort {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split(':').collect();
if parts.len() != 2 {
return Err("format must be IP:PORT (e.g., 127.0.0.1:8080)".to_string());
}
let ip = parts[0]
.parse()
.map_err(|_| format!("invalid IP address: {}", parts[0]))?;
let port = parts[1]
.parse()
.map_err(|_| format!("invalid port: {}", parts[1]))?;
Ok(IpPort { ip, port })
}
}
#[derive(Parser)]
struct Cli {
/// Bind address (IP:PORT)
#[arg(short, long)]
bind: IpPort,
}
```
**Usage:**
```bash
$ my-cli --bind 127.0.0.1:8080 # ✓ Valid
$ my-cli --bind 192.168.1.1:3000 # ✓ Valid
$ my-cli --bind invalid # ❌ Error
```
## Testing Validation
Use the provided test script:
```bash
bash scripts/test-cli.sh ./target/debug/my-cli validation
```
## Best Practices
1. **Provide Clear Error Messages**: Tell users what went wrong and how to fix it
2. **Validate Early**: Use value parsers instead of validating after parsing
3. **Use Type System**: Leverage Rust's type system for compile-time safety
4. **Document Constraints**: Add constraints to help text
5. **Test Edge Cases**: Test boundary values and invalid inputs
## Resources
- Value parser template: `templates/value-parser.rs`
- Test script: `scripts/test-cli.sh`
- Clap docs: https://docs.rs/clap/latest/clap/

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Generate shell completions for Clap CLI applications
#
# Usage: ./generate-completions.sh <binary-name> [output-dir]
#
# This script generates shell completion scripts for bash, zsh, fish, and powershell.
# The CLI binary must support the --generate-completions flag (built with Clap).
set -euo pipefail
BINARY="${1:-}"
OUTPUT_DIR="${2:-completions}"
if [ -z "$BINARY" ]; then
echo "Error: Binary name required"
echo "Usage: $0 <binary-name> [output-dir]"
exit 1
fi
# Create output directory
mkdir -p "$OUTPUT_DIR"
echo "Generating shell completions for: $BINARY"
echo "Output directory: $OUTPUT_DIR"
echo ""
# Check if binary exists
if ! command -v "$BINARY" &> /dev/null; then
echo "Warning: Binary '$BINARY' not found in PATH"
echo "Make sure to build and install it first: cargo install --path ."
exit 1
fi
# Generate completions for each shell
for shell in bash zsh fish powershell elvish; do
echo "Generating $shell completions..."
case "$shell" in
bash)
"$BINARY" --generate-completion "$shell" > "$OUTPUT_DIR/${BINARY}.bash" 2>/dev/null || {
echo " ⚠️ Failed (CLI may not support --generate-completion)"
continue
}
echo " ✓ Generated: $OUTPUT_DIR/${BINARY}.bash"
;;
zsh)
"$BINARY" --generate-completion "$shell" > "$OUTPUT_DIR/_${BINARY}" 2>/dev/null || {
echo " ⚠️ Failed"
continue
}
echo " ✓ Generated: $OUTPUT_DIR/_${BINARY}"
;;
fish)
"$BINARY" --generate-completion "$shell" > "$OUTPUT_DIR/${BINARY}.fish" 2>/dev/null || {
echo " ⚠️ Failed"
continue
}
echo " ✓ Generated: $OUTPUT_DIR/${BINARY}.fish"
;;
powershell)
"$BINARY" --generate-completion "$shell" > "$OUTPUT_DIR/_${BINARY}.ps1" 2>/dev/null || {
echo " ⚠️ Failed"
continue
}
echo " ✓ Generated: $OUTPUT_DIR/_${BINARY}.ps1"
;;
elvish)
"$BINARY" --generate-completion "$shell" > "$OUTPUT_DIR/${BINARY}.elv" 2>/dev/null || {
echo " ⚠️ Failed"
continue
}
echo " ✓ Generated: $OUTPUT_DIR/${BINARY}.elv"
;;
esac
done
echo ""
echo "✓ Completion generation complete!"
echo ""
echo "Installation instructions:"
echo ""
echo "Bash:"
echo " sudo cp $OUTPUT_DIR/${BINARY}.bash /etc/bash_completion.d/"
echo " Or: echo 'source $PWD/$OUTPUT_DIR/${BINARY}.bash' >> ~/.bashrc"
echo ""
echo "Zsh:"
echo " cp $OUTPUT_DIR/_${BINARY} /usr/local/share/zsh/site-functions/"
echo " Or add to fpath: fpath=($PWD/$OUTPUT_DIR \$fpath)"
echo ""
echo "Fish:"
echo " cp $OUTPUT_DIR/${BINARY}.fish ~/.config/fish/completions/"
echo ""
echo "PowerShell:"
echo " Add to profile: . $PWD/$OUTPUT_DIR/_${BINARY}.ps1"

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env bash
# Test a Clap CLI application with various argument combinations
#
# Usage: ./test-cli.sh <binary-path> [test-suite]
#
# Test suites: basic, subcommands, validation, env, all (default)
set -euo pipefail
BINARY="${1:-}"
TEST_SUITE="${2:-all}"
if [ -z "$BINARY" ]; then
echo "Error: Binary path required"
echo "Usage: $0 <binary-path> [test-suite]"
echo ""
echo "Test suites:"
echo " basic - Test help, version, basic flags"
echo " subcommands - Test subcommand routing"
echo " validation - Test input validation"
echo " env - Test environment variables"
echo " all - Run all tests (default)"
exit 1
fi
if [ ! -x "$BINARY" ]; then
echo "Error: Binary not found or not executable: $BINARY"
exit 1
fi
PASS=0
FAIL=0
run_test() {
local name="$1"
local expected_exit="$2"
shift 2
echo -n "Testing: $name ... "
if "$BINARY" "$@" &>/dev/null; then
actual_exit=0
else
actual_exit=$?
fi
if [ "$actual_exit" -eq "$expected_exit" ]; then
echo "✓ PASS"
((PASS++))
else
echo "❌ FAIL (expected exit $expected_exit, got $actual_exit)"
((FAIL++))
fi
}
test_basic() {
echo ""
echo "=== Basic Tests ==="
run_test "Help output" 0 --help
run_test "Version output" 0 --version
run_test "Short help" 0 -h
run_test "Invalid flag" 1 --invalid-flag
run_test "No arguments (might fail for some CLIs)" 0
}
test_subcommands() {
echo ""
echo "=== Subcommand Tests ==="
run_test "Subcommand help" 0 help
run_test "Invalid subcommand" 1 invalid-command
# Try common subcommands
for cmd in init add build test deploy; do
if "$BINARY" help 2>&1 | grep -q "$cmd"; then
run_test "Subcommand '$cmd' help" 0 "$cmd" --help
fi
done
}
test_validation() {
echo ""
echo "=== Validation Tests ==="
# Test file arguments with non-existent files
run_test "Non-existent file" 1 --input /nonexistent/file.txt
# Test numeric ranges
run_test "Invalid number" 1 --count abc
run_test "Negative number" 1 --count -5
# Test conflicting flags
if "$BINARY" --help 2>&1 | grep -q "conflicts with"; then
echo " (Found conflicting arguments in help text)"
fi
}
test_env() {
echo ""
echo "=== Environment Variable Tests ==="
# Check if binary supports environment variables
if "$BINARY" --help 2>&1 | grep -q "\[env:"; then
echo "✓ Environment variable support detected"
# Extract env vars from help text
ENV_VARS=$("$BINARY" --help 2>&1 | grep -o '\[env: [A-Z_]*\]' | sed 's/\[env: \(.*\)\]/\1/' || true)
if [ -n "$ENV_VARS" ]; then
echo "Found environment variables:"
echo "$ENV_VARS" | while read -r var; do
echo " - $var"
done
fi
else
echo " No environment variable support detected"
fi
}
# Run requested test suite
case "$TEST_SUITE" in
basic)
test_basic
;;
subcommands)
test_subcommands
;;
validation)
test_validation
;;
env)
test_env
;;
all)
test_basic
test_subcommands
test_validation
test_env
;;
*)
echo "Error: Unknown test suite: $TEST_SUITE"
exit 1
;;
esac
echo ""
echo "=== Test Summary ==="
echo "Passed: $PASS"
echo "Failed: $FAIL"
echo ""
if [ "$FAIL" -gt 0 ]; then
echo "❌ Some tests failed"
exit 1
else
echo "✓ All tests passed!"
exit 0
fi

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Validate Cargo.toml for correct Clap configuration
#
# Usage: ./validate-cargo.sh [path-to-Cargo.toml]
#
# Checks:
# - Clap dependency exists
# - Clap version is 4.x or newer
# - Required features are enabled (derive)
# - Optional features (env, cargo) are present if needed
set -euo pipefail
CARGO_TOML="${1:-Cargo.toml}"
if [ ! -f "$CARGO_TOML" ]; then
echo "❌ Error: $CARGO_TOML not found"
exit 1
fi
echo "Validating Clap configuration in: $CARGO_TOML"
echo ""
# Check if clap is listed as a dependency
if ! grep -q "clap" "$CARGO_TOML"; then
echo "❌ Clap not found in dependencies"
echo ""
echo "Add to $CARGO_TOML:"
echo ""
echo '[dependencies]'
echo 'clap = { version = "4.5", features = ["derive"] }'
exit 1
fi
echo "✓ Clap dependency found"
# Extract clap version
VERSION=$(grep -A 5 '^\[dependencies\]' "$CARGO_TOML" | grep 'clap' | head -1)
# Check version
if echo "$VERSION" | grep -q '"4\.' || echo "$VERSION" | grep -q "'4\."; then
echo "✓ Clap version 4.x detected"
elif echo "$VERSION" | grep -q '"3\.' || echo "$VERSION" | grep -q "'3\."; then
echo "⚠️ Warning: Clap version 3.x detected"
echo " Consider upgrading to 4.x for latest features"
else
echo "⚠️ Warning: Could not determine Clap version"
fi
# Check for derive feature
if echo "$VERSION" | grep -q 'features.*derive' || echo "$VERSION" | grep -q 'derive.*features'; then
echo "✓ 'derive' feature enabled"
else
echo "❌ 'derive' feature not found"
echo " Add: features = [\"derive\"]"
exit 1
fi
# Check for optional but recommended features
echo ""
echo "Optional features:"
if echo "$VERSION" | grep -q '"env"' || echo "$VERSION" | grep -q "'env'"; then
echo "✓ 'env' feature enabled (environment variable support)"
else
echo " 'env' feature not enabled"
echo " Add for environment variable support: features = [\"derive\", \"env\"]"
fi
if echo "$VERSION" | grep -q '"cargo"' || echo "$VERSION" | grep -q "'cargo'"; then
echo "✓ 'cargo' feature enabled (automatic version from Cargo.toml)"
else
echo " 'cargo' feature not enabled"
echo " Add for automatic version: features = [\"derive\", \"cargo\"]"
fi
if echo "$VERSION" | grep -q '"color"' || echo "$VERSION" | grep -q "'color'"; then
echo "✓ 'color' feature enabled (colored output)"
else
echo " 'color' feature not enabled"
echo " Add for colored help: features = [\"derive\", \"color\"]"
fi
echo ""
# Check for common patterns in src/
if [ -d "src" ]; then
echo "Checking source files for Clap usage patterns..."
if grep -r "use clap::Parser" src/ &>/dev/null; then
echo "✓ Parser trait usage found"
fi
if grep -r "use clap::Subcommand" src/ &>/dev/null; then
echo "✓ Subcommand trait usage found"
fi
if grep -r "use clap::ValueEnum" src/ &>/dev/null; then
echo "✓ ValueEnum trait usage found"
fi
if grep -r "#\[derive(Parser)\]" src/ &>/dev/null; then
echo "✓ Parser derive macro usage found"
fi
fi
echo ""
echo "✓ Validation complete!"
echo ""
echo "Recommended Cargo.toml configuration:"
echo ""
echo '[dependencies]'
echo 'clap = { version = "4.5", features = ["derive", "env", "cargo"] }'

View File

@@ -0,0 +1,66 @@
/// Basic Parser Template with Clap Derive Macros
///
/// This template demonstrates:
/// - Parser derive macro
/// - Argument attributes (short, long, default_value)
/// - PathBuf for file handling
/// - Boolean flags
/// - Doc comments as help text
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "myapp")]
#[command(author = "Your Name <you@example.com>")]
#[command(version = "1.0.0")]
#[command(about = "A simple CLI application", long_about = None)]
struct Cli {
/// Input file to process
#[arg(short, long, value_name = "FILE")]
input: PathBuf,
/// Optional output file
#[arg(short, long)]
output: Option<PathBuf>,
/// Enable verbose output
#[arg(short, long)]
verbose: bool,
/// Number of items to process
#[arg(short = 'c', long, default_value_t = 10)]
count: usize,
/// Dry run mode (don't make changes)
#[arg(short = 'n', long)]
dry_run: bool,
}
fn main() {
let cli = Cli::parse();
if cli.verbose {
println!("Input file: {:?}", cli.input);
println!("Output file: {:?}", cli.output);
println!("Count: {}", cli.count);
println!("Dry run: {}", cli.dry_run);
}
// Check if input file exists
if !cli.input.exists() {
eprintln!("Error: Input file does not exist: {:?}", cli.input);
std::process::exit(1);
}
// Your processing logic here
println!("Processing {} with count {}...", cli.input.display(), cli.count);
if let Some(output) = cli.output {
if !cli.dry_run {
println!("Would write to: {}", output.display());
} else {
println!("Dry run: Skipping write to {}", output.display());
}
}
}

View File

@@ -0,0 +1,112 @@
/// Builder Pattern Template (Manual API)
///
/// This template demonstrates the builder API for advanced use cases:
/// - Dynamic CLI construction
/// - Runtime configuration
/// - Custom help templates
/// - Complex validation logic
///
/// Note: Prefer derive macros unless you need this level of control.
use clap::{Arg, ArgAction, ArgMatches, Command};
use std::path::PathBuf;
fn build_cli() -> Command {
Command::new("advanced-cli")
.version("1.0.0")
.author("Your Name <you@example.com>")
.about("Advanced CLI using builder pattern")
.arg(
Arg::new("input")
.short('i')
.long("input")
.value_name("FILE")
.help("Input file to process")
.required(true)
.value_parser(clap::value_parser!(PathBuf)),
)
.arg(
Arg::new("output")
.short('o')
.long("output")
.value_name("FILE")
.help("Output file (optional)")
.value_parser(clap::value_parser!(PathBuf)),
)
.arg(
Arg::new("verbose")
.short('v')
.long("verbose")
.help("Enable verbose output")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("count")
.short('c')
.long("count")
.value_name("NUM")
.help("Number of items to process")
.default_value("10")
.value_parser(clap::value_parser!(usize)),
)
.arg(
Arg::new("format")
.short('f')
.long("format")
.value_name("FORMAT")
.help("Output format")
.value_parser(["json", "yaml", "toml"])
.default_value("json"),
)
.arg(
Arg::new("tags")
.short('t')
.long("tag")
.value_name("TAG")
.help("Tags to apply (can be specified multiple times)")
.action(ArgAction::Append),
)
}
fn process_args(matches: &ArgMatches) {
let input = matches.get_one::<PathBuf>("input").unwrap();
let output = matches.get_one::<PathBuf>("output");
let verbose = matches.get_flag("verbose");
let count = *matches.get_one::<usize>("count").unwrap();
let format = matches.get_one::<String>("format").unwrap();
let tags: Vec<_> = matches
.get_many::<String>("tags")
.unwrap_or_default()
.map(|s| s.as_str())
.collect();
if verbose {
println!("Configuration:");
println!(" Input: {:?}", input);
println!(" Output: {:?}", output);
println!(" Count: {}", count);
println!(" Format: {}", format);
println!(" Tags: {:?}", tags);
}
// Your processing logic here
println!("Processing {} items from {}", count, input.display());
if !tags.is_empty() {
println!("Applying tags: {}", tags.join(", "));
}
if let Some(output_path) = output {
println!("Writing {} format to {}", format, output_path.display());
}
}
fn main() {
let matches = build_cli().get_matches();
process_args(&matches);
}
// Example usage:
//
// cargo run -- -i input.txt -o output.json -v -c 20 -f yaml -t alpha -t beta
// cargo run -- --input data.txt --format toml --tag important

View File

@@ -0,0 +1,99 @@
/// Environment Variable Integration Template
///
/// This template demonstrates:
/// - Reading from environment variables
/// - Fallback to CLI arguments
/// - Default values
/// - Sensitive data handling (API keys, tokens)
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "envapp")]
#[command(about = "CLI with environment variable support")]
struct Cli {
/// API key (or set API_KEY env var)
///
/// Sensitive data like API keys should preferably be set via environment
/// variables to avoid exposing them in shell history or process lists.
#[arg(long, env = "API_KEY", hide_env_values = true)]
api_key: String,
/// Database URL (or set DATABASE_URL env var)
#[arg(long, env = "DATABASE_URL")]
database_url: String,
/// Log level: debug, info, warn, error
///
/// Defaults to "info" if not provided via CLI or LOG_LEVEL env var.
#[arg(long, env = "LOG_LEVEL", default_value = "info")]
log_level: String,
/// Configuration file path
///
/// Reads from CONFIG_FILE env var, or uses default if not specified.
#[arg(long, env = "CONFIG_FILE", default_value = "config.toml")]
config: PathBuf,
/// Number of workers (default from env or 4)
#[arg(long, env = "WORKER_COUNT", default_value_t = 4)]
workers: usize,
/// Enable debug mode
///
/// Can be set via DEBUG=1 or --debug flag
#[arg(long, env = "DEBUG", value_parser = clap::value_parser!(bool))]
debug: bool,
/// Host to bind to
#[arg(long, env = "HOST", default_value = "127.0.0.1")]
host: String,
/// Port to listen on
#[arg(short, long, env = "PORT", default_value_t = 8080)]
port: u16,
}
fn main() {
let cli = Cli::parse();
println!("Configuration loaded:");
println!(" Database URL: {}", cli.database_url);
println!(" API Key: {}...", &cli.api_key[..4.min(cli.api_key.len())]);
println!(" Log level: {}", cli.log_level);
println!(" Config file: {}", cli.config.display());
println!(" Workers: {}", cli.workers);
println!(" Debug mode: {}", cli.debug);
println!(" Host: {}", cli.host);
println!(" Port: {}", cli.port);
// Initialize logging based on log_level
match cli.log_level.to_lowercase().as_str() {
"debug" => println!("Log level set to DEBUG"),
"info" => println!("Log level set to INFO"),
"warn" => println!("Log level set to WARN"),
"error" => println!("Log level set to ERROR"),
_ => println!("Unknown log level: {}", cli.log_level),
}
// Your application logic here
println!("\nStarting application...");
println!("Listening on {}:{}", cli.host, cli.port);
}
// Example usage:
//
// 1. Set environment variables:
// export API_KEY="sk-1234567890abcdef"
// export DATABASE_URL="postgres://localhost/mydb"
// export LOG_LEVEL="debug"
// export WORKER_COUNT="8"
// cargo run
//
// 2. Override with CLI arguments:
// cargo run -- --api-key "other-key" --workers 16
//
// 3. Mix environment and CLI:
// export DATABASE_URL="postgres://localhost/mydb"
// cargo run -- --api-key "sk-1234" --debug

View File

@@ -0,0 +1,290 @@
/// Full-Featured CLI Template
///
/// This template combines all patterns:
/// - Parser derive with subcommands
/// - ValueEnum for type-safe options
/// - Environment variable support
/// - Custom value parsers
/// - Global arguments
/// - Comprehensive help text
use clap::{Parser, Subcommand, ValueEnum};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "myapp")]
#[command(author = "Your Name <you@example.com>")]
#[command(version = "1.0.0")]
#[command(about = "A full-featured CLI application", long_about = None)]
#[command(propagate_version = true)]
struct Cli {
/// Configuration file path
#[arg(short, long, env = "CONFIG_FILE", global = true)]
config: Option<PathBuf>,
/// Enable verbose output
#[arg(short, long, global = true)]
verbose: bool,
/// Output format
#[arg(short, long, value_enum, global = true, default_value_t = Format::Text)]
format: Format,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize a new project
Init {
/// Project directory
#[arg(default_value = ".")]
path: PathBuf,
/// Project template
#[arg(short, long, value_enum, default_value_t = Template::Basic)]
template: Template,
/// Skip interactive prompts
#[arg(short = 'y', long)]
yes: bool,
},
/// Build the project
Build {
/// Build mode
#[arg(short, long, value_enum, default_value_t = BuildMode::Debug)]
mode: BuildMode,
/// Number of parallel jobs
#[arg(short, long, value_parser = clap::value_parser!(u8).range(1..=32), default_value_t = 4)]
jobs: u8,
/// Target directory
#[arg(short, long, default_value = "target")]
target_dir: PathBuf,
/// Clean before building
#[arg(long)]
clean: bool,
},
/// Test the project
Test {
/// Test name pattern
pattern: Option<String>,
/// Run ignored tests
#[arg(long)]
ignored: bool,
/// Number of test threads
#[arg(long, value_parser = clap::value_parser!(usize).range(1..))]
test_threads: Option<usize>,
/// Show output for passing tests
#[arg(long)]
nocapture: bool,
},
/// Deploy the project
Deploy {
/// Deployment environment
#[arg(value_enum)]
environment: Environment,
/// Skip pre-deployment checks
#[arg(long)]
skip_checks: bool,
/// Deployment tag/version
#[arg(short, long)]
tag: Option<String>,
/// Deployment configuration
#[command(subcommand)]
config: Option<DeployConfig>,
},
}
#[derive(Subcommand)]
enum DeployConfig {
/// Configure database settings
Database {
/// Database URL
#[arg(long, env = "DATABASE_URL")]
url: String,
/// Run migrations
#[arg(long)]
migrate: bool,
},
/// Configure server settings
Server {
/// Server host
#[arg(long, default_value = "0.0.0.0")]
host: String,
/// Server port
#[arg(long, default_value_t = 8080, value_parser = port_in_range)]
port: u16,
/// Number of workers
#[arg(long, default_value_t = 4)]
workers: usize,
},
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Format {
/// Human-readable text
Text,
/// JSON output
Json,
/// YAML output
Yaml,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Template {
/// Basic template
Basic,
/// Full-featured template
Full,
/// Minimal template
Minimal,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum BuildMode {
/// Debug build with symbols
Debug,
/// Release build with optimizations
Release,
}
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Environment {
/// Development environment
Dev,
/// Staging environment
Staging,
/// Production environment
Prod,
}
use std::ops::RangeInclusive;
const PORT_RANGE: RangeInclusive<usize> = 1..=65535;
fn port_in_range(s: &str) -> Result<u16, String> {
let port: usize = s
.parse()
.map_err(|_| format!("`{}` isn't a valid port number", s))?;
if PORT_RANGE.contains(&port) {
Ok(port as u16)
} else {
Err(format!(
"port not in range {}-{}",
PORT_RANGE.start(),
PORT_RANGE.end()
))
}
}
fn main() {
let cli = Cli::parse();
if cli.verbose {
println!("Verbose mode enabled");
if let Some(config) = &cli.config {
println!("Using config: {}", config.display());
}
println!("Output format: {:?}", cli.format);
}
match &cli.command {
Commands::Init { path, template, yes } => {
println!("Initializing project at {}", path.display());
println!("Template: {:?}", template);
if *yes {
println!("Skipping prompts");
}
}
Commands::Build {
mode,
jobs,
target_dir,
clean,
} => {
if *clean {
println!("Cleaning target directory");
}
println!("Building in {:?} mode", mode);
println!("Using {} parallel jobs", jobs);
println!("Target directory: {}", target_dir.display());
}
Commands::Test {
pattern,
ignored,
test_threads,
nocapture,
} => {
println!("Running tests");
if let Some(pat) = pattern {
println!("Pattern: {}", pat);
}
if *ignored {
println!("Including ignored tests");
}
if let Some(threads) = test_threads {
println!("Test threads: {}", threads);
}
if *nocapture {
println!("Showing test output");
}
}
Commands::Deploy {
environment,
skip_checks,
tag,
config,
} => {
println!("Deploying to {:?}", environment);
if *skip_checks {
println!("⚠️ Skipping pre-deployment checks");
}
if let Some(version) = tag {
println!("Version: {}", version);
}
if let Some(deploy_config) = config {
match deploy_config {
DeployConfig::Database { url, migrate } => {
println!("Database URL: {}", url);
if *migrate {
println!("Running migrations");
}
}
DeployConfig::Server { host, port, workers } => {
println!("Server: {}:{}", host, port);
println!("Workers: {}", workers);
}
}
}
}
}
}
// Example usage:
//
// myapp init --template full
// myapp build --mode release --jobs 8 --clean
// myapp test integration --test-threads 4
// myapp deploy prod --tag v1.0.0 server --host 0.0.0.0 --port 443 --workers 16

View File

@@ -0,0 +1,139 @@
/// Subcommand Template with Clap
///
/// This template demonstrates:
/// - Subcommand derive macro
/// - Nested command structure
/// - Per-subcommand arguments
/// - Enum-based command routing
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "git-like")]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
/// Enable verbose output
#[arg(global = true, short, long)]
verbose: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Initialize a new repository
Init {
/// Directory to initialize
#[arg(value_name = "DIR", default_value = ".")]
path: PathBuf,
/// Create a bare repository
#[arg(long)]
bare: bool,
},
/// Add files to staging area
Add {
/// Files to add
#[arg(value_name = "FILE", required = true)]
files: Vec<PathBuf>,
/// Add all files
#[arg(short = 'A', long)]
all: bool,
},
/// Commit staged changes
Commit {
/// Commit message
#[arg(short, long)]
message: String,
/// Amend previous commit
#[arg(long)]
amend: bool,
},
/// Remote repository operations
Remote {
#[command(subcommand)]
command: RemoteCommands,
},
}
#[derive(Subcommand)]
enum RemoteCommands {
/// Add a new remote
Add {
/// Remote name
name: String,
/// Remote URL
url: String,
},
/// Remove a remote
Remove {
/// Remote name
name: String,
},
/// List all remotes
List {
/// Show URLs
#[arg(short, long)]
verbose: bool,
},
}
fn main() {
let cli = Cli::parse();
match &cli.command {
Commands::Init { path, bare } => {
if cli.verbose {
println!("Initializing repository at {:?}", path);
}
println!(
"Initialized {} repository in {}",
if *bare { "bare" } else { "normal" },
path.display()
);
}
Commands::Add { files, all } => {
if *all {
println!("Adding all files");
} else {
println!("Adding {} file(s)", files.len());
if cli.verbose {
for file in files {
println!(" - {}", file.display());
}
}
}
}
Commands::Commit { message, amend } => {
if *amend {
println!("Amending previous commit");
}
println!("Committing with message: {}", message);
}
Commands::Remote { command } => match command {
RemoteCommands::Add { name, url } => {
println!("Adding remote '{}' -> {}", name, url);
}
RemoteCommands::Remove { name } => {
println!("Removing remote '{}'", name);
}
RemoteCommands::List { verbose } => {
println!("Listing remotes{}", if *verbose { " (verbose)" } else { "" });
}
},
}
}

View File

@@ -0,0 +1,143 @@
/// ValueEnum Template for Type-Safe Options
///
/// This template demonstrates:
/// - ValueEnum trait for constrained choices
/// - Type-safe option selection
/// - Automatic validation and help text
/// - Pattern matching on enums
use clap::{Parser, ValueEnum};
/// Output format options
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum Format {
/// JavaScript Object Notation
Json,
/// YAML Ain't Markup Language
Yaml,
/// Tom's Obvious, Minimal Language
Toml,
/// Comma-Separated Values
Csv,
}
/// Log level options
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum LogLevel {
/// Detailed debug information
Debug,
/// General information
Info,
/// Warning messages
Warn,
/// Error messages only
Error,
}
/// Color output mode
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
enum ColorMode {
/// Always use colors
Always,
/// Never use colors
Never,
/// Automatically detect (default)
Auto,
}
#[derive(Parser)]
#[command(name = "converter")]
#[command(about = "Convert data between formats with type-safe options")]
struct Cli {
/// Input file
input: std::path::PathBuf,
/// Output format
#[arg(short, long, value_enum, default_value_t = Format::Json)]
format: Format,
/// Log level
#[arg(short, long, value_enum, default_value_t = LogLevel::Info)]
log_level: LogLevel,
/// Color mode for output
#[arg(long, value_enum, default_value_t = ColorMode::Auto)]
color: ColorMode,
/// Pretty print output (for supported formats)
#[arg(short, long)]
pretty: bool,
}
fn main() {
let cli = Cli::parse();
// Configure logging based on log level
match cli.log_level {
LogLevel::Debug => println!("🔍 Debug logging enabled"),
LogLevel::Info => println!(" Info logging enabled"),
LogLevel::Warn => println!("⚠️ Warning logging enabled"),
LogLevel::Error => println!("❌ Error logging only"),
}
// Check color mode
let use_colors = match cli.color {
ColorMode::Always => true,
ColorMode::Never => false,
ColorMode::Auto => atty::is(atty::Stream::Stdout),
};
if use_colors {
println!("🎨 Color output enabled");
}
// Process based on format
println!("Converting {} to {:?}", cli.input.display(), cli.format);
match cli.format {
Format::Json => {
println!("Converting to JSON{}", if cli.pretty { " (pretty)" } else { "" });
// JSON conversion logic here
}
Format::Yaml => {
println!("Converting to YAML");
// YAML conversion logic here
}
Format::Toml => {
println!("Converting to TOML");
// TOML conversion logic here
}
Format::Csv => {
println!("Converting to CSV");
// CSV conversion logic here
}
}
println!("✓ Conversion complete");
}
// Helper function to check if stdout is a terminal (for color auto-detection)
mod atty {
pub enum Stream {
Stdout,
}
pub fn is(_stream: Stream) -> bool {
// Simple implementation - checks if stdout is a TTY
#[cfg(unix)]
{
unsafe { libc::isatty(libc::STDOUT_FILENO) != 0 }
}
#[cfg(not(unix))]
{
false
}
}
}
// Example usage:
//
// cargo run -- input.txt --format json --log-level debug
// cargo run -- data.yml --format toml --color always --pretty
// cargo run -- config.json --format yaml --log-level warn

View File

@@ -0,0 +1,109 @@
/// Value Parser Template with Custom Validation
///
/// This template demonstrates:
/// - Custom value parsers
/// - Range validation
/// - Format validation (regex)
/// - Error handling with helpful messages
use clap::Parser;
use std::ops::RangeInclusive;
const PORT_RANGE: RangeInclusive<usize> = 1..=65535;
/// Parse and validate port number
fn port_in_range(s: &str) -> Result<u16, String> {
let port: usize = s
.parse()
.map_err(|_| format!("`{}` isn't a valid port number", s))?;
if PORT_RANGE.contains(&port) {
Ok(port as u16)
} else {
Err(format!(
"port not in range {}-{}",
PORT_RANGE.start(),
PORT_RANGE.end()
))
}
}
/// Validate email format (basic validation)
fn validate_email(s: &str) -> Result<String, String> {
if s.contains('@') && s.contains('.') && s.len() > 5 {
Ok(s.to_string())
} else {
Err(format!("`{}` is not a valid email address", s))
}
}
/// Parse percentage (0-100)
fn parse_percentage(s: &str) -> Result<u8, String> {
let value: u8 = s
.parse()
.map_err(|_| format!("`{}` isn't a valid number", s))?;
if value <= 100 {
Ok(value)
} else {
Err("percentage must be between 0 and 100".to_string())
}
}
/// Validate directory exists
fn validate_directory(s: &str) -> Result<std::path::PathBuf, String> {
let path = std::path::PathBuf::from(s);
if path.exists() && path.is_dir() {
Ok(path)
} else {
Err(format!("directory does not exist: {}", s))
}
}
#[derive(Parser)]
#[command(name = "validator")]
#[command(about = "CLI with custom value parsers and validation")]
struct Cli {
/// Port number (1-65535)
#[arg(short, long, value_parser = port_in_range)]
port: u16,
/// Email address
#[arg(short, long, value_parser = validate_email)]
email: String,
/// Success threshold percentage (0-100)
#[arg(short, long, value_parser = parse_percentage, default_value = "80")]
threshold: u8,
/// Working directory (must exist)
#[arg(short, long, value_parser = validate_directory)]
workdir: Option<std::path::PathBuf>,
/// Number of retries (1-10)
#[arg(
short,
long,
default_value = "3",
value_parser = clap::value_parser!(u8).range(1..=10)
)]
retries: u8,
}
fn main() {
let cli = Cli::parse();
println!("Configuration:");
println!(" Port: {}", cli.port);
println!(" Email: {}", cli.email);
println!(" Threshold: {}%", cli.threshold);
println!(" Retries: {}", cli.retries);
if let Some(workdir) = cli.workdir {
println!(" Working directory: {}", workdir.display());
}
// Your application logic here
println!("\nValidation passed! All inputs are valid.");
}

View File

@@ -0,0 +1,334 @@
---
name: cli-patterns
description: Lightweight Go CLI patterns using urfave/cli. Use when building CLI tools, creating commands with flags, implementing subcommands, adding before/after hooks, organizing command categories, or when user mentions Go CLI, urfave/cli, cobra alternatives, CLI flags, CLI categories.
allowed-tools: Bash, Read, Write, Edit
---
# CLI Patterns Skill
Lightweight Go CLI patterns using urfave/cli for fast, simple command-line applications.
## Overview
Provides battle-tested patterns for building production-ready CLI tools in Go using urfave/cli v2. Focus on simplicity, speed, and maintainability over complex frameworks like Cobra.
## Why urfave/cli?
- **Lightweight**: Minimal dependencies, small binary size
- **Fast**: Quick compilation, fast execution
- **Simple API**: Easy to learn, less boilerplate than Cobra
- **Production-ready**: Used by Docker, Nomad, and many other tools
- **Native Go**: Feels like standard library code
## Core Patterns
### 1. Basic CLI Structure
Use `templates/basic-cli.go` for simple single-command CLIs:
- Main command with flags
- Help text generation
- Error handling
- Exit codes
### 2. Subcommands
Use `templates/subcommands-cli.go` for multi-command CLIs:
- Command hierarchy (app → command → subcommand)
- Shared flags across commands
- Command aliases
- Command categories
### 3. Flags and Options
Use `templates/flags-demo.go` for comprehensive flag examples:
- String, int, bool, duration flags
- Required vs optional flags
- Default values
- Environment variable fallbacks
- Flag aliases (short and long forms)
- Custom flag types
### 4. Command Categories
Use `templates/categories-cli.go` for organized command groups:
- Group related commands
- Better help text organization
- Professional CLI UX
- Examples: database commands, deploy commands, etc.
### 5. Before/After Hooks
Use `templates/hooks-cli.go` for lifecycle management:
- Global setup (before all commands)
- Global cleanup (after all commands)
- Per-command setup/teardown
- Initialization and validation
- Resource management
### 6. Context and State
Use `templates/context-cli.go` for shared state:
- Pass configuration between commands
- Share database connections
- Manage API clients
- Context values
## Scripts
### Generation Scripts
**`scripts/generate-basic.sh <app-name>`**
- Generates basic CLI structure
- Creates main.go with single command
- Adds common flags (verbose, config)
- Includes help text template
**`scripts/generate-subcommands.sh <app-name>`**
- Generates multi-command CLI
- Creates command structure
- Adds subcommand examples
- Includes command categories
**`scripts/generate-full.sh <app-name>`**
- Generates complete CLI with all patterns
- Includes before/after hooks
- Adds comprehensive flag examples
- Sets up command categories
- Includes context management
### Utility Scripts
**`scripts/add-command.sh <app-name> <command-name>`**
- Adds new command to existing CLI
- Updates command registration
- Creates command file
- Adds to appropriate category
**`scripts/add-flag.sh <file> <flag-name> <flag-type>`**
- Adds flag to command
- Supports all flag types
- Includes environment variable fallback
- Adds help text
**`scripts/validate-cli.sh <project-path>`**
- Validates CLI structure
- Checks for common mistakes
- Verifies flag definitions
- Ensures help text exists
## Templates
### Core Templates
**`templates/basic-cli.go`**
- Single-command CLI
- Standard flags (verbose, version)
- Error handling patterns
- Exit code management
**`templates/subcommands-cli.go`**
- Multi-command structure
- Command registration
- Shared flags
- Help text organization
**`templates/flags-demo.go`**
- All flag types demonstrated
- Environment variable fallbacks
- Required flag validation
- Custom flag types
**`templates/categories-cli.go`**
- Command categorization
- Professional help output
- Grouped commands
- Category-based organization
**`templates/hooks-cli.go`**
- Before/After hooks
- Global setup/teardown
- Per-command hooks
- Resource initialization
**`templates/context-cli.go`**
- Context management
- Shared state
- Configuration passing
- API client sharing
### TypeScript Equivalent (Node.js)
**`templates/commander-basic.ts`**
- commander.js equivalent patterns
- TypeScript type safety
- Similar API to urfave/cli
**`templates/oclif-basic.ts`**
- oclif framework patterns (Heroku/Salesforce style)
- Class-based commands
- Plugin system
### Python Equivalent
**`templates/click-basic.py`**
- click framework patterns
- Decorator-based commands
- Python CLI best practices
**`templates/typer-basic.py`**
- typer framework (FastAPI CLI)
- Type hints for validation
- Modern Python patterns
## Examples
### Example 1: Database CLI Tool
**`examples/db-cli/`**
- Complete database management CLI
- Commands: connect, migrate, seed, backup
- Categories: schema, data, admin
- Before hook: validate connection
- After hook: close connections
### Example 2: Deployment Tool
**`examples/deploy-cli/`**
- Deployment automation CLI
- Commands: build, test, deploy, rollback
- Categories: build, deploy, monitor
- Context: share deployment config
- Hooks: setup AWS credentials
### Example 3: API Client
**`examples/api-cli/`**
- REST API client CLI
- Commands: get, post, put, delete
- Global flags: auth token, base URL
- Before hook: authenticate
- Context: share HTTP client
### Example 4: File Processor
**`examples/file-cli/`**
- File processing tool
- Commands: convert, validate, optimize
- Categories: input, output, processing
- Flags: input format, output format
- Progress indicators
## Best Practices
### CLI Design
1. **Keep it simple**: Start with basic structure, add complexity as needed
2. **Consistent naming**: Use kebab-case for commands (deploy-app, not deployApp)
3. **Clear help text**: Every command and flag needs description
4. **Exit codes**: Use standard codes (0=success, 1=error, 2=usage error)
### Flag Patterns
1. **Environment variables**: Always provide env var fallback for important flags
2. **Sensible defaults**: Required flags should be rare
3. **Short and long forms**: -v/--verbose, -c/--config
4. **Validation**: Validate flags in Before hook, not in action
### Command Organization
1. **Categories**: Group related commands (>5 commands = use categories)
2. **Aliases**: Provide shortcuts for common commands
3. **Subcommands**: Use for hierarchical operations (db migrate up/down)
4. **Help text**: Keep concise, provide examples
### Performance
1. **Fast compilation**: urfave/cli compiles faster than Cobra
2. **Small binaries**: Minimal dependencies = smaller output
3. **Startup time**: Use Before hooks for expensive initialization
4. **Lazy loading**: Don't initialize resources unless command needs them
## Common Patterns
### Configuration File Loading
```go
app.Before = func(c *cli.Context) error {
configPath := c.String("config")
if configPath != "" {
return loadConfig(configPath)
}
return nil
}
```
### Environment Variable Fallbacks
```go
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Usage: "API token",
EnvVars: []string{"API_TOKEN"},
}
```
### Required Flags
```go
&cli.StringFlag{
Name: "host",
Required: true,
Usage: "Database host",
}
```
### Global State Management
```go
type AppContext struct {
Config *Config
DB *sql.DB
}
app.Before = func(c *cli.Context) error {
ctx := &AppContext{
Config: loadConfig(),
}
c.App.Metadata["ctx"] = ctx
return nil
}
```
## Validation
Run `scripts/validate-cli.sh` to check:
- All commands have descriptions
- All flags have usage text
- Before/After hooks are properly defined
- Help text is clear and concise
- No unused imports
- Proper error handling
## Migration Guides
### From Cobra to urfave/cli
See `examples/cobra-migration/` for:
- Command mapping (cobra.Command → cli.Command)
- Flag conversion (cobra flags → cli flags)
- Hook equivalents (PreRun → Before)
- Context differences
### From Click (Python) to urfave/cli
See `examples/click-migration/` for:
- Decorator to struct conversion
- Option to flag mapping
- Context passing patterns
## References
- [urfave/cli v2 Documentation](https://cli.urfave.org/v2/)
- [Docker CLI Source](https://github.com/docker/cli) - Real-world example
- [Go CLI Best Practices](https://github.com/cli-dev/guide)

View File

@@ -0,0 +1,212 @@
# CLI Patterns Examples Index
Comprehensive examples demonstrating urfave/cli patterns in production-ready applications.
## Example Applications
### 1. Database CLI Tool (`db-cli/`)
**Purpose**: Complete database management CLI with categories, hooks, and connection handling.
**Features**:
- Command categories (Schema, Data, Admin)
- Before hook for connection validation
- After hook for cleanup
- Required and optional flags
- Environment variable fallbacks
**Commands**:
- `migrate` - Run migrations with direction and steps
- `rollback` - Rollback last migration
- `seed` - Seed database with test data
- `backup` - Create database backup
- `restore` - Restore from backup
- `status` - Check database status
- `vacuum` - Optimize database
**Key Patterns**:
```go
// Connection validation in Before hook
Before: func(c *cli.Context) error {
conn := c.String("connection")
// Validate connection
return nil
}
// Cleanup in After hook
After: func(c *cli.Context) error {
// Close connections
return nil
}
```
---
### 2. Deployment CLI Tool (`deploy-cli/`)
**Purpose**: Deployment automation with context management and environment validation.
**Features**:
- Context management with shared state
- Environment validation
- Confirmation prompts for destructive actions
- AWS region configuration
- Build, deploy, and monitor workflows
**Commands**:
- `build` - Build application with tags
- `test` - Run test suite
- `deploy` - Deploy to environment (with confirmation)
- `rollback` - Rollback to previous version
- `logs` - View deployment logs
- `status` - Check deployment status
**Key Patterns**:
```go
// Shared context across commands
type DeployContext struct {
Environment string
AWSRegion string
Verbose bool
}
// Store context in Before hook
ctx := &DeployContext{...}
c.App.Metadata["ctx"] = ctx
// Retrieve in command
ctx := c.App.Metadata["ctx"].(*DeployContext)
```
---
### 3. API Client CLI Tool (`api-cli/`)
**Purpose**: REST API client with HTTP client sharing and authentication.
**Features**:
- HTTP client sharing via context
- Authentication in Before hook
- Multiple HTTP methods (GET, POST, PUT, DELETE)
- Request timeout configuration
- Token masking for security
**Commands**:
- `get` - GET request with headers
- `post` - POST request with data
- `put` - PUT request with data
- `delete` - DELETE request
- `auth-test` - Test authentication
**Key Patterns**:
```go
// HTTP client in context
type APIContext struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// Initialize in Before hook
client := &http.Client{Timeout: timeout}
ctx := &APIContext{
HTTPClient: client,
...
}
// Use in commands
ctx := c.App.Metadata["ctx"].(*APIContext)
resp, err := ctx.HTTPClient.Get(url)
```
---
## Pattern Summary
### Context Management
All three examples demonstrate different context patterns:
- **db-cli**: Connection validation and cleanup
- **deploy-cli**: Shared deployment configuration
- **api-cli**: HTTP client and authentication sharing
### Before/After Hooks
- **Before**: Setup, validation, authentication, connection establishment
- **After**: Cleanup, resource release, connection closing
### Command Categories
Organized command groups for better UX:
- **db-cli**: Schema, Data, Admin
- **deploy-cli**: Build, Deploy, Monitor
- **api-cli**: No categories (simple HTTP verbs)
### Flag Patterns
- Required flags: `--connection`, `--env`, `--token`
- Environment variables: All support env var fallbacks
- Aliases: Short forms (-v, -e, -t)
- Multiple values: StringSlice for headers
- Custom types: Duration for timeouts
### Error Handling
All examples demonstrate:
- Validation in Before hooks
- Proper error returns
- User-friendly error messages
- Exit code handling
## Running the Examples
### Database CLI
```bash
export DATABASE_URL="postgres://user:pass@localhost/mydb"
cd examples/db-cli
go build -o dbctl .
./dbctl migrate
./dbctl backup --output backup.sql
```
### Deployment CLI
```bash
export DEPLOY_ENV=staging
export AWS_REGION=us-east-1
cd examples/deploy-cli
go build -o deploy .
./deploy build --tag v1.0.0
./deploy deploy
```
### API Client CLI
```bash
export API_URL=https://api.example.com
export API_TOKEN=your_token_here
cd examples/api-cli
go build -o api .
./api get /users
./api post /users '{"name":"John"}'
```
## Learning Path
**Beginner**:
1. Start with `db-cli` - demonstrates basic categories and hooks
2. Study Before/After hook patterns
3. Learn flag types and validation
**Intermediate**:
4. Study `deploy-cli` - context management and shared state
5. Learn environment validation
6. Understand confirmation prompts
**Advanced**:
7. Study `api-cli` - HTTP client sharing and authentication
8. Learn complex context patterns
9. Understand resource lifecycle management
## Cross-Language Comparison
Each example can be implemented in other languages:
- **TypeScript**: Use commander.js (see templates/)
- **Python**: Use click or typer (see templates/)
- **Ruby**: Use thor
- **Rust**: Use clap
The patterns translate directly across languages with similar CLI frameworks.

View File

@@ -0,0 +1,69 @@
# API Client CLI Tool Example
Complete REST API client CLI demonstrating:
- HTTP client sharing via context
- Authentication in Before hook
- Multiple HTTP methods (GET, POST, PUT, DELETE)
- Headers and request configuration
- Arguments handling
## Usage
```bash
# Set environment variables
export API_URL=https://api.example.com
export API_TOKEN=your_token_here
# GET request
api get /users
api get /users/123
# POST request
api post /users '{"name": "John", "email": "john@example.com"}'
api post /posts '{"title": "Hello", "body": "World"}' --content-type application/json
# PUT request
api put /users/123 '{"name": "Jane"}'
# DELETE request
api delete /users/123
# Test authentication
api auth-test
# Custom timeout
api --timeout 60s get /slow-endpoint
# Additional headers
api get /users -H "Accept:application/json" -H "X-Custom:value"
```
## Features Demonstrated
1. **Context Management**: Shared HTTPClient and auth across requests
2. **Before Hook**: Authenticates and sets up HTTP client
3. **Arguments**: Commands accept endpoint and data as arguments
4. **Required Flags**: --url and --token are required
5. **Environment Variables**: API_URL, API_TOKEN, API_TIMEOUT fallbacks
6. **Duration Flags**: --timeout uses time.Duration type
7. **Multiple Values**: --header can be specified multiple times
8. **Helper Functions**: maskToken() for secure token display
## HTTP Client Pattern
```go
type APIContext struct {
BaseURL string
Token string
HTTPClient *http.Client
}
// Initialize in Before hook
client := &http.Client{Timeout: timeout}
ctx := &APIContext{...}
c.App.Metadata["ctx"] = ctx
// Use in commands
ctx := c.App.Metadata["ctx"].(*APIContext)
resp, err := ctx.HTTPClient.Get(url)
```

View File

@@ -0,0 +1,205 @@
package main
import (
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/urfave/cli/v2"
)
type APIContext struct {
BaseURL string
Token string
HTTPClient *http.Client
}
func main() {
app := &cli.App{
Name: "api",
Usage: "REST API client CLI",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "url",
Usage: "API base URL",
EnvVars: []string{"API_URL"},
Required: true,
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Usage: "Authentication token",
EnvVars: []string{"API_TOKEN"},
Required: true,
},
&cli.DurationFlag{
Name: "timeout",
Usage: "Request timeout",
Value: 30 * time.Second,
EnvVars: []string{"API_TIMEOUT"},
},
},
Before: func(c *cli.Context) error {
baseURL := c.String("url")
token := c.String("token")
timeout := c.Duration("timeout")
fmt.Println("🔐 Authenticating with API...")
// Create HTTP client
client := &http.Client{
Timeout: timeout,
}
// Store context
ctx := &APIContext{
BaseURL: baseURL,
Token: token,
HTTPClient: client,
}
c.App.Metadata["ctx"] = ctx
fmt.Println("✅ Authentication successful")
return nil
},
Commands: []*cli.Command{
{
Name: "get",
Usage: "GET request",
ArgsUsage: "<endpoint>",
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "header",
Aliases: []string{"H"},
Usage: "Additional headers (key:value)",
},
},
Action: func(c *cli.Context) error {
ctx := c.App.Metadata["ctx"].(*APIContext)
if c.NArg() < 1 {
return fmt.Errorf("endpoint required")
}
endpoint := c.Args().Get(0)
url := fmt.Sprintf("%s%s", ctx.BaseURL, endpoint)
fmt.Printf("GET %s\n", url)
fmt.Printf("Authorization: Bearer %s\n", maskToken(ctx.Token))
// In real app: make HTTP request
fmt.Println("Response: 200 OK")
return nil
},
},
{
Name: "post",
Usage: "POST request",
ArgsUsage: "<endpoint> <data>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "content-type",
Aliases: []string{"ct"},
Usage: "Content-Type header",
Value: "application/json",
},
},
Action: func(c *cli.Context) error {
ctx := c.App.Metadata["ctx"].(*APIContext)
if c.NArg() < 2 {
return fmt.Errorf("usage: post <endpoint> <data>")
}
endpoint := c.Args().Get(0)
data := c.Args().Get(1)
url := fmt.Sprintf("%s%s", ctx.BaseURL, endpoint)
contentType := c.String("content-type")
fmt.Printf("POST %s\n", url)
fmt.Printf("Content-Type: %s\n", contentType)
fmt.Printf("Data: %s\n", data)
// In real app: make HTTP POST request
return nil
},
},
{
Name: "put",
Usage: "PUT request",
ArgsUsage: "<endpoint> <data>",
Action: func(c *cli.Context) error {
ctx := c.App.Metadata["ctx"].(*APIContext)
if c.NArg() < 2 {
return fmt.Errorf("usage: put <endpoint> <data>")
}
endpoint := c.Args().Get(0)
data := c.Args().Get(1)
url := fmt.Sprintf("%s%s", ctx.BaseURL, endpoint)
fmt.Printf("PUT %s\n", url)
fmt.Printf("Data: %s\n", data)
return nil
},
},
{
Name: "delete",
Usage: "DELETE request",
ArgsUsage: "<endpoint>",
Action: func(c *cli.Context) error {
ctx := c.App.Metadata["ctx"].(*APIContext)
if c.NArg() < 1 {
return fmt.Errorf("endpoint required")
}
endpoint := c.Args().Get(0)
url := fmt.Sprintf("%s%s", ctx.BaseURL, endpoint)
fmt.Printf("DELETE %s\n", url)
return nil
},
},
{
Name: "auth-test",
Usage: "Test authentication",
Action: func(c *cli.Context) error {
ctx := c.App.Metadata["ctx"].(*APIContext)
fmt.Println("Testing authentication...")
fmt.Printf("API URL: %s\n", ctx.BaseURL)
fmt.Printf("Token: %s\n", maskToken(ctx.Token))
fmt.Println("Status: Authenticated ✅")
return nil
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
func maskToken(token string) string {
if len(token) < 8 {
return "****"
}
return token[:4] + "****" + token[len(token)-4:]
}

View File

@@ -0,0 +1,46 @@
# Database CLI Tool Example
Complete database management CLI demonstrating:
- Command categories (Schema, Data, Admin)
- Before hook for connection validation
- After hook for cleanup
- Required and optional flags
- Environment variable fallbacks
## Usage
```bash
# Set connection string
export DATABASE_URL="postgres://user:pass@localhost/mydb"
# Run migrations
dbctl migrate
dbctl migrate --direction down --steps 2
# Rollback
dbctl rollback
# Seed database
dbctl seed --file seeds/test-data.sql
# Backup and restore
dbctl backup --output backups/db-$(date +%Y%m%d).sql
dbctl restore --input backups/db-20240101.sql
# Admin tasks
dbctl status
dbctl vacuum
# Verbose output
dbctl -v migrate
```
## Features Demonstrated
1. **Command Categories**: Schema, Data, Admin
2. **Global Flags**: --connection, --verbose
3. **Before Hook**: Validates connection before any command
4. **After Hook**: Closes connections after command completes
5. **Required Flags**: backup/restore require file paths
6. **Environment Variables**: DATABASE_URL fallback
7. **Flag Aliases**: -v for --verbose, -d for --direction

View File

@@ -0,0 +1,183 @@
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "dbctl",
Usage: "Database management CLI tool",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "connection",
Aliases: []string{"conn"},
Usage: "Database connection string",
EnvVars: []string{"DATABASE_URL"},
Required: true,
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Enable verbose output",
},
},
Before: func(c *cli.Context) error {
conn := c.String("connection")
verbose := c.Bool("verbose")
if verbose {
fmt.Println("🔗 Validating database connection...")
}
// Validate connection string
if conn == "" {
return fmt.Errorf("database connection string required")
}
if verbose {
fmt.Println("✅ Connection string validated")
}
return nil
},
After: func(c *cli.Context) error {
if c.Bool("verbose") {
fmt.Println("🔚 Closing database connections...")
}
return nil
},
Commands: []*cli.Command{
// Schema category
{
Name: "migrate",
Category: "Schema",
Usage: "Run database migrations",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "direction",
Aliases: []string{"d"},
Usage: "Migration direction (up/down)",
Value: "up",
},
&cli.IntFlag{
Name: "steps",
Usage: "Number of steps to migrate",
Value: 0,
},
},
Action: func(c *cli.Context) error {
direction := c.String("direction")
steps := c.Int("steps")
fmt.Printf("Running migrations %s", direction)
if steps > 0 {
fmt.Printf(" (%d steps)", steps)
}
fmt.Println()
return nil
},
},
{
Name: "rollback",
Category: "Schema",
Usage: "Rollback last migration",
Action: func(c *cli.Context) error {
fmt.Println("Rolling back last migration...")
return nil
},
},
// Data category
{
Name: "seed",
Category: "Data",
Usage: "Seed database with test data",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "file",
Aliases: []string{"f"},
Usage: "Seed file path",
Value: "seeds/default.sql",
},
},
Action: func(c *cli.Context) error {
file := c.String("file")
fmt.Printf("Seeding database from: %s\n", file)
return nil
},
},
{
Name: "backup",
Category: "Data",
Usage: "Backup database",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "Backup output path",
Required: true,
},
},
Action: func(c *cli.Context) error {
output := c.String("output")
fmt.Printf("Backing up database to: %s\n", output)
return nil
},
},
{
Name: "restore",
Category: "Data",
Usage: "Restore database from backup",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "input",
Aliases: []string{"i"},
Usage: "Backup file path",
Required: true,
},
},
Action: func(c *cli.Context) error {
input := c.String("input")
fmt.Printf("Restoring database from: %s\n", input)
return nil
},
},
// Admin category
{
Name: "status",
Category: "Admin",
Usage: "Check database status",
Action: func(c *cli.Context) error {
fmt.Println("Database Status:")
fmt.Println(" Connection: Active")
fmt.Println(" Tables: 15")
fmt.Println(" Size: 245 MB")
return nil
},
},
{
Name: "vacuum",
Category: "Admin",
Usage: "Optimize database",
Action: func(c *cli.Context) error {
fmt.Println("Optimizing database...")
return nil
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,60 @@
# Deployment CLI Tool Example
Complete deployment automation CLI demonstrating:
- Context management with shared state
- Environment validation in Before hook
- Command categories (Build, Deploy, Monitor)
- Confirmation prompts for destructive actions
## Usage
```bash
# Set environment variables
export DEPLOY_ENV=staging
export AWS_REGION=us-west-2
# Build application
deploy --env staging build
deploy -e production build --tag v1.2.3
# Run tests
deploy --env staging test
# Deploy
deploy --env staging deploy
deploy -e production deploy --auto-approve
# Rollback
deploy --env production rollback
# Monitor
deploy --env production logs --follow
deploy -e staging status
```
## Features Demonstrated
1. **Context Management**: Shared DeployContext across commands
2. **Environment Validation**: Before hook validates target environment
3. **Required Flags**: --env is required for all operations
4. **Confirmation Prompts**: Deploy asks for confirmation (unless --auto-approve)
5. **Command Categories**: Build, Deploy, Monitor
6. **Environment Variables**: DEPLOY_ENV, AWS_REGION fallbacks
7. **Shared State**: Context passed to all commands via metadata
## Context Pattern
```go
type DeployContext struct {
Environment string
AWSRegion string
Verbose bool
}
// Store in Before hook
ctx := &DeployContext{...}
c.App.Metadata["ctx"] = ctx
// Retrieve in command
ctx := c.App.Metadata["ctx"].(*DeployContext)
```

View File

@@ -0,0 +1,192 @@
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
type DeployContext struct {
Environment string
AWSRegion string
Verbose bool
}
func main() {
app := &cli.App{
Name: "deploy",
Usage: "Deployment automation CLI",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "env",
Aliases: []string{"e"},
Usage: "Target environment",
EnvVars: []string{"DEPLOY_ENV"},
Required: true,
},
&cli.StringFlag{
Name: "region",
Usage: "AWS region",
EnvVars: []string{"AWS_REGION"},
Value: "us-east-1",
},
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Enable verbose output",
},
},
Before: func(c *cli.Context) error {
env := c.String("env")
region := c.String("region")
verbose := c.Bool("verbose")
if verbose {
fmt.Println("🔧 Setting up deployment context...")
}
// Validate environment
validEnvs := []string{"dev", "staging", "production"}
valid := false
for _, e := range validEnvs {
if env == e {
valid = true
break
}
}
if !valid {
return fmt.Errorf("invalid environment: %s (must be dev, staging, or production)", env)
}
// Store context
ctx := &DeployContext{
Environment: env,
AWSRegion: region,
Verbose: verbose,
}
c.App.Metadata["ctx"] = ctx
if verbose {
fmt.Printf("Environment: %s\n", env)
fmt.Printf("Region: %s\n", region)
}
return nil
},
Commands: []*cli.Command{
// Build category
{
Name: "build",
Category: "Build",
Usage: "Build application",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "tag",
Usage: "Docker image tag",
Value: "latest",
},
},
Action: func(c *cli.Context) error {
ctx := c.App.Metadata["ctx"].(*DeployContext)
tag := c.String("tag")
fmt.Printf("Building for environment: %s\n", ctx.Environment)
fmt.Printf("Image tag: %s\n", tag)
return nil
},
},
{
Name: "test",
Category: "Build",
Usage: "Run tests",
Action: func(c *cli.Context) error {
fmt.Println("Running test suite...")
return nil
},
},
// Deploy category
{
Name: "deploy",
Category: "Deploy",
Usage: "Deploy application",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "auto-approve",
Usage: "Skip confirmation prompt",
},
},
Action: func(c *cli.Context) error {
ctx := c.App.Metadata["ctx"].(*DeployContext)
autoApprove := c.Bool("auto-approve")
fmt.Printf("Deploying to %s in %s...\n", ctx.Environment, ctx.AWSRegion)
if !autoApprove {
fmt.Print("Continue? (y/n): ")
// In real app: read user input
fmt.Println("y")
}
fmt.Println("Deployment started...")
return nil
},
},
{
Name: "rollback",
Category: "Deploy",
Usage: "Rollback to previous version",
Action: func(c *cli.Context) error {
ctx := c.App.Metadata["ctx"].(*DeployContext)
fmt.Printf("Rolling back %s deployment...\n", ctx.Environment)
return nil
},
},
// Monitor category
{
Name: "logs",
Category: "Monitor",
Usage: "View deployment logs",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "follow",
Aliases: []string{"f"},
Usage: "Follow log output",
},
},
Action: func(c *cli.Context) error {
follow := c.Bool("follow")
fmt.Println("Fetching logs...")
if follow {
fmt.Println("Following logs (Ctrl+C to stop)...")
}
return nil
},
},
{
Name: "status",
Category: "Monitor",
Usage: "Check deployment status",
Action: func(c *cli.Context) error {
ctx := c.App.Metadata["ctx"].(*DeployContext)
fmt.Printf("Deployment Status (%s):\n", ctx.Environment)
fmt.Println(" Status: Running")
fmt.Println(" Instances: 3/3")
fmt.Println(" Health: Healthy")
return nil
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,52 @@
#!/bin/bash
# Add a new command to existing CLI
set -euo pipefail
if [ $# -lt 2 ]; then
echo "Usage: $0 <app-name> <command-name> [category]"
echo "Example: $0 myapp backup Deploy"
exit 1
fi
APP_NAME="$1"
COMMAND_NAME="$2"
CATEGORY="${3:-General}"
if [ ! -d "$APP_NAME" ]; then
echo "Error: Directory $APP_NAME not found"
exit 1
fi
cd "$APP_NAME"
# Create command implementation
FUNC_NAME="${COMMAND_NAME}Command"
cat >> commands.go <<EOF
func ${FUNC_NAME}(c *cli.Context) error {
fmt.Println("Executing ${COMMAND_NAME} command...")
// TODO: Implement ${COMMAND_NAME} logic
return nil
}
EOF
# Generate command definition
cat > /tmp/new_command.txt <<EOF
{
Name: "${COMMAND_NAME}",
Category: "${CATEGORY}",
Usage: "TODO: Add usage description",
Action: ${FUNC_NAME},
},
EOF
echo "✅ Command stub created!"
echo ""
echo "Next steps:"
echo "1. Add the following to your Commands slice in main.go:"
cat /tmp/new_command.txt
echo ""
echo "2. Implement the logic in commands.go:${FUNC_NAME}"
echo "3. Add flags if needed"

View File

@@ -0,0 +1,109 @@
#!/bin/bash
# Generate basic CLI structure with urfave/cli
set -euo pipefail
APP_NAME="${1:-myapp}"
echo "Generating basic CLI: $APP_NAME"
# Create project structure
mkdir -p "$APP_NAME"
cd "$APP_NAME"
# Initialize Go module
go mod init "$APP_NAME" 2>/dev/null || true
# Install urfave/cli
echo "Installing urfave/cli v2..."
go get github.com/urfave/cli/v2@latest
# Create main.go
cat > main.go <<'EOF'
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "APP_NAME_PLACEHOLDER",
Usage: "A simple CLI tool",
Version: "0.1.0",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Enable verbose output",
EnvVars: []string{"VERBOSE"},
},
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Usage: "Path to config file",
EnvVars: []string{"CONFIG_PATH"},
},
},
Action: func(c *cli.Context) error {
verbose := c.Bool("verbose")
config := c.String("config")
if verbose {
fmt.Println("Verbose mode enabled")
}
if config != "" {
fmt.Printf("Using config: %s\n", config)
}
fmt.Println("Hello from APP_NAME_PLACEHOLDER!")
return nil
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
EOF
# Replace placeholder
sed -i "s/APP_NAME_PLACEHOLDER/$APP_NAME/g" main.go
# Create README
cat > README.md <<EOF
# $APP_NAME
A CLI tool built with urfave/cli.
## Installation
\`\`\`bash
go install
\`\`\`
## Usage
\`\`\`bash
$APP_NAME --help
$APP_NAME --verbose
$APP_NAME --config config.yaml
\`\`\`
## Environment Variables
- \`VERBOSE\`: Enable verbose output
- \`CONFIG_PATH\`: Path to config file
EOF
# Build
echo "Building..."
go build -o "$APP_NAME" .
echo "✅ Basic CLI generated successfully!"
echo "Run: ./$APP_NAME --help"

View File

@@ -0,0 +1,313 @@
#!/bin/bash
# Generate complete CLI with all patterns
set -euo pipefail
APP_NAME="${1:-myapp}"
echo "Generating full-featured CLI: $APP_NAME"
# Create project structure
mkdir -p "$APP_NAME"
cd "$APP_NAME"
# Initialize Go module
go mod init "$APP_NAME" 2>/dev/null || true
# Install dependencies
echo "Installing dependencies..."
go get github.com/urfave/cli/v2@latest
# Create main.go with all patterns
cat > main.go <<'EOF'
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
// AppContext holds shared state
type AppContext struct {
Verbose bool
Config string
}
func main() {
app := &cli.App{
Name: "APP_NAME_PLACEHOLDER",
Usage: "A full-featured CLI tool with all patterns",
Version: "0.1.0",
// Global flags available to all commands
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Enable verbose output",
EnvVars: []string{"VERBOSE"},
},
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Usage: "Path to config file",
EnvVars: []string{"CONFIG_PATH"},
Value: "config.yaml",
},
},
// Before hook - runs before any command
Before: func(c *cli.Context) error {
verbose := c.Bool("verbose")
config := c.String("config")
if verbose {
fmt.Println("🚀 Initializing application...")
}
// Store context for use in commands
ctx := &AppContext{
Verbose: verbose,
Config: config,
}
c.App.Metadata["ctx"] = ctx
return nil
},
// After hook - runs after any command
After: func(c *cli.Context) error {
if ctx, ok := c.App.Metadata["ctx"].(*AppContext); ok {
if ctx.Verbose {
fmt.Println("✅ Application finished successfully")
}
}
return nil
},
// Commands organized by category
Commands: []*cli.Command{
{
Name: "build",
Category: "Build",
Usage: "Build the project",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "output",
Aliases: []string{"o"},
Usage: "Output file path",
Value: "dist/app",
},
&cli.BoolFlag{
Name: "optimize",
Usage: "Enable optimizations",
Value: true,
},
},
Before: func(c *cli.Context) error {
fmt.Println("Preparing build...")
return nil
},
Action: func(c *cli.Context) error {
output := c.String("output")
optimize := c.Bool("optimize")
fmt.Printf("Building to: %s\n", output)
if optimize {
fmt.Println("Optimizations: enabled")
}
return nil
},
After: func(c *cli.Context) error {
fmt.Println("Build complete!")
return nil
},
},
{
Name: "test",
Category: "Build",
Usage: "Run tests",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "coverage",
Aliases: []string{"cov"},
Usage: "Generate coverage report",
},
},
Action: func(c *cli.Context) error {
coverage := c.Bool("coverage")
fmt.Println("Running tests...")
if coverage {
fmt.Println("Generating coverage report...")
}
return nil
},
},
{
Name: "deploy",
Category: "Deploy",
Usage: "Deploy the application",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "env",
Aliases: []string{"e"},
Usage: "Target environment",
Required: true,
Value: "staging",
},
},
Action: func(c *cli.Context) error {
env := c.String("env")
fmt.Printf("Deploying to %s...\n", env)
return nil
},
},
{
Name: "rollback",
Category: "Deploy",
Usage: "Rollback deployment",
Action: func(c *cli.Context) error {
fmt.Println("Rolling back deployment...")
return nil
},
},
{
Name: "logs",
Category: "Monitor",
Usage: "View application logs",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "tail",
Aliases: []string{"n"},
Usage: "Number of lines to show",
Value: 100,
},
&cli.BoolFlag{
Name: "follow",
Aliases: []string{"f"},
Usage: "Follow log output",
},
},
Action: func(c *cli.Context) error {
tail := c.Int("tail")
follow := c.Bool("follow")
fmt.Printf("Showing last %d lines...\n", tail)
if follow {
fmt.Println("Following logs...")
}
return nil
},
},
{
Name: "status",
Category: "Monitor",
Usage: "Check application status",
Action: func(c *cli.Context) error {
fmt.Println("Application status: healthy")
return nil
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
EOF
# Replace placeholder
sed -i "s/APP_NAME_PLACEHOLDER/$APP_NAME/g" main.go
# Create comprehensive README
cat > README.md <<EOF
# $APP_NAME
A full-featured CLI tool demonstrating all urfave/cli patterns.
## Features
- ✅ Global flags with environment variable fallbacks
- ✅ Command categories for organization
- ✅ Before/After hooks for lifecycle management
- ✅ Context management for shared state
- ✅ Comprehensive flag types
- ✅ Subcommands and aliases
- ✅ Help text and documentation
## Installation
\`\`\`bash
go install
\`\`\`
## Usage
### Build Commands
\`\`\`bash
$APP_NAME build
$APP_NAME build --output dist/myapp --optimize
$APP_NAME test --coverage
\`\`\`
### Deploy Commands
\`\`\`bash
$APP_NAME deploy --env staging
$APP_NAME deploy -e production
$APP_NAME rollback
\`\`\`
### Monitor Commands
\`\`\`bash
$APP_NAME logs
$APP_NAME logs --tail 50 --follow
$APP_NAME status
\`\`\`
### Global Flags
\`\`\`bash
$APP_NAME --verbose build
$APP_NAME --config custom.yaml deploy --env prod
\`\`\`
## Environment Variables
- \`VERBOSE\`: Enable verbose output
- \`CONFIG_PATH\`: Path to config file
## Examples
\`\`\`bash
# Build with optimizations
$APP_NAME -v build -o dist/app --optimize
# Deploy to production
$APP_NAME --config prod.yaml deploy -e production
# Follow logs
$APP_NAME logs -f -n 200
\`\`\`
EOF
# Build
echo "Building..."
go build -o "$APP_NAME" .
echo "✅ Full-featured CLI generated successfully!"
echo ""
echo "Try these commands:"
echo " ./$APP_NAME --help"
echo " ./$APP_NAME build --help"
echo " ./$APP_NAME -v build"

View File

@@ -0,0 +1,174 @@
#!/bin/bash
# Generate CLI with subcommands structure
set -euo pipefail
APP_NAME="${1:-myapp}"
echo "Generating CLI with subcommands: $APP_NAME"
# Create project structure
mkdir -p "$APP_NAME/commands"
cd "$APP_NAME"
# Initialize Go module
go mod init "$APP_NAME" 2>/dev/null || true
# Install urfave/cli
echo "Installing urfave/cli v2..."
go get github.com/urfave/cli/v2@latest
# Create main.go
cat > main.go <<'EOF'
package main
import (
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "APP_NAME_PLACEHOLDER",
Usage: "A multi-command CLI tool",
Version: "0.1.0",
Commands: []*cli.Command{
{
Name: "start",
Aliases: []string{"s"},
Usage: "Start the service",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "port",
Aliases: []string{"p"},
Value: 8080,
Usage: "Port to listen on",
},
},
Action: func(c *cli.Context) error {
return startCommand(c)
},
},
{
Name: "stop",
Usage: "Stop the service",
Action: stopCommand,
},
{
Name: "status",
Usage: "Check service status",
Action: statusCommand,
},
{
Name: "config",
Usage: "Configuration management",
Subcommands: []*cli.Command{
{
Name: "show",
Usage: "Show current configuration",
Action: configShowCommand,
},
{
Name: "set",
Usage: "Set configuration value",
Action: configSetCommand,
},
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
EOF
# Create commands.go
cat > commands.go <<'EOF'
package main
import (
"fmt"
"github.com/urfave/cli/v2"
)
func startCommand(c *cli.Context) error {
port := c.Int("port")
fmt.Printf("Starting service on port %d...\n", port)
return nil
}
func stopCommand(c *cli.Context) error {
fmt.Println("Stopping service...")
return nil
}
func statusCommand(c *cli.Context) error {
fmt.Println("Service status: running")
return nil
}
func configShowCommand(c *cli.Context) error {
fmt.Println("Current configuration:")
fmt.Println(" port: 8080")
fmt.Println(" host: localhost")
return nil
}
func configSetCommand(c *cli.Context) error {
key := c.Args().Get(0)
value := c.Args().Get(1)
if key == "" || value == "" {
return fmt.Errorf("usage: config set <key> <value>")
}
fmt.Printf("Setting %s = %s\n", key, value)
return nil
}
EOF
# Replace placeholder
sed -i "s/APP_NAME_PLACEHOLDER/$APP_NAME/g" main.go
# Create README
cat > README.md <<EOF
# $APP_NAME
A CLI tool with subcommands built with urfave/cli.
## Installation
\`\`\`bash
go install
\`\`\`
## Usage
\`\`\`bash
# Start service
$APP_NAME start --port 8080
$APP_NAME s -p 3000
# Stop service
$APP_NAME stop
# Check status
$APP_NAME status
# Configuration
$APP_NAME config show
$APP_NAME config set host 0.0.0.0
\`\`\`
EOF
# Build
echo "Building..."
go build -o "$APP_NAME" .
echo "✅ CLI with subcommands generated successfully!"
echo "Run: ./$APP_NAME --help"

View File

@@ -0,0 +1,103 @@
#!/bin/bash
# Validate CLI structure and best practices
set -euo pipefail
PROJECT_PATH="${1:-.}"
echo "🔍 Validating CLI project: $PROJECT_PATH"
cd "$PROJECT_PATH"
ERRORS=0
# Check if main.go exists
if [ ! -f "main.go" ]; then
echo "❌ main.go not found"
ERRORS=$((ERRORS + 1))
else
echo "✅ main.go exists"
fi
# Check if go.mod exists
if [ ! -f "go.mod" ]; then
echo "❌ go.mod not found (run 'go mod init')"
ERRORS=$((ERRORS + 1))
else
echo "✅ go.mod exists"
fi
# Check for urfave/cli dependency
if grep -q "github.com/urfave/cli/v2" go.mod 2>/dev/null; then
echo "✅ urfave/cli dependency found"
else
echo "⚠️ urfave/cli dependency not found"
fi
# Check for App definition
if grep -q "cli.App" main.go 2>/dev/null; then
echo "✅ cli.App definition found"
else
echo "❌ cli.App definition not found"
ERRORS=$((ERRORS + 1))
fi
# Check for Usage field
if grep -q "Usage:" main.go 2>/dev/null; then
echo "✅ Usage field defined"
else
echo "⚠️ Usage field not found (recommended)"
fi
# Check for Version field
if grep -q "Version:" main.go 2>/dev/null; then
echo "✅ Version field defined"
else
echo "⚠️ Version field not found (recommended)"
fi
# Check if commands have descriptions
if grep -A 5 "Commands:" main.go 2>/dev/null | grep -q "Usage:"; then
echo "✅ Commands have usage descriptions"
else
echo "⚠️ Some commands might be missing usage descriptions"
fi
# Check for proper error handling
if grep -q "if err := app.Run" main.go 2>/dev/null; then
echo "✅ Proper error handling in main"
else
echo "❌ Missing error handling for app.Run"
ERRORS=$((ERRORS + 1))
fi
# Try to build
echo ""
echo "🔨 Attempting build..."
if go build -o /tmp/test_build . 2>&1; then
echo "✅ Build successful"
rm -f /tmp/test_build
else
echo "❌ Build failed"
ERRORS=$((ERRORS + 1))
fi
# Run go vet
echo ""
echo "🔍 Running go vet..."
if go vet ./... 2>&1; then
echo "✅ go vet passed"
else
echo "⚠️ go vet found issues"
fi
# Summary
echo ""
echo "================================"
if [ $ERRORS -eq 0 ]; then
echo "✅ Validation passed! No critical errors found."
exit 0
else
echo "❌ Validation failed with $ERRORS critical error(s)"
exit 1
fi

View File

@@ -0,0 +1,52 @@
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "myapp",
Usage: "A simple CLI application",
Version: "0.1.0",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Enable verbose output",
EnvVars: []string{"VERBOSE"},
},
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Usage: "Path to config file",
EnvVars: []string{"CONFIG_PATH"},
},
},
Action: func(c *cli.Context) error {
verbose := c.Bool("verbose")
config := c.String("config")
if verbose {
fmt.Println("Verbose mode enabled")
}
if config != "" {
fmt.Printf("Using config: %s\n", config)
}
// Your application logic here
fmt.Println("Hello, World!")
return nil
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,141 @@
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "myapp",
Usage: "CLI tool with categorized commands",
Commands: []*cli.Command{
// Database category
{
Name: "create-db",
Category: "Database",
Usage: "Create a new database",
Action: func(c *cli.Context) error {
fmt.Println("Creating database...")
return nil
},
},
{
Name: "migrate",
Category: "Database",
Usage: "Run database migrations",
Action: func(c *cli.Context) error {
fmt.Println("Running migrations...")
return nil
},
},
{
Name: "seed",
Category: "Database",
Usage: "Seed database with test data",
Action: func(c *cli.Context) error {
fmt.Println("Seeding database...")
return nil
},
},
// Deploy category
{
Name: "deploy",
Category: "Deploy",
Usage: "Deploy application",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "env",
Aliases: []string{"e"},
Usage: "Target environment",
Required: true,
},
},
Action: func(c *cli.Context) error {
env := c.String("env")
fmt.Printf("Deploying to %s...\n", env)
return nil
},
},
{
Name: "rollback",
Category: "Deploy",
Usage: "Rollback deployment",
Action: func(c *cli.Context) error {
fmt.Println("Rolling back...")
return nil
},
},
// Monitor category
{
Name: "logs",
Category: "Monitor",
Usage: "View application logs",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "follow",
Aliases: []string{"f"},
Usage: "Follow log output",
},
},
Action: func(c *cli.Context) error {
follow := c.Bool("follow")
fmt.Println("Fetching logs...")
if follow {
fmt.Println("Following logs (Ctrl+C to stop)...")
}
return nil
},
},
{
Name: "status",
Category: "Monitor",
Usage: "Check application status",
Action: func(c *cli.Context) error {
fmt.Println("Status: Running")
return nil
},
},
{
Name: "metrics",
Category: "Monitor",
Usage: "View application metrics",
Action: func(c *cli.Context) error {
fmt.Println("Fetching metrics...")
return nil
},
},
// Config category
{
Name: "show-config",
Category: "Config",
Usage: "Show current configuration",
Action: func(c *cli.Context) error {
fmt.Println("Current configuration:")
fmt.Println(" env: production")
fmt.Println(" port: 8080")
return nil
},
},
{
Name: "set-config",
Category: "Config",
Usage: "Set configuration value",
Action: func(c *cli.Context) error {
fmt.Println("Setting configuration...")
return nil
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
# Python equivalent using click (similar API to urfave/cli)
import click
@click.group()
@click.version_option('0.1.0')
@click.option('--verbose', '-v', is_flag=True, help='Enable verbose output')
@click.option('--config', '-c', envvar='CONFIG_PATH', help='Path to config file')
@click.pass_context
def cli(ctx, verbose, config):
"""A simple CLI application"""
ctx.ensure_object(dict)
ctx.obj['verbose'] = verbose
ctx.obj['config'] = config
if verbose:
click.echo('Verbose mode enabled')
if config:
click.echo(f'Using config: {config}')
@cli.command()
@click.option('--port', '-p', default=8080, help='Port to listen on')
@click.pass_context
def start(ctx, port):
"""Start the service"""
if ctx.obj['verbose']:
click.echo(f'Starting service on port {port}')
else:
click.echo(f'Starting on port {port}')
@cli.command()
@click.pass_context
def stop(ctx):
"""Stop the service"""
click.echo('Stopping service...')
@cli.command()
def status():
"""Check service status"""
click.echo('Service is running')
@cli.command()
@click.argument('key')
@click.argument('value')
def config(key, value):
"""Set configuration value"""
click.echo(f'Setting {key} = {value}')
if __name__ == '__main__':
cli(obj={})

View File

@@ -0,0 +1,51 @@
#!/usr/bin/env node
// TypeScript equivalent using commander.js (similar API to urfave/cli)
import { Command } from 'commander';
const program = new Command();
program
.name('myapp')
.description('A simple CLI application')
.version('0.1.0');
program
.option('-v, --verbose', 'Enable verbose output')
.option('-c, --config <path>', 'Path to config file', process.env.CONFIG_PATH)
.action((options) => {
if (options.verbose) {
console.log('Verbose mode enabled');
}
if (options.config) {
console.log(`Using config: ${options.config}`);
}
console.log('Hello, World!');
});
// Subcommands
program
.command('start')
.description('Start the service')
.option('-p, --port <number>', 'Port to listen on', '8080')
.action((options) => {
console.log(`Starting service on port ${options.port}`);
});
program
.command('stop')
.description('Stop the service')
.action(() => {
console.log('Stopping service...');
});
program
.command('status')
.description('Check service status')
.action(() => {
console.log('Service is running');
});
program.parse();

View File

@@ -0,0 +1,152 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
// AppContext holds shared state across commands
type AppContext struct {
Config *Config
DB *sql.DB
Verbose bool
}
// Config represents application configuration
type Config struct {
Host string
Port int
Database string
}
func main() {
app := &cli.App{
Name: "context-demo",
Usage: "Demonstration of context and state management",
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Enable verbose output",
},
&cli.StringFlag{
Name: "config",
Aliases: []string{"c"},
Usage: "Path to config file",
Value: "config.yaml",
},
},
// Initialize shared context
Before: func(c *cli.Context) error {
verbose := c.Bool("verbose")
configPath := c.String("config")
if verbose {
fmt.Printf("Loading config from: %s\n", configPath)
}
// Create application context
appCtx := &AppContext{
Config: &Config{
Host: "localhost",
Port: 5432,
Database: "mydb",
},
Verbose: verbose,
}
// Simulate database connection
// In real app: appCtx.DB, err = sql.Open("postgres", connStr)
if verbose {
fmt.Println("Connected to database")
}
// Store context in app metadata
c.App.Metadata["ctx"] = appCtx
return nil
},
// Cleanup shared resources
After: func(c *cli.Context) error {
if ctx, ok := c.App.Metadata["ctx"].(*AppContext); ok {
if ctx.DB != nil {
// ctx.DB.Close()
if ctx.Verbose {
fmt.Println("Database connection closed")
}
}
}
return nil
},
Commands: []*cli.Command{
{
Name: "query",
Usage: "Execute a database query",
Action: func(c *cli.Context) error {
// Retrieve context
ctx := c.App.Metadata["ctx"].(*AppContext)
if ctx.Verbose {
fmt.Printf("Connecting to %s:%d/%s\n",
ctx.Config.Host,
ctx.Config.Port,
ctx.Config.Database)
}
fmt.Println("Executing query...")
// Use ctx.DB for actual query
return nil
},
},
{
Name: "migrate",
Usage: "Run database migrations",
Action: func(c *cli.Context) error {
// Retrieve context
ctx := c.App.Metadata["ctx"].(*AppContext)
if ctx.Verbose {
fmt.Println("Running migrations with context...")
}
fmt.Printf("Migrating database: %s\n", ctx.Config.Database)
// Use ctx.DB for migrations
return nil
},
},
{
Name: "status",
Usage: "Check database status",
Action: func(c *cli.Context) error {
// Retrieve context
ctx := c.App.Metadata["ctx"].(*AppContext)
fmt.Printf("Database: %s\n", ctx.Config.Database)
fmt.Printf("Host: %s:%d\n", ctx.Config.Host, ctx.Config.Port)
fmt.Println("Status: Connected")
if ctx.Verbose {
fmt.Println("Verbose mode: enabled")
}
return nil
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,172 @@
package main
import (
"fmt"
"log"
"os"
"time"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "flags-demo",
Usage: "Demonstration of all flag types in urfave/cli",
Flags: []cli.Flag{
// String flag
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Value: "World",
Usage: "Name to greet",
EnvVars: []string{"GREETING_NAME"},
},
// Int flag
&cli.IntFlag{
Name: "count",
Aliases: []string{"c"},
Value: 1,
Usage: "Number of times to repeat",
EnvVars: []string{"REPEAT_COUNT"},
},
// Bool flag
&cli.BoolFlag{
Name: "verbose",
Aliases: []string{"v"},
Usage: "Enable verbose output",
EnvVars: []string{"VERBOSE"},
},
// Int64 flag
&cli.Int64Flag{
Name: "size",
Value: 1024,
Usage: "Size in bytes",
},
// Uint flag
&cli.UintFlag{
Name: "port",
Value: 8080,
Usage: "Port number",
},
// Float64 flag
&cli.Float64Flag{
Name: "timeout",
Value: 30.0,
Usage: "Timeout in seconds",
},
// Duration flag
&cli.DurationFlag{
Name: "wait",
Value: 10 * time.Second,
Usage: "Wait duration",
},
// StringSlice flag (multiple values)
&cli.StringSliceFlag{
Name: "tag",
Aliases: []string{"t"},
Usage: "Tags (can be specified multiple times)",
},
// IntSlice flag (multiple int values)
&cli.IntSliceFlag{
Name: "priority",
Usage: "Priority values",
},
// Required flag
&cli.StringFlag{
Name: "token",
Usage: "API token (required)",
Required: true,
EnvVars: []string{"API_TOKEN"},
},
// Flag with default from env
&cli.StringFlag{
Name: "env",
Aliases: []string{"e"},
Value: "development",
Usage: "Environment name",
EnvVars: []string{"ENV", "ENVIRONMENT"},
},
// Hidden flag (not shown in help)
&cli.StringFlag{
Name: "secret",
Usage: "Secret value",
Hidden: true,
},
},
Action: func(c *cli.Context) error {
// String flag
name := c.String("name")
fmt.Printf("Name: %s\n", name)
// Int flag
count := c.Int("count")
fmt.Printf("Count: %d\n", count)
// Bool flag
verbose := c.Bool("verbose")
if verbose {
fmt.Println("Verbose mode: enabled")
}
// Int64 flag
size := c.Int64("size")
fmt.Printf("Size: %d bytes\n", size)
// Uint flag
port := c.Uint("port")
fmt.Printf("Port: %d\n", port)
// Float64 flag
timeout := c.Float64("timeout")
fmt.Printf("Timeout: %.2f seconds\n", timeout)
// Duration flag
wait := c.Duration("wait")
fmt.Printf("Wait: %s\n", wait)
// StringSlice flag
tags := c.StringSlice("tag")
if len(tags) > 0 {
fmt.Printf("Tags: %v\n", tags)
}
// IntSlice flag
priorities := c.IntSlice("priority")
if len(priorities) > 0 {
fmt.Printf("Priorities: %v\n", priorities)
}
// Required flag
token := c.String("token")
fmt.Printf("Token: %s\n", token)
// Environment flag
env := c.String("env")
fmt.Printf("Environment: %s\n", env)
// Greeting logic
fmt.Println("\n---")
for i := 0; i < count; i++ {
fmt.Printf("Hello, %s!\n", name)
}
return nil
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,94 @@
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "hooks-demo",
Usage: "Demonstration of Before/After hooks",
// Global Before hook - runs before any command
Before: func(c *cli.Context) error {
fmt.Println("🚀 [GLOBAL BEFORE] Initializing application...")
fmt.Println(" - Loading configuration")
fmt.Println(" - Setting up connections")
return nil
},
// Global After hook - runs after any command
After: func(c *cli.Context) error {
fmt.Println("✅ [GLOBAL AFTER] Cleaning up...")
fmt.Println(" - Closing connections")
fmt.Println(" - Saving state")
return nil
},
Commands: []*cli.Command{
{
Name: "process",
Usage: "Process data with hooks",
// Command-specific Before hook
Before: func(c *cli.Context) error {
fmt.Println(" [COMMAND BEFORE] Preparing to process...")
fmt.Println(" - Validating input")
return nil
},
// Command action
Action: func(c *cli.Context) error {
fmt.Println(" [ACTION] Processing data...")
return nil
},
// Command-specific After hook
After: func(c *cli.Context) error {
fmt.Println(" [COMMAND AFTER] Processing complete!")
return nil
},
},
{
Name: "validate",
Usage: "Validate configuration",
Before: func(c *cli.Context) error {
fmt.Println(" [COMMAND BEFORE] Starting validation...")
return nil
},
Action: func(c *cli.Context) error {
fmt.Println(" [ACTION] Validating...")
return nil
},
After: func(c *cli.Context) error {
fmt.Println(" [COMMAND AFTER] Validation complete!")
return nil
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
// Example output when running "hooks-demo process":
// 🚀 [GLOBAL BEFORE] Initializing application...
// - Loading configuration
// - Setting up connections
// [COMMAND BEFORE] Preparing to process...
// - Validating input
// [ACTION] Processing data...
// [COMMAND AFTER] Processing complete!
// ✅ [GLOBAL AFTER] Cleaning up...
// - Closing connections
// - Saving state

View File

@@ -0,0 +1,116 @@
package main
import (
"fmt"
"log"
"os"
"github.com/urfave/cli/v2"
)
func main() {
app := &cli.App{
Name: "myapp",
Usage: "A CLI tool with subcommands",
Version: "0.1.0",
Commands: []*cli.Command{
{
Name: "start",
Aliases: []string{"s"},
Usage: "Start the service",
Flags: []cli.Flag{
&cli.IntFlag{
Name: "port",
Aliases: []string{"p"},
Value: 8080,
Usage: "Port to listen on",
EnvVars: []string{"PORT"},
},
&cli.StringFlag{
Name: "host",
Value: "localhost",
Usage: "Host to bind to",
EnvVars: []string{"HOST"},
},
},
Action: func(c *cli.Context) error {
port := c.Int("port")
host := c.String("host")
fmt.Printf("Starting service on %s:%d\n", host, port)
return nil
},
},
{
Name: "stop",
Usage: "Stop the service",
Action: func(c *cli.Context) error {
fmt.Println("Stopping service...")
return nil
},
},
{
Name: "restart",
Usage: "Restart the service",
Action: func(c *cli.Context) error {
fmt.Println("Restarting service...")
return nil
},
},
{
Name: "status",
Usage: "Check service status",
Action: func(c *cli.Context) error {
fmt.Println("Service is running")
return nil
},
},
{
Name: "config",
Usage: "Configuration management",
Subcommands: []*cli.Command{
{
Name: "show",
Usage: "Show current configuration",
Action: func(c *cli.Context) error {
fmt.Println("Current configuration:")
fmt.Println(" port: 8080")
fmt.Println(" host: localhost")
return nil
},
},
{
Name: "set",
Usage: "Set configuration value",
ArgsUsage: "<key> <value>",
Action: func(c *cli.Context) error {
if c.NArg() < 2 {
return fmt.Errorf("usage: config set <key> <value>")
}
key := c.Args().Get(0)
value := c.Args().Get(1)
fmt.Printf("Setting %s = %s\n", key, value)
return nil
},
},
{
Name: "get",
Usage: "Get configuration value",
ArgsUsage: "<key>",
Action: func(c *cli.Context) error {
if c.NArg() < 1 {
return fmt.Errorf("usage: config get <key>")
}
key := c.Args().Get(0)
fmt.Printf("%s = <value>\n", key)
return nil
},
},
},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
# Modern Python CLI using typer (FastAPI style)
import typer
from typing import Optional
from enum import Enum
app = typer.Typer()
class Environment(str, Enum):
development = "development"
staging = "staging"
production = "production"
@app.callback()
def main(
verbose: bool = typer.Option(False, "--verbose", "-v", help="Enable verbose output"),
config: Optional[str] = typer.Option(None, "--config", "-c", envvar="CONFIG_PATH", help="Path to config file")
):
"""
A simple CLI application built with Typer
"""
if verbose:
typer.echo("Verbose mode enabled")
if config:
typer.echo(f"Using config: {config}")
@app.command()
def start(
port: int = typer.Option(8080, "--port", "-p", help="Port to listen on"),
host: str = typer.Option("localhost", help="Host to bind to"),
):
"""Start the service"""
typer.echo(f"Starting service on {host}:{port}")
@app.command()
def stop():
"""Stop the service"""
typer.echo("Stopping service...")
@app.command()
def status():
"""Check service status"""
typer.echo("Service is running")
@app.command()
def deploy(
env: Environment = typer.Option(..., "--env", "-e", help="Target environment"),
force: bool = typer.Option(False, "--force", help="Force deployment")
):
"""Deploy to environment"""
typer.echo(f"Deploying to {env.value}...")
if force:
typer.echo("Force flag enabled")
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,155 @@
---
name: cli-testing-patterns
description: CLI testing strategies and patterns for Node.js (Jest) and Python (pytest, Click.testing.CliRunner). Use when writing tests for CLI tools, testing command execution, validating exit codes, testing output, implementing CLI test suites, or when user mentions CLI testing, Jest CLI tests, pytest CLI, Click.testing.CliRunner, command testing, or exit code validation.
allowed-tools: Read, Write, Bash
---
# CLI Testing Patterns
Comprehensive testing strategies for CLI applications using industry-standard testing frameworks. Covers command execution testing, exit code validation, output verification, interactive prompt testing, and integration testing patterns.
## Instructions
### When Testing Node.js CLI Tools
1. **Use Jest for testing CLI commands**
- Import `child_process.execSync` for command execution
- Create helper function to run CLI and capture output
- Test exit codes, stdout, stderr separately
- Handle both success and error cases
2. **Test Structure**
- Set up CLI path relative to test location
- Create `runCLI()` helper that returns `{stdout, stderr, code}`
- Use try-catch to handle non-zero exit codes
- Test common scenarios: version, help, unknown commands
3. **What to Test**
- Command execution with various argument combinations
- Exit code validation (0 for success, non-zero for errors)
- Output content (stdout) validation
- Error messages (stderr) validation
- Configuration file handling
- Interactive prompts (with mocked input)
### When Testing Python CLI Tools
1. **Use pytest with Click.testing.CliRunner**
- Import `CliRunner` from `click.testing`
- Create runner fixture for reusable test setup
- Invoke commands with `runner.invoke(cli, ['args'])`
- Check `result.exit_code` and `result.output`
2. **Test Structure**
- Create pytest fixture for CliRunner instance
- Use `runner.invoke()` to execute CLI commands
- Access results through `result` object
- Simulate interactive input with `input='responses\n'`
3. **What to Test**
- Command invocation with various arguments
- Exit code validation
- Output content verification
- Error handling and messages
- Interactive prompt responses
- Configuration handling
### Exit Code Testing Patterns
**Standard Exit Codes:**
- `0` - Success
- `1` - General error
- `2` - Misuse of command (invalid arguments)
- `126` - Command cannot execute
- `127` - Command not found
- `128+N` - Fatal error signal N
**Testing Strategy:**
- Always test both success (0) and failure (non-zero) cases
- Verify specific exit codes for different error conditions
- Test argument validation returns appropriate codes
- Ensure help/version return 0 (success)
### Output Validation Patterns
**Content Testing:**
- Check for presence of key text in output
- Validate format (JSON, YAML, tables)
- Test color/formatting codes (if applicable)
- Verify error messages are user-friendly
**Best Practices:**
- Use `.toContain()` for flexible matching (Jest)
- Use `in result.output` for Python tests
- Test both positive and negative cases
- Validate complete workflows (multi-command)
## Templates
Use these templates for CLI testing:
### Node.js/Jest Templates
- `templates/jest-cli-test.ts` - Complete Jest test suite with execSync
- `templates/jest-config-test.ts` - Configuration file testing
- `templates/jest-integration-test.ts` - Multi-command integration tests
### Python/Pytest Templates
- `templates/pytest-click-test.py` - Click.testing.CliRunner tests
- `templates/pytest-fixtures.py` - Reusable pytest fixtures
- `templates/pytest-integration-test.py` - Integration test patterns
### Test Utilities
- `templates/test-helpers.ts` - Node.js test helper functions
- `templates/test-helpers.py` - Python test helper functions
## Scripts
Use these scripts for test setup and execution:
- `scripts/setup-jest-testing.sh` - Install Jest and configure for CLI testing
- `scripts/setup-pytest-testing.sh` - Install pytest and Click testing dependencies
- `scripts/run-cli-tests.sh` - Execute all CLI tests with coverage
- `scripts/validate-test-coverage.sh` - Check test coverage thresholds
## Examples
See complete examples in the `examples/` directory:
- `examples/jest-basic/` - Basic Jest CLI testing setup
- `examples/jest-advanced/` - Advanced Jest patterns with mocking
- `examples/pytest-click/` - Click.testing.CliRunner examples
- `examples/integration-testing/` - Full integration test suites
- `examples/exit-code-testing/` - Exit code validation patterns
## Requirements
**Node.js Testing:**
- Jest 29.x or later
- TypeScript support (ts-jest)
- Node.js 16+
**Python Testing:**
- pytest 7.x or later
- Click 8.x or later
- Python 3.8+
**Both:**
- Test coverage reporting tools
- CI/CD integration support
- Mock/stub capabilities for external dependencies
## Best Practices
1. **Test in Isolation** - Each test should be independent
2. **Mock External Dependencies** - Don't make real API calls or file system changes
3. **Test Error Paths** - Test failures as thoroughly as successes
4. **Use Fixtures** - Share setup code across tests
5. **Clear Test Names** - Name tests to describe what they validate
6. **Fast Execution** - Keep tests fast for rapid feedback
7. **Coverage Goals** - Aim for 80%+ code coverage
8. **Integration Tests** - Test complete workflows, not just units
---
**Purpose**: Standardize CLI testing across Node.js and Python projects
**Load when**: Writing tests for CLI tools, validating command execution, testing exit codes

View File

@@ -0,0 +1,406 @@
# Exit Code Testing Patterns
Comprehensive guide to testing CLI exit codes correctly.
## Standard Exit Codes
### POSIX Standard Exit Codes
| Code | Meaning | When to Use |
|------|---------|-------------|
| 0 | Success | Command completed successfully |
| 1 | General Error | Catchall for general errors |
| 2 | Misuse of Command | Invalid arguments or options |
| 126 | Command Cannot Execute | Permission problem or not executable |
| 127 | Command Not Found | Command not found in PATH |
| 128+N | Fatal Error Signal N | Process terminated by signal N |
| 130 | Ctrl+C Termination | Process terminated by SIGINT |
### Custom Application Exit Codes
```typescript
// Define custom exit codes
enum ExitCode {
SUCCESS = 0,
GENERAL_ERROR = 1,
INVALID_ARGUMENT = 2,
CONFIG_ERROR = 3,
NETWORK_ERROR = 4,
AUTH_ERROR = 5,
NOT_FOUND = 6,
ALREADY_EXISTS = 7,
PERMISSION_DENIED = 8,
}
```
## Node.js Exit Code Testing
### Basic Exit Code Testing
```typescript
describe('Exit Code Tests', () => {
test('success returns 0', () => {
const { code } = runCLI('status');
expect(code).toBe(0);
});
test('general error returns 1', () => {
const { code } = runCLI('fail-command');
expect(code).toBe(1);
});
test('invalid argument returns 2', () => {
const { code } = runCLI('deploy --invalid-env unknown');
expect(code).toBe(2);
});
test('command not found returns 127', () => {
const { code } = runCLI('nonexistent-command');
expect(code).toBe(127);
});
});
```
### Specific Error Conditions
```typescript
describe('Specific Exit Codes', () => {
test('configuration error', () => {
const { code, stderr } = runCLI('deploy production');
expect(code).toBe(3); // CONFIG_ERROR
expect(stderr).toContain('configuration');
});
test('network error', () => {
// Mock network failure
const { code, stderr } = runCLI('fetch --url https://unreachable.example.com');
expect(code).toBe(4); // NETWORK_ERROR
expect(stderr).toContain('network');
});
test('authentication error', () => {
const { code, stderr } = runCLI('login --token invalid');
expect(code).toBe(5); // AUTH_ERROR
expect(stderr).toContain('authentication');
});
test('resource not found', () => {
const { code, stderr } = runCLI('get resource-123');
expect(code).toBe(6); // NOT_FOUND
expect(stderr).toContain('not found');
});
test('resource already exists', () => {
runCLI('create my-resource');
const { code, stderr } = runCLI('create my-resource');
expect(code).toBe(7); // ALREADY_EXISTS
expect(stderr).toContain('already exists');
});
});
```
### Testing Exit Code Consistency
```typescript
describe('Exit Code Consistency', () => {
const errorScenarios = [
{ args: 'deploy', expectedCode: 2, reason: 'missing required argument' },
{ args: 'deploy --env invalid', expectedCode: 2, reason: 'invalid environment' },
{ args: 'config get missing', expectedCode: 6, reason: 'config key not found' },
{ args: 'unknown-cmd', expectedCode: 127, reason: 'command not found' },
];
test.each(errorScenarios)(
'should return exit code $expectedCode for $reason',
({ args, expectedCode }) => {
const { code } = runCLI(args);
expect(code).toBe(expectedCode);
}
);
});
```
## Python Exit Code Testing
### Basic Exit Code Testing
```python
class TestExitCodes:
"""Test CLI exit codes"""
def test_success_exit_code(self, runner):
"""Success should return 0"""
result = runner.invoke(cli, ['status'])
assert result.exit_code == 0
def test_general_error_exit_code(self, runner):
"""General error should return 1"""
result = runner.invoke(cli, ['fail-command'])
assert result.exit_code == 1
def test_usage_error_exit_code(self, runner):
"""Usage error should return 2"""
result = runner.invoke(cli, ['deploy']) # Missing required arg
assert result.exit_code == 2
def test_unknown_command_exit_code(self, runner):
"""Unknown command handling"""
result = runner.invoke(cli, ['nonexistent'])
assert result.exit_code != 0
```
### Custom Exit Codes with Click
```python
import click
import sys
# Define custom exit codes
class ExitCode:
SUCCESS = 0
GENERAL_ERROR = 1
INVALID_ARGUMENT = 2
CONFIG_ERROR = 3
NETWORK_ERROR = 4
AUTH_ERROR = 5
@click.command()
def deploy():
"""Deploy command with custom exit codes"""
try:
# Check configuration
if not has_valid_config():
click.echo("Configuration error", err=True)
sys.exit(ExitCode.CONFIG_ERROR)
# Check authentication
if not is_authenticated():
click.echo("Authentication failed", err=True)
sys.exit(ExitCode.AUTH_ERROR)
# Deploy
deploy_application()
click.echo("Deployment successful")
sys.exit(ExitCode.SUCCESS)
except NetworkError:
click.echo("Network error", err=True)
sys.exit(ExitCode.NETWORK_ERROR)
except Exception as e:
click.echo(f"Error: {e}", err=True)
sys.exit(ExitCode.GENERAL_ERROR)
```
### Testing Custom Exit Codes
```python
class TestCustomExitCodes:
"""Test custom exit codes"""
def test_config_error_exit_code(self, runner, tmp_path):
"""Configuration error should return 3"""
# Remove config file
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 3
assert 'configuration' in result.output.lower()
def test_network_error_exit_code(self, runner, monkeypatch):
"""Network error should return 4"""
def mock_request(*args, **kwargs):
raise NetworkError("Connection failed")
monkeypatch.setattr('requests.post', mock_request)
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 4
assert 'network' in result.output.lower()
def test_auth_error_exit_code(self, runner):
"""Authentication error should return 5"""
result = runner.invoke(cli, ['deploy', 'production', '--token', 'invalid'])
assert result.exit_code == 5
assert 'authentication' in result.output.lower()
```
## Testing Exit Codes in Scripts
### Bash Script Exit Code Testing
```typescript
describe('Script Exit Codes', () => {
test('should respect shell exit codes', () => {
// Test that CLI properly exits with script error codes
const script = `
#!/bin/bash
${CLI_PATH} deploy staging
if [ $? -ne 0 ]; then
echo "Deployment failed"
exit 1
fi
echo "Deployment succeeded"
`;
const { code, stdout } = execSync(script, { encoding: 'utf8' });
expect(code).toBe(0);
expect(stdout).toContain('Deployment succeeded');
});
test('should propagate errors in pipelines', () => {
const { code } = execSync(`${CLI_PATH} invalid | tee output.log`, {
encoding: 'utf8',
});
expect(code).not.toBe(0);
});
});
```
## Exit Code Best Practices
### 1. Document Exit Codes
```typescript
/**
* CLI Exit Codes
*
* 0 - Success
* 1 - General error
* 2 - Invalid arguments
* 3 - Configuration error
* 4 - Network error
* 5 - Authentication error
* 6 - Resource not found
* 7 - Resource already exists
* 8 - Permission denied
*/
```
### 2. Consistent Error Handling
```python
def handle_error(error: Exception) -> int:
"""
Handle errors and return appropriate exit code
Returns:
Appropriate exit code for the error type
"""
if isinstance(error, ConfigurationError):
click.echo(f"Configuration error: {error}", err=True)
return ExitCode.CONFIG_ERROR
elif isinstance(error, NetworkError):
click.echo(f"Network error: {error}", err=True)
return ExitCode.NETWORK_ERROR
elif isinstance(error, AuthenticationError):
click.echo(f"Authentication failed: {error}", err=True)
return ExitCode.AUTH_ERROR
else:
click.echo(f"Error: {error}", err=True)
return ExitCode.GENERAL_ERROR
```
### 3. Test Exit Codes with Error Messages
```typescript
test('exit code matches error type', () => {
const errorCases = [
{ args: 'deploy', expectedCode: 2, expectedMsg: 'missing required argument' },
{ args: 'login --token bad', expectedCode: 5, expectedMsg: 'authentication failed' },
{ args: 'get missing-id', expectedCode: 6, expectedMsg: 'not found' },
];
errorCases.forEach(({ args, expectedCode, expectedMsg }) => {
const { code, stderr } = runCLI(args);
expect(code).toBe(expectedCode);
expect(stderr.toLowerCase()).toContain(expectedMsg);
});
});
```
### 4. Test Help and Version Return 0
```python
def test_help_returns_success(runner):
"""Help should return 0"""
result = runner.invoke(cli, ['--help'])
assert result.exit_code == 0
def test_version_returns_success(runner):
"""Version should return 0"""
result = runner.invoke(cli, ['--version'])
assert result.exit_code == 0
```
## Common Pitfalls
### 1. Don't Use Exit Code 0 for Errors
```typescript
// ❌ Wrong - using 0 for errors
if (error) {
console.error('Error occurred');
process.exit(0); // Should be non-zero!
}
// ✅ Correct - using non-zero for errors
if (error) {
console.error('Error occurred');
process.exit(1);
}
```
### 2. Don't Ignore Exit Codes in Tests
```python
# ❌ Wrong - not checking exit code
def test_deploy(runner):
result = runner.invoke(cli, ['deploy', 'production'])
assert 'deployed' in result.output # What if it failed?
# ✅ Correct - always check exit code
def test_deploy(runner):
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 0
assert 'deployed' in result.output
```
### 3. Use Specific Exit Codes
```typescript
// ❌ Wrong - using 1 for everything
if (configError) process.exit(1);
if (networkError) process.exit(1);
if (authError) process.exit(1);
// ✅ Correct - using specific codes
if (configError) process.exit(ExitCode.CONFIG_ERROR);
if (networkError) process.exit(ExitCode.NETWORK_ERROR);
if (authError) process.exit(ExitCode.AUTH_ERROR);
```
## Testing Exit Codes in CI/CD
```yaml
# GitHub Actions example
- name: Test CLI Exit Codes
run: |
# Should succeed
./cli status && echo "Status check passed" || exit 1
# Should fail
./cli invalid-command && exit 1 || echo "Error handling works"
# Check specific exit code
./cli deploy --missing-arg
if [ $? -eq 2 ]; then
echo "Correct exit code for invalid argument"
else
echo "Wrong exit code"
exit 1
fi
```
## Resources
- [Exit Codes on Linux](https://tldp.org/LDP/abs/html/exitcodes.html)
- [POSIX Exit Codes](https://pubs.opengroup.org/onlinepubs/9699919799/)
- [GNU Exit Codes](https://www.gnu.org/software/libc/manual/html_node/Exit-Status.html)

View File

@@ -0,0 +1,349 @@
# Integration Testing for CLI Applications
Complete workflows and integration testing patterns for CLI applications.
## Overview
Integration tests verify that multiple CLI commands work together correctly, testing complete user workflows rather than individual commands in isolation.
## Key Differences from Unit Tests
| Unit Tests | Integration Tests |
|------------|-------------------|
| Test individual commands | Test command sequences |
| Mock external dependencies | May use real dependencies |
| Fast execution | Slower execution |
| Isolated state | Shared state across commands |
## Node.js Integration Testing
### Multi-Command Workflow
```typescript
describe('Complete Deployment Workflow', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-integration-'));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
test('full deployment workflow', () => {
// Step 1: Initialize project
let result = runCLI(`init my-project --cwd ${tempDir}`);
expect(result.code).toBe(0);
expect(fs.existsSync(path.join(tempDir, 'my-project'))).toBe(true);
// Step 2: Configure
const projectDir = path.join(tempDir, 'my-project');
result = runCLI(`config set api_key test_key --cwd ${projectDir}`);
expect(result.code).toBe(0);
// Step 3: Build
result = runCLI(`build --production --cwd ${projectDir}`);
expect(result.code).toBe(0);
expect(fs.existsSync(path.join(projectDir, 'dist'))).toBe(true);
// Step 4: Deploy
result = runCLI(`deploy staging --cwd ${projectDir}`);
expect(result.code).toBe(0);
expect(result.stdout).toContain('Deployed successfully');
// Step 5: Verify
result = runCLI(`status --cwd ${projectDir}`);
expect(result.code).toBe(0);
expect(result.stdout).toContain('staging');
});
});
```
### State Persistence Testing
```typescript
describe('State Persistence', () => {
test('state persists across commands', () => {
const workspace = createTempWorkspace();
try {
// Create initial state
runCLI(`init --cwd ${workspace}`);
runCLI(`config set key1 value1 --cwd ${workspace}`);
runCLI(`config set key2 value2 --cwd ${workspace}`);
// Verify state persists
let result = runCLI(`config get key1 --cwd ${workspace}`);
expect(result.stdout).toContain('value1');
// Modify state
runCLI(`config set key1 updated --cwd ${workspace}`);
// Verify modification
result = runCLI(`config get key1 --cwd ${workspace}`);
expect(result.stdout).toContain('updated');
// Verify other keys unchanged
result = runCLI(`config get key2 --cwd ${workspace}`);
expect(result.stdout).toContain('value2');
} finally {
cleanupWorkspace(workspace);
}
});
});
```
## Python Integration Testing
### Complete Workflow Testing
```python
class TestCompleteWorkflow:
"""Test complete CLI workflows"""
def test_project_lifecycle(self, runner):
"""Test complete project lifecycle"""
with runner.isolated_filesystem():
# Initialize
result = runner.invoke(cli, ['create', 'test-project'])
assert result.exit_code == 0
# Enter project directory
os.chdir('test-project')
# Configure
result = runner.invoke(cli, ['config', 'set', 'api_key', 'test_key'])
assert result.exit_code == 0
# Add dependencies
result = runner.invoke(cli, ['add', 'dependency', 'requests'])
assert result.exit_code == 0
# Build
result = runner.invoke(cli, ['build'])
assert result.exit_code == 0
assert os.path.exists('dist')
# Test
result = runner.invoke(cli, ['test'])
assert result.exit_code == 0
# Deploy
result = runner.invoke(cli, ['deploy', 'staging'])
assert result.exit_code == 0
# Verify
result = runner.invoke(cli, ['status'])
assert result.exit_code == 0
assert 'staging' in result.output
def test_multi_environment_workflow(self, runner):
"""Test workflow across multiple environments"""
with runner.isolated_filesystem():
# Setup
runner.invoke(cli, ['init', 'multi-env-app'])
os.chdir('multi-env-app')
# Configure environments
environments = ['development', 'staging', 'production']
for env in environments:
result = runner.invoke(
cli,
['config', 'set', 'api_key', f'{env}_key', '--env', env]
)
assert result.exit_code == 0
# Deploy to each environment
for env in environments:
result = runner.invoke(cli, ['deploy', env])
assert result.exit_code == 0
assert env in result.output
```
### Error Recovery Testing
```python
class TestErrorRecovery:
"""Test error recovery workflows"""
def test_rollback_on_failure(self, runner):
"""Test rollback after failed deployment"""
with runner.isolated_filesystem():
# Setup
runner.invoke(cli, ['init', 'rollback-test'])
os.chdir('rollback-test')
runner.invoke(cli, ['config', 'set', 'api_key', 'test_key'])
# Successful deployment
result = runner.invoke(cli, ['deploy', 'staging'])
assert result.exit_code == 0
# Failed deployment (simulate)
result = runner.invoke(cli, ['deploy', 'staging', '--force-fail'])
assert result.exit_code != 0
# Rollback
result = runner.invoke(cli, ['rollback'])
assert result.exit_code == 0
assert 'rollback successful' in result.output.lower()
def test_recovery_from_corruption(self, runner):
"""Test recovery from corrupted state"""
with runner.isolated_filesystem():
# Create valid state
runner.invoke(cli, ['init', 'corrupt-test'])
os.chdir('corrupt-test')
runner.invoke(cli, ['config', 'set', 'key', 'value'])
# Corrupt state file
with open('.cli-state', 'w') as f:
f.write('invalid json {[}')
# Should detect and recover
result = runner.invoke(cli, ['config', 'get', 'key'])
assert result.exit_code != 0
assert 'corrupt' in result.output.lower()
# Reset state
result = runner.invoke(cli, ['reset', '--force'])
assert result.exit_code == 0
# Should work after reset
result = runner.invoke(cli, ['config', 'set', 'key', 'new_value'])
assert result.exit_code == 0
```
## Integration Test Patterns
### 1. Sequential Command Testing
Test commands that must run in a specific order:
```python
def test_sequential_workflow(runner):
"""Test commands that depend on each other"""
with runner.isolated_filesystem():
# Each command depends on the previous
commands = [
['init', 'project'],
['config', 'set', 'key', 'value'],
['build'],
['test'],
['deploy', 'staging']
]
for cmd in commands:
result = runner.invoke(cli, cmd)
assert result.exit_code == 0, \
f"Command {' '.join(cmd)} failed: {result.output}"
```
### 2. Concurrent Operation Testing
Test that concurrent operations are handled correctly:
```python
def test_concurrent_operations(runner):
"""Test handling of concurrent operations"""
import threading
results = []
def run_command():
result = runner.invoke(cli, ['deploy', 'staging'])
results.append(result)
# Start multiple deployments
threads = [threading.Thread(target=run_command) for _ in range(3)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
# Only one should succeed, others should detect lock
successful = sum(1 for r in results if r.exit_code == 0)
assert successful == 1
assert any('locked' in r.output.lower() for r in results if r.exit_code != 0)
```
### 3. Data Migration Testing
Test data migration between versions:
```python
def test_data_migration(runner):
"""Test data migration workflow"""
with runner.isolated_filesystem():
# Create old version data
old_data = {'version': 1, 'data': {'key': 'value'}}
with open('data.json', 'w') as f:
json.dump(old_data, f)
# Run migration
result = runner.invoke(cli, ['migrate', '--to', '2.0'])
assert result.exit_code == 0
# Verify new format
with open('data.json', 'r') as f:
new_data = json.load(f)
assert new_data['version'] == 2
assert new_data['data']['key'] == 'value'
# Verify backup created
assert os.path.exists('data.json.backup')
```
## Best Practices
1. **Use Isolated Environments**: Each test should run in a clean environment
2. **Test Real Workflows**: Test actual user scenarios, not artificial sequences
3. **Include Error Paths**: Test recovery from failures
4. **Test State Persistence**: Verify data persists correctly across commands
5. **Use Realistic Data**: Test with data similar to production use cases
6. **Clean Up Resources**: Always cleanup temp files and resources
7. **Document Workflows**: Clearly document what workflow each test verifies
8. **Set Appropriate Timeouts**: Integration tests may take longer
9. **Mark Slow Tests**: Use test markers for slow-running integration tests
10. **Test Concurrency**: Verify handling of simultaneous operations
## Running Integration Tests
### Node.js/Jest
```bash
# Run all integration tests
npm test -- --testPathPattern=integration
# Run specific integration test
npm test -- integration/deployment.test.ts
# Run with extended timeout
npm test -- --testTimeout=30000
```
### Python/pytest
```bash
# Run all integration tests
pytest tests/integration
# Run specific test
pytest tests/integration/test_workflow.py
# Run marked integration tests
pytest -m integration
# Run with verbose output
pytest tests/integration -v
# Skip slow tests
pytest -m "not slow"
```
## Resources
- [Integration Testing Best Practices](https://martinfowler.com/bliki/IntegrationTest.html)
- [Testing Strategies](https://testing.googleblog.com/)
- [CLI Testing Patterns](https://clig.dev/#testing)

View File

@@ -0,0 +1,277 @@
# Jest Advanced CLI Testing Example
Advanced testing patterns for CLI applications including mocking, fixtures, and integration tests.
## Advanced Patterns
### 1. Async Command Testing
```typescript
import { spawn } from 'child_process';
async function runCLIAsync(args: string[]): Promise<CLIResult> {
return new Promise((resolve) => {
const child = spawn(CLI_PATH, args, { stdio: 'pipe' });
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
resolve({ stdout, stderr, code: code || 0 });
});
});
}
test('should handle long-running command', async () => {
const result = await runCLIAsync(['deploy', 'production']);
expect(result.code).toBe(0);
}, 30000); // 30 second timeout
```
### 2. Environment Variable Mocking
```typescript
describe('environment configuration', () => {
const originalEnv = { ...process.env };
afterEach(() => {
process.env = { ...originalEnv };
});
test('should use API key from environment', () => {
process.env.API_KEY = 'test_key_123';
const { stdout, code } = runCLI('status');
expect(code).toBe(0);
expect(stdout).toContain('Authenticated');
});
test('should fail without API key', () => {
delete process.env.API_KEY;
const { stderr, code } = runCLI('status');
expect(code).toBe(1);
expect(stderr).toContain('API key not found');
});
});
```
### 3. File System Fixtures
```typescript
import fs from 'fs';
import os from 'os';
describe('config file handling', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-test-'));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
test('should create config file', () => {
const configFile = path.join(tempDir, '.config');
const result = runCLI(`init --config ${configFile}`);
expect(result.code).toBe(0);
expect(fs.existsSync(configFile)).toBe(true);
const config = JSON.parse(fs.readFileSync(configFile, 'utf8'));
expect(config).toHaveProperty('api_key');
});
});
```
### 4. Mocking External APIs
```typescript
import nock from 'nock';
describe('API interaction', () => {
beforeEach(() => {
nock.cleanAll();
});
test('should fetch deployment status', () => {
nock('https://api.example.com')
.get('/deployments/123')
.reply(200, { status: 'success', environment: 'production' });
const { stdout, code } = runCLI('status --deployment 123');
expect(code).toBe(0);
expect(stdout).toContain('success');
expect(stdout).toContain('production');
});
test('should handle API errors', () => {
nock('https://api.example.com')
.get('/deployments/123')
.reply(500, { error: 'Internal Server Error' });
const { stderr, code } = runCLI('status --deployment 123');
expect(code).toBe(1);
expect(stderr).toContain('API error');
});
});
```
### 5. Test Fixtures
```typescript
// test-fixtures.ts
export const createTestFixtures = () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-test-'));
// Create sample project structure
fs.mkdirSync(path.join(tempDir, 'src'));
fs.writeFileSync(
path.join(tempDir, 'package.json'),
JSON.stringify({ name: 'test-project', version: '1.0.0' })
);
return {
tempDir,
cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
};
};
// Usage in tests
test('should build project', () => {
const fixtures = createTestFixtures();
try {
const result = runCLI(`build --cwd ${fixtures.tempDir}`);
expect(result.code).toBe(0);
expect(fs.existsSync(path.join(fixtures.tempDir, 'dist'))).toBe(true);
} finally {
fixtures.cleanup();
}
});
```
### 6. Snapshot Testing
```typescript
test('help output matches snapshot', () => {
const { stdout } = runCLI('--help');
expect(stdout).toMatchSnapshot();
});
test('version format matches snapshot', () => {
const { stdout } = runCLI('--version');
expect(stdout).toMatchSnapshot();
});
```
### 7. Parameterized Tests
```typescript
describe.each([
['development', 'dev.example.com'],
['staging', 'staging.example.com'],
['production', 'api.example.com'],
])('deploy to %s', (environment, expectedUrl) => {
test(`should deploy to ${environment}`, () => {
const { stdout, code } = runCLI(`deploy ${environment}`);
expect(code).toBe(0);
expect(stdout).toContain(expectedUrl);
});
});
```
### 8. Interactive Command Testing
```typescript
import { Readable, Writable } from 'stream';
test('should handle interactive prompts', (done) => {
const child = spawn(CLI_PATH, ['init'], { stdio: 'pipe' });
const inputs = ['my-project', 'John Doe', 'john@example.com'];
let inputIndex = 0;
child.stdout?.on('data', (data) => {
const output = data.toString();
if (output.includes('?') && inputIndex < inputs.length) {
child.stdin?.write(inputs[inputIndex] + '\n');
inputIndex++;
}
});
child.on('close', (code) => {
expect(code).toBe(0);
done();
});
});
```
### 9. Coverage-Driven Testing
```typescript
// Ensure all CLI commands are tested
describe('CLI command coverage', () => {
const commands = ['init', 'build', 'deploy', 'status', 'config'];
commands.forEach((command) => {
test(`${command} command exists`, () => {
const { stdout } = runCLI('--help');
expect(stdout).toContain(command);
});
test(`${command} has help text`, () => {
const { stdout, code } = runCLI(`${command} --help`);
expect(code).toBe(0);
expect(stdout).toContain('Usage:');
});
});
});
```
### 10. Performance Testing
```typescript
test('command executes within time limit', () => {
const startTime = Date.now();
const { code } = runCLI('status');
const duration = Date.now() - startTime;
expect(code).toBe(0);
expect(duration).toBeLessThan(2000); // Should complete within 2 seconds
});
```
## Best Practices
1. **Use Test Fixtures**: Create reusable test data and cleanup functions
2. **Mock External Dependencies**: Never make real API calls or database connections
3. **Test Edge Cases**: Test boundary conditions, empty inputs, special characters
4. **Async Handling**: Use proper async/await or promises for async operations
5. **Cleanup**: Always cleanup temp files, reset mocks, restore environment
6. **Isolation**: Tests should not depend on execution order
7. **Clear Error Messages**: Write assertions with helpful failure messages
## Common Advanced Patterns
- Concurrent execution testing
- File locking and race conditions
- Signal handling (SIGTERM, SIGINT)
- Large file processing
- Streaming output
- Progress indicators
- Error recovery and retry logic
## Resources
- [Jest Advanced Features](https://jestjs.io/docs/advanced)
- [Mocking with Jest](https://jestjs.io/docs/mock-functions)
- [Snapshot Testing](https://jestjs.io/docs/snapshot-testing)

View File

@@ -0,0 +1,145 @@
# Jest Basic CLI Testing Example
This example demonstrates basic CLI testing patterns using Jest for Node.js/TypeScript projects.
## Setup
```bash
npm install --save-dev jest @types/jest ts-jest @types/node
```
## Test Structure
```typescript
import { execSync } from 'child_process';
import path from 'path';
describe('CLI Tool Tests', () => {
const CLI_PATH = path.join(__dirname, '../bin/mycli');
function runCLI(args: string) {
try {
const stdout = execSync(`${CLI_PATH} ${args}`, {
encoding: 'utf8',
stdio: 'pipe',
});
return { stdout, stderr: '', code: 0 };
} catch (error: any) {
return {
stdout: error.stdout || '',
stderr: error.stderr || '',
code: error.status || 1,
};
}
}
test('should display version', () => {
const { stdout, code } = runCLI('--version');
expect(code).toBe(0);
expect(stdout).toContain('1.0.0');
});
test('should display help', () => {
const { stdout, code } = runCLI('--help');
expect(code).toBe(0);
expect(stdout).toContain('Usage:');
});
test('should handle unknown command', () => {
const { stderr, code } = runCLI('unknown-command');
expect(code).toBe(1);
expect(stderr).toContain('unknown command');
});
});
```
## Running Tests
```bash
# Run all tests
npm test
# Run with coverage
npm run test:coverage
# Run in watch mode
npm run test:watch
```
## Key Patterns
### 1. Command Execution Helper
Create a reusable `runCLI()` function that:
- Executes CLI commands using `execSync`
- Captures stdout, stderr, and exit codes
- Handles both success and failure cases
### 2. Exit Code Testing
Always test exit codes:
- `0` for success
- Non-zero for errors
- Specific codes for different error types
### 3. Output Validation
Test output content using Jest matchers:
- `.toContain()` for substring matching
- `.toMatch()` for regex patterns
- `.toBe()` for exact matches
### 4. Error Handling
Test error scenarios:
- Unknown commands
- Invalid options
- Missing required arguments
- Invalid argument types
## Example Test Cases
```typescript
describe('deploy command', () => {
test('should deploy with valid arguments', () => {
const { stdout, code } = runCLI('deploy production --force');
expect(code).toBe(0);
expect(stdout).toContain('Deploying to production');
});
test('should fail without required arguments', () => {
const { stderr, code } = runCLI('deploy');
expect(code).toBe(1);
expect(stderr).toContain('missing required argument');
});
test('should validate environment names', () => {
const { stderr, code } = runCLI('deploy invalid-env');
expect(code).toBe(1);
expect(stderr).toContain('invalid environment');
});
});
```
## Best Practices
1. **Isolate Tests**: Each test should be independent
2. **Use Descriptive Names**: Test names should describe what they validate
3. **Test Both Success and Failure**: Cover happy path and error cases
4. **Mock External Dependencies**: Don't make real API calls or file system changes
5. **Use Type Safety**: Leverage TypeScript for better test reliability
6. **Keep Tests Fast**: Fast tests encourage frequent running
## Common Pitfalls
- ❌ Not testing exit codes
- ❌ Only testing success cases
- ❌ Hardcoding paths instead of using `path.join()`
- ❌ Not handling async operations properly
- ❌ Testing implementation details instead of behavior
## Resources
- [Jest Documentation](https://jestjs.io/docs/getting-started)
- [Testing CLI Applications](https://jestjs.io/docs/cli)
- [TypeScript with Jest](https://jestjs.io/docs/getting-started#using-typescript)

View File

@@ -0,0 +1,353 @@
# Pytest Click Testing Example
Comprehensive examples for testing Click-based CLI applications using pytest and CliRunner.
## Basic Setup
```python
import pytest
from click.testing import CliRunner
from mycli.cli import cli
@pytest.fixture
def runner():
return CliRunner()
```
## Basic Command Testing
```python
class TestBasicCommands:
"""Test basic CLI commands"""
def test_version(self, runner):
"""Test version command"""
result = runner.invoke(cli, ['--version'])
assert result.exit_code == 0
assert '1.0.0' in result.output
def test_help(self, runner):
"""Test help command"""
result = runner.invoke(cli, ['--help'])
assert result.exit_code == 0
assert 'Usage:' in result.output
def test_unknown_command(self, runner):
"""Test unknown command handling"""
result = runner.invoke(cli, ['unknown'])
assert result.exit_code != 0
assert 'no such command' in result.output.lower()
```
## Testing with Arguments
```python
class TestArgumentParsing:
"""Test argument parsing"""
def test_required_argument(self, runner):
"""Test command with required argument"""
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 0
assert 'production' in result.output
def test_missing_required_argument(self, runner):
"""Test missing required argument"""
result = runner.invoke(cli, ['deploy'])
assert result.exit_code != 0
assert 'missing argument' in result.output.lower()
def test_optional_argument(self, runner):
"""Test optional argument"""
result = runner.invoke(cli, ['build', '--output', 'dist'])
assert result.exit_code == 0
assert 'dist' in result.output
```
## Testing with Options
```python
class TestOptionParsing:
"""Test option parsing"""
def test_boolean_flag(self, runner):
"""Test boolean flag option"""
result = runner.invoke(cli, ['deploy', 'staging', '--force'])
assert result.exit_code == 0
assert 'force' in result.output.lower()
def test_option_with_value(self, runner):
"""Test option with value"""
result = runner.invoke(cli, ['config', 'set', '--key', 'api_key', '--value', 'test'])
assert result.exit_code == 0
def test_multiple_options(self, runner):
"""Test multiple options"""
result = runner.invoke(
cli,
['deploy', 'production', '--verbose', '--dry-run', '--timeout', '60']
)
assert result.exit_code == 0
```
## Testing Interactive Prompts
```python
class TestInteractivePrompts:
"""Test interactive prompt handling"""
def test_simple_prompt(self, runner):
"""Test simple text prompt"""
result = runner.invoke(cli, ['init'], input='my-project\n')
assert result.exit_code == 0
assert 'my-project' in result.output
def test_confirmation_prompt(self, runner):
"""Test confirmation prompt (yes)"""
result = runner.invoke(cli, ['delete', 'resource-id'], input='y\n')
assert result.exit_code == 0
assert 'deleted' in result.output.lower()
def test_confirmation_prompt_no(self, runner):
"""Test confirmation prompt (no)"""
result = runner.invoke(cli, ['delete', 'resource-id'], input='n\n')
assert result.exit_code == 1
assert 'cancelled' in result.output.lower()
def test_multiple_prompts(self, runner):
"""Test multiple prompts in sequence"""
inputs = 'my-project\nJohn Doe\njohn@example.com\n'
result = runner.invoke(cli, ['init', '--interactive'], input=inputs)
assert result.exit_code == 0
assert 'my-project' in result.output
assert 'John Doe' in result.output
def test_choice_prompt(self, runner):
"""Test choice prompt"""
result = runner.invoke(cli, ['deploy'], input='1\n') # Select option 1
assert result.exit_code == 0
```
## Testing with Isolated Filesystem
```python
class TestFileOperations:
"""Test file operations with isolated filesystem"""
def test_create_file(self, runner):
"""Test file creation"""
with runner.isolated_filesystem():
result = runner.invoke(cli, ['init', 'test-project'])
assert result.exit_code == 0
import os
assert os.path.exists('test-project')
def test_read_file(self, runner):
"""Test reading from file"""
with runner.isolated_filesystem():
# Create test file
with open('input.txt', 'w') as f:
f.write('test data')
result = runner.invoke(cli, ['process', '--input', 'input.txt'])
assert result.exit_code == 0
def test_write_file(self, runner):
"""Test writing to file"""
with runner.isolated_filesystem():
result = runner.invoke(cli, ['export', '--output', 'output.txt'])
assert result.exit_code == 0
import os
assert os.path.exists('output.txt')
with open('output.txt', 'r') as f:
content = f.read()
assert len(content) > 0
```
## Testing Environment Variables
```python
class TestEnvironmentVariables:
"""Test environment variable handling"""
def test_with_env_var(self, runner):
"""Test command with environment variable"""
result = runner.invoke(
cli,
['status'],
env={'API_KEY': 'test_key_123'}
)
assert result.exit_code == 0
def test_without_env_var(self, runner):
"""Test command without required environment variable"""
result = runner.invoke(cli, ['status'])
# Assuming API_KEY is required
if 'API_KEY' not in result.output:
assert result.exit_code != 0
def test_env_var_override(self, runner, monkeypatch):
"""Test environment variable override"""
monkeypatch.setenv('API_KEY', 'overridden_key')
result = runner.invoke(cli, ['status'])
assert result.exit_code == 0
```
## Testing Output Formats
```python
class TestOutputFormats:
"""Test different output formats"""
def test_json_output(self, runner):
"""Test JSON output format"""
result = runner.invoke(cli, ['status', '--format', 'json'])
assert result.exit_code == 0
import json
try:
data = json.loads(result.output)
assert isinstance(data, dict)
except json.JSONDecodeError:
pytest.fail("Output is not valid JSON")
def test_yaml_output(self, runner):
"""Test YAML output format"""
result = runner.invoke(cli, ['status', '--format', 'yaml'])
assert result.exit_code == 0
assert ':' in result.output
def test_table_output(self, runner):
"""Test table output format"""
result = runner.invoke(cli, ['list'])
assert result.exit_code == 0
assert '' in result.output or '|' in result.output
```
## Testing Exit Codes
```python
class TestExitCodes:
"""Test exit codes"""
def test_success_exit_code(self, runner):
"""Test success returns 0"""
result = runner.invoke(cli, ['status'])
assert result.exit_code == 0
def test_error_exit_code(self, runner):
"""Test error returns non-zero"""
result = runner.invoke(cli, ['invalid-command'])
assert result.exit_code != 0
def test_validation_error_exit_code(self, runner):
"""Test validation error returns 2"""
result = runner.invoke(cli, ['deploy', '--invalid-option'])
assert result.exit_code == 2 # Click uses 2 for usage errors
def test_exception_exit_code(self, runner):
"""Test uncaught exception returns 1"""
result = runner.invoke(cli, ['command-that-throws'])
assert result.exit_code == 1
```
## Testing with Fixtures
```python
@pytest.fixture
def sample_config(tmp_path):
"""Create sample config file"""
config_file = tmp_path / '.myclirc'
config_file.write_text('''
api_key: your_test_key_here
environment: development
verbose: false
''')
return config_file
@pytest.fixture
def mock_api(monkeypatch):
"""Mock external API calls"""
class MockAPI:
def __init__(self):
self.calls = []
def get(self, endpoint):
self.calls.append(('GET', endpoint))
return {'status': 'success'}
mock = MockAPI()
monkeypatch.setattr('mycli.api.client', mock)
return mock
class TestWithFixtures:
"""Test using fixtures"""
def test_with_config_file(self, runner, sample_config):
"""Test with config file"""
result = runner.invoke(
cli,
['status', '--config', str(sample_config)]
)
assert result.exit_code == 0
def test_with_mock_api(self, runner, mock_api):
"""Test with mocked API"""
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 0
assert len(mock_api.calls) > 0
```
## Testing Error Handling
```python
class TestErrorHandling:
"""Test error handling"""
def test_network_error(self, runner, monkeypatch):
"""Test network error handling"""
def mock_request(*args, **kwargs):
raise ConnectionError("Network unreachable")
monkeypatch.setattr('requests.get', mock_request)
result = runner.invoke(cli, ['status'])
assert result.exit_code != 0
assert 'network' in result.output.lower()
def test_file_not_found(self, runner):
"""Test file not found error"""
result = runner.invoke(cli, ['process', '--input', 'nonexistent.txt'])
assert result.exit_code != 0
assert 'not found' in result.output.lower()
def test_invalid_json(self, runner):
"""Test invalid JSON handling"""
with runner.isolated_filesystem():
with open('config.json', 'w') as f:
f.write('invalid json {[}')
result = runner.invoke(cli, ['config', 'load', 'config.json'])
assert result.exit_code != 0
assert 'invalid' in result.output.lower()
```
## Best Practices
1. **Use Fixtures**: Share common setup across tests
2. **Isolated Filesystem**: Use `runner.isolated_filesystem()` for file operations
3. **Test Exit Codes**: Always check exit codes
4. **Clear Test Names**: Use descriptive test method names
5. **Test Edge Cases**: Test boundary conditions and error cases
6. **Mock External Dependencies**: Don't make real API calls
7. **Use Markers**: Mark tests as unit, integration, slow, etc.
## Resources
- [Click Testing Documentation](https://click.palletsprojects.com/en/8.1.x/testing/)
- [Pytest Documentation](https://docs.pytest.org/)
- [CliRunner API](https://click.palletsprojects.com/en/8.1.x/api/#click.testing.CliRunner)

View File

@@ -0,0 +1,82 @@
#!/bin/bash
#
# Run CLI Tests
#
# Detects the project type and runs appropriate tests with coverage
set -e
echo "🧪 Running CLI tests..."
# Detect project type
if [ -f "package.json" ]; then
PROJECT_TYPE="node"
elif [ -f "setup.py" ] || [ -f "pyproject.toml" ]; then
PROJECT_TYPE="python"
else
echo "❌ Error: Could not detect project type"
echo " Expected package.json (Node.js) or setup.py/pyproject.toml (Python)"
exit 1
fi
# Run tests based on project type
if [ "$PROJECT_TYPE" == "node" ]; then
echo "📦 Node.js project detected"
# Check if npm test is configured
if ! grep -q '"test"' package.json 2>/dev/null; then
echo "❌ Error: No test script found in package.json"
echo " Run setup-jest-testing.sh first"
exit 1
fi
# Install dependencies if needed
if [ ! -d "node_modules" ]; then
echo "📦 Installing dependencies..."
npm install
fi
# Run tests with coverage
echo "🧪 Running Jest tests..."
npm run test:coverage
# Display coverage summary
if [ -f "coverage/lcov-report/index.html" ]; then
echo ""
echo "✅ Tests complete!"
echo "📊 Coverage report: coverage/lcov-report/index.html"
fi
elif [ "$PROJECT_TYPE" == "python" ]; then
echo "🐍 Python project detected"
# Check if pytest is installed
if ! command -v pytest &> /dev/null; then
echo "❌ Error: pytest is not installed"
echo " Run setup-pytest-testing.sh first"
exit 1
fi
# Create/activate virtual environment if it exists
if [ -d "venv" ]; then
echo "🔧 Activating virtual environment..."
source venv/bin/activate
elif [ -d ".venv" ]; then
echo "🔧 Activating virtual environment..."
source .venv/bin/activate
fi
# Run tests with coverage
echo "🧪 Running pytest tests..."
pytest --cov --cov-report=term-missing --cov-report=html
# Display coverage summary
if [ -d "htmlcov" ]; then
echo ""
echo "✅ Tests complete!"
echo "📊 Coverage report: htmlcov/index.html"
fi
fi
echo ""
echo "🎉 All tests passed!"

View File

@@ -0,0 +1,235 @@
#!/bin/bash
#
# Setup Jest for CLI Testing (Node.js/TypeScript)
#
# This script installs and configures Jest for testing CLI applications
# Includes TypeScript support, coverage reporting, and CLI testing utilities
set -e
echo "🔧 Setting up Jest for CLI testing..."
# Check if npm is available
if ! command -v npm &> /dev/null; then
echo "❌ Error: npm is not installed"
exit 1
fi
# Install Jest and related dependencies
echo "📦 Installing Jest and dependencies..."
npm install --save-dev \
jest \
@types/jest \
ts-jest \
@types/node
# Create Jest configuration
echo "⚙️ Creating Jest configuration..."
cat > jest.config.js << 'EOF'
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/tests'],
testMatch: [
'**/__tests__/**/*.ts',
'**/?(*.)+(spec|test).ts'
],
collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.d.ts',
'!src/**/*.test.ts',
'!src/**/__tests__/**'
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
coverageThresholds: {
global: {
branches: 70,
functions: 70,
lines: 70,
statements: 70
}
},
verbose: true,
testTimeout: 10000
};
EOF
# Create tests directory structure
echo "📁 Creating test directory structure..."
mkdir -p tests/{unit,integration,helpers}
# Create test helper file
echo "📝 Creating test helpers..."
cat > tests/helpers/cli-helpers.ts << 'EOF'
import { execSync } from 'child_process';
import path from 'path';
export interface CLIResult {
stdout: string;
stderr: string;
code: number;
}
export const CLI_PATH = path.join(__dirname, '../../bin/cli');
export function runCLI(args: string): CLIResult {
try {
const stdout = execSync(`${CLI_PATH} ${args}`, {
encoding: 'utf8',
stdio: 'pipe',
});
return { stdout, stderr: '', code: 0 };
} catch (error: any) {
return {
stdout: error.stdout || '',
stderr: error.stderr || '',
code: error.status || 1,
};
}
}
EOF
# Create sample test file
echo "📝 Creating sample test file..."
cat > tests/unit/cli.test.ts << 'EOF'
import { runCLI } from '../helpers/cli-helpers';
describe('CLI Tests', () => {
test('should display version', () => {
const { stdout, code } = runCLI('--version');
expect(code).toBe(0);
expect(stdout).toMatch(/\d+\.\d+\.\d+/);
});
test('should display help', () => {
const { stdout, code } = runCLI('--help');
expect(code).toBe(0);
expect(stdout).toContain('Usage:');
});
});
EOF
# Create TypeScript configuration for tests
echo "⚙️ Creating TypeScript configuration..."
if [ ! -f tsconfig.json ]; then
cat > tsconfig.json << 'EOF'
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}
EOF
fi
# Update package.json scripts
echo "⚙️ Updating package.json scripts..."
if [ -f package.json ]; then
# Check if jq is available for JSON manipulation
if command -v jq &> /dev/null; then
# Add test scripts using jq
tmp=$(mktemp)
jq '.scripts.test = "jest" |
.scripts["test:watch"] = "jest --watch" |
.scripts["test:coverage"] = "jest --coverage" |
.scripts["test:ci"] = "jest --ci --coverage --maxWorkers=2"' \
package.json > "$tmp"
mv "$tmp" package.json
else
echo "⚠️ jq not found. Please manually add test scripts to package.json:"
echo ' "test": "jest"'
echo ' "test:watch": "jest --watch"'
echo ' "test:coverage": "jest --coverage"'
echo ' "test:ci": "jest --ci --coverage --maxWorkers=2"'
fi
fi
# Create .gitignore entries
echo "📝 Updating .gitignore..."
if [ -f .gitignore ]; then
grep -qxF 'coverage/' .gitignore || echo 'coverage/' >> .gitignore
grep -qxF '*.log' .gitignore || echo '*.log' >> .gitignore
else
cat > .gitignore << 'EOF'
node_modules/
dist/
coverage/
*.log
.env
.env.local
EOF
fi
# Create README for tests
echo "📝 Creating test documentation..."
cat > tests/README.md << 'EOF'
# CLI Tests
## Running Tests
```bash
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm run test:coverage
# Run tests in CI mode
npm run test:ci
```
## Test Structure
- `unit/` - Unit tests for individual functions
- `integration/` - Integration tests for complete workflows
- `helpers/` - Test helper functions and utilities
## Writing Tests
Use the `runCLI` helper to execute CLI commands:
```typescript
import { runCLI } from '../helpers/cli-helpers';
test('should execute command', () => {
const { stdout, stderr, code } = runCLI('command --flag');
expect(code).toBe(0);
expect(stdout).toContain('expected output');
});
```
## Coverage
Coverage reports are generated in the `coverage/` directory.
Target: 70% coverage for branches, functions, lines, and statements.
EOF
echo "✅ Jest setup complete!"
echo ""
echo "Next steps:"
echo " 1. Run 'npm test' to execute tests"
echo " 2. Add more tests in tests/unit/ and tests/integration/"
echo " 3. Run 'npm run test:coverage' to see coverage report"
echo ""
echo "📚 Test files created:"
echo " - jest.config.js"
echo " - tests/helpers/cli-helpers.ts"
echo " - tests/unit/cli.test.ts"
echo " - tests/README.md"

View File

@@ -0,0 +1,448 @@
#!/bin/bash
#
# Setup pytest for CLI Testing (Python)
#
# This script installs and configures pytest for testing Click-based CLI applications
# Includes coverage reporting, fixtures, and CLI testing utilities
set -e
echo "🔧 Setting up pytest for CLI testing..."
# Check if Python is available
if ! command -v python3 &> /dev/null; then
echo "❌ Error: python3 is not installed"
exit 1
fi
# Check if pip is available
if ! command -v pip3 &> /dev/null; then
echo "❌ Error: pip3 is not installed"
exit 1
fi
# Install pytest and related dependencies
echo "📦 Installing pytest and dependencies..."
pip3 install --upgrade \
pytest \
pytest-cov \
pytest-mock \
click
# Create pytest configuration
echo "⚙️ Creating pytest configuration..."
cat > pytest.ini << 'EOF'
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
addopts =
-v
--strict-markers
--tb=short
--cov=src
--cov-report=term-missing
--cov-report=html
--cov-report=xml
markers =
unit: Unit tests
integration: Integration tests
slow: Slow running tests
cli: CLI command tests
filterwarnings =
ignore::DeprecationWarning
EOF
# Create tests directory structure
echo "📁 Creating test directory structure..."
mkdir -p tests/{unit,integration,fixtures}
# Create conftest.py with common fixtures
echo "📝 Creating pytest fixtures..."
cat > tests/conftest.py << 'EOF'
"""
Pytest configuration and fixtures for CLI testing
"""
import pytest
import tempfile
import shutil
from pathlib import Path
from click.testing import CliRunner
from src.cli import cli # Adjust import based on your CLI module
@pytest.fixture
def runner():
"""Create a CliRunner instance for testing"""
return CliRunner()
@pytest.fixture
def isolated_runner():
"""Create a CliRunner with isolated filesystem"""
runner = CliRunner()
with runner.isolated_filesystem():
yield runner
@pytest.fixture
def temp_workspace(tmp_path):
"""Create a temporary workspace directory"""
workspace = tmp_path / 'workspace'
workspace.mkdir()
yield workspace
# Cleanup handled by tmp_path fixture
@pytest.fixture
def mock_config(temp_workspace):
"""Create a mock configuration file"""
config_file = temp_workspace / '.clirc'
config_content = """
api_key: your_test_key_here
environment: development
verbose: false
"""
config_file.write_text(config_content)
return config_file
@pytest.fixture
def cli_harness(runner):
"""Create CLI test harness with helper methods"""
class CLIHarness:
def __init__(self, runner):
self.runner = runner
def run(self, args, input_data=None):
"""Run CLI command and return result"""
return self.runner.invoke(cli, args, input=input_data)
def assert_success(self, args, expected_in_output=None):
"""Assert command succeeds"""
result = self.run(args)
assert result.exit_code == 0, f"Command failed: {result.output}"
if expected_in_output:
assert expected_in_output in result.output
return result
def assert_failure(self, args, expected_in_output=None):
"""Assert command fails"""
result = self.run(args)
assert result.exit_code != 0, f"Command should have failed: {result.output}"
if expected_in_output:
assert expected_in_output in result.output
return result
return CLIHarness(runner)
EOF
# Create __init__.py files
touch tests/__init__.py
touch tests/unit/__init__.py
touch tests/integration/__init__.py
touch tests/fixtures/__init__.py
# Create sample test file
echo "📝 Creating sample test file..."
cat > tests/unit/test_cli.py << 'EOF'
"""
Unit tests for CLI commands
"""
import pytest
from click.testing import CliRunner
from src.cli import cli # Adjust import based on your CLI module
class TestVersionCommand:
"""Test version command"""
def test_version_flag(self, runner):
"""Should display version with --version"""
result = runner.invoke(cli, ['--version'])
assert result.exit_code == 0
# Adjust assertion based on your version format
def test_version_output_format(self, runner):
"""Should display version in correct format"""
result = runner.invoke(cli, ['--version'])
assert result.output.count('.') >= 2 # X.Y.Z format
class TestHelpCommand:
"""Test help command"""
def test_help_flag(self, runner):
"""Should display help with --help"""
result = runner.invoke(cli, ['--help'])
assert result.exit_code == 0
assert 'Usage:' in result.output
def test_help_shows_commands(self, runner):
"""Should list available commands"""
result = runner.invoke(cli, ['--help'])
assert 'Commands:' in result.output
class TestErrorHandling:
"""Test error handling"""
def test_unknown_command(self, runner):
"""Should handle unknown commands gracefully"""
result = runner.invoke(cli, ['unknown-command'])
assert result.exit_code != 0
assert 'no such command' in result.output.lower()
def test_invalid_option(self, runner):
"""Should handle invalid options"""
result = runner.invoke(cli, ['--invalid-option'])
assert result.exit_code != 0
EOF
# Create sample integration test
echo "📝 Creating sample integration test..."
cat > tests/integration/test_workflow.py << 'EOF'
"""
Integration tests for CLI workflows
"""
import pytest
from click.testing import CliRunner
from src.cli import cli # Adjust import based on your CLI module
@pytest.mark.integration
class TestCompleteWorkflow:
"""Test complete CLI workflows"""
def test_init_and_config_workflow(self, isolated_runner):
"""Should complete init -> config workflow"""
runner = isolated_runner
# Initialize project
result = runner.invoke(cli, ['init', 'test-project'])
assert result.exit_code == 0
# Configure project
result = runner.invoke(cli, ['config', 'set', 'key', 'value'])
assert result.exit_code == 0
# Verify configuration
result = runner.invoke(cli, ['config', 'get', 'key'])
assert result.exit_code == 0
assert 'value' in result.output
EOF
# Create requirements file for testing
echo "📝 Creating requirements-test.txt..."
cat > requirements-test.txt << 'EOF'
pytest>=7.0.0
pytest-cov>=4.0.0
pytest-mock>=3.10.0
click>=8.0.0
EOF
# Create .coveragerc for coverage configuration
echo "⚙️ Creating coverage configuration..."
cat > .coveragerc << 'EOF'
[run]
source = src
omit =
tests/*
*/venv/*
*/virtualenv/*
*/__pycache__/*
[report]
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
if TYPE_CHECKING:
@abstractmethod
precision = 2
show_missing = True
[html]
directory = htmlcov
EOF
# Update .gitignore
echo "📝 Updating .gitignore..."
if [ -f .gitignore ]; then
grep -qxF '__pycache__/' .gitignore || echo '__pycache__/' >> .gitignore
grep -qxF '*.pyc' .gitignore || echo '*.pyc' >> .gitignore
grep -qxF '.pytest_cache/' .gitignore || echo '.pytest_cache/' >> .gitignore
grep -qxF 'htmlcov/' .gitignore || echo 'htmlcov/' >> .gitignore
grep -qxF '.coverage' .gitignore || echo '.coverage' >> .gitignore
grep -qxF 'coverage.xml' .gitignore || echo 'coverage.xml' >> .gitignore
else
cat > .gitignore << 'EOF'
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
htmlcov/
.coverage
coverage.xml
*.log
.env
.env.local
EOF
fi
# Create Makefile for convenient test commands
echo "📝 Creating Makefile..."
cat > Makefile << 'EOF'
.PHONY: test test-unit test-integration test-cov clean
test:
pytest
test-unit:
pytest tests/unit -v
test-integration:
pytest tests/integration -v
test-cov:
pytest --cov --cov-report=html --cov-report=term
test-watch:
pytest --watch
clean:
rm -rf .pytest_cache htmlcov .coverage coverage.xml
find . -type d -name __pycache__ -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
EOF
# Create README for tests
echo "📝 Creating test documentation..."
cat > tests/README.md << 'EOF'
# CLI Tests
## Running Tests
```bash
# Run all tests
pytest
# Run unit tests only
pytest tests/unit
# Run integration tests only
pytest tests/integration
# Run with coverage
pytest --cov --cov-report=html
# Run specific test file
pytest tests/unit/test_cli.py
# Run specific test function
pytest tests/unit/test_cli.py::test_version_flag
# Run with verbose output
pytest -v
# Run and show print statements
pytest -s
```
## Using Makefile
```bash
# Run all tests
make test
# Run unit tests
make test-unit
# Run integration tests
make test-integration
# Run with coverage report
make test-cov
# Clean test artifacts
make clean
```
## Test Structure
- `unit/` - Unit tests for individual functions and commands
- `integration/` - Integration tests for complete workflows
- `fixtures/` - Shared test fixtures and utilities
- `conftest.py` - Pytest configuration and common fixtures
## Writing Tests
Use the fixtures from `conftest.py`:
```python
def test_example(runner):
"""Test using CliRunner fixture"""
result = runner.invoke(cli, ['command', '--flag'])
assert result.exit_code == 0
assert 'expected' in result.output
def test_with_harness(cli_harness):
"""Test using CLI harness"""
result = cli_harness.assert_success(['command'], 'expected output')
```
## Test Markers
Use markers to categorize tests:
```python
@pytest.mark.unit
def test_unit_example():
pass
@pytest.mark.integration
def test_integration_example():
pass
@pytest.mark.slow
def test_slow_operation():
pass
```
Run specific markers:
```bash
pytest -m unit
pytest -m "not slow"
```
## Coverage
Coverage reports are generated in `htmlcov/` directory.
Open `htmlcov/index.html` to view detailed coverage report.
Target: 80%+ coverage for all modules.
EOF
echo "✅ pytest setup complete!"
echo ""
echo "Next steps:"
echo " 1. Run 'pytest' to execute tests"
echo " 2. Run 'make test-cov' to see coverage report"
echo " 3. Add more tests in tests/unit/ and tests/integration/"
echo ""
echo "📚 Test files created:"
echo " - pytest.ini"
echo " - .coveragerc"
echo " - tests/conftest.py"
echo " - tests/unit/test_cli.py"
echo " - tests/integration/test_workflow.py"
echo " - tests/README.md"
echo " - Makefile"

View File

@@ -0,0 +1,127 @@
#!/bin/bash
#
# Validate Test Coverage
#
# Checks that test coverage meets minimum thresholds
set -e
# Default thresholds
MIN_COVERAGE=${MIN_COVERAGE:-70}
echo "📊 Validating test coverage..."
# Detect project type
if [ -f "package.json" ]; then
PROJECT_TYPE="node"
elif [ -f "setup.py" ] || [ -f "pyproject.toml" ]; then
PROJECT_TYPE="python"
else
echo "❌ Error: Could not detect project type"
exit 1
fi
# Check coverage for Node.js projects
if [ "$PROJECT_TYPE" == "node" ]; then
echo "📦 Node.js project detected"
# Check if coverage data exists
if [ ! -d "coverage" ]; then
echo "❌ Error: No coverage data found"
echo " Run 'npm run test:coverage' first"
exit 1
fi
# Check if coverage summary exists
if [ ! -f "coverage/coverage-summary.json" ]; then
echo "❌ Error: coverage-summary.json not found"
exit 1
fi
# Extract coverage percentages using jq if available
if command -v jq &> /dev/null; then
LINES=$(jq '.total.lines.pct' coverage/coverage-summary.json)
STATEMENTS=$(jq '.total.statements.pct' coverage/coverage-summary.json)
FUNCTIONS=$(jq '.total.functions.pct' coverage/coverage-summary.json)
BRANCHES=$(jq '.total.branches.pct' coverage/coverage-summary.json)
echo ""
echo "Coverage Summary:"
echo " Lines: ${LINES}%"
echo " Statements: ${STATEMENTS}%"
echo " Functions: ${FUNCTIONS}%"
echo " Branches: ${BRANCHES}%"
echo ""
# Check thresholds
FAILED=0
if (( $(echo "$LINES < $MIN_COVERAGE" | bc -l) )); then
echo "❌ Lines coverage (${LINES}%) below threshold (${MIN_COVERAGE}%)"
FAILED=1
fi
if (( $(echo "$STATEMENTS < $MIN_COVERAGE" | bc -l) )); then
echo "❌ Statements coverage (${STATEMENTS}%) below threshold (${MIN_COVERAGE}%)"
FAILED=1
fi
if (( $(echo "$FUNCTIONS < $MIN_COVERAGE" | bc -l) )); then
echo "❌ Functions coverage (${FUNCTIONS}%) below threshold (${MIN_COVERAGE}%)"
FAILED=1
fi
if (( $(echo "$BRANCHES < $MIN_COVERAGE" | bc -l) )); then
echo "❌ Branches coverage (${BRANCHES}%) below threshold (${MIN_COVERAGE}%)"
FAILED=1
fi
if [ $FAILED -eq 1 ]; then
echo ""
echo "❌ Coverage validation failed"
exit 1
fi
echo "✅ Coverage thresholds met!"
else
echo "⚠️ jq not installed, skipping detailed validation"
echo " Install jq for detailed coverage validation"
fi
# Check coverage for Python projects
elif [ "$PROJECT_TYPE" == "python" ]; then
echo "🐍 Python project detected"
# Check if coverage data exists
if [ ! -f ".coverage" ]; then
echo "❌ Error: No coverage data found"
echo " Run 'pytest --cov' first"
exit 1
fi
# Generate coverage report
if command -v coverage &> /dev/null; then
echo ""
coverage report
# Get total coverage percentage
TOTAL_COVERAGE=$(coverage report | tail -1 | awk '{print $NF}' | sed 's/%//')
echo ""
echo "Total Coverage: ${TOTAL_COVERAGE}%"
echo "Minimum Required: ${MIN_COVERAGE}%"
# Compare coverage
if (( $(echo "$TOTAL_COVERAGE < $MIN_COVERAGE" | bc -l) )); then
echo ""
echo "❌ Coverage (${TOTAL_COVERAGE}%) below threshold (${MIN_COVERAGE}%)"
exit 1
fi
echo ""
echo "✅ Coverage thresholds met!"
else
echo "❌ Error: coverage tool not installed"
echo " Install with: pip install coverage"
exit 1
fi
fi
echo ""
echo "🎉 Coverage validation passed!"

View File

@@ -0,0 +1,175 @@
/**
* Jest CLI Test Template
*
* Complete test suite for CLI tools using Jest and child_process.execSync
* Tests command execution, exit codes, stdout/stderr output
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
describe('CLI Tool Tests', () => {
const CLI_PATH = path.join(__dirname, '../bin/mycli');
/**
* Helper function to execute CLI commands and capture output
* @param args - Command line arguments as string
* @returns Object with stdout, stderr, and exit code
*/
function runCLI(args: string): {
stdout: string;
stderr: string;
code: number;
} {
try {
const stdout = execSync(`${CLI_PATH} ${args}`, {
encoding: 'utf8',
stdio: 'pipe',
});
return { stdout, stderr: '', code: 0 };
} catch (error: any) {
return {
stdout: error.stdout || '',
stderr: error.stderr || '',
code: error.status || 1,
};
}
}
// Version Testing
describe('version command', () => {
test('should display version with --version', () => {
const { stdout, code } = runCLI('--version');
expect(code).toBe(0);
expect(stdout).toContain('1.0.0');
});
test('should display version with -v', () => {
const { stdout, code } = runCLI('-v');
expect(code).toBe(0);
expect(stdout).toMatch(/\d+\.\d+\.\d+/);
});
});
// Help Testing
describe('help command', () => {
test('should display help with --help', () => {
const { stdout, code } = runCLI('--help');
expect(code).toBe(0);
expect(stdout).toContain('Usage:');
expect(stdout).toContain('Commands:');
expect(stdout).toContain('Options:');
});
test('should display help with -h', () => {
const { stdout, code } = runCLI('-h');
expect(code).toBe(0);
expect(stdout).toContain('Usage:');
});
});
// Error Handling
describe('error handling', () => {
test('should handle unknown command', () => {
const { stderr, code } = runCLI('unknown-command');
expect(code).toBe(1);
expect(stderr).toContain('unknown command');
});
test('should handle invalid options', () => {
const { stderr, code } = runCLI('--invalid-option');
expect(code).toBe(1);
expect(stderr).toContain('unknown option');
});
test('should validate required arguments', () => {
const { stderr, code } = runCLI('deploy');
expect(code).toBe(1);
expect(stderr).toContain('missing required argument');
});
});
// Command Execution
describe('command execution', () => {
test('should execute deploy command', () => {
const { stdout, code } = runCLI('deploy production --force');
expect(code).toBe(0);
expect(stdout).toContain('Deploying to production');
expect(stdout).toContain('Force mode enabled');
});
test('should execute with flags', () => {
const { stdout, code } = runCLI('build --verbose --output dist');
expect(code).toBe(0);
expect(stdout).toContain('Building project');
expect(stdout).toContain('Output: dist');
});
});
// Configuration Testing
describe('configuration', () => {
test('should set configuration value', () => {
const { stdout, code } = runCLI('config set key value');
expect(code).toBe(0);
expect(stdout).toContain('Configuration updated');
});
test('should get configuration value', () => {
runCLI('config set api_key your_key_here');
const { stdout, code } = runCLI('config get api_key');
expect(code).toBe(0);
expect(stdout).toContain('your_key_here');
});
test('should list all configuration', () => {
const { stdout, code } = runCLI('config list');
expect(code).toBe(0);
expect(stdout).toContain('Configuration:');
});
});
// Exit Code Validation
describe('exit codes', () => {
test('should return 0 on success', () => {
const { code } = runCLI('status');
expect(code).toBe(0);
});
test('should return 1 on general error', () => {
const { code } = runCLI('invalid-command');
expect(code).toBe(1);
});
test('should return 2 on invalid arguments', () => {
const { code } = runCLI('deploy --invalid-flag');
expect(code).toBe(2);
});
});
// Output Format Testing
describe('output formatting', () => {
test('should output JSON when requested', () => {
const { stdout, code } = runCLI('status --format json');
expect(code).toBe(0);
expect(() => JSON.parse(stdout)).not.toThrow();
});
test('should output YAML when requested', () => {
const { stdout, code } = runCLI('status --format yaml');
expect(code).toBe(0);
expect(stdout).toContain(':');
});
test('should output table by default', () => {
const { stdout, code } = runCLI('status');
expect(code).toBe(0);
expect(stdout).toMatch(/[─┼│]/); // Table characters
});
});
// Cleanup
afterAll(() => {
// Clean up any test artifacts
});
});

View File

@@ -0,0 +1,198 @@
/**
* Jest Configuration Testing Template
*
* Test CLI configuration file handling, validation, and persistence
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
describe('CLI Configuration Tests', () => {
const CLI_PATH = path.join(__dirname, '../bin/mycli');
const TEST_CONFIG_DIR = path.join(os.tmpdir(), 'cli-test-config');
const TEST_CONFIG_FILE = path.join(TEST_CONFIG_DIR, '.myclirc');
function runCLI(args: string, env: Record<string, string> = {}): {
stdout: string;
stderr: string;
code: number;
} {
try {
const stdout = execSync(`${CLI_PATH} ${args}`, {
encoding: 'utf8',
stdio: 'pipe',
env: {
...process.env,
HOME: TEST_CONFIG_DIR,
...env,
},
});
return { stdout, stderr: '', code: 0 };
} catch (error: any) {
return {
stdout: error.stdout || '',
stderr: error.stderr || '',
code: error.status || 1,
};
}
}
beforeEach(() => {
// Create temporary config directory
if (!fs.existsSync(TEST_CONFIG_DIR)) {
fs.mkdirSync(TEST_CONFIG_DIR, { recursive: true });
}
});
afterEach(() => {
// Clean up test config directory
if (fs.existsSync(TEST_CONFIG_DIR)) {
fs.rmSync(TEST_CONFIG_DIR, { recursive: true, force: true });
}
});
describe('config initialization', () => {
test('should create config file on first run', () => {
runCLI('config init');
expect(fs.existsSync(TEST_CONFIG_FILE)).toBe(true);
});
test('should not overwrite existing config', () => {
fs.writeFileSync(TEST_CONFIG_FILE, 'existing: data\n');
const { stderr, code } = runCLI('config init');
expect(code).toBe(1);
expect(stderr).toContain('Config file already exists');
});
test('should create config with default values', () => {
runCLI('config init');
const config = fs.readFileSync(TEST_CONFIG_FILE, 'utf8');
expect(config).toContain('api_key: your_api_key_here');
expect(config).toContain('environment: development');
});
});
describe('config set operations', () => {
beforeEach(() => {
runCLI('config init');
});
test('should set string value', () => {
const { code } = runCLI('config set api_key test_key_123');
expect(code).toBe(0);
const config = fs.readFileSync(TEST_CONFIG_FILE, 'utf8');
expect(config).toContain('api_key: test_key_123');
});
test('should set boolean value', () => {
const { code } = runCLI('config set verbose true');
expect(code).toBe(0);
const config = fs.readFileSync(TEST_CONFIG_FILE, 'utf8');
expect(config).toContain('verbose: true');
});
test('should set nested value', () => {
const { code } = runCLI('config set logging.level debug');
expect(code).toBe(0);
const config = fs.readFileSync(TEST_CONFIG_FILE, 'utf8');
expect(config).toContain('level: debug');
});
test('should handle invalid key names', () => {
const { stderr, code } = runCLI('config set invalid..key value');
expect(code).toBe(1);
expect(stderr).toContain('Invalid key name');
});
});
describe('config get operations', () => {
beforeEach(() => {
runCLI('config init');
runCLI('config set api_key test_key_123');
runCLI('config set environment production');
});
test('should get existing value', () => {
const { stdout, code } = runCLI('config get api_key');
expect(code).toBe(0);
expect(stdout).toContain('test_key_123');
});
test('should handle non-existent key', () => {
const { stderr, code } = runCLI('config get nonexistent');
expect(code).toBe(1);
expect(stderr).toContain('Key not found');
});
test('should get nested value', () => {
runCLI('config set database.host localhost');
const { stdout, code } = runCLI('config get database.host');
expect(code).toBe(0);
expect(stdout).toContain('localhost');
});
});
describe('config list operations', () => {
beforeEach(() => {
runCLI('config init');
runCLI('config set api_key test_key_123');
runCLI('config set verbose true');
});
test('should list all configuration', () => {
const { stdout, code } = runCLI('config list');
expect(code).toBe(0);
expect(stdout).toContain('api_key');
expect(stdout).toContain('verbose');
});
test('should format list output', () => {
const { stdout, code } = runCLI('config list --format json');
expect(code).toBe(0);
const config = JSON.parse(stdout);
expect(config.api_key).toBe('test_key_123');
expect(config.verbose).toBe(true);
});
});
describe('config validation', () => {
test('should validate config file on load', () => {
fs.writeFileSync(TEST_CONFIG_FILE, 'invalid yaml: [}');
const { stderr, code } = runCLI('config list');
expect(code).toBe(1);
expect(stderr).toContain('Invalid configuration file');
});
test('should validate required fields', () => {
runCLI('config init');
fs.writeFileSync(TEST_CONFIG_FILE, 'optional: value\n');
const { stderr, code } = runCLI('deploy production');
expect(code).toBe(1);
expect(stderr).toContain('api_key is required');
});
});
describe('environment variable overrides', () => {
beforeEach(() => {
runCLI('config init');
runCLI('config set api_key file_key_123');
});
test('should override with environment variable', () => {
const { stdout } = runCLI('config get api_key', {
MYCLI_API_KEY: 'env_key_123',
});
expect(stdout).toContain('env_key_123');
});
test('should use file value when env var not set', () => {
const { stdout } = runCLI('config get api_key');
expect(stdout).toContain('file_key_123');
});
});
});

View File

@@ -0,0 +1,223 @@
/**
* Jest Integration Test Template
*
* Test complete CLI workflows with multiple commands and state persistence
*/
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
describe('CLI Integration Tests', () => {
const CLI_PATH = path.join(__dirname, '../bin/mycli');
const TEST_WORKSPACE = path.join(os.tmpdir(), 'cli-integration-test');
function runCLI(args: string, cwd: string = TEST_WORKSPACE): {
stdout: string;
stderr: string;
code: number;
} {
try {
const stdout = execSync(`${CLI_PATH} ${args}`, {
encoding: 'utf8',
stdio: 'pipe',
cwd,
});
return { stdout, stderr: '', code: 0 };
} catch (error: any) {
return {
stdout: error.stdout || '',
stderr: error.stderr || '',
code: error.status || 1,
};
}
}
beforeEach(() => {
// Create clean test workspace
if (fs.existsSync(TEST_WORKSPACE)) {
fs.rmSync(TEST_WORKSPACE, { recursive: true, force: true });
}
fs.mkdirSync(TEST_WORKSPACE, { recursive: true });
});
afterEach(() => {
// Clean up test workspace
if (fs.existsSync(TEST_WORKSPACE)) {
fs.rmSync(TEST_WORKSPACE, { recursive: true, force: true });
}
});
describe('complete deployment workflow', () => {
test('should initialize, configure, and deploy', () => {
// Step 1: Initialize project
const init = runCLI('init my-project');
expect(init.code).toBe(0);
expect(init.stdout).toContain('Project initialized');
// Step 2: Configure deployment
const config = runCLI('config set api_key test_key_123');
expect(config.code).toBe(0);
// Step 3: Build project
const build = runCLI('build --production');
expect(build.code).toBe(0);
expect(build.stdout).toContain('Build successful');
// Step 4: Deploy
const deploy = runCLI('deploy production');
expect(deploy.code).toBe(0);
expect(deploy.stdout).toContain('Deployed successfully');
// Verify deployment artifacts
const deployFile = path.join(TEST_WORKSPACE, '.deploy');
expect(fs.existsSync(deployFile)).toBe(true);
});
test('should fail deployment without configuration', () => {
runCLI('init my-project');
// Try to deploy without configuring API key
const { stderr, code } = runCLI('deploy production');
expect(code).toBe(1);
expect(stderr).toContain('API key not configured');
});
});
describe('multi-environment workflow', () => {
test('should manage multiple environments', () => {
// Initialize project
runCLI('init my-project');
// Configure development environment
runCLI('config set api_key dev_key_123 --env development');
runCLI('config set base_url https://dev.example.com --env development');
// Configure production environment
runCLI('config set api_key prod_key_123 --env production');
runCLI('config set base_url https://api.example.com --env production');
// Deploy to development
const devDeploy = runCLI('deploy development');
expect(devDeploy.code).toBe(0);
expect(devDeploy.stdout).toContain('dev.example.com');
// Deploy to production
const prodDeploy = runCLI('deploy production');
expect(prodDeploy.code).toBe(0);
expect(prodDeploy.stdout).toContain('api.example.com');
});
});
describe('state persistence workflow', () => {
test('should persist and restore state', () => {
// Create initial state
runCLI('state set counter 0');
// Increment counter multiple times
runCLI('increment');
runCLI('increment');
runCLI('increment');
// Verify final state
const { stdout } = runCLI('state get counter');
expect(stdout).toContain('3');
});
test('should handle state file corruption', () => {
runCLI('state set key value');
// Corrupt state file
const stateFile = path.join(TEST_WORKSPACE, '.state');
fs.writeFileSync(stateFile, 'invalid json {[}');
// Should recover gracefully
const { stderr, code } = runCLI('state get key');
expect(code).toBe(1);
expect(stderr).toContain('Corrupted state file');
});
});
describe('plugin workflow', () => {
test('should install and use plugins', () => {
// Initialize project
runCLI('init my-project');
// Install plugin
const install = runCLI('plugin install my-plugin');
expect(install.code).toBe(0);
// Verify plugin is listed
const list = runCLI('plugin list');
expect(list.stdout).toContain('my-plugin');
// Use plugin command
const usePlugin = runCLI('my-plugin:command');
expect(usePlugin.code).toBe(0);
// Uninstall plugin
const uninstall = runCLI('plugin uninstall my-plugin');
expect(uninstall.code).toBe(0);
// Verify plugin is removed
const listAfter = runCLI('plugin list');
expect(listAfter.stdout).not.toContain('my-plugin');
});
});
describe('error recovery workflow', () => {
test('should recover from partial failure', () => {
runCLI('init my-project');
// Simulate partial deployment failure
runCLI('deploy staging --force');
// Should be able to rollback
const rollback = runCLI('rollback');
expect(rollback.code).toBe(0);
expect(rollback.stdout).toContain('Rollback successful');
// Should be able to retry
const retry = runCLI('deploy staging --retry');
expect(retry.code).toBe(0);
});
});
describe('concurrent operations', () => {
test('should handle file locking', async () => {
runCLI('init my-project');
// Start long-running operation
const longOp = execSync(`${CLI_PATH} long-running-task &`, {
cwd: TEST_WORKSPACE,
});
// Try to run another operation that needs lock
const { stderr, code } = runCLI('another-task');
expect(code).toBe(1);
expect(stderr).toContain('Another operation in progress');
});
});
describe('data migration workflow', () => {
test('should migrate data between versions', () => {
// Create old version data
const oldData = { version: 1, data: 'legacy format' };
fs.writeFileSync(
path.join(TEST_WORKSPACE, 'data.json'),
JSON.stringify(oldData)
);
// Run migration
const migrate = runCLI('migrate --to 2.0');
expect(migrate.code).toBe(0);
// Verify new format
const newData = JSON.parse(
fs.readFileSync(path.join(TEST_WORKSPACE, 'data.json'), 'utf8')
);
expect(newData.version).toBe(2);
});
});
});

View File

@@ -0,0 +1,270 @@
"""
Pytest Click Testing Template
Complete test suite for Click-based CLI applications using CliRunner
Tests command execution, exit codes, output validation, and interactive prompts
"""
import pytest
from click.testing import CliRunner
from mycli.cli import cli
@pytest.fixture
def runner():
"""Create a CliRunner instance for testing"""
return CliRunner()
class TestVersionCommand:
"""Test version display"""
def test_version_flag(self, runner):
"""Should display version with --version"""
result = runner.invoke(cli, ['--version'])
assert result.exit_code == 0
assert '1.0.0' in result.output
def test_version_short_flag(self, runner):
"""Should display version with -v"""
result = runner.invoke(cli, ['-v'])
assert result.exit_code == 0
assert result.output.count('.') == 2 # Version format X.Y.Z
class TestHelpCommand:
"""Test help display"""
def test_help_flag(self, runner):
"""Should display help with --help"""
result = runner.invoke(cli, ['--help'])
assert result.exit_code == 0
assert 'Usage:' in result.output
assert 'Commands:' in result.output
assert 'Options:' in result.output
def test_help_short_flag(self, runner):
"""Should display help with -h"""
result = runner.invoke(cli, ['-h'])
assert result.exit_code == 0
assert 'Usage:' in result.output
def test_command_help(self, runner):
"""Should display help for specific command"""
result = runner.invoke(cli, ['deploy', '--help'])
assert result.exit_code == 0
assert 'deploy' in result.output.lower()
class TestErrorHandling:
"""Test error handling and validation"""
def test_unknown_command(self, runner):
"""Should handle unknown commands"""
result = runner.invoke(cli, ['unknown-command'])
assert result.exit_code != 0
assert 'no such command' in result.output.lower()
def test_invalid_option(self, runner):
"""Should handle invalid options"""
result = runner.invoke(cli, ['--invalid-option'])
assert result.exit_code != 0
assert 'no such option' in result.output.lower()
def test_missing_required_argument(self, runner):
"""Should validate required arguments"""
result = runner.invoke(cli, ['deploy'])
assert result.exit_code != 0
assert 'missing argument' in result.output.lower()
def test_invalid_argument_type(self, runner):
"""Should validate argument types"""
result = runner.invoke(cli, ['retry', '--count', 'invalid'])
assert result.exit_code != 0
assert 'invalid' in result.output.lower()
class TestCommandExecution:
"""Test command execution with various arguments"""
def test_deploy_command(self, runner):
"""Should execute deploy command"""
result = runner.invoke(cli, ['deploy', 'production', '--force'])
assert result.exit_code == 0
assert 'Deploying to production' in result.output
assert 'Force mode enabled' in result.output
def test_deploy_with_flags(self, runner):
"""Should handle multiple flags"""
result = runner.invoke(cli, ['deploy', 'staging', '--verbose', '--dry-run'])
assert result.exit_code == 0
assert 'staging' in result.output
assert 'dry run' in result.output.lower()
def test_build_command(self, runner):
"""Should execute build command"""
result = runner.invoke(cli, ['build', '--output', 'dist'])
assert result.exit_code == 0
assert 'Building project' in result.output
assert 'dist' in result.output
class TestConfiguration:
"""Test configuration management"""
def test_config_set(self, runner):
"""Should set configuration value"""
result = runner.invoke(cli, ['config', 'set', 'api_key', 'your_key_here'])
assert result.exit_code == 0
assert 'Configuration updated' in result.output
def test_config_get(self, runner):
"""Should get configuration value"""
runner.invoke(cli, ['config', 'set', 'api_key', 'your_key_here'])
result = runner.invoke(cli, ['config', 'get', 'api_key'])
assert result.exit_code == 0
assert 'your_key_here' in result.output
def test_config_list(self, runner):
"""Should list all configuration"""
result = runner.invoke(cli, ['config', 'list'])
assert result.exit_code == 0
assert 'Configuration:' in result.output
def test_config_delete(self, runner):
"""Should delete configuration value"""
runner.invoke(cli, ['config', 'set', 'temp_key', 'temp_value'])
result = runner.invoke(cli, ['config', 'delete', 'temp_key'])
assert result.exit_code == 0
assert 'deleted' in result.output.lower()
class TestExitCodes:
"""Test exit code validation"""
def test_success_exit_code(self, runner):
"""Should return 0 on success"""
result = runner.invoke(cli, ['status'])
assert result.exit_code == 0
def test_error_exit_code(self, runner):
"""Should return non-zero on error"""
result = runner.invoke(cli, ['invalid-command'])
assert result.exit_code != 0
def test_validation_error_exit_code(self, runner):
"""Should return specific code for validation errors"""
result = runner.invoke(cli, ['deploy', '--invalid-flag'])
assert result.exit_code == 2 # Click uses 2 for usage errors
class TestInteractivePrompts:
"""Test interactive prompt handling"""
def test_interactive_deploy_wizard(self, runner):
"""Should handle interactive prompts"""
result = runner.invoke(
cli,
['deploy-wizard'],
input='my-app\n1\nyes\n'
)
assert result.exit_code == 0
assert 'my-app' in result.output
def test_confirmation_prompt(self, runner):
"""Should handle confirmation prompts"""
result = runner.invoke(
cli,
['delete', 'resource-id'],
input='y\n'
)
assert result.exit_code == 0
assert 'deleted' in result.output.lower()
def test_confirmation_prompt_denied(self, runner):
"""Should handle denied confirmation"""
result = runner.invoke(
cli,
['delete', 'resource-id'],
input='n\n'
)
assert result.exit_code == 1
assert 'cancelled' in result.output.lower()
def test_multiple_prompts(self, runner):
"""Should handle multiple prompts in sequence"""
result = runner.invoke(
cli,
['init'],
input='my-project\nJohn Doe\njohn@example.com\n'
)
assert result.exit_code == 0
assert 'my-project' in result.output
assert 'John Doe' in result.output
class TestOutputFormatting:
"""Test output formatting options"""
def test_json_output(self, runner):
"""Should output JSON format"""
result = runner.invoke(cli, ['status', '--format', 'json'])
assert result.exit_code == 0
import json
try:
json.loads(result.output)
except json.JSONDecodeError:
pytest.fail("Output is not valid JSON")
def test_yaml_output(self, runner):
"""Should output YAML format"""
result = runner.invoke(cli, ['status', '--format', 'yaml'])
assert result.exit_code == 0
assert ':' in result.output
def test_table_output(self, runner):
"""Should output table format by default"""
result = runner.invoke(cli, ['list'])
assert result.exit_code == 0
assert '' in result.output or '|' in result.output
def test_quiet_mode(self, runner):
"""Should suppress output in quiet mode"""
result = runner.invoke(cli, ['deploy', 'production', '--quiet'])
assert result.exit_code == 0
assert len(result.output.strip()) == 0
class TestFileOperations:
"""Test file-based operations"""
def test_file_input(self, runner):
"""Should read from file"""
with runner.isolated_filesystem():
with open('input.txt', 'w') as f:
f.write('test data\n')
result = runner.invoke(cli, ['process', '--input', 'input.txt'])
assert result.exit_code == 0
def test_file_output(self, runner):
"""Should write to file"""
with runner.isolated_filesystem():
result = runner.invoke(cli, ['export', '--output', 'output.txt'])
assert result.exit_code == 0
with open('output.txt', 'r') as f:
content = f.read()
assert len(content) > 0
class TestIsolation:
"""Test isolated filesystem operations"""
def test_isolated_filesystem(self, runner):
"""Should work in isolated filesystem"""
with runner.isolated_filesystem():
result = runner.invoke(cli, ['init', 'test-project'])
assert result.exit_code == 0
import os
assert os.path.exists('test-project')

View File

@@ -0,0 +1,346 @@
"""
Pytest Fixtures Template
Reusable pytest fixtures for CLI testing with Click.testing.CliRunner
Provides common setup, teardown, and test utilities
"""
import pytest
import os
import tempfile
import shutil
from pathlib import Path
from click.testing import CliRunner
from mycli.cli import cli
# Basic Fixtures
@pytest.fixture
def runner():
"""Create a CliRunner instance for testing"""
return CliRunner()
@pytest.fixture
def isolated_runner():
"""Create a CliRunner with isolated filesystem"""
runner = CliRunner()
with runner.isolated_filesystem():
yield runner
# Configuration Fixtures
@pytest.fixture
def temp_config_dir(tmp_path):
"""Create a temporary configuration directory"""
config_dir = tmp_path / '.mycli'
config_dir.mkdir()
return config_dir
@pytest.fixture
def config_file(temp_config_dir):
"""Create a temporary configuration file"""
config_path = temp_config_dir / 'config.yaml'
config_content = """
api_key: your_test_key_here
environment: development
verbose: false
timeout: 30
"""
config_path.write_text(config_content)
return config_path
@pytest.fixture
def env_with_config(temp_config_dir, monkeypatch):
"""Set up environment with config directory"""
monkeypatch.setenv('MYCLI_CONFIG_DIR', str(temp_config_dir))
return temp_config_dir
# File System Fixtures
@pytest.fixture
def temp_workspace(tmp_path):
"""Create a temporary workspace directory"""
workspace = tmp_path / 'workspace'
workspace.mkdir()
return workspace
@pytest.fixture
def sample_project(temp_workspace):
"""Create a sample project structure"""
project = temp_workspace / 'sample-project'
project.mkdir()
# Create sample files
(project / 'package.json').write_text('{"name": "sample", "version": "1.0.0"}')
(project / 'README.md').write_text('# Sample Project')
src_dir = project / 'src'
src_dir.mkdir()
(src_dir / 'index.js').write_text('console.log("Hello, World!");')
return project
@pytest.fixture
def sample_files(temp_workspace):
"""Create sample files for testing"""
files = {
'input.txt': 'test input data\n',
'config.yaml': 'key: value\n',
'data.json': '{"id": 1, "name": "test"}\n'
}
created_files = {}
for filename, content in files.items():
file_path = temp_workspace / filename
file_path.write_text(content)
created_files[filename] = file_path
return created_files
# Mock Fixtures
@pytest.fixture
def mock_api_key(monkeypatch):
"""Mock API key environment variable"""
monkeypatch.setenv('MYCLI_API_KEY', 'test_api_key_123')
return 'test_api_key_123'
@pytest.fixture
def mock_home_dir(tmp_path, monkeypatch):
"""Mock home directory"""
home = tmp_path / 'home'
home.mkdir()
monkeypatch.setenv('HOME', str(home))
return home
@pytest.fixture
def mock_no_config(monkeypatch):
"""Remove all configuration environment variables"""
vars_to_remove = [
'MYCLI_CONFIG_DIR',
'MYCLI_API_KEY',
'MYCLI_ENVIRONMENT',
]
for var in vars_to_remove:
monkeypatch.delenv(var, raising=False)
# State Management Fixtures
@pytest.fixture
def cli_state(temp_workspace):
"""Create a CLI state file"""
state_file = temp_workspace / '.mycli-state'
state = {
'initialized': True,
'last_command': None,
'history': []
}
import json
state_file.write_text(json.dumps(state, indent=2))
return state_file
@pytest.fixture
def clean_state(temp_workspace):
"""Ensure no state file exists"""
state_file = temp_workspace / '.mycli-state'
if state_file.exists():
state_file.unlink()
return temp_workspace
# Helper Function Fixtures
@pytest.fixture
def run_cli_command(runner):
"""Helper function to run CLI commands and return parsed results"""
def _run(args, input_data=None, env=None):
"""
Run a CLI command and return structured results
Args:
args: List of command arguments
input_data: Optional input for interactive prompts
env: Optional environment variables dict
Returns:
dict with keys: exit_code, output, lines, success
"""
result = runner.invoke(cli, args, input=input_data, env=env)
return {
'exit_code': result.exit_code,
'output': result.output,
'lines': result.output.splitlines(),
'success': result.exit_code == 0
}
return _run
@pytest.fixture
def assert_cli_success(runner):
"""Helper to assert successful CLI execution"""
def _assert(args, expected_in_output=None):
"""
Run CLI command and assert success
Args:
args: List of command arguments
expected_in_output: Optional string expected in output
"""
result = runner.invoke(cli, args)
assert result.exit_code == 0, f"Command failed: {result.output}"
if expected_in_output:
assert expected_in_output in result.output
return result
return _assert
@pytest.fixture
def assert_cli_failure(runner):
"""Helper to assert CLI command failure"""
def _assert(args, expected_in_output=None):
"""
Run CLI command and assert failure
Args:
args: List of command arguments
expected_in_output: Optional string expected in output
"""
result = runner.invoke(cli, args)
assert result.exit_code != 0, f"Command should have failed: {result.output}"
if expected_in_output:
assert expected_in_output in result.output
return result
return _assert
# Cleanup Fixtures
@pytest.fixture(autouse=True)
def cleanup_temp_files(request):
"""Automatically clean up temporary files after tests"""
temp_files = []
def _register(filepath):
temp_files.append(filepath)
request.addfinalizer(lambda: [
os.remove(f) for f in temp_files if os.path.exists(f)
])
return _register
@pytest.fixture(scope='session')
def test_data_dir():
"""Provide path to test data directory"""
return Path(__file__).parent / 'test_data'
# Parametrized Fixtures
@pytest.fixture(params=['json', 'yaml', 'table'])
def output_format(request):
"""Parametrize tests across different output formats"""
return request.param
@pytest.fixture(params=[True, False])
def verbose_mode(request):
"""Parametrize tests with and without verbose mode"""
return request.param
@pytest.fixture(params=['development', 'staging', 'production'])
def environment(request):
"""Parametrize tests across different environments"""
return request.param
# Integration Test Fixtures
@pytest.fixture
def integration_workspace(tmp_path):
"""
Create a complete integration test workspace with all necessary files
"""
workspace = tmp_path / 'integration'
workspace.mkdir()
# Create directory structure
(workspace / 'src').mkdir()
(workspace / 'tests').mkdir()
(workspace / 'config').mkdir()
(workspace / 'data').mkdir()
# Create config files
(workspace / 'config' / 'dev.yaml').write_text('env: development\n')
(workspace / 'config' / 'prod.yaml').write_text('env: production\n')
# Initialize CLI
runner = CliRunner()
with runner.isolated_filesystem(temp_dir=workspace):
runner.invoke(cli, ['init'])
return workspace
@pytest.fixture
def mock_external_service(monkeypatch):
"""Mock external service API calls"""
class MockService:
def __init__(self):
self.calls = []
def call_api(self, endpoint, method='GET', data=None):
self.calls.append({
'endpoint': endpoint,
'method': method,
'data': data
})
return {'status': 'success', 'data': 'mock response'}
mock = MockService()
# Replace actual service with mock
monkeypatch.setattr('mycli.services.api', mock)
return mock
# Snapshot Testing Fixtures
@pytest.fixture
def snapshot_dir(tmp_path):
"""Create directory for snapshot testing"""
snapshot = tmp_path / 'snapshots'
snapshot.mkdir()
return snapshot
@pytest.fixture
def compare_output(snapshot_dir):
"""Compare CLI output with saved snapshot"""
def _compare(output, snapshot_name):
snapshot_file = snapshot_dir / f'{snapshot_name}.txt'
if not snapshot_file.exists():
# Create snapshot
snapshot_file.write_text(output)
return True
# Compare with existing snapshot
expected = snapshot_file.read_text()
return output == expected
return _compare

View File

@@ -0,0 +1,378 @@
"""
Pytest Integration Test Template
Complete workflow testing for CLI applications using Click.testing.CliRunner
Tests multi-command workflows, state persistence, and end-to-end scenarios
"""
import pytest
import os
import json
import yaml
from pathlib import Path
from click.testing import CliRunner
from mycli.cli import cli
@pytest.fixture
def integration_runner():
"""Create runner with isolated filesystem for integration tests"""
runner = CliRunner()
with runner.isolated_filesystem():
yield runner
class TestDeploymentWorkflow:
"""Test complete deployment workflow"""
def test_full_deployment_workflow(self, integration_runner):
"""Should complete init -> configure -> build -> deploy workflow"""
runner = integration_runner
# Step 1: Initialize project
result = runner.invoke(cli, ['init', 'my-project'])
assert result.exit_code == 0
assert 'Project initialized' in result.output
assert os.path.exists('my-project')
# Step 2: Configure API key
os.chdir('my-project')
result = runner.invoke(cli, ['config', 'set', 'api_key', 'your_key_here'])
assert result.exit_code == 0
# Step 3: Build project
result = runner.invoke(cli, ['build', '--production'])
assert result.exit_code == 0
assert 'Build successful' in result.output
# Step 4: Deploy to production
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 0
assert 'Deployed successfully' in result.output
def test_deployment_without_config_fails(self, integration_runner):
"""Should fail deployment without required configuration"""
runner = integration_runner
# Initialize but don't configure
runner.invoke(cli, ['init', 'my-project'])
os.chdir('my-project')
# Try to deploy without API key
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code != 0
assert 'api_key' in result.output.lower()
def test_deployment_rollback(self, integration_runner):
"""Should rollback failed deployment"""
runner = integration_runner
# Setup and deploy
runner.invoke(cli, ['init', 'my-project'])
os.chdir('my-project')
runner.invoke(cli, ['config', 'set', 'api_key', 'your_key_here'])
runner.invoke(cli, ['deploy', 'staging'])
# Rollback
result = runner.invoke(cli, ['rollback'])
assert result.exit_code == 0
assert 'Rollback successful' in result.output
class TestMultiEnvironmentWorkflow:
"""Test multi-environment configuration and deployment"""
def test_manage_multiple_environments(self, integration_runner):
"""Should manage dev, staging, and production environments"""
runner = integration_runner
runner.invoke(cli, ['init', 'multi-env-project'])
os.chdir('multi-env-project')
# Configure development
runner.invoke(cli, ['config', 'set', 'api_key', 'dev_key', '--env', 'development'])
runner.invoke(cli, ['config', 'set', 'base_url', 'https://dev.api.example.com', '--env', 'development'])
# Configure staging
runner.invoke(cli, ['config', 'set', 'api_key', 'staging_key', '--env', 'staging'])
runner.invoke(cli, ['config', 'set', 'base_url', 'https://staging.api.example.com', '--env', 'staging'])
# Configure production
runner.invoke(cli, ['config', 'set', 'api_key', 'prod_key', '--env', 'production'])
runner.invoke(cli, ['config', 'set', 'base_url', 'https://api.example.com', '--env', 'production'])
# Deploy to each environment
dev_result = runner.invoke(cli, ['deploy', 'development'])
assert dev_result.exit_code == 0
assert 'dev.api.example.com' in dev_result.output
staging_result = runner.invoke(cli, ['deploy', 'staging'])
assert staging_result.exit_code == 0
assert 'staging.api.example.com' in staging_result.output
prod_result = runner.invoke(cli, ['deploy', 'production'])
assert prod_result.exit_code == 0
assert 'api.example.com' in prod_result.output
def test_environment_isolation(self, integration_runner):
"""Should keep environment configurations isolated"""
runner = integration_runner
runner.invoke(cli, ['init', 'isolated-project'])
os.chdir('isolated-project')
# Set different values for each environment
runner.invoke(cli, ['config', 'set', 'timeout', '10', '--env', 'development'])
runner.invoke(cli, ['config', 'set', 'timeout', '30', '--env', 'production'])
# Verify values are isolated
dev_result = runner.invoke(cli, ['config', 'get', 'timeout', '--env', 'development'])
assert '10' in dev_result.output
prod_result = runner.invoke(cli, ['config', 'get', 'timeout', '--env', 'production'])
assert '30' in prod_result.output
class TestStatePersistence:
"""Test state management and persistence"""
def test_state_persistence_across_commands(self, integration_runner):
"""Should maintain state across multiple commands"""
runner = integration_runner
# Initialize state
result = runner.invoke(cli, ['state', 'init'])
assert result.exit_code == 0
# Set multiple state values
runner.invoke(cli, ['state', 'set', 'counter', '0'])
runner.invoke(cli, ['state', 'set', 'user', 'testuser'])
# Increment counter multiple times
for i in range(5):
runner.invoke(cli, ['increment'])
# Verify final state
result = runner.invoke(cli, ['state', 'get', 'counter'])
assert result.exit_code == 0
assert '5' in result.output
result = runner.invoke(cli, ['state', 'get', 'user'])
assert 'testuser' in result.output
def test_state_recovery_from_corruption(self, integration_runner):
"""Should recover from corrupted state file"""
runner = integration_runner
# Create valid state
runner.invoke(cli, ['state', 'init'])
runner.invoke(cli, ['state', 'set', 'key', 'value'])
# Corrupt the state file
with open('.mycli-state', 'w') as f:
f.write('invalid json {[}')
# Should detect corruption and recover
result = runner.invoke(cli, ['state', 'get', 'key'])
assert result.exit_code != 0
assert 'corrupt' in result.output.lower()
# Should be able to reset
result = runner.invoke(cli, ['state', 'reset'])
assert result.exit_code == 0
class TestPluginWorkflow:
"""Test plugin installation and usage"""
def test_plugin_lifecycle(self, integration_runner):
"""Should install, use, and uninstall plugins"""
runner = integration_runner
runner.invoke(cli, ['init', 'plugin-project'])
os.chdir('plugin-project')
# Install plugin
result = runner.invoke(cli, ['plugin', 'install', 'test-plugin'])
assert result.exit_code == 0
assert 'installed' in result.output.lower()
# Verify plugin is listed
result = runner.invoke(cli, ['plugin', 'list'])
assert 'test-plugin' in result.output
# Use plugin command
result = runner.invoke(cli, ['test-plugin:command', '--arg', 'value'])
assert result.exit_code == 0
# Uninstall plugin
result = runner.invoke(cli, ['plugin', 'uninstall', 'test-plugin'])
assert result.exit_code == 0
# Verify plugin is removed
result = runner.invoke(cli, ['plugin', 'list'])
assert 'test-plugin' not in result.output
def test_plugin_conflict_detection(self, integration_runner):
"""Should detect and handle plugin conflicts"""
runner = integration_runner
runner.invoke(cli, ['init', 'conflict-project'])
os.chdir('conflict-project')
# Install first plugin
runner.invoke(cli, ['plugin', 'install', 'plugin-a'])
# Try to install conflicting plugin
result = runner.invoke(cli, ['plugin', 'install', 'plugin-b'])
if 'conflict' in result.output.lower():
assert result.exit_code != 0
class TestDataMigration:
"""Test data migration workflows"""
def test_version_migration(self, integration_runner):
"""Should migrate data between versions"""
runner = integration_runner
# Create old version data
old_data = {
'version': 1,
'format': 'legacy',
'data': {'key': 'value'}
}
with open('data.json', 'w') as f:
json.dump(old_data, f)
# Run migration
result = runner.invoke(cli, ['migrate', '--to', '2.0'])
assert result.exit_code == 0
# Verify new format
with open('data.json', 'r') as f:
new_data = json.load(f)
assert new_data['version'] == 2
assert 'legacy' not in new_data.get('format', '')
def test_migration_backup(self, integration_runner):
"""Should create backup during migration"""
runner = integration_runner
# Create data
data = {'version': 1, 'data': 'important'}
with open('data.json', 'w') as f:
json.dump(data, f)
# Migrate with backup
result = runner.invoke(cli, ['migrate', '--to', '2.0', '--backup'])
assert result.exit_code == 0
# Verify backup exists
assert os.path.exists('data.json.backup')
class TestConcurrentOperations:
"""Test handling of concurrent operations"""
def test_file_locking(self, integration_runner):
"""Should prevent concurrent modifications"""
runner = integration_runner
runner.invoke(cli, ['init', 'lock-project'])
os.chdir('lock-project')
# Create lock file
with open('.mycli.lock', 'w') as f:
f.write('locked')
# Try to run command that needs lock
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code != 0
assert 'lock' in result.output.lower()
def test_lock_timeout(self, integration_runner):
"""Should timeout waiting for lock"""
runner = integration_runner
runner.invoke(cli, ['init', 'timeout-project'])
os.chdir('timeout-project')
# Create stale lock
with open('.mycli.lock', 'w') as f:
import time
f.write(str(time.time() - 3600)) # 1 hour old
# Should detect stale lock and continue
result = runner.invoke(cli, ['build'])
assert result.exit_code == 0
class TestErrorRecovery:
"""Test error recovery and retry logic"""
def test_retry_on_failure(self, integration_runner):
"""Should retry failed operations"""
runner = integration_runner
runner.invoke(cli, ['init', 'retry-project'])
os.chdir('retry-project')
runner.invoke(cli, ['config', 'set', 'api_key', 'your_key_here'])
# Simulate failure and retry
result = runner.invoke(cli, ['deploy', 'staging', '--retry', '3'])
# Should attempt retry logic
def test_partial_failure_recovery(self, integration_runner):
"""Should recover from partial failures"""
runner = integration_runner
runner.invoke(cli, ['init', 'recovery-project'])
os.chdir('recovery-project')
# Create partial state
runner.invoke(cli, ['build', '--step', '1'])
runner.invoke(cli, ['build', '--step', '2'])
# Complete from last successful step
result = runner.invoke(cli, ['build', '--continue'])
assert result.exit_code == 0
class TestCompleteWorkflow:
"""Test complete end-to-end workflows"""
def test_full_project_lifecycle(self, integration_runner):
"""Should complete entire project lifecycle"""
runner = integration_runner
# Create project
result = runner.invoke(cli, ['create', 'full-project'])
assert result.exit_code == 0
os.chdir('full-project')
# Configure
runner.invoke(cli, ['config', 'set', 'api_key', 'your_key_here'])
runner.invoke(cli, ['config', 'set', 'region', 'us-west-1'])
# Add dependencies
result = runner.invoke(cli, ['add', 'dependency', 'package-name'])
assert result.exit_code == 0
# Build
result = runner.invoke(cli, ['build', '--production'])
assert result.exit_code == 0
# Test
result = runner.invoke(cli, ['test'])
assert result.exit_code == 0
# Deploy
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 0
# Verify deployment
result = runner.invoke(cli, ['status'])
assert result.exit_code == 0
assert 'deployed' in result.output.lower()

View File

@@ -0,0 +1,509 @@
"""
Python Test Helper Functions
Utility functions for CLI testing with pytest and Click.testing.CliRunner
"""
import os
import json
import tempfile
import shutil
from pathlib import Path
from typing import Any, Dict, List, Optional, Callable
from click.testing import CliRunner, Result
class CLITestHarness:
"""Test harness for CLI testing with helpful assertion methods"""
def __init__(self, cli_app):
"""
Initialize test harness
Args:
cli_app: Click CLI application to test
"""
self.cli = cli_app
self.runner = CliRunner()
def run(
self,
args: List[str],
input_data: Optional[str] = None,
env: Optional[Dict[str, str]] = None
) -> Result:
"""
Run CLI command
Args:
args: Command arguments
input_data: Input for interactive prompts
env: Environment variables
Returns:
Click Result object
"""
return self.runner.invoke(self.cli, args, input=input_data, env=env)
def assert_success(
self,
args: List[str],
expected_in_output: Optional[str] = None
) -> Result:
"""
Run command and assert successful execution
Args:
args: Command arguments
expected_in_output: Optional string expected in output
Returns:
Click Result object
Raises:
AssertionError: If command fails or output doesn't match
"""
result = self.run(args)
assert result.exit_code == 0, f"Command failed: {result.output}"
if expected_in_output:
assert expected_in_output in result.output, \
f"Expected '{expected_in_output}' in output: {result.output}"
return result
def assert_failure(
self,
args: List[str],
expected_in_output: Optional[str] = None
) -> Result:
"""
Run command and assert it fails
Args:
args: Command arguments
expected_in_output: Optional string expected in output
Returns:
Click Result object
Raises:
AssertionError: If command succeeds or output doesn't match
"""
result = self.run(args)
assert result.exit_code != 0, f"Command should have failed: {result.output}"
if expected_in_output:
assert expected_in_output in result.output, \
f"Expected '{expected_in_output}' in output: {result.output}"
return result
def assert_exit_code(self, args: List[str], expected_code: int) -> Result:
"""
Run command and assert specific exit code
Args:
args: Command arguments
expected_code: Expected exit code
Returns:
Click Result object
Raises:
AssertionError: If exit code doesn't match
"""
result = self.run(args)
assert result.exit_code == expected_code, \
f"Expected exit code {expected_code}, got {result.exit_code}"
return result
def run_json(self, args: List[str]) -> Dict[str, Any]:
"""
Run command and parse JSON output
Args:
args: Command arguments
Returns:
Parsed JSON object
Raises:
AssertionError: If command fails
json.JSONDecodeError: If output is not valid JSON
"""
result = self.assert_success(args)
return json.loads(result.output)
def create_temp_workspace() -> Path:
"""
Create temporary workspace directory
Returns:
Path to temporary workspace
"""
temp_dir = Path(tempfile.mkdtemp(prefix='cli-test-'))
return temp_dir
def cleanup_workspace(workspace: Path) -> None:
"""
Clean up temporary workspace
Args:
workspace: Path to workspace to remove
"""
if workspace.exists():
shutil.rmtree(workspace)
def create_temp_file(content: str, suffix: str = '.txt') -> Path:
"""
Create temporary file with content
Args:
content: File content
suffix: File extension
Returns:
Path to created file
"""
fd, path = tempfile.mkstemp(suffix=suffix)
with os.fdopen(fd, 'w') as f:
f.write(content)
return Path(path)
def assert_file_exists(filepath: Path, message: Optional[str] = None) -> None:
"""
Assert file exists
Args:
filepath: Path to file
message: Optional custom error message
"""
assert filepath.exists(), message or f"File does not exist: {filepath}"
def assert_file_contains(filepath: Path, expected: str) -> None:
"""
Assert file contains expected text
Args:
filepath: Path to file
expected: Expected text
"""
content = filepath.read_text()
assert expected in content, \
f"Expected '{expected}' in file {filepath}\nActual content: {content}"
def assert_json_output(result: Result, schema: Dict[str, type]) -> Dict[str, Any]:
"""
Assert output is valid JSON matching schema
Args:
result: Click Result object
schema: Expected schema as dict of {key: expected_type}
Returns:
Parsed JSON object
Raises:
AssertionError: If JSON is invalid or doesn't match schema
"""
try:
data = json.loads(result.output)
except json.JSONDecodeError as e:
raise AssertionError(f"Invalid JSON output: {e}\nOutput: {result.output}")
for key, expected_type in schema.items():
assert key in data, f"Missing key in JSON output: {key}"
assert isinstance(data[key], expected_type), \
f"Expected type {expected_type} for key {key}, got {type(data[key])}"
return data
def mock_env_vars(vars_dict: Dict[str, str]) -> Callable[[], None]:
"""
Mock environment variables
Args:
vars_dict: Dictionary of environment variables to set
Returns:
Function to restore original environment
Example:
restore = mock_env_vars({'API_KEY': 'test_key'})
# ... run tests ...
restore()
"""
original = {}
for key, value in vars_dict.items():
original[key] = os.environ.get(key)
os.environ[key] = value
def restore():
for key, value in original.items():
if value is None:
os.environ.pop(key, None)
else:
os.environ[key] = value
return restore
def compare_output_lines(result: Result, expected_lines: List[str]) -> None:
"""
Compare output with expected lines
Args:
result: Click Result object
expected_lines: List of expected lines in output
Raises:
AssertionError: If any expected line is missing
"""
output = result.output
for expected in expected_lines:
assert expected in output, \
f"Expected line '{expected}' not found in output:\n{output}"
def parse_table_output(result: Result) -> List[Dict[str, str]]:
"""
Parse table output into list of dictionaries
Args:
result: Click Result object with table output
Returns:
List of row dictionaries
Note:
Expects table with headers and │ separators
"""
lines = result.output.strip().split('\n')
# Find header line
header_line = None
for i, line in enumerate(lines):
if '' in line and i > 0:
header_line = i
break
if header_line is None:
raise ValueError("Could not find table header")
# Parse headers
headers = [h.strip() for h in lines[header_line].split('') if h.strip()]
# Parse rows
rows = []
for line in lines[header_line + 2:]: # Skip separator
if '' in line:
values = [v.strip() for v in line.split('') if v.strip()]
if len(values) == len(headers):
rows.append(dict(zip(headers, values)))
return rows
class SnapshotTester:
"""Helper for snapshot testing CLI output"""
def __init__(self, snapshot_dir: Path):
"""
Initialize snapshot tester
Args:
snapshot_dir: Directory to store snapshots
"""
self.snapshot_dir = snapshot_dir
self.snapshot_dir.mkdir(exist_ok=True)
def assert_matches(
self,
result: Result,
snapshot_name: str,
update: bool = False
) -> None:
"""
Assert output matches snapshot
Args:
result: Click Result object
snapshot_name: Name of snapshot file
update: Whether to update snapshot
Raises:
AssertionError: If output doesn't match snapshot
"""
snapshot_file = self.snapshot_dir / f'{snapshot_name}.txt'
if update or not snapshot_file.exists():
snapshot_file.write_text(result.output)
return
expected = snapshot_file.read_text()
assert result.output == expected, \
f"Output doesn't match snapshot {snapshot_name}\n" \
f"Expected:\n{expected}\n\nActual:\n{result.output}"
class MockConfig:
"""Mock configuration file for testing"""
def __init__(self, workspace: Path, filename: str = '.myclirc'):
"""
Initialize mock config
Args:
workspace: Workspace directory
filename: Config filename
"""
self.config_path = workspace / filename
self.data = {}
def set(self, key: str, value: Any) -> None:
"""Set configuration value"""
self.data[key] = value
self.save()
def get(self, key: str, default: Any = None) -> Any:
"""Get configuration value"""
return self.data.get(key, default)
def save(self) -> None:
"""Save configuration to file"""
import yaml
with open(self.config_path, 'w') as f:
yaml.dump(self.data, f)
def load(self) -> None:
"""Load configuration from file"""
if self.config_path.exists():
import yaml
with open(self.config_path, 'r') as f:
self.data = yaml.safe_load(f) or {}
def wait_for_file(filepath: Path, timeout: float = 5.0) -> None:
"""
Wait for file to exist
Args:
filepath: Path to file
timeout: Timeout in seconds
Raises:
TimeoutError: If file doesn't exist within timeout
"""
import time
start = time.time()
while not filepath.exists():
if time.time() - start > timeout:
raise TimeoutError(f"Timeout waiting for file: {filepath}")
time.sleep(0.1)
def capture_output(func: Callable) -> Dict[str, str]:
"""
Capture stdout and stderr during function execution
Args:
func: Function to execute
Returns:
Dictionary with 'stdout' and 'stderr' keys
"""
import sys
from io import StringIO
old_stdout = sys.stdout
old_stderr = sys.stderr
stdout_capture = StringIO()
stderr_capture = StringIO()
sys.stdout = stdout_capture
sys.stderr = stderr_capture
try:
func()
finally:
sys.stdout = old_stdout
sys.stderr = old_stderr
return {
'stdout': stdout_capture.getvalue(),
'stderr': stderr_capture.getvalue()
}
class IntegrationTestHelper:
"""Helper for integration testing with state management"""
def __init__(self, cli_app, workspace: Optional[Path] = None):
"""
Initialize integration test helper
Args:
cli_app: Click CLI application
workspace: Optional workspace directory
"""
self.harness = CLITestHarness(cli_app)
self.workspace = workspace or create_temp_workspace()
self.original_cwd = Path.cwd()
def __enter__(self):
"""Enter context - change to workspace"""
os.chdir(self.workspace)
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Exit context - restore cwd and cleanup"""
os.chdir(self.original_cwd)
cleanup_workspace(self.workspace)
def run_workflow(self, commands: List[List[str]]) -> List[Result]:
"""
Run multiple commands in sequence
Args:
commands: List of command argument lists
Returns:
List of Result objects
"""
results = []
for cmd in commands:
result = self.harness.run(cmd)
results.append(result)
if result.exit_code != 0:
break
return results
def assert_workflow_success(self, commands: List[List[str]]) -> List[Result]:
"""
Run workflow and assert all commands succeed
Args:
commands: List of command argument lists
Returns:
List of Result objects
Raises:
AssertionError: If any command fails
"""
results = []
for i, cmd in enumerate(commands):
result = self.harness.assert_success(cmd)
results.append(result)
return results

View File

@@ -0,0 +1,362 @@
/**
* Node.js Test Helper Functions
*
* Utility functions for CLI testing with Jest
*/
import { execSync, spawn, SpawnOptions } from 'child_process';
import fs from 'fs';
import path from 'path';
import os from 'os';
/**
* CLI execution result interface
*/
export interface CLIResult {
stdout: string;
stderr: string;
code: number;
success: boolean;
}
/**
* Execute CLI command synchronously
* @param cliPath - Path to CLI executable
* @param args - Command arguments
* @param options - Execution options
* @returns CLI execution result
*/
export function runCLI(
cliPath: string,
args: string,
options: {
cwd?: string;
env?: Record<string, string>;
timeout?: number;
} = {}
): CLIResult {
try {
const stdout = execSync(`${cliPath} ${args}`, {
encoding: 'utf8',
stdio: 'pipe',
cwd: options.cwd,
env: { ...process.env, ...options.env },
timeout: options.timeout,
});
return {
stdout,
stderr: '',
code: 0,
success: true,
};
} catch (error: any) {
return {
stdout: error.stdout || '',
stderr: error.stderr || '',
code: error.status || 1,
success: false,
};
}
}
/**
* Execute CLI command asynchronously
* @param cliPath - Path to CLI executable
* @param args - Command arguments array
* @param options - Spawn options
* @returns Promise of CLI execution result
*/
export function runCLIAsync(
cliPath: string,
args: string[],
options: SpawnOptions = {}
): Promise<CLIResult> {
return new Promise((resolve) => {
const child = spawn(cliPath, args, {
...options,
stdio: 'pipe',
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (data) => {
stdout += data.toString();
});
child.stderr?.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
resolve({
stdout,
stderr,
code: code || 0,
success: code === 0,
});
});
child.on('error', (error) => {
resolve({
stdout,
stderr: stderr + error.message,
code: 1,
success: false,
});
});
});
}
/**
* Create temporary test directory
* @returns Path to temporary directory
*/
export function createTempDir(): string {
const tempDir = path.join(os.tmpdir(), `cli-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.mkdirSync(tempDir, { recursive: true });
return tempDir;
}
/**
* Clean up temporary directory
* @param dirPath - Directory to remove
*/
export function cleanupTempDir(dirPath: string): void {
if (fs.existsSync(dirPath)) {
fs.rmSync(dirPath, { recursive: true, force: true });
}
}
/**
* Create temporary file with content
* @param content - File content
* @param extension - File extension
* @returns Path to created file
*/
export function createTempFile(content: string, extension: string = 'txt'): string {
const tempFile = path.join(os.tmpdir(), `test-${Date.now()}.${extension}`);
fs.writeFileSync(tempFile, content);
return tempFile;
}
/**
* Assert CLI command succeeds
* @param result - CLI execution result
* @param expectedOutput - Optional expected output substring
*/
export function assertSuccess(result: CLIResult, expectedOutput?: string): void {
if (!result.success) {
throw new Error(`CLI command failed with exit code ${result.code}\nStderr: ${result.stderr}`);
}
if (expectedOutput && !result.stdout.includes(expectedOutput)) {
throw new Error(`Expected output to contain "${expectedOutput}"\nActual: ${result.stdout}`);
}
}
/**
* Assert CLI command fails
* @param result - CLI execution result
* @param expectedError - Optional expected error substring
*/
export function assertFailure(result: CLIResult, expectedError?: string): void {
if (result.success) {
throw new Error(`CLI command should have failed but succeeded\nStdout: ${result.stdout}`);
}
if (expectedError && !result.stderr.includes(expectedError) && !result.stdout.includes(expectedError)) {
throw new Error(`Expected error to contain "${expectedError}"\nActual stderr: ${result.stderr}\nActual stdout: ${result.stdout}`);
}
}
/**
* Assert exit code matches expected value
* @param result - CLI execution result
* @param expectedCode - Expected exit code
*/
export function assertExitCode(result: CLIResult, expectedCode: number): void {
if (result.code !== expectedCode) {
throw new Error(`Expected exit code ${expectedCode} but got ${result.code}\nStderr: ${result.stderr}`);
}
}
/**
* Parse JSON output from CLI
* @param result - CLI execution result
* @returns Parsed JSON object
*/
export function parseJSONOutput<T = any>(result: CLIResult): T {
try {
return JSON.parse(result.stdout);
} catch (error) {
throw new Error(`Failed to parse JSON output: ${error}\nStdout: ${result.stdout}`);
}
}
/**
* Mock environment variables for test
* @param vars - Environment variables to set
* @returns Function to restore original environment
*/
export function mockEnv(vars: Record<string, string>): () => void {
const original = { ...process.env };
Object.entries(vars).forEach(([key, value]) => {
process.env[key] = value;
});
return () => {
Object.keys(process.env).forEach((key) => {
if (!(key in original)) {
delete process.env[key];
}
});
Object.entries(original).forEach(([key, value]) => {
process.env[key] = value;
});
};
}
/**
* Wait for file to exist
* @param filePath - Path to file
* @param timeout - Timeout in milliseconds
* @returns Promise that resolves when file exists
*/
export async function waitForFile(filePath: string, timeout: number = 5000): Promise<void> {
const startTime = Date.now();
while (!fs.existsSync(filePath)) {
if (Date.now() - startTime > timeout) {
throw new Error(`Timeout waiting for file: ${filePath}`);
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
/**
* Create CLI test fixture with setup and teardown
* @param setup - Setup function
* @param teardown - Teardown function
* @returns Test fixture object
*/
export function createFixture<T>(
setup: () => T | Promise<T>,
teardown: (fixture: T) => void | Promise<void>
): {
beforeEach: () => Promise<T>;
afterEach: (fixture: T) => Promise<void>;
} {
return {
beforeEach: async () => setup(),
afterEach: async (fixture: T) => teardown(fixture),
};
}
/**
* Capture stdout/stderr during function execution
* @param fn - Function to execute
* @returns Captured output
*/
export function captureOutput(fn: () => void): { stdout: string; stderr: string } {
const originalStdout = process.stdout.write;
const originalStderr = process.stderr.write;
let stdout = '';
let stderr = '';
process.stdout.write = ((chunk: any) => {
stdout += chunk.toString();
return true;
}) as any;
process.stderr.write = ((chunk: any) => {
stderr += chunk.toString();
return true;
}) as any;
try {
fn();
} finally {
process.stdout.write = originalStdout;
process.stderr.write = originalStderr;
}
return { stdout, stderr };
}
/**
* Test helper for testing CLI with different input combinations
*/
export class CLITestHarness {
constructor(private cliPath: string) {}
/**
* Run command with arguments
*/
run(args: string, options?: { cwd?: string; env?: Record<string, string> }): CLIResult {
return runCLI(this.cliPath, args, options);
}
/**
* Run command and assert success
*/
assertSuccess(args: string, expectedOutput?: string): CLIResult {
const result = this.run(args);
assertSuccess(result, expectedOutput);
return result;
}
/**
* Run command and assert failure
*/
assertFailure(args: string, expectedError?: string): CLIResult {
const result = this.run(args);
assertFailure(result, expectedError);
return result;
}
/**
* Run command and parse JSON output
*/
runJSON<T = any>(args: string): T {
const result = this.run(args);
assertSuccess(result);
return parseJSONOutput<T>(result);
}
}
/**
* Validate JSON schema in CLI output
* @param result - CLI execution result
* @param schema - Expected schema object
*/
export function validateJSONSchema(result: CLIResult, schema: Record<string, string>): void {
const output = parseJSONOutput(result);
Object.entries(schema).forEach(([key, expectedType]) => {
if (!(key in output)) {
throw new Error(`Missing expected key in JSON output: ${key}`);
}
const actualType = typeof output[key];
if (actualType !== expectedType) {
throw new Error(`Expected type ${expectedType} for key ${key}, but got ${actualType}`);
}
});
}
/**
* Compare CLI output with snapshot
* @param result - CLI execution result
* @param snapshotPath - Path to snapshot file
* @param update - Whether to update snapshot
*/
export function compareSnapshot(result: CLIResult, snapshotPath: string, update: boolean = false): void {
if (update || !fs.existsSync(snapshotPath)) {
fs.writeFileSync(snapshotPath, result.stdout);
return;
}
const snapshot = fs.readFileSync(snapshotPath, 'utf8');
if (result.stdout !== snapshot) {
throw new Error(`Output does not match snapshot\nExpected:\n${snapshot}\n\nActual:\n${result.stdout}`);
}
}

View File

@@ -0,0 +1,126 @@
---
name: click-patterns
description: Click framework examples and templates - decorators, nested commands, parameter validation. Use when building Python CLI with Click, implementing command groups, adding CLI options/arguments, validating CLI parameters, creating nested subcommands, or when user mentions Click framework, @click decorators, command-line interface.
allowed-tools: Read, Write, Bash
---
# Click Framework Patterns
This skill provides comprehensive Click framework patterns, templates, and examples for building production-ready Python CLIs.
## Instructions
### When Building a Click CLI
1. Read the appropriate template based on complexity:
- Simple CLI: `templates/basic-cli.py`
- Nested commands: `templates/nested-commands.py`
- Custom validators: `templates/validators.py`
2. Generate new Click project:
```bash
bash scripts/generate-click-cli.sh <project-name> <cli-type>
```
Where cli-type is: basic, nested, or advanced
3. Study complete examples:
- `examples/complete-example.md` - Full-featured CLI
- `examples/patterns.md` - Common patterns and best practices
4. Validate your Click setup:
```bash
bash scripts/validate-click.sh <cli-file.py>
```
### Core Click Patterns
**Command Groups:**
```python
@click.group()
def cli():
"""Main CLI entry point"""
pass
@cli.command()
def subcommand():
"""A subcommand"""
pass
```
**Options and Arguments:**
```python
@click.option('--template', '-t', default='basic', help='Template name')
@click.argument('environment')
def deploy(template, environment):
pass
```
**Nested Groups:**
```python
@cli.group()
def config():
"""Configuration management"""
pass
@config.command()
def get():
"""Get config value"""
pass
```
**Parameter Validation:**
```python
@click.option('--mode', type=click.Choice(['fast', 'safe', 'rollback']))
@click.option('--count', type=click.IntRange(1, 100))
def command(mode, count):
pass
```
### Available Templates
1. **basic-cli.py** - Simple single-command CLI
2. **nested-commands.py** - Command groups and subcommands
3. **validators.py** - Custom parameter validators
4. **advanced-cli.py** - Advanced patterns with plugins and chaining
### Available Scripts
1. **generate-click-cli.sh** - Creates Click project structure
2. **validate-click.sh** - Validates Click CLI implementation
3. **setup-click-project.sh** - Setup dependencies and environment
### Available Examples
1. **complete-example.md** - Production-ready Click CLI
2. **patterns.md** - Best practices and common patterns
3. **edge-cases.md** - Edge cases and solutions
## Requirements
- Python 3.8+
- Click 8.0+ (`pip install click`)
- Rich for colored output (`pip install rich`)
## Best Practices
1. **Use command groups** for organizing related commands
2. **Add help text** to all commands and options
3. **Validate parameters** using Click's built-in validators
4. **Use context** (@click.pass_context) for sharing state
5. **Handle errors gracefully** with try-except blocks
6. **Add version info** with @click.version_option()
7. **Use Rich** for beautiful colored output
## Common Use Cases
- Building CLI tools with multiple commands
- Creating deployment scripts with options
- Implementing configuration management CLIs
- Building database migration tools
- Creating API testing CLIs
- Implementing project scaffolding tools
---
**Purpose:** Provide Click framework templates and patterns for Python CLI development
**Load when:** Building Click CLIs, implementing command groups, or validating CLI parameters

View File

@@ -0,0 +1,405 @@
# Complete Click CLI Example
A production-ready Click CLI demonstrating all major patterns and best practices.
## Full Implementation
```python
#!/usr/bin/env python3
"""
Production-ready Click CLI with all major patterns.
Features:
- Command groups and nested subcommands
- Options and arguments with validation
- Context sharing across commands
- Error handling and colored output
- Configuration management
- Environment-specific commands
"""
import click
from rich.console import Console
from pathlib import Path
import json
console = Console()
# Custom validators
def validate_email(ctx, param, value):
"""Validate email format"""
import re
if value and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', value):
raise click.BadParameter('Invalid email format')
return value
# Main CLI group
@click.group()
@click.version_option(version='1.0.0')
@click.option('--config', type=click.Path(), default='config.json',
help='Configuration file path')
@click.option('--verbose', '-v', is_flag=True, help='Verbose output')
@click.pass_context
def cli(ctx, config, verbose):
"""
A powerful CLI tool for project management.
Examples:
cli init --template basic
cli deploy production --mode safe
cli config get api-key
cli database migrate --create-tables
"""
ctx.ensure_object(dict)
ctx.obj['console'] = console
ctx.obj['verbose'] = verbose
ctx.obj['config_file'] = config
if verbose:
console.print(f"[dim]Config file: {config}[/dim]")
# Initialize command
@cli.command()
@click.option('--template', '-t',
type=click.Choice(['basic', 'advanced', 'minimal']),
default='basic',
help='Project template')
@click.option('--name', prompt=True, help='Project name')
@click.option('--description', prompt=True, help='Project description')
@click.pass_context
def init(ctx, template, name, description):
"""Initialize a new project"""
console = ctx.obj['console']
verbose = ctx.obj['verbose']
console.print(f"[cyan]Initializing project: {name}[/cyan]")
console.print(f"[dim]Template: {template}[/dim]")
console.print(f"[dim]Description: {description}[/dim]")
# Create project structure
project_dir = Path(name)
if project_dir.exists():
console.print(f"[red]✗[/red] Directory already exists: {name}")
raise click.Abort()
try:
project_dir.mkdir(parents=True)
(project_dir / 'src').mkdir()
(project_dir / 'tests').mkdir()
(project_dir / 'docs').mkdir()
# Create config file
config = {
'name': name,
'description': description,
'template': template,
'version': '1.0.0'
}
with open(project_dir / 'config.json', 'w') as f:
json.dump(config, f, indent=2)
console.print(f"[green]✓[/green] Project initialized successfully!")
if verbose:
console.print(f"[dim]Created directories: src/, tests/, docs/[/dim]")
console.print(f"[dim]Created config.json[/dim]")
except Exception as e:
console.print(f"[red]✗[/red] Error: {e}")
raise click.Abort()
# Deploy command
@cli.command()
@click.argument('environment',
type=click.Choice(['dev', 'staging', 'production']))
@click.option('--force', '-f', is_flag=True, help='Force deployment')
@click.option('--mode', '-m',
type=click.Choice(['fast', 'safe', 'rollback']),
default='safe',
help='Deployment mode')
@click.option('--skip-tests', is_flag=True, help='Skip test execution')
@click.pass_context
def deploy(ctx, environment, force, mode, skip_tests):
"""Deploy to specified environment"""
console = ctx.obj['console']
verbose = ctx.obj['verbose']
console.print(f"[cyan]Deploying to {environment} in {mode} mode[/cyan]")
if force:
console.print("[yellow]⚠ Force mode enabled - skipping safety checks[/yellow]")
# Pre-deployment checks
if not skip_tests and not force:
console.print("[dim]Running tests...[/dim]")
# Simulate test execution
if verbose:
console.print("[green]✓[/green] All tests passed")
# Deployment simulation
steps = [
"Building artifacts",
"Uploading to server",
"Running migrations",
"Restarting services",
"Verifying deployment"
]
for step in steps:
console.print(f"[dim]- {step}...[/dim]")
console.print(f"[green]✓[/green] Deployment completed successfully!")
if mode == 'safe':
console.print("[dim]Rollback available for 24 hours[/dim]")
# Config group
@cli.group()
def config():
"""Manage configuration settings"""
pass
@config.command()
@click.argument('key')
@click.pass_context
def get(ctx, key):
"""Get configuration value"""
console = ctx.obj['console']
config_file = ctx.obj['config_file']
try:
if Path(config_file).exists():
with open(config_file) as f:
config_data = json.load(f)
if key in config_data:
console.print(f"[dim]Config[/dim] {key}: [green]{config_data[key]}[/green]")
else:
console.print(f"[yellow]Key not found: {key}[/yellow]")
else:
console.print(f"[red]Config file not found: {config_file}[/red]")
except Exception as e:
console.print(f"[red]Error reading config: {e}[/red]")
@config.command()
@click.argument('key')
@click.argument('value')
@click.pass_context
def set(ctx, key, value):
"""Set configuration value"""
console = ctx.obj['console']
config_file = ctx.obj['config_file']
try:
config_data = {}
if Path(config_file).exists():
with open(config_file) as f:
config_data = json.load(f)
config_data[key] = value
with open(config_file, 'w') as f:
json.dump(config_data, f, indent=2)
console.print(f"[green]✓[/green] Set {key} = {value}")
except Exception as e:
console.print(f"[red]Error writing config: {e}[/red]")
@config.command()
@click.pass_context
def list(ctx):
"""List all configuration settings"""
console = ctx.obj['console']
config_file = ctx.obj['config_file']
try:
if Path(config_file).exists():
with open(config_file) as f:
config_data = json.load(f)
console.print("[cyan]Configuration Settings:[/cyan]")
for key, value in config_data.items():
console.print(f" {key}: [green]{value}[/green]")
else:
console.print("[yellow]No configuration file found[/yellow]")
except Exception as e:
console.print(f"[red]Error reading config: {e}[/red]")
# Database group
@cli.group()
def database():
"""Database management commands"""
pass
@database.command()
@click.option('--create-tables', is_flag=True, help='Create tables')
@click.option('--seed-data', is_flag=True, help='Seed initial data')
@click.pass_context
def migrate(ctx, create_tables, seed_data):
"""Run database migrations"""
console = ctx.obj['console']
console.print("[cyan]Running migrations...[/cyan]")
if create_tables:
console.print("[dim]- Creating tables...[/dim]")
console.print("[green]✓[/green] Tables created")
if seed_data:
console.print("[dim]- Seeding data...[/dim]")
console.print("[green]✓[/green] Data seeded")
console.print("[green]✓[/green] Migrations completed")
@database.command()
@click.option('--confirm', is_flag=True,
prompt='This will delete all data. Continue?',
help='Confirm reset')
@click.pass_context
def reset(ctx, confirm):
"""Reset database (destructive operation)"""
console = ctx.obj['console']
if not confirm:
console.print("[yellow]Operation cancelled[/yellow]")
raise click.Abort()
console.print("[red]Resetting database...[/red]")
console.print("[dim]- Dropping tables...[/dim]")
console.print("[dim]- Clearing cache...[/dim]")
console.print("[green]✓[/green] Database reset completed")
# User management group
@cli.group()
def user():
"""User management commands"""
pass
@user.command()
@click.option('--email', callback=validate_email, prompt=True,
help='User email address')
@click.option('--name', prompt=True, help='User full name')
@click.option('--role',
type=click.Choice(['admin', 'user', 'guest']),
default='user',
help='User role')
@click.pass_context
def create(ctx, email, name, role):
"""Create a new user"""
console = ctx.obj['console']
console.print(f"[cyan]Creating user: {name}[/cyan]")
console.print(f"[dim]Email: {email}[/dim]")
console.print(f"[dim]Role: {role}[/dim]")
console.print(f"[green]✓[/green] User created successfully")
@user.command()
@click.argument('email')
@click.pass_context
def delete(ctx, email):
"""Delete a user"""
console = ctx.obj['console']
if not click.confirm(f"Delete user {email}?"):
console.print("[yellow]Operation cancelled[/yellow]")
return
console.print(f"[cyan]Deleting user: {email}[/cyan]")
console.print(f"[green]✓[/green] User deleted")
# Error handling wrapper
def main():
"""Main entry point with error handling"""
try:
cli(obj={})
except click.Abort:
console.print("[yellow]Operation aborted[/yellow]")
except Exception as e:
console.print(f"[red]Unexpected error: {e}[/red]")
raise
if __name__ == '__main__':
main()
```
## Usage Examples
### Initialize a project
```bash
python cli.py init --template advanced --name myproject --description "My awesome project"
```
### Deploy to production
```bash
python cli.py deploy production --mode safe
python cli.py deploy staging --force --skip-tests
```
### Configuration management
```bash
python cli.py config set api-key abc123
python cli.py config get api-key
python cli.py config list
```
### Database operations
```bash
python cli.py database migrate --create-tables --seed-data
python cli.py database reset --confirm
```
### User management
```bash
python cli.py user create --email user@example.com --name "John Doe" --role admin
python cli.py user delete user@example.com
```
### With verbose output
```bash
python cli.py --verbose deploy production
```
### With custom config file
```bash
python cli.py --config /path/to/config.json config list
```
## Key Features Demonstrated
1. **Command Groups**: Organized commands into logical groups (config, database, user)
2. **Context Sharing**: Using @click.pass_context to share state
3. **Input Validation**: Custom validators for email, built-in validators for choices
4. **Colored Output**: Using Rich console for beautiful output
5. **Error Handling**: Graceful error handling and user feedback
6. **Interactive Prompts**: Using prompt=True for interactive input
7. **Confirmation Dialogs**: Using click.confirm() for dangerous operations
8. **File Operations**: Reading/writing JSON configuration files
9. **Flags and Options**: Boolean flags, default values, short flags
10. **Version Information**: @click.version_option() decorator
## Best Practices Applied
- Clear help text for all commands and options
- Sensible defaults for options
- Validation for user inputs
- Colored output for better UX
- Verbose mode for debugging
- Confirmation for destructive operations
- Proper error handling and messages
- Clean separation of concerns with command groups
- Context object for sharing state

View File

@@ -0,0 +1,482 @@
# Click Framework Edge Cases and Solutions
Common edge cases, gotchas, and their solutions when working with Click.
## Table of Contents
1. [Parameter Handling Edge Cases](#parameter-handling-edge-cases)
2. [Context and State Edge Cases](#context-and-state-edge-cases)
3. [Error Handling Edge Cases](#error-handling-edge-cases)
4. [Testing Edge Cases](#testing-edge-cases)
5. [Platform-Specific Edge Cases](#platform-specific-edge-cases)
---
## Parameter Handling Edge Cases
### Case 1: Multiple Values with Same Option
**Problem**: User specifies the same option multiple times
```bash
cli --tag python --tag docker --tag kubernetes
```
**Solution**: Use `multiple=True`
```python
@click.option('--tag', multiple=True)
def command(tag):
"""Handle multiple values"""
for t in tag:
click.echo(t)
```
### Case 2: Option vs Argument Ambiguity
**Problem**: Argument that looks like an option
```bash
cli process --file=-myfile.txt # -myfile.txt looks like option
```
**Solution**: Use `--` separator or quotes
```python
@click.command()
@click.argument('filename')
def process(filename):
pass
# Usage:
# cli process -- -myfile.txt
# cli process "-myfile.txt"
```
### Case 3: Empty String vs None
**Problem**: Distinguishing between no value and empty string
```python
@click.option('--name')
def command(name):
# name is None when not provided
# name is '' when provided as empty
if name is None:
click.echo('Not provided')
elif name == '':
click.echo('Empty string provided')
```
**Solution**: Use callback for custom handling
```python
def handle_empty(ctx, param, value):
if value == '':
return None # Treat empty as None
return value
@click.option('--name', callback=handle_empty)
def command(name):
pass
```
### Case 4: Boolean Flag with Default True
**Problem**: Need a flag that's True by default, but can be disabled
```python
# Wrong approach:
@click.option('--enable', is_flag=True, default=True) # Doesn't work as expected
# Correct approach:
@click.option('--disable', is_flag=True)
def command(disable):
enabled = not disable
```
**Better Solution**: Use flag_value
```python
@click.option('--ssl/--no-ssl', default=True)
def command(ssl):
"""SSL is enabled by default, use --no-ssl to disable"""
pass
```
### Case 5: Required Option with Environment Variable
**Problem**: Make option required unless env var is set
```python
def require_if_no_env(ctx, param, value):
"""Require option if environment variable not set"""
if value is None:
import os
env_value = os.getenv('API_KEY')
if env_value:
return env_value
raise click.MissingParameter(param=param)
return value
@click.option('--api-key', callback=require_if_no_env)
def command(api_key):
pass
```
---
## Context and State Edge Cases
### Case 6: Context Not Available in Callbacks
**Problem**: Need context in parameter callback
```python
# This doesn't work - context not yet initialized:
def my_callback(ctx, param, value):
config = ctx.obj['config'] # Error: ctx.obj is None
return value
```
**Solution**: Use command decorator to set up context first
```python
@click.command()
@click.pass_context
def cli(ctx):
ctx.ensure_object(dict)
ctx.obj['config'] = load_config()
@cli.command()
@click.option('--value', callback=validate_with_config)
@click.pass_context
def subcommand(ctx, value):
# Now ctx.obj is available
pass
```
### Case 7: Sharing State Between Command Groups
**Problem**: State not persisting across nested groups
```python
@click.group()
@click.pass_context
def cli(ctx):
ctx.ensure_object(dict)
ctx.obj['data'] = 'test'
@cli.group()
@click.pass_context
def subgroup(ctx):
# ctx.obj is still available here
assert ctx.obj['data'] == 'test'
@subgroup.command()
@click.pass_context
def command(ctx):
# ctx.obj is still available here too
assert ctx.obj['data'] == 'test'
```
### Case 8: Mutating Context Objects
**Problem**: Changes to context not persisting
```python
# This works:
ctx.obj['key'] = 'value' # Modifying dict
# This doesn't persist:
ctx.obj = {'key': 'value'} # Replacing dict
```
---
## Error Handling Edge Cases
### Case 9: Graceful Handling of Ctrl+C
**Problem**: Ugly traceback on keyboard interrupt
```python
def main():
try:
cli()
except KeyboardInterrupt:
click.echo('\n\nOperation cancelled by user')
raise SystemExit(130) # Standard exit code for Ctrl+C
if __name__ == '__main__':
main()
```
### Case 10: Custom Error Messages for Validation
**Problem**: Default error messages aren't user-friendly
```python
# Default error:
@click.option('--port', type=click.IntRange(1, 65535))
# Error: Invalid value for '--port': 70000 is not in the range 1<=x<=65535
# Custom error:
def validate_port(ctx, param, value):
if not 1 <= value <= 65535:
raise click.BadParameter(
f'Port {value} is out of range. Please use a port between 1 and 65535.'
)
return value
@click.option('--port', type=int, callback=validate_port)
```
### Case 11: Handling Mutually Exclusive Options
**Problem**: Options that can't be used together
```python
def validate_exclusive(ctx, param, value):
"""Ensure mutually exclusive options"""
if value and ctx.params.get('other_option'):
raise click.UsageError(
'Cannot use --option and --other-option together'
)
return value
@click.command()
@click.option('--option', callback=validate_exclusive)
@click.option('--other-option')
def command(option, other_option):
pass
```
### Case 12: Dependent Options
**Problem**: One option requires another
```python
@click.command()
@click.option('--ssl', is_flag=True)
@click.option('--cert', type=click.Path(exists=True))
@click.option('--key', type=click.Path(exists=True))
def server(ssl, cert, key):
"""Validate dependent options"""
if ssl:
if not cert or not key:
raise click.UsageError(
'--ssl requires both --cert and --key'
)
```
---
## Testing Edge Cases
### Case 13: Testing with Environment Variables
**Problem**: Tests failing due to environment pollution
```python
def test_with_clean_env():
"""Test with isolated environment"""
runner = CliRunner()
# This isolates environment variables:
result = runner.invoke(
cli,
['command'],
env={'API_KEY': 'test'},
catch_exceptions=False
)
assert result.exit_code == 0
```
### Case 14: Testing Interactive Prompts with Validation
**Problem**: Prompts with retry logic
```python
def test_interactive_retry():
"""Test prompt with retry on invalid input"""
runner = CliRunner()
# Provide multiple inputs (first invalid, second valid)
result = runner.invoke(
cli,
['create'],
input='invalid-email\nvalid@email.com\n'
)
assert 'Invalid email' in result.output
assert result.exit_code == 0
```
### Case 15: Testing File Operations
**Problem**: Tests creating actual files
```python
def test_file_operations():
"""Test with isolated filesystem"""
runner = CliRunner()
with runner.isolated_filesystem():
# Create test file
with open('input.txt', 'w') as f:
f.write('test data')
result = runner.invoke(cli, ['process', 'input.txt'])
# Verify output file
assert Path('output.txt').exists()
```
---
## Platform-Specific Edge Cases
### Case 16: Windows Path Handling
**Problem**: Backslashes in Windows paths
```python
@click.option('--path', type=click.Path())
def command(path):
# Use pathlib for cross-platform compatibility
from pathlib import Path
p = Path(path) # Handles Windows/Unix paths
```
### Case 17: Unicode in Command Line Arguments
**Problem**: Non-ASCII characters in arguments
```python
@click.command()
@click.argument('name')
def greet(name):
"""Handle unicode properly"""
# Click handles unicode automatically on Python 3
click.echo(f'Hello, {name}!')
# This works:
# cli greet "José"
# cli greet "北京"
```
### Case 18: Terminal Width Detection
**Problem**: Output formatting for different terminal sizes
```python
@click.command()
def status():
"""Adapt to terminal width"""
terminal_width = click.get_terminal_size()[0]
if terminal_width < 80:
# Compact output for narrow terminals
click.echo('Status: OK')
else:
# Detailed output for wide terminals
click.echo('=' * terminal_width)
click.echo('Detailed Status Information')
click.echo('=' * terminal_width)
```
---
## Advanced Edge Cases
### Case 19: Dynamic Command Registration
**Problem**: Register commands at runtime
```python
class DynamicGroup(click.Group):
"""Group that discovers commands dynamically"""
def list_commands(self, ctx):
"""List available commands"""
# Dynamically discover commands
return ['cmd1', 'cmd2', 'cmd3']
def get_command(self, ctx, name):
"""Load command on demand"""
if name in self.list_commands(ctx):
# Import and return command
module = __import__(f'commands.{name}')
return getattr(module, name).cli
return None
@click.command(cls=DynamicGroup)
def cli():
pass
```
### Case 20: Command Aliases
**Problem**: Support command aliases
```python
class AliasedGroup(click.Group):
"""Group that supports command aliases"""
def get_command(self, ctx, cmd_name):
"""Resolve aliases"""
aliases = {
'ls': 'list',
'rm': 'remove',
'cp': 'copy'
}
# Resolve alias
resolved = aliases.get(cmd_name, cmd_name)
return super().get_command(ctx, resolved)
@click.group(cls=AliasedGroup)
def cli():
pass
@cli.command()
def list():
"""List items (alias: ls)"""
pass
```
### Case 21: Progress Bar with Unknown Length
**Problem**: Show progress when total is unknown
```python
@click.command()
def process():
"""Process with indeterminate progress"""
import time
# For unknown length, use length=None
with click.progressbar(
range(100),
length=None,
label='Processing'
) as bar:
for _ in bar:
time.sleep(0.1)
```
---
## Summary
Key takeaways for handling edge cases:
1. **Parameters**: Use callbacks and custom types for complex validation
2. **Context**: Ensure context is initialized before accessing ctx.obj
3. **Errors**: Provide clear, actionable error messages
4. **Testing**: Use CliRunner's isolation features
5. **Platform**: Use pathlib and Click's built-in utilities for portability
For more edge cases, consult the [Click documentation](https://click.palletsprojects.com/) and [GitHub issues](https://github.com/pallets/click/issues).

View File

@@ -0,0 +1,521 @@
# Click Framework Common Patterns
Best practices and common patterns for building production-ready Click CLIs.
## Table of Contents
1. [Command Structure Patterns](#command-structure-patterns)
2. [Parameter Patterns](#parameter-patterns)
3. [Validation Patterns](#validation-patterns)
4. [Error Handling Patterns](#error-handling-patterns)
5. [Output Patterns](#output-patterns)
6. [Configuration Patterns](#configuration-patterns)
7. [Testing Patterns](#testing-patterns)
---
## Command Structure Patterns
### Single Command CLI
For simple tools with one main function:
```python
@click.command()
@click.option('--name', default='World')
def hello(name):
"""Simple greeting CLI"""
click.echo(f'Hello, {name}!')
```
### Command Group Pattern
For CLIs with multiple related commands:
```python
@click.group()
def cli():
"""Main CLI entry point"""
pass
@cli.command()
def cmd1():
"""First command"""
pass
@cli.command()
def cmd2():
"""Second command"""
pass
```
### Nested Command Groups
For complex CLIs with logical grouping:
```python
@click.group()
def cli():
"""Main CLI"""
pass
@cli.group()
def database():
"""Database commands"""
pass
@database.command()
def migrate():
"""Run migrations"""
pass
@database.command()
def reset():
"""Reset database"""
pass
```
### Context-Aware Commands
Share state across commands:
```python
@click.group()
@click.pass_context
def cli(ctx):
"""Main CLI with shared context"""
ctx.ensure_object(dict)
ctx.obj['config'] = load_config()
@cli.command()
@click.pass_context
def deploy(ctx):
"""Use shared config"""
config = ctx.obj['config']
```
---
## Parameter Patterns
### Options vs Arguments
**Options** (optional, named):
```python
@click.option('--name', '-n', default='World', help='Name to greet')
@click.option('--count', '-c', default=1, type=int)
```
**Arguments** (required, positional):
```python
@click.argument('filename')
@click.argument('output', type=click.Path())
```
### Required Options
```python
@click.option('--api-key', required=True, help='API key (required)')
@click.option('--config', required=True, type=click.Path(exists=True))
```
### Multiple Values
```python
# Multiple option values
@click.option('--tag', multiple=True, help='Tags (can specify multiple times)')
def command(tag):
for t in tag:
click.echo(t)
# Variable arguments
@click.argument('files', nargs=-1, type=click.Path(exists=True))
def process(files):
for f in files:
click.echo(f)
```
### Environment Variables
```python
@click.option('--api-key', envvar='API_KEY', help='API key (from env: API_KEY)')
@click.option('--debug', envvar='DEBUG', is_flag=True)
```
### Interactive Prompts
```python
@click.option('--name', prompt=True, help='Your name')
@click.option('--password', prompt=True, hide_input=True)
@click.option('--confirm', prompt='Continue?', confirmation_prompt=True)
```
---
## Validation Patterns
### Built-in Validators
```python
# Choice validation
@click.option('--env', type=click.Choice(['dev', 'staging', 'prod']))
# Range validation
@click.option('--port', type=click.IntRange(1, 65535))
@click.option('--rate', type=click.FloatRange(0.0, 1.0))
# Path validation
@click.option('--input', type=click.Path(exists=True, dir_okay=False))
@click.option('--output', type=click.Path(writable=True))
@click.option('--dir', type=click.Path(exists=True, file_okay=False))
```
### Custom Validators with Callbacks
```python
def validate_email(ctx, param, value):
"""Validate email format"""
import re
if value and not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', value):
raise click.BadParameter('Invalid email format')
return value
@click.option('--email', callback=validate_email)
def command(email):
pass
```
### Custom Click Types
```python
class EmailType(click.ParamType):
"""Custom email type"""
name = 'email'
def convert(self, value, param, ctx):
import re
if not re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', value):
self.fail(f'{value} is not a valid email', param, ctx)
return value
@click.option('--email', type=EmailType())
def command(email):
pass
```
### Conditional Validation
```python
@click.command()
@click.option('--ssl', is_flag=True)
@click.option('--cert', type=click.Path(exists=True))
@click.option('--key', type=click.Path(exists=True))
def server(ssl, cert, key):
"""Start server with SSL validation"""
if ssl and (not cert or not key):
raise click.UsageError('SSL requires --cert and --key')
```
---
## Error Handling Patterns
### Graceful Error Handling
```python
@click.command()
def command():
"""Command with error handling"""
try:
# Operation that might fail
result = risky_operation()
except FileNotFoundError as e:
raise click.FileError(str(e))
except Exception as e:
raise click.ClickException(f'Operation failed: {e}')
```
### Custom Exit Codes
```python
@click.command()
def deploy():
"""Deploy with custom exit codes"""
if not check_prerequisites():
ctx = click.get_current_context()
ctx.exit(1)
if not deploy_application():
ctx = click.get_current_context()
ctx.exit(2)
click.echo('Deployment successful')
ctx = click.get_current_context()
ctx.exit(0)
```
### Confirmation for Dangerous Operations
```python
@click.command()
@click.option('--force', is_flag=True, help='Skip confirmation')
def delete(force):
"""Delete with confirmation"""
if not force:
click.confirm('This will delete all data. Continue?', abort=True)
# Proceed with deletion
click.echo('Deleting...')
```
---
## Output Patterns
### Colored Output with Click
```python
@click.command()
def status():
"""Show status with colors"""
click.secho('Success!', fg='green', bold=True)
click.secho('Warning!', fg='yellow')
click.secho('Error!', fg='red', bold=True)
click.echo(click.style('Info', fg='cyan'))
```
### Rich Console Integration
```python
from rich.console import Console
console = Console()
@click.command()
def deploy():
"""Deploy with Rich output"""
console.print('[cyan]Starting deployment...[/cyan]')
console.print('[green]✓[/green] Build successful')
console.print('[yellow]⚠[/yellow] Warning: Cache cleared')
```
### Progress Bars
```python
@click.command()
@click.argument('files', nargs=-1)
def process(files):
"""Process with progress bar"""
with click.progressbar(files, label='Processing files') as bar:
for file in bar:
# Process file
time.sleep(0.1)
```
### Verbose Mode Pattern
```python
@click.command()
@click.option('--verbose', '-v', is_flag=True)
def command(verbose):
"""Command with verbose output"""
click.echo('Starting operation...')
if verbose:
click.echo('Debug: Loading configuration')
click.echo('Debug: Connecting to database')
# Main operation
click.echo('Operation completed')
```
---
## Configuration Patterns
### Configuration File Loading
```python
import json
from pathlib import Path
@click.group()
@click.option('--config', type=click.Path(), default='config.json')
@click.pass_context
def cli(ctx, config):
"""CLI with config file"""
ctx.ensure_object(dict)
if Path(config).exists():
with open(config) as f:
ctx.obj['config'] = json.load(f)
else:
ctx.obj['config'] = {}
@cli.command()
@click.pass_context
def deploy(ctx):
"""Use config"""
config = ctx.obj['config']
api_key = config.get('api_key')
```
### Environment-Based Configuration
```python
@click.command()
@click.option('--env', type=click.Choice(['dev', 'staging', 'prod']), default='dev')
def deploy(env):
"""Deploy with environment config"""
config_file = f'config.{env}.json'
with open(config_file) as f:
config = json.load(f)
# Use environment-specific config
```
### Configuration Priority
```python
def get_config_value(ctx, param_value, env_var, config_key, default):
"""Get value with priority: param > env > config > default"""
if param_value:
return param_value
if env_var in os.environ:
return os.environ[env_var]
if config_key in ctx.obj['config']:
return ctx.obj['config'][config_key]
return default
```
---
## Testing Patterns
### Basic Testing with CliRunner
```python
from click.testing import CliRunner
import pytest
def test_command():
"""Test Click command"""
runner = CliRunner()
result = runner.invoke(cli, ['--help'])
assert result.exit_code == 0
assert 'Usage:' in result.output
def test_command_with_args():
"""Test with arguments"""
runner = CliRunner()
result = runner.invoke(cli, ['deploy', 'production'])
assert result.exit_code == 0
assert 'Deploying to production' in result.output
```
### Testing with Temporary Files
```python
def test_with_file():
"""Test with temporary file"""
runner = CliRunner()
with runner.isolated_filesystem():
with open('test.txt', 'w') as f:
f.write('test content')
result = runner.invoke(cli, ['process', 'test.txt'])
assert result.exit_code == 0
```
### Testing Interactive Prompts
```python
def test_interactive():
"""Test interactive prompts"""
runner = CliRunner()
result = runner.invoke(cli, ['create'], input='username\npassword\n')
assert result.exit_code == 0
assert 'User created' in result.output
```
### Testing Environment Variables
```python
def test_with_env():
"""Test with environment variables"""
runner = CliRunner()
result = runner.invoke(cli, ['deploy'], env={'API_KEY': 'test123'})
assert result.exit_code == 0
```
---
## Advanced Patterns
### Plugin System
```python
@click.group()
def cli():
"""CLI with plugin support"""
pass
# Allow plugins to register commands
def register_plugin(group, plugin_name):
"""Register plugin commands"""
plugin_module = importlib.import_module(f'plugins.{plugin_name}')
for name, cmd in plugin_module.commands.items():
group.add_command(cmd, name)
```
### Lazy Loading
```python
class LazyGroup(click.Group):
"""Lazy load commands"""
def get_command(self, ctx, cmd_name):
"""Load command on demand"""
module = importlib.import_module(f'commands.{cmd_name}')
return module.cli
@click.command(cls=LazyGroup)
def cli():
"""CLI with lazy loading"""
pass
```
### Middleware Pattern
```python
def with_database(f):
"""Decorator to inject database connection"""
@click.pass_context
def wrapper(ctx, *args, **kwargs):
ctx.obj['db'] = connect_database()
try:
return f(*args, **kwargs)
finally:
ctx.obj['db'].close()
return wrapper
@cli.command()
@with_database
@click.pass_context
def query(ctx):
"""Command with database"""
db = ctx.obj['db']
```
---
## Summary
These patterns cover the most common use cases for Click CLIs:
1. **Structure**: Choose between single command, command group, or nested groups
2. **Parameters**: Use options for named parameters, arguments for positional
3. **Validation**: Leverage built-in validators or create custom ones
4. **Errors**: Handle errors gracefully with proper messages
5. **Output**: Use colored output and progress bars for better UX
6. **Config**: Load configuration from files with proper priority
7. **Testing**: Test thoroughly with CliRunner
For more patterns and advanced usage, see the [Click documentation](https://click.palletsprojects.com/).

View File

@@ -0,0 +1,334 @@
#!/bin/bash
#
# generate-click-cli.sh - Generate Click CLI project structure
#
# Usage: generate-click-cli.sh <project-name> [cli-type]
# cli-type: basic, nested, or advanced (default: basic)
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() { echo -e "${CYAN}${NC} $1"; }
print_success() { echo -e "${GREEN}${NC} $1"; }
print_error() { echo -e "${RED}${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}${NC} $1"; }
# Validate arguments
if [ $# -lt 1 ]; then
print_error "Usage: $0 <project-name> [cli-type]"
echo " cli-type: basic, nested, or advanced (default: basic)"
exit 1
fi
PROJECT_NAME="$1"
CLI_TYPE="${2:-basic}"
# Validate CLI type
if [[ ! "$CLI_TYPE" =~ ^(basic|nested|advanced)$ ]]; then
print_error "Invalid CLI type: $CLI_TYPE"
echo " Valid types: basic, nested, advanced"
exit 1
fi
# Validate project name
if [[ ! "$PROJECT_NAME" =~ ^[a-z0-9_-]+$ ]]; then
print_error "Invalid project name: $PROJECT_NAME"
echo " Must contain only lowercase letters, numbers, hyphens, and underscores"
exit 1
fi
# Create project directory
if [ -d "$PROJECT_NAME" ]; then
print_error "Directory already exists: $PROJECT_NAME"
exit 1
fi
print_info "Creating Click CLI project: $PROJECT_NAME (type: $CLI_TYPE)"
# Create directory structure
mkdir -p "$PROJECT_NAME"/{src,tests,docs}
# Determine which template to use
SKILL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
TEMPLATE_FILE=""
case "$CLI_TYPE" in
basic)
TEMPLATE_FILE="$SKILL_DIR/templates/basic-cli.py"
;;
nested)
TEMPLATE_FILE="$SKILL_DIR/templates/nested-commands.py"
;;
advanced)
# For advanced, use nested as base with validators
TEMPLATE_FILE="$SKILL_DIR/templates/nested-commands.py"
;;
esac
# Copy template
if [ ! -f "$TEMPLATE_FILE" ]; then
print_error "Template file not found: $TEMPLATE_FILE"
exit 1
fi
cp "$TEMPLATE_FILE" "$PROJECT_NAME/src/cli.py"
print_success "Created src/cli.py from template"
# Copy validators if advanced type
if [ "$CLI_TYPE" = "advanced" ]; then
VALIDATORS_FILE="$SKILL_DIR/templates/validators.py"
if [ -f "$VALIDATORS_FILE" ]; then
cp "$VALIDATORS_FILE" "$PROJECT_NAME/src/validators.py"
print_success "Created src/validators.py"
fi
fi
# Create __init__.py
cat > "$PROJECT_NAME/src/__init__.py" <<'EOF'
"""
CLI application package
"""
from .cli import cli
__version__ = "1.0.0"
__all__ = ["cli"]
EOF
print_success "Created src/__init__.py"
# Create requirements.txt
cat > "$PROJECT_NAME/requirements.txt" <<'EOF'
click>=8.0.0
rich>=13.0.0
EOF
print_success "Created requirements.txt"
# Create setup.py
cat > "$PROJECT_NAME/setup.py" <<EOF
from setuptools import setup, find_packages
setup(
name="${PROJECT_NAME}",
version="1.0.0",
packages=find_packages(),
install_requires=[
"click>=8.0.0",
"rich>=13.0.0",
],
entry_points={
"console_scripts": [
"${PROJECT_NAME}=src.cli:cli",
],
},
python_requires=">=3.8",
)
EOF
print_success "Created setup.py"
# Create pyproject.toml
cat > "$PROJECT_NAME/pyproject.toml" <<EOF
[build-system]
requires = ["setuptools>=45", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "${PROJECT_NAME}"
version = "1.0.0"
description = "A Click-based CLI tool"
requires-python = ">=3.8"
dependencies = [
"click>=8.0.0",
"rich>=13.0.0",
]
[project.scripts]
${PROJECT_NAME} = "src.cli:cli"
EOF
print_success "Created pyproject.toml"
# Create README.md
cat > "$PROJECT_NAME/README.md" <<EOF
# ${PROJECT_NAME}
A CLI tool built with Click framework.
## Installation
\`\`\`bash
pip install -e .
\`\`\`
## Usage
\`\`\`bash
# Show help
${PROJECT_NAME} --help
# Run command
${PROJECT_NAME} <command>
\`\`\`
## Development
\`\`\`bash
# Install in development mode
pip install -e .
# Run tests
pytest tests/
# Format code
black src/ tests/
# Lint code
pylint src/ tests/
\`\`\`
## Project Structure
\`\`\`
${PROJECT_NAME}/
├── src/
│ ├── __init__.py
│ └── cli.py # Main CLI implementation
├── tests/
│ └── test_cli.py # Unit tests
├── docs/
│ └── usage.md # Usage documentation
├── requirements.txt # Dependencies
├── setup.py # Setup configuration
└── README.md # This file
\`\`\`
## License
MIT
EOF
print_success "Created README.md"
# Create basic test file
cat > "$PROJECT_NAME/tests/test_cli.py" <<'EOF'
import pytest
from click.testing import CliRunner
from src.cli import cli
def test_cli_help():
"""Test CLI help output"""
runner = CliRunner()
result = runner.invoke(cli, ['--help'])
assert result.exit_code == 0
assert 'Usage:' in result.output
def test_cli_version():
"""Test CLI version output"""
runner = CliRunner()
result = runner.invoke(cli, ['--version'])
assert result.exit_code == 0
assert '1.0.0' in result.output
EOF
print_success "Created tests/test_cli.py"
# Create .gitignore
cat > "$PROJECT_NAME/.gitignore" <<'EOF'
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Virtual environments
venv/
ENV/
env/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Testing
.pytest_cache/
.coverage
htmlcov/
# Environment
.env
.env.local
EOF
print_success "Created .gitignore"
# Create usage documentation
cat > "$PROJECT_NAME/docs/usage.md" <<EOF
# ${PROJECT_NAME} Usage Guide
## Installation
Install the CLI tool:
\`\`\`bash
pip install -e .
\`\`\`
## Commands
### Help
Show available commands:
\`\`\`bash
${PROJECT_NAME} --help
\`\`\`
### Version
Show version information:
\`\`\`bash
${PROJECT_NAME} --version
\`\`\`
## Examples
Add specific examples for your CLI commands here.
EOF
print_success "Created docs/usage.md"
# Print summary
echo ""
print_success "Click CLI project created successfully!"
echo ""
print_info "Next steps:"
echo " 1. cd $PROJECT_NAME"
echo " 2. python -m venv venv"
echo " 3. source venv/bin/activate"
echo " 4. pip install -e ."
echo " 5. $PROJECT_NAME --help"
echo ""
print_info "Project type: $CLI_TYPE"
print_info "Location: $(pwd)/$PROJECT_NAME"

View File

@@ -0,0 +1,108 @@
#!/bin/bash
#
# setup-click-project.sh - Setup Click project dependencies and environment
#
# Usage: setup-click-project.sh [project-directory]
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() { echo -e "${CYAN}${NC} $1"; }
print_success() { echo -e "${GREEN}${NC} $1"; }
print_error() { echo -e "${RED}${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}${NC} $1"; }
PROJECT_DIR="${1:-.}"
print_info "Setting up Click project in: $PROJECT_DIR"
# Check if Python is installed
if ! command -v python3 &> /dev/null; then
print_error "Python 3 is not installed"
exit 1
fi
PYTHON_VERSION=$(python3 --version | cut -d' ' -f2)
print_success "Python $PYTHON_VERSION detected"
# Navigate to project directory
cd "$PROJECT_DIR"
# Check if virtual environment exists
if [ ! -d "venv" ]; then
print_info "Creating virtual environment..."
python3 -m venv venv
print_success "Virtual environment created"
else
print_info "Virtual environment already exists"
fi
# Activate virtual environment
print_info "Activating virtual environment..."
source venv/bin/activate
# Upgrade pip
print_info "Upgrading pip..."
pip install --upgrade pip > /dev/null 2>&1
print_success "pip upgraded"
# Install Click and dependencies
print_info "Installing Click and dependencies..."
if [ -f "requirements.txt" ]; then
pip install -r requirements.txt
print_success "Installed from requirements.txt"
else
pip install click rich
print_success "Installed click and rich"
fi
# Install development dependencies
print_info "Installing development dependencies..."
pip install pytest pytest-cov black pylint mypy
print_success "Development dependencies installed"
# Create .env.example if it doesn't exist
if [ ! -f ".env.example" ]; then
cat > .env.example <<'EOF'
# Environment variables for CLI
API_KEY=your_api_key_here
DEBUG=false
LOG_LEVEL=info
EOF
print_success "Created .env.example"
fi
# Setup pre-commit hooks if git repo
if [ -d ".git" ]; then
print_info "Setting up git hooks..."
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/bash
# Run tests before commit
source venv/bin/activate
black src/ tests/ --check || exit 1
pylint src/ || exit 1
pytest tests/ || exit 1
EOF
chmod +x .git/hooks/pre-commit
print_success "Git hooks configured"
fi
# Verify installation
print_info "Verifying installation..."
python3 -c "import click; print(f'Click version: {click.__version__}')"
print_success "Click is properly installed"
echo ""
print_success "Setup completed successfully!"
echo ""
print_info "Next steps:"
echo " 1. source venv/bin/activate"
echo " 2. python src/cli.py --help"
echo " 3. pytest tests/"

View File

@@ -0,0 +1,162 @@
#!/bin/bash
#
# validate-click.sh - Validate Click CLI implementation
#
# Usage: validate-click.sh <cli-file.py>
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
# Function to print colored output
print_info() { echo -e "${CYAN}${NC} $1"; }
print_success() { echo -e "${GREEN}${NC} $1"; }
print_error() { echo -e "${RED}${NC} $1" >&2; }
print_warning() { echo -e "${YELLOW}${NC} $1"; }
# Validate arguments
if [ $# -lt 1 ]; then
print_error "Usage: $0 <cli-file.py>"
exit 1
fi
CLI_FILE="$1"
# Check if file exists
if [ ! -f "$CLI_FILE" ]; then
print_error "File not found: $CLI_FILE"
exit 1
fi
print_info "Validating Click CLI: $CLI_FILE"
echo ""
VALIDATION_PASSED=true
# Check 1: File is a Python file
if [[ ! "$CLI_FILE" =~ \.py$ ]]; then
print_error "File must be a Python file (.py)"
VALIDATION_PASSED=false
else
print_success "File extension is valid (.py)"
fi
# Check 2: File imports Click
if grep -q "import click" "$CLI_FILE"; then
print_success "Click module is imported"
else
print_error "Click module is not imported"
VALIDATION_PASSED=false
fi
# Check 3: Has at least one Click decorator
DECORATOR_COUNT=$(grep -c "@click\." "$CLI_FILE" || true)
if [ "$DECORATOR_COUNT" -gt 0 ]; then
print_success "Found $DECORATOR_COUNT Click decorator(s)"
else
print_error "No Click decorators found"
VALIDATION_PASSED=false
fi
# Check 4: Has main entry point or group
if grep -q "@click.command()\|@click.group()" "$CLI_FILE"; then
print_success "Has Click command or group decorator"
else
print_error "Missing @click.command() or @click.group()"
VALIDATION_PASSED=false
fi
# Check 5: Has if __name__ == '__main__' block
if grep -q "if __name__ == '__main__':" "$CLI_FILE"; then
print_success "Has main execution block"
else
print_warning "Missing main execution block (if __name__ == '__main__':)"
fi
# Check 6: Python syntax is valid
if python3 -m py_compile "$CLI_FILE" 2>/dev/null; then
print_success "Python syntax is valid"
else
print_error "Python syntax errors detected"
VALIDATION_PASSED=false
fi
# Check 7: Has help text
if grep -q '"""' "$CLI_FILE"; then
print_success "Contains docstrings/help text"
else
print_warning "No docstrings found (recommended for help text)"
fi
# Check 8: Has option or argument decorators
if grep -q "@click.option\|@click.argument" "$CLI_FILE"; then
print_success "Has options or arguments defined"
else
print_warning "No options or arguments defined"
fi
# Check 9: Uses recommended patterns
echo ""
print_info "Checking best practices..."
# Check for version option
if grep -q "@click.version_option" "$CLI_FILE"; then
print_success "Has version option"
else
print_warning "Consider adding @click.version_option()"
fi
# Check for help parameter
if grep -q "help=" "$CLI_FILE"; then
print_success "Uses help parameters"
else
print_warning "Consider adding help text to options"
fi
# Check for context usage
if grep -q "@click.pass_context" "$CLI_FILE"; then
print_success "Uses context for state sharing"
else
print_info "No context usage detected (optional)"
fi
# Check for command groups
if grep -q "@click.group()" "$CLI_FILE"; then
print_success "Uses command groups"
# Check for subcommands
SUBCOMMAND_COUNT=$(grep -c "\.command()" "$CLI_FILE" || true)
if [ "$SUBCOMMAND_COUNT" -gt 0 ]; then
print_success "Has $SUBCOMMAND_COUNT subcommand(s)"
fi
fi
# Check for validation
if grep -q "click.Choice\|click.IntRange\|click.FloatRange\|click.Path" "$CLI_FILE"; then
print_success "Uses Click's built-in validators"
else
print_info "No built-in validators detected (optional)"
fi
# Check for colored output (Rich or Click's styling)
if grep -q "from rich\|click.style\|click.echo.*fg=" "$CLI_FILE"; then
print_success "Uses colored output"
else
print_info "No colored output detected (optional)"
fi
# Summary
echo ""
if [ "$VALIDATION_PASSED" = true ]; then
print_success "All critical validations passed!"
echo ""
print_info "Try running: python3 $CLI_FILE --help"
exit 0
else
print_error "Validation failed. Please fix the errors above."
exit 1
fi

View File

@@ -0,0 +1,310 @@
#!/usr/bin/env python3
"""
Advanced Click CLI Template
Demonstrates advanced patterns including:
- Custom parameter types
- Command chaining
- Plugin architecture
- Configuration management
- Logging integration
"""
import click
import logging
from rich.console import Console
from pathlib import Path
import json
from typing import Optional
console = Console()
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Custom parameter types
class JsonType(click.ParamType):
"""Custom type for JSON parsing"""
name = 'json'
def convert(self, value, param, ctx):
try:
return json.loads(value)
except json.JSONDecodeError as e:
self.fail(f'Invalid JSON: {e}', param, ctx)
class PathListType(click.ParamType):
"""Custom type for comma-separated paths"""
name = 'pathlist'
def convert(self, value, param, ctx):
paths = [Path(p.strip()) for p in value.split(',')]
for path in paths:
if not path.exists():
self.fail(f'Path does not exist: {path}', param, ctx)
return paths
# Configuration class
class Config:
"""Application configuration"""
def __init__(self):
self.debug = False
self.log_level = 'INFO'
self.config_file = 'config.json'
self._data = {}
def load(self, config_file: Optional[str] = None):
"""Load configuration from file"""
file_path = Path(config_file or self.config_file)
if file_path.exists():
with open(file_path) as f:
self._data = json.load(f)
logger.info(f"Loaded config from {file_path}")
def get(self, key: str, default=None):
"""Get configuration value"""
return self._data.get(key, default)
def set(self, key: str, value):
"""Set configuration value"""
self._data[key] = value
def save(self):
"""Save configuration to file"""
file_path = Path(self.config_file)
with open(file_path, 'w') as f:
json.dump(self._data, f, indent=2)
logger.info(f"Saved config to {file_path}")
# Pass config between commands
pass_config = click.make_pass_decorator(Config, ensure=True)
# Main CLI group
@click.group(chain=True)
@click.option('--debug', is_flag=True, help='Enable debug mode')
@click.option('--config', type=click.Path(), default='config.json',
help='Configuration file')
@click.option('--log-level',
type=click.Choice(['DEBUG', 'INFO', 'WARNING', 'ERROR']),
default='INFO',
help='Logging level')
@click.version_option(version='2.0.0')
@pass_config
def cli(config: Config, debug: bool, config: str, log_level: str):
"""
Advanced CLI with chaining and plugin support.
Commands can be chained together:
cli init process deploy
cli config set key=value process --validate
"""
config.debug = debug
config.log_level = log_level
config.config_file = config
config.load()
# Set logging level
logger.setLevel(getattr(logging, log_level))
if debug:
console.print("[dim]Debug mode enabled[/dim]")
# Pipeline commands (chainable)
@cli.command()
@click.option('--template', type=click.Choice(['basic', 'advanced', 'api']),
default='basic')
@pass_config
def init(config: Config, template: str):
"""Initialize project (chainable)"""
console.print(f"[cyan]Initializing with {template} template...[/cyan]")
config.set('template', template)
return config
@cli.command()
@click.option('--validate', is_flag=True, help='Validate before processing')
@click.option('--parallel', is_flag=True, help='Process in parallel')
@pass_config
def process(config: Config, validate: bool, parallel: bool):
"""Process data (chainable)"""
console.print("[cyan]Processing data...[/cyan]")
if validate:
console.print("[dim]Validating input...[/dim]")
mode = "parallel" if parallel else "sequential"
console.print(f"[dim]Processing mode: {mode}[/dim]")
return config
@cli.command()
@click.argument('environment', type=click.Choice(['dev', 'staging', 'prod']))
@click.option('--dry-run', is_flag=True, help='Simulate deployment')
@pass_config
def deploy(config: Config, environment: str, dry_run: bool):
"""Deploy to environment (chainable)"""
prefix = "[yellow][DRY RUN][/yellow] " if dry_run else ""
console.print(f"{prefix}[cyan]Deploying to {environment}...[/cyan]")
template = config.get('template', 'unknown')
console.print(f"[dim]Template: {template}[/dim]")
return config
# Advanced configuration commands
@cli.group()
def config():
"""Advanced configuration management"""
pass
@config.command()
@click.argument('key')
@pass_config
def get(config: Config, key: str):
"""Get configuration value"""
value = config.get(key)
if value is not None:
console.print(f"{key}: [green]{value}[/green]")
else:
console.print(f"[yellow]Key not found: {key}[/yellow]")
@config.command()
@click.argument('pair')
@pass_config
def set(config: Config, pair: str):
"""Set configuration (format: key=value)"""
if '=' not in pair:
raise click.BadParameter('Format must be key=value')
key, value = pair.split('=', 1)
config.set(key, value)
config.save()
console.print(f"[green]✓[/green] Set {key} = {value}")
@config.command()
@click.option('--format', type=click.Choice(['json', 'yaml', 'env']),
default='json')
@pass_config
def export(config: Config, format: str):
"""Export configuration in different formats"""
console.print(f"[cyan]Exporting config as {format}...[/cyan]")
if format == 'json':
output = json.dumps(config._data, indent=2)
elif format == 'yaml':
# Simplified YAML output
output = '\n'.join(f"{k}: {v}" for k, v in config._data.items())
else: # env
output = '\n'.join(f"{k.upper()}={v}" for k, v in config._data.items())
console.print(output)
# Advanced data operations
@cli.group()
def data():
"""Data operations with advanced types"""
pass
@data.command()
@click.option('--json-data', type=JsonType(), help='JSON data to import')
@click.option('--paths', type=PathListType(), help='Comma-separated paths')
@pass_config
def import_data(config: Config, json_data: Optional[dict], paths: Optional[list]):
"""Import data from various sources"""
console.print("[cyan]Importing data...[/cyan]")
if json_data:
console.print(f"[dim]JSON data: {json_data}[/dim]")
if paths:
console.print(f"[dim]Processing {len(paths)} path(s)[/dim]")
for path in paths:
console.print(f" - {path}")
@data.command()
@click.option('--input', type=click.File('r'), help='Input file')
@click.option('--output', type=click.File('w'), help='Output file')
@click.option('--format',
type=click.Choice(['json', 'csv', 'xml']),
default='json')
def transform(input, output, format):
"""Transform data between formats"""
console.print(f"[cyan]Transforming data to {format}...[/cyan]")
if input:
data = input.read()
console.print(f"[dim]Read {len(data)} bytes[/dim]")
if output:
# Would write transformed data here
output.write('{}') # Placeholder
console.print("[green]✓[/green] Transformation complete")
# Plugin system
@cli.group()
def plugin():
"""Plugin management"""
pass
@plugin.command()
@click.argument('plugin_name')
@click.option('--version', help='Plugin version')
def install(plugin_name: str, version: Optional[str]):
"""Install a plugin"""
version_str = f"@{version}" if version else "@latest"
console.print(f"[cyan]Installing plugin: {plugin_name}{version_str}...[/cyan]")
console.print("[green]✓[/green] Plugin installed successfully")
@plugin.command()
def list():
"""List installed plugins"""
console.print("[cyan]Installed Plugins:[/cyan]")
# Placeholder plugin list
plugins = [
{"name": "auth-plugin", "version": "1.0.0", "status": "active"},
{"name": "database-plugin", "version": "2.1.0", "status": "active"},
]
for p in plugins:
status_color = "green" if p["status"] == "active" else "yellow"
console.print(f" - {p['name']} ({p['version']}) [{status_color}]{p['status']}[/{status_color}]")
# Batch operations
@cli.command()
@click.argument('commands', nargs=-1, required=True)
@pass_config
def batch(config: Config, commands: tuple):
"""Execute multiple commands in batch"""
console.print(f"[cyan]Executing {len(commands)} command(s)...[/cyan]")
for i, cmd in enumerate(commands, 1):
console.print(f"[dim]{i}. {cmd}[/dim]")
# Would execute actual commands here
console.print("[green]✓[/green] Batch execution completed")
if __name__ == '__main__':
cli()

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python3
"""
Basic Click CLI Template
A simple single-command CLI using Click framework.
"""
import click
from rich.console import Console
console = Console()
@click.command()
@click.version_option(version='1.0.0')
@click.option('--name', '-n', default='World', help='Name to greet')
@click.option('--count', '-c', default=1, type=int, help='Number of greetings')
@click.option('--verbose', '-v', is_flag=True, help='Verbose output')
def cli(name, count, verbose):
"""
A simple greeting CLI tool.
Example:
python cli.py --name Alice --count 3
"""
if verbose:
console.print(f"[dim]Running with name={name}, count={count}[/dim]")
for i in range(count):
console.print(f"[green]Hello, {name}![/green]")
if verbose:
console.print(f"[dim]Completed {count} greeting(s)[/dim]")
if __name__ == '__main__':
cli()

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""
Nested Commands Click Template
Demonstrates command groups, nested subcommands, and context sharing.
"""
import click
from rich.console import Console
console = Console()
@click.group()
@click.version_option(version='1.0.0')
@click.pass_context
def cli(ctx):
"""
A powerful CLI tool with nested commands.
Example:
python cli.py init --template basic
python cli.py deploy production --mode safe
python cli.py config get api-key
"""
ctx.ensure_object(dict)
ctx.obj['console'] = console
@cli.command()
@click.option('--template', '-t', default='basic',
type=click.Choice(['basic', 'advanced', 'minimal']),
help='Project template')
@click.pass_context
def init(ctx, template):
"""Initialize a new project"""
console = ctx.obj['console']
console.print(f"[green]✓[/green] Initializing project with {template} template...")
@cli.command()
@click.argument('environment', type=click.Choice(['dev', 'staging', 'production']))
@click.option('--force', '-f', is_flag=True, help='Force deployment')
@click.option('--mode', '-m',
type=click.Choice(['fast', 'safe', 'rollback']),
default='safe',
help='Deployment mode')
@click.pass_context
def deploy(ctx, environment, force, mode):
"""Deploy to specified environment"""
console = ctx.obj['console']
console.print(f"[cyan]Deploying to {environment} in {mode} mode[/cyan]")
if force:
console.print("[yellow]⚠ Force mode enabled[/yellow]")
@cli.group()
def config():
"""Manage configuration settings"""
pass
@config.command()
@click.argument('key')
@click.pass_context
def get(ctx, key):
"""Get configuration value"""
console = ctx.obj['console']
# Placeholder for actual config retrieval
value = "example_value"
console.print(f"[dim]Config[/dim] {key}: [green]{value}[/green]")
@config.command()
@click.argument('key')
@click.argument('value')
@click.pass_context
def set(ctx, key, value):
"""Set configuration value"""
console = ctx.obj['console']
# Placeholder for actual config storage
console.print(f"[green]✓[/green] Set {key} = {value}")
@config.command()
@click.pass_context
def list(ctx):
"""List all configuration settings"""
console = ctx.obj['console']
console.print("[cyan]Configuration Settings:[/cyan]")
# Placeholder for actual config listing
console.print(" api-key: [dim]***hidden***[/dim]")
console.print(" debug: [green]true[/green]")
@cli.group()
def database():
"""Database management commands"""
pass
@database.command()
@click.option('--create-tables', is_flag=True, help='Create tables')
@click.pass_context
def migrate(ctx, create_tables):
"""Run database migrations"""
console = ctx.obj['console']
console.print("[cyan]Running migrations...[/cyan]")
if create_tables:
console.print("[green]✓[/green] Tables created")
@database.command()
@click.option('--confirm', is_flag=True, help='Confirm reset')
@click.pass_context
def reset(ctx, confirm):
"""Reset database (destructive)"""
console = ctx.obj['console']
if not confirm:
console.print("[yellow]⚠ Use --confirm to proceed[/yellow]")
return
console.print("[red]Resetting database...[/red]")
if __name__ == '__main__':
cli(obj={})

View File

@@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""
Click Custom Validators Template
Demonstrates custom parameter validation, callbacks, and type conversion.
"""
import click
import re
from pathlib import Path
from rich.console import Console
console = Console()
# Custom validator callbacks
def validate_email(ctx, param, value):
"""Validate email format"""
if value and not re.match(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$', value):
raise click.BadParameter('Invalid email format')
return value
def validate_port(ctx, param, value):
"""Validate port number"""
if value < 1 or value > 65535:
raise click.BadParameter('Port must be between 1 and 65535')
return value
def validate_path_exists(ctx, param, value):
"""Validate that path exists"""
if value and not Path(value).exists():
raise click.BadParameter(f'Path does not exist: {value}')
return value
def validate_url(ctx, param, value):
"""Validate URL format"""
if value and not re.match(r'^https?://[^\s]+$', value):
raise click.BadParameter('Invalid URL format (must start with http:// or https://)')
return value
# Custom Click types
class CommaSeparatedList(click.ParamType):
"""Custom type for comma-separated lists"""
name = 'comma-list'
def convert(self, value, param, ctx):
if isinstance(value, list):
return value
try:
return [item.strip() for item in value.split(',') if item.strip()]
except Exception:
self.fail(f'{value} is not a valid comma-separated list', param, ctx)
class EnvironmentVariable(click.ParamType):
"""Custom type for environment variables"""
name = 'env-var'
def convert(self, value, param, ctx):
if not re.match(r'^[A-Z_][A-Z0-9_]*$', value):
self.fail(f'{value} is not a valid environment variable name', param, ctx)
return value
@click.group()
def cli():
"""CLI with custom validators"""
pass
@cli.command()
@click.option('--email', callback=validate_email, required=True, help='User email address')
@click.option('--age', type=click.IntRange(0, 150), required=True, help='User age')
@click.option('--username', type=click.STRING, required=True,
help='Username (3-20 characters)',
callback=lambda ctx, param, value: value if 3 <= len(value) <= 20
else ctx.fail('Username must be 3-20 characters'))
def create_user(email, age, username):
"""Create a new user with validation"""
console.print(f"[green]✓[/green] User created: {username} ({email}), age {age}")
@cli.command()
@click.option('--port', type=int, callback=validate_port, default=8080, help='Server port')
@click.option('--host', default='localhost', help='Server host')
@click.option('--workers', type=click.IntRange(1, 32), default=4, help='Number of workers')
@click.option('--ssl', is_flag=True, help='Enable SSL')
def start_server(port, host, workers, ssl):
"""Start server with validated parameters"""
protocol = 'https' if ssl else 'http'
console.print(f"[cyan]Starting server at {protocol}://{host}:{port}[/cyan]")
console.print(f"[dim]Workers: {workers}[/dim]")
@cli.command()
@click.option('--config', type=click.Path(exists=True, dir_okay=False),
callback=validate_path_exists, required=True, help='Config file path')
@click.option('--output', type=click.Path(dir_okay=False), required=True, help='Output file path')
@click.option('--format', type=click.Choice(['json', 'yaml', 'toml']), default='json',
help='Output format')
def convert_config(config, output, format):
"""Convert configuration file"""
console.print(f"[cyan]Converting {config} to {format} format[/cyan]")
console.print(f"[green]✓[/green] Output: {output}")
@cli.command()
@click.option('--url', callback=validate_url, required=True, help='API URL')
@click.option('--method', type=click.Choice(['GET', 'POST', 'PUT', 'DELETE']),
default='GET', help='HTTP method')
@click.option('--headers', type=CommaSeparatedList(), help='Headers (comma-separated key:value)')
@click.option('--timeout', type=click.FloatRange(0.1, 300.0), default=30.0,
help='Request timeout in seconds')
def api_call(url, method, headers, timeout):
"""Make API call with validation"""
console.print(f"[cyan]{method} {url}[/cyan]")
console.print(f"[dim]Timeout: {timeout}s[/dim]")
if headers:
console.print(f"[dim]Headers: {headers}[/dim]")
@cli.command()
@click.option('--env-var', type=EnvironmentVariable(), required=True,
help='Environment variable name')
@click.option('--value', required=True, help='Environment variable value')
@click.option('--scope', type=click.Choice(['user', 'system', 'project']),
default='user', help='Variable scope')
def set_env(env_var, value, scope):
"""Set environment variable with validation"""
console.print(f"[green]✓[/green] Set {env_var}={value} (scope: {scope})")
@cli.command()
@click.option('--min', type=float, required=True, help='Minimum value')
@click.option('--max', type=float, required=True, help='Maximum value')
@click.option('--step', type=click.FloatRange(0.01, None), default=1.0, help='Step size')
def generate_range(min, max, step):
"""Generate numeric range with validation"""
if min >= max:
raise click.BadParameter('min must be less than max')
count = int((max - min) / step) + 1
console.print(f"[cyan]Generating range from {min} to {max} (step: {step})[/cyan]")
console.print(f"[dim]Total values: {count}[/dim]")
# Example combining multiple validators
@cli.command()
@click.option('--name', required=True, help='Project name',
callback=lambda ctx, param, value: value.lower().replace(' ', '-'))
@click.option('--tags', type=CommaSeparatedList(), help='Project tags (comma-separated)')
@click.option('--priority', type=click.IntRange(1, 10), default=5, help='Priority (1-10)')
@click.option('--template', type=click.Path(exists=True), help='Template directory')
def create_project(name, tags, priority, template):
"""Create project with multiple validators"""
console.print(f"[green]✓[/green] Project created: {name}")
console.print(f"[dim]Priority: {priority}[/dim]")
if tags:
console.print(f"[dim]Tags: {', '.join(tags)}[/dim]")
if template:
console.print(f"[dim]Template: {template}[/dim]")
if __name__ == '__main__':
cli()

View File

@@ -0,0 +1,693 @@
---
name: cobra-patterns
description: Production-ready Cobra CLI patterns including command structure, flags (local and persistent), nested commands, PreRun/PostRun hooks, argument validation, and initialization patterns used by kubectl and hugo. Use when building Go CLIs, implementing Cobra commands, creating nested command structures, managing flags, validating arguments, or when user mentions Cobra, CLI development, command-line tools, kubectl patterns, or Go CLI frameworks.
allowed-tools: Bash, Read, Write, Edit
---
# Cobra Patterns Skill
Production-ready patterns for building powerful CLI applications with Cobra, following best practices from kubectl, hugo, and other production CLIs.
## Instructions
### 1. Choose CLI Structure Pattern
Select the appropriate CLI structure based on your use case:
- **simple**: Single command with flags (quick utilities)
- **flat**: Root command with subcommands at one level
- **nested**: Hierarchical command structure (kubectl-style)
- **plugin**: Extensible CLI with plugin support
- **hybrid**: Mix of built-in and dynamic commands
### 2. Generate Cobra CLI Structure
Use the setup script to scaffold a new Cobra CLI:
```bash
cd /home/gotime2022/.claude/plugins/repos/cli-builder/skills/cobra-patterns
./scripts/setup-cobra-cli.sh <cli-name> <structure-type>
```
**Structure types:** `simple`, `flat`, `nested`, `plugin`, `hybrid`
**Example:**
```bash
./scripts/setup-cobra-cli.sh myctl nested
```
**What This Creates:**
- Complete directory structure with cmd/ package
- Root command with initialization
- Example subcommands
- Flag definitions (local and persistent)
- Cobra initialization (cobra init pattern)
- Go module configuration
- Main entry point
### 3. Command Structure Patterns
#### Basic Command Structure
```go
var exampleCmd = &cobra.Command{
Use: "example [flags]",
Short: "Brief description",
Long: `Detailed description with examples`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
// Command logic
},
}
```
#### Command with Lifecycle Hooks
```go
var advancedCmd = &cobra.Command{
Use: "advanced",
Short: "Advanced command with hooks",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Runs before command execution (inherited by children)
},
PreRun: func(cmd *cobra.Command, args []string) {
// Runs before command execution (local only)
},
Run: func(cmd *cobra.Command, args []string) {
// Main command logic
},
PostRun: func(cmd *cobra.Command, args []string) {
// Runs after command execution (local only)
},
PersistentPostRun: func(cmd *cobra.Command, args []string) {
// Runs after command execution (inherited by children)
},
}
```
#### Command with Error Handling
```go
var robustCmd = &cobra.Command{
Use: "robust",
Short: "Command with proper error handling",
RunE: func(cmd *cobra.Command, args []string) error {
// Return errors instead of os.Exit
if err := validateInput(args); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
if err := executeOperation(); err != nil {
return fmt.Errorf("operation failed: %w", err)
}
return nil
},
}
```
### 4. Flag Management Patterns
#### Persistent Flags (Global Options)
```go
func init() {
// Available to this command and all subcommands
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "log level")
}
```
#### Local Flags (Command-Specific)
```go
func init() {
// Only available to this specific command
createCmd.Flags().StringVarP(&name, "name", "n", "", "resource name")
createCmd.Flags().IntVar(&replicas, "replicas", 1, "number of replicas")
createCmd.Flags().BoolVar(&dryRun, "dry-run", false, "simulate operation")
// Mark required flags
createCmd.MarkFlagRequired("name")
}
```
#### Flag Groups and Validation
```go
func init() {
// Mutually exclusive flags (only one allowed)
createCmd.MarkFlagsMutuallyExclusive("json", "yaml", "text")
// Required together (all or none)
createCmd.MarkFlagsRequiredTogether("username", "password")
// At least one required
createCmd.MarkFlagsOneRequired("file", "stdin", "url")
// Custom flag completion
createCmd.RegisterFlagCompletionFunc("format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return []string{"json", "yaml", "text"}, cobra.ShellCompDirectiveNoFileComp
})
}
```
### 5. Nested Command Patterns
#### Root Command Setup
```go
// cmd/root.go
var rootCmd = &cobra.Command{
Use: "myctl",
Short: "A production-grade CLI tool",
Long: `A complete CLI application built with Cobra.
This application demonstrates production patterns including
nested commands, flag management, and proper error handling.`,
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
cobra.OnInitialize(initConfig)
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
// Add subcommands
rootCmd.AddCommand(getCmd)
rootCmd.AddCommand(createCmd)
rootCmd.AddCommand(deleteCmd)
}
func initConfig() {
// Initialize configuration, logging, etc.
}
```
#### Subcommand with Children (kubectl-style)
```go
// cmd/create/create.go
var createCmd = &cobra.Command{
Use: "create",
Short: "Create resources",
Long: `Create various types of resources`,
}
func init() {
// Add nested subcommands
createCmd.AddCommand(createDeploymentCmd)
createCmd.AddCommand(createServiceCmd)
createCmd.AddCommand(createConfigMapCmd)
}
// cmd/create/deployment.go
var createDeploymentCmd = &cobra.Command{
Use: "deployment [name]",
Short: "Create a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return createDeployment(args[0])
},
}
```
#### Command Groups (Organized Help)
```go
func init() {
// Define command groups
rootCmd.AddGroup(&cobra.Group{
ID: "basic",
Title: "Basic Commands:",
})
rootCmd.AddGroup(&cobra.Group{
ID: "management",
Title: "Management Commands:",
})
// Assign commands to groups
getCmd.GroupID = "basic"
createCmd.GroupID = "management"
}
```
### 6. Argument Validation Patterns
```go
// No arguments allowed
var noArgsCmd = &cobra.Command{
Use: "list",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return listResources()
},
}
// Exactly n arguments
var exactArgsCmd = &cobra.Command{
Use: "get <name>",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return getResource(args[0])
},
}
// Range of arguments
var rangeArgsCmd = &cobra.Command{
Use: "delete <name> [names...]",
Args: cobra.RangeArgs(1, 5),
RunE: func(cmd *cobra.Command, args []string) error {
return deleteResources(args)
},
}
// Custom validation
var customValidationCmd = &cobra.Command{
Use: "custom",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return fmt.Errorf("requires at least 1 argument")
}
for _, arg := range args {
if !isValid(arg) {
return fmt.Errorf("invalid argument: %s", arg)
}
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return processArgs(args)
},
}
// Valid args with completion
var validArgsCmd = &cobra.Command{
Use: "select <resource>",
ValidArgs: []string{"pod", "service", "deployment", "configmap"},
Args: cobra.OnlyValidArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return selectResource(args[0])
},
}
```
### 7. Initialization and Configuration Patterns
#### cobra.OnInitialize Pattern
```go
var (
cfgFile string
config Config
)
func init() {
// Register initialization functions
cobra.OnInitialize(initConfig, initLogging, initClient)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
viper.AddConfigPath(home)
viper.SetConfigType("yaml")
viper.SetConfigName(".myctl")
}
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
func initLogging() {
// Setup logging based on flags
}
func initClient() {
// Initialize API clients, connections, etc.
}
```
#### Viper Integration
```go
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func init() {
// Bind flags to viper
rootCmd.PersistentFlags().String("output", "json", "output format")
viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
// Set defaults
viper.SetDefault("output", "json")
viper.SetDefault("timeout", 30)
}
func Execute() error {
// Access config via viper
output := viper.GetString("output")
timeout := viper.GetInt("timeout")
return rootCmd.Execute()
}
```
### 8. Production Patterns
#### Kubectl-Style Command Structure
```go
// Organize commands by resource type
// myctl get pods
// myctl create deployment
// myctl delete service
var getCmd = &cobra.Command{
Use: "get",
Short: "Display resources",
}
var createCmd = &cobra.Command{
Use: "create",
Short: "Create resources",
}
func init() {
// Resource-specific subcommands
getCmd.AddCommand(getPodsCmd)
getCmd.AddCommand(getServicesCmd)
createCmd.AddCommand(createDeploymentCmd)
createCmd.AddCommand(createServiceCmd)
}
```
#### Hugo-Style Plugin Commands
```go
// Support external commands (hugo server, hugo new, etc.)
func init() {
rootCmd.AddCommand(serverCmd)
rootCmd.AddCommand(newCmd)
// Auto-discover plugin commands
discoverPluginCommands(rootCmd)
}
func discoverPluginCommands(root *cobra.Command) {
// Look for executables like "myctl-plugin-*"
// Add them as dynamic commands
}
```
#### Context and Cancellation
```go
var longRunningCmd = &cobra.Command{
Use: "process",
Short: "Long-running operation",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
// Respect context cancellation (Ctrl+C)
return processWithContext(ctx)
},
}
func processWithContext(ctx context.Context) error {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
// Do work
}
}
}
```
### 9. Validation and Testing
Use validation scripts to ensure CLI compliance:
```bash
# Validate command structure
./scripts/validate-cobra-cli.sh <cli-directory>
# Test command execution
./scripts/test-cobra-commands.sh <cli-binary>
# Generate shell completions
./scripts/generate-completions.sh <cli-binary>
```
**Validation Checks:**
- All commands have Use, Short, and Long descriptions
- Flags are properly defined and documented
- Required flags are marked
- Argument validation is implemented
- RunE is used for error handling
- Commands are organized in logical groups
### 10. Shell Completion Support
```go
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate completion script",
Long: `Generate shell completion script.
Example usage:
# Bash
source <(myctl completion bash)
# Zsh
source <(myctl completion zsh)
# Fish
myctl completion fish | source
# PowerShell
myctl completion powershell | Out-String | Invoke-Expression
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell: %s", args[0])
}
},
}
```
## Available Scripts
- **setup-cobra-cli.sh**: Scaffold new Cobra CLI with chosen structure
- **validate-cobra-cli.sh**: Validate CLI structure and patterns
- **test-cobra-commands.sh**: Test all commands and flags
- **generate-completions.sh**: Generate shell completion scripts
- **add-command.sh**: Add new command to existing CLI
- **refactor-flags.sh**: Reorganize flags (local to persistent, etc.)
## Templates
### Core Templates
- **root.go**: Root command with initialization
- **command.go**: Basic command template
- **nested-command.go**: Subcommand with children
- **main.go**: CLI entry point
- **config.go**: Configuration management with Viper
### Command Templates
- **get-command.go**: Read/retrieve operation
- **create-command.go**: Create operation with validation
- **delete-command.go**: Delete with confirmation
- **list-command.go**: List resources with filtering
- **update-command.go**: Update with partial modifications
### Advanced Templates
- **plugin-command.go**: Extensible plugin support
- **completion-command.go**: Shell completion generation
- **version-command.go**: Version information display
- **middleware.go**: Command middleware pattern
- **context-command.go**: Context-aware command
### Flag Templates
- **persistent-flags.go**: Global flag definitions
- **flag-groups.go**: Flag validation groups
- **custom-flags.go**: Custom flag types
- **viper-flags.go**: Viper-integrated flags
### Testing Templates
- **command_test.go**: Command unit test
- **integration_test.go**: CLI integration test
- **mock_test.go**: Mock dependencies for testing
## Examples
See `examples/` directory for production patterns:
- `kubectl-style/`: Kubectl command organization pattern
- `hugo-style/`: Hugo plugin architecture pattern
- `simple-cli/`: Basic single-level CLI
- `nested-cli/`: Multi-level command hierarchy
- `production-cli/`: Full production CLI with all features
Each example includes:
- Complete working CLI
- Command structure documentation
- Flag management examples
- Test suite
- Shell completion setup
## Best Practices
### Command Organization
1. One command per file for maintainability
2. Group related commands in subdirectories
3. Use command groups for organized help output
4. Keep root command focused on initialization
### Flag Management
1. Use persistent flags for truly global options
2. Mark required flags explicitly
3. Provide sensible defaults
4. Use flag groups for related options
5. Implement custom completion for better UX
### Error Handling
1. Always use RunE instead of Run
2. Return wrapped errors with context
3. Use cobra.CheckErr() for fatal errors
4. Provide helpful error messages with suggestions
### Code Organization
1. Separate command definition from logic
2. Keep business logic in separate packages
3. Use dependency injection for testability
4. Avoid global state where possible
### Documentation
1. Provide both Short and Long descriptions
2. Include usage examples in Long description
3. Document all flags with clear help text
4. Generate and maintain shell completions
### Testing
1. Unit test command functions separately
2. Integration test full command execution
3. Mock external dependencies
4. Test flag validation and argument parsing
5. Verify error messages and exit codes
### Performance
1. Use cobra.OnInitialize for lazy loading
2. Avoid expensive operations in init()
3. Implement context cancellation
4. Profile and optimize hot paths
## Common Workflows
### Creating a New Nested CLI
```bash
# 1. Generate CLI structure
./scripts/setup-cobra-cli.sh myctl nested
# 2. Add commands
cd myctl
../scripts/add-command.sh get
../scripts/add-command.sh create --parent get
# 3. Validate structure
../scripts/validate-cobra-cli.sh .
# 4. Build and test
go build -o myctl
./myctl --help
```
### Adding Authentication to CLI
```bash
# Use authentication template
cp templates/auth-command.go cmd/login.go
# Add persistent auth flags
cp templates/auth-flags.go cmd/root.go
# Implement token management
# Edit cmd/root.go to add initAuth() to cobra.OnInitialize
```
### Implementing kubectl-Style Resource Commands
```bash
# Generate resource-based structure
./scripts/setup-cobra-cli.sh myctl nested
# Add resource commands (get, create, delete, update)
./scripts/add-command.sh get --style kubectl
./scripts/add-command.sh create --style kubectl
# Add resource types as subcommands
./scripts/add-command.sh pods --parent get
./scripts/add-command.sh services --parent get
```
## Troubleshooting
**Commands not showing in help**: Ensure AddCommand() is called in init()
**Flags not recognized**: Check if flag is registered before command execution
**PersistentFlags not inherited**: Verify parent command has PersistentFlags defined
**Completion not working**: Run completion command and source output, check ValidArgs
**Context cancellation ignored**: Ensure you're checking ctx.Done() in long-running operations
## Integration
This skill is used by:
- CLI generation commands - Scaffolding new CLIs
- Code generation agents - Implementing CLI patterns
- Testing commands - Validating CLI structure
- All Go CLI development workflows
---
**Plugin:** cli-builder
**Version:** 1.0.0
**Category:** Go CLI Development
**Skill Type:** Patterns & Templates

View File

@@ -0,0 +1,366 @@
# Kubectl-Style CLI Example
This example demonstrates how to build a kubectl-style CLI with nested resource commands and consistent flag handling.
## Structure
```
myctl/
├── cmd/
│ ├── root.go # Root command with global flags
│ ├── get/
│ │ ├── get.go # Parent "get" command
│ │ ├── pods.go # Get pods subcommand
│ │ ├── services.go # Get services subcommand
│ │ └── deployments.go # Get deployments subcommand
│ ├── create/
│ │ ├── create.go # Parent "create" command
│ │ ├── deployment.go # Create deployment subcommand
│ │ └── service.go # Create service subcommand
│ ├── delete/
│ │ └── delete.go # Delete command (accepts any resource)
│ ├── apply.go # Apply from file/stdin
│ └── completion.go # Shell completion
└── main.go
```
## Usage Pattern
```bash
# Get resources
myctl get pods
myctl get pods my-pod
myctl get pods --namespace production
myctl get services --all-namespaces
# Create resources
myctl create deployment my-app --image nginx:latest --replicas 3
myctl create service my-svc --port 80 --target-port 8080
# Delete resources
myctl delete pod my-pod
myctl delete deployment my-app --force
# Apply configuration
myctl apply -f deployment.yaml
myctl apply -f config.yaml --dry-run
```
## Key Features
### 1. Resource-Based Organization
Commands are organized by resource type:
- `get <resource>` - Retrieve resources
- `create <resource>` - Create resources
- `delete <resource>` - Delete resources
### 2. Consistent Flag Handling
Global flags available to all commands:
- `--namespace, -n` - Target namespace
- `--all-namespaces, -A` - Query all namespaces
- `--output, -o` - Output format (json|yaml|text)
- `--verbose, -v` - Verbose logging
### 3. Command Groups
Organized help output:
```
Basic Commands:
get Display resources
describe Show detailed information
Management Commands:
create Create resources
delete Delete resources
apply Apply configuration
```
## Implementation Example
### Root Command (cmd/root.go)
```go
package cmd
import (
"github.com/spf13/cobra"
"myctl/cmd/get"
"myctl/cmd/create"
"myctl/cmd/delete"
)
var (
namespace string
allNamespaces bool
output string
)
var rootCmd = &cobra.Command{
Use: "myctl",
Short: "Kubernetes-style resource management CLI",
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
// Global flags
rootCmd.PersistentFlags().StringVarP(&namespace, "namespace", "n", "default", "target namespace")
rootCmd.PersistentFlags().BoolVarP(&allNamespaces, "all-namespaces", "A", false, "query all namespaces")
rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "text", "output format (text|json|yaml)")
// Command groups
rootCmd.AddGroup(&cobra.Group{ID: "basic", Title: "Basic Commands:"})
rootCmd.AddGroup(&cobra.Group{ID: "management", Title: "Management Commands:"})
// Register commands
rootCmd.AddCommand(get.GetCmd)
rootCmd.AddCommand(create.CreateCmd)
rootCmd.AddCommand(delete.DeleteCmd)
}
// Helper to get global flags
func GetNamespace() string {
return namespace
}
func GetAllNamespaces() bool {
return allNamespaces
}
func GetOutput() string {
return output
}
```
### Get Command Parent (cmd/get/get.go)
```go
package get
import (
"github.com/spf13/cobra"
)
var GetCmd = &cobra.Command{
Use: "get",
Short: "Display resources",
Long: `Display one or many resources`,
GroupID: "basic",
}
func init() {
// Add resource subcommands
GetCmd.AddCommand(podsCmd)
GetCmd.AddCommand(servicesCmd)
GetCmd.AddCommand(deploymentsCmd)
}
```
### Get Pods Subcommand (cmd/get/pods.go)
```go
package get
import (
"fmt"
"github.com/spf13/cobra"
"myctl/cmd"
"myctl/internal/client"
)
var (
selector string
watch bool
)
var podsCmd = &cobra.Command{
Use: "pods [NAME]",
Short: "Display pods",
Long: `Display one or many pods`,
Args: cobra.MaximumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
// Dynamic completion: fetch pod names
return client.ListPodNames(cmd.GetNamespace()), cobra.ShellCompDirectiveNoFileComp
},
RunE: func(cmd *cobra.Command, args []string) error {
namespace := cmd.GetNamespace()
allNamespaces := cmd.GetAllNamespaces()
output := cmd.GetOutput()
if len(args) == 0 {
// List pods
return listPods(namespace, allNamespaces, selector, output)
}
// Get specific pod
podName := args[0]
return getPod(namespace, podName, output)
},
}
func init() {
// Command-specific flags
podsCmd.Flags().StringVarP(&selector, "selector", "l", "", "label selector")
podsCmd.Flags().BoolVarP(&watch, "watch", "w", false, "watch for changes")
}
func listPods(namespace string, allNamespaces bool, selector string, output string) error {
// Implementation
fmt.Printf("Listing pods (namespace: %s, all: %v, selector: %s, format: %s)\n",
namespace, allNamespaces, selector, output)
return nil
}
func getPod(namespace, name, output string) error {
// Implementation
fmt.Printf("Getting pod: %s (namespace: %s, format: %s)\n", name, namespace, output)
return nil
}
```
### Create Deployment (cmd/create/deployment.go)
```go
package create
import (
"fmt"
"github.com/spf13/cobra"
)
var (
image string
replicas int
port int
)
var deploymentCmd = &cobra.Command{
Use: "deployment NAME",
Short: "Create a deployment",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
if image == "" {
return fmt.Errorf("--image is required")
}
fmt.Printf("Creating deployment: %s\n", name)
fmt.Printf(" Image: %s\n", image)
fmt.Printf(" Replicas: %d\n", replicas)
if port > 0 {
fmt.Printf(" Container Port: %d\n", port)
}
return createDeployment(name, image, replicas, port)
},
}
func init() {
deploymentCmd.Flags().StringVar(&image, "image", "", "container image (required)")
deploymentCmd.Flags().IntVar(&replicas, "replicas", 1, "number of replicas")
deploymentCmd.Flags().IntVar(&port, "port", 0, "container port")
deploymentCmd.MarkFlagRequired("image")
}
func createDeployment(name, image string, replicas, port int) error {
// Implementation
return nil
}
```
## Best Practices
### 1. Consistent Flag Naming
- Use single-letter shortcuts for common flags (`-n`, `-o`, `-v`)
- Use descriptive long names (`--namespace`, `--output`, `--verbose`)
- Keep flag behavior consistent across commands
### 2. Dynamic Completion
Provide shell completion for resource names:
```go
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return client.ListResourceNames(), cobra.ShellCompDirectiveNoFileComp
}
```
### 3. Error Messages
Provide helpful error messages with suggestions:
```go
if image == "" {
return fmt.Errorf("--image is required. Example: --image nginx:latest")
}
```
### 4. Dry Run Support
Support `--dry-run` for preview:
```go
if dryRun {
fmt.Printf("Would create deployment: %s\n", name)
return nil
}
```
### 5. Output Formats
Support multiple output formats:
```go
switch output {
case "json":
return printJSON(pods)
case "yaml":
return printYAML(pods)
default:
return printTable(pods)
}
```
## Testing
```go
func TestGetPodsCommand(t *testing.T) {
cmd := get.GetCmd
cmd.SetArgs([]string{"pods", "--namespace", "production"})
err := cmd.Execute()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
```
## Advanced Features
### 1. Watch Mode
```go
if watch {
return watchPods(namespace, selector)
}
```
### 2. Label Selectors
```go
podsCmd.Flags().StringVarP(&selector, "selector", "l", "", "label selector (e.g., app=nginx)")
```
### 3. Field Selectors
```go
podsCmd.Flags().StringVar(&fieldSelector, "field-selector", "", "field selector (e.g., status.phase=Running)")
```
### 4. Multiple Output Formats
```go
podsCmd.Flags().StringVarP(&output, "output", "o", "text", "output format (text|json|yaml|wide)")
```
This example provides a complete kubectl-style CLI structure that you can adapt for your resource management needs.

View File

@@ -0,0 +1,538 @@
# Complete Production CLI Example
A complete example demonstrating all production features: configuration management, error handling, logging, context support, and testing.
## Features
- ✅ Viper configuration management
- ✅ Structured logging (with levels)
- ✅ Context-aware commands (cancellation support)
- ✅ Proper error handling with wrapped errors
- ✅ Shell completion
- ✅ Unit and integration tests
- ✅ Dry-run support
- ✅ Multiple output formats
- ✅ Version information
- ✅ Configuration file support
## Complete Implementation
### main.go
```go
package main
import (
"context"
"os"
"os/signal"
"syscall"
"github.com/example/myapp/cmd"
)
func main() {
// Setup context with cancellation
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle interrupt signals gracefully
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
// Execute with context
if err := cmd.ExecuteContext(ctx); err != nil {
os.Exit(1)
}
}
```
### cmd/root.go
```go
package cmd
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var (
cfgFile string
verbose bool
logLevel string
logger *zap.Logger
)
var rootCmd = &cobra.Command{
Use: "myapp",
Short: "A production-grade CLI application",
Long: `A complete production CLI with proper error handling,
configuration management, logging, and context support.`,
Version: "1.0.0",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Initialize logger based on flags
return initLogger()
},
}
func ExecuteContext(ctx context.Context) error {
rootCmd.SetContext(ctx)
return rootCmd.Execute()
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
cobra.OnInitialize(initConfig)
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.myapp.yaml)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringVar(&logLevel, "log-level", "info", "log level (debug|info|warn|error)")
// Bind to viper
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
viper.BindPFlag("log-level", rootCmd.PersistentFlags().Lookup("log-level"))
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, err := os.UserHomeDir()
cobra.CheckErr(err)
viper.AddConfigPath(home)
viper.AddConfigPath(".")
viper.SetConfigType("yaml")
viper.SetConfigName(".myapp")
}
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err == nil && verbose {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
func initLogger() error {
// Parse log level
level := zapcore.InfoLevel
if err := level.UnmarshalText([]byte(logLevel)); err != nil {
return fmt.Errorf("invalid log level: %w", err)
}
// Create logger config
config := zap.NewProductionConfig()
config.Level = zap.NewAtomicLevelAt(level)
if verbose {
config = zap.NewDevelopmentConfig()
}
// Build logger
var err error
logger, err = config.Build()
if err != nil {
return fmt.Errorf("failed to initialize logger: %w", err)
}
return nil
}
func GetLogger() *zap.Logger {
if logger == nil {
// Fallback logger
logger, _ = zap.NewProduction()
}
return logger
}
```
### cmd/process.go (Context-Aware Command)
```go
package cmd
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
)
var (
processTimeout time.Duration
processDryRun bool
processWorkers int
)
var processCmd = &cobra.Command{
Use: "process [files...]",
Short: "Process files with context support",
Long: `Process files with proper context handling,
graceful cancellation, and timeout support.`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
logger := GetLogger()
// Apply timeout if specified
if processTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, processTimeout)
defer cancel()
}
logger.Info("Starting process",
zap.Strings("files", args),
zap.Int("workers", processWorkers),
zap.Bool("dry-run", processDryRun))
if processDryRun {
logger.Info("Dry run mode - no changes will be made")
return nil
}
// Process with context
if err := processFiles(ctx, args, processWorkers); err != nil {
logger.Error("Processing failed", zap.Error(err))
return fmt.Errorf("process failed: %w", err)
}
logger.Info("Processing completed successfully")
return nil
},
}
func init() {
rootCmd.AddCommand(processCmd)
processCmd.Flags().DurationVar(&processTimeout, "timeout", 0, "processing timeout")
processCmd.Flags().BoolVar(&processDryRun, "dry-run", false, "simulate without changes")
processCmd.Flags().IntVarP(&processWorkers, "workers", "w", 4, "number of workers")
}
func processFiles(ctx context.Context, files []string, workers int) error {
logger := GetLogger()
for _, file := range files {
// Check context cancellation
select {
case <-ctx.Done():
return ctx.Err()
default:
}
logger.Debug("Processing file", zap.String("file", file))
// Simulate work
if err := processFile(ctx, file); err != nil {
return fmt.Errorf("failed to process %s: %w", file, err)
}
}
return nil
}
func processFile(ctx context.Context, file string) error {
// Simulate processing with context awareness
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for i := 0; i < 10; i++ {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
// Do work
}
}
return nil
}
```
### cmd/config.go (Configuration Management)
```go
package cmd
import (
"fmt"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var configCmd = &cobra.Command{
Use: "config",
Short: "Manage configuration",
}
var configViewCmd = &cobra.Command{
Use: "view",
Short: "View current configuration",
RunE: func(cmd *cobra.Command, args []string) error {
settings := viper.AllSettings()
fmt.Println("Current Configuration:")
fmt.Println("=====================")
for key, value := range settings {
fmt.Printf("%s: %v\n", key, value)
}
return nil
},
}
var configSetCmd = &cobra.Command{
Use: "set KEY VALUE",
Short: "Set configuration value",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
value := args[1]
viper.Set(key, value)
if err := viper.WriteConfig(); err != nil {
if err := viper.SafeWriteConfig(); err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
}
fmt.Printf("Set %s = %s\n", key, value)
return nil
},
}
func init() {
rootCmd.AddCommand(configCmd)
configCmd.AddCommand(configViewCmd)
configCmd.AddCommand(configSetCmd)
}
```
### cmd/version.go
```go
package cmd
import (
"fmt"
"runtime"
"github.com/spf13/cobra"
)
var (
Version = "dev"
Commit = "none"
BuildTime = "unknown"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print version information",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("myapp version %s\n", Version)
fmt.Printf(" Commit: %s\n", Commit)
fmt.Printf(" Built: %s\n", BuildTime)
fmt.Printf(" Go version: %s\n", runtime.Version())
fmt.Printf(" OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}
```
### Testing (cmd/root_test.go)
```go
package cmd
import (
"bytes"
"context"
"testing"
"time"
)
func TestProcessCommand(t *testing.T) {
// Reset command for testing
processCmd.SetArgs([]string{"file1.txt", "file2.txt"})
// Capture output
buf := new(bytes.Buffer)
processCmd.SetOut(buf)
processCmd.SetErr(buf)
// Execute
err := processCmd.Execute()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
func TestProcessCommandWithContext(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
processCmd.SetContext(ctx)
processCmd.SetArgs([]string{"file1.txt"})
err := processCmd.Execute()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}
func TestProcessCommandCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
processCmd.SetContext(ctx)
processCmd.SetArgs([]string{"file1.txt", "file2.txt"})
// Cancel context immediately
cancel()
err := processCmd.Execute()
if err == nil {
t.Error("Expected context cancellation error")
}
}
func TestConfigViewCommand(t *testing.T) {
configViewCmd.SetArgs([]string{})
buf := new(bytes.Buffer)
configViewCmd.SetOut(buf)
err := configViewCmd.Execute()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
output := buf.String()
if output == "" {
t.Error("Expected output, got empty string")
}
}
```
### Configuration File (.myapp.yaml)
```yaml
# Application configuration
verbose: false
log-level: info
timeout: 30s
# Custom settings
api:
endpoint: https://api.example.com
timeout: 10s
retries: 3
database:
host: localhost
port: 5432
name: myapp
features:
experimental: false
beta: true
```
### Makefile
```makefile
VERSION := $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse HEAD)
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
LDFLAGS := -X github.com/example/myapp/cmd.Version=$(VERSION) \
-X github.com/example/myapp/cmd.Commit=$(COMMIT) \
-X github.com/example/myapp/cmd.BuildTime=$(BUILD_TIME)
.PHONY: build
build:
go build -ldflags "$(LDFLAGS)" -o myapp
.PHONY: test
test:
go test -v ./...
.PHONY: coverage
coverage:
go test -coverprofile=coverage.out ./...
go tool cover -html=coverage.out
.PHONY: lint
lint:
golangci-lint run
.PHONY: install
install:
go install -ldflags "$(LDFLAGS)"
.PHONY: clean
clean:
rm -f myapp coverage.out
```
## Usage Examples
```bash
# Basic usage with verbose logging
myapp process file.txt -v
# With timeout and workers
myapp process *.txt --timeout 30s --workers 8
# Dry run
myapp process file.txt --dry-run
# Custom config file
myapp --config prod.yaml process file.txt
# View configuration
myapp config view
# Set configuration
myapp config set api.timeout 15s
# Version information
myapp version
# Shell completion
myapp completion bash > /etc/bash_completion.d/myapp
```
## Key Patterns
1. **Context Awareness**: All long-running operations respect context cancellation
2. **Structured Logging**: Use zap for performance and structure
3. **Configuration Management**: Viper for flexible config handling
4. **Error Wrapping**: Use fmt.Errorf with %w for error chains
5. **Testing**: Comprehensive unit and integration tests
6. **Build Info**: Version, commit, and build time injection
This example provides a complete production-ready CLI that you can use as a foundation for your own applications.

View File

@@ -0,0 +1,381 @@
# Simple CLI - Basic Example
A minimal example for building a simple single-command CLI with Cobra.
## Use Case
Perfect for:
- Quick utility tools
- Single-purpose commands
- Personal automation scripts
- Simple wrappers around existing tools
## Complete Example
### main.go
```go
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var (
// Flags
input string
output string
verbose bool
force bool
)
var rootCmd = &cobra.Command{
Use: "mytool [file]",
Short: "A simple utility tool",
Long: `A simple command-line utility that processes files.
This tool demonstrates a basic Cobra CLI with:
- Flag management
- Argument validation
- Error handling
- Help generation`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
filename := args[0]
if verbose {
fmt.Printf("Processing file: %s\n", filename)
fmt.Printf(" Input format: %s\n", input)
fmt.Printf(" Output format: %s\n", output)
fmt.Printf(" Force mode: %v\n", force)
}
// Process the file
if err := processFile(filename, input, output, force); err != nil {
return fmt.Errorf("failed to process file: %w", err)
}
fmt.Printf("Successfully processed: %s\n", filename)
return nil
},
}
func init() {
// Define flags
rootCmd.Flags().StringVarP(&input, "input", "i", "text", "input format (text|json|yaml)")
rootCmd.Flags().StringVarP(&output, "output", "o", "text", "output format (text|json|yaml)")
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.Flags().BoolVarP(&force, "force", "f", false, "force overwrite")
// Set version
rootCmd.Version = "1.0.0"
}
func processFile(filename, input, output string, force bool) error {
// Your processing logic here
if verbose {
fmt.Printf("Processing %s: %s -> %s\n", filename, input, output)
}
return nil
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
```
## Usage
```bash
# Build
go build -o mytool
# Show help
./mytool --help
# Process file
./mytool data.txt
# With options
./mytool data.txt --input json --output yaml --verbose
# Force mode
./mytool data.txt --force
# Show version
./mytool --version
```
## Key Features
### 1. Single Command Structure
Everything in one file - perfect for simple tools:
- Command definition
- Flag management
- Business logic
- Main function
### 2. Flag Types
```go
// String flags with shorthand
rootCmd.Flags().StringVarP(&input, "input", "i", "text", "input format")
// Boolean flags
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
// Integer flags
var count int
rootCmd.Flags().IntVar(&count, "count", 1, "number of iterations")
// String slice flags
var tags []string
rootCmd.Flags().StringSliceVar(&tags, "tags", []string{}, "list of tags")
```
### 3. Argument Validation
```go
// Exactly one argument
Args: cobra.ExactArgs(1)
// No arguments
Args: cobra.NoArgs
// At least one argument
Args: cobra.MinimumNArgs(1)
// Between 1 and 3 arguments
Args: cobra.RangeArgs(1, 3)
// Any number of arguments
Args: cobra.ArbitraryArgs
```
### 4. Error Handling
```go
RunE: func(cmd *cobra.Command, args []string) error {
// Return errors instead of os.Exit
if err := validate(args); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
if err := process(); err != nil {
return fmt.Errorf("processing failed: %w", err)
}
return nil
}
```
### 5. Auto-Generated Help
Cobra automatically generates help from your command definition:
```bash
$ ./mytool --help
A simple command-line utility that processes files.
This tool demonstrates a basic Cobra CLI with:
- Flag management
- Argument validation
- Error handling
- Help generation
Usage:
mytool [file] [flags]
Flags:
-f, --force force overwrite
-h, --help help for mytool
-i, --input string input format (text|json|yaml) (default "text")
-o, --output string output format (text|json|yaml) (default "text")
-v, --verbose verbose output
--version version for mytool
```
## Enhancements
### Add Configuration File Support
```go
import "github.com/spf13/viper"
func init() {
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")
}
func initConfig() {
if cfgFile != "" {
viper.SetConfigFile(cfgFile)
} else {
home, _ := os.UserHomeDir()
viper.AddConfigPath(home)
viper.SetConfigName(".mytool")
viper.SetConfigType("yaml")
}
viper.AutomaticEnv()
viper.ReadInConfig()
}
```
### Add Dry Run Mode
```go
var dryRun bool
func init() {
rootCmd.Flags().BoolVar(&dryRun, "dry-run", false, "simulate without making changes")
}
func processFile(filename string) error {
if dryRun {
fmt.Printf("DRY RUN: Would process %s\n", filename)
return nil
}
// Actual processing
return nil
}
```
### Add Progress Indication
```go
import "github.com/schollz/progressbar/v3"
func processFile(filename string) error {
bar := progressbar.Default(100)
for i := 0; i < 100; i++ {
// Do work
bar.Add(1)
time.Sleep(10 * time.Millisecond)
}
return nil
}
```
## Testing
```go
package main
import (
"bytes"
"testing"
)
func TestRootCommand(t *testing.T) {
// Reset command for testing
rootCmd.SetArgs([]string{"test.txt", "--verbose"})
// Capture output
buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
// Execute
err := rootCmd.Execute()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
// Check output
output := buf.String()
if !bytes.Contains([]byte(output), []byte("Processing file")) {
t.Errorf("Expected verbose output, got: %s", output)
}
}
func TestRootCommandRequiresArgument(t *testing.T) {
rootCmd.SetArgs([]string{})
err := rootCmd.Execute()
if err == nil {
t.Error("Expected error when no argument provided")
}
}
func TestFlagParsing(t *testing.T) {
rootCmd.SetArgs([]string{"test.txt", "--input", "json", "--output", "yaml"})
err := rootCmd.Execute()
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
// Verify flags were parsed
if input != "json" {
t.Errorf("Expected input=json, got %s", input)
}
if output != "yaml" {
t.Errorf("Expected output=yaml, got %s", output)
}
}
```
## go.mod
```go
module github.com/example/mytool
go 1.21
require github.com/spf13/cobra v1.8.0
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
)
```
## Build and Distribution
### Simple Build
```bash
go build -o mytool
```
### Cross-Platform Build
```bash
# Linux
GOOS=linux GOARCH=amd64 go build -o mytool-linux
# macOS
GOOS=darwin GOARCH=amd64 go build -o mytool-macos
# Windows
GOOS=windows GOARCH=amd64 go build -o mytool.exe
```
### With Version Info
```bash
VERSION=$(git describe --tags --always)
go build -ldflags "-X main.version=$VERSION" -o mytool
```
## Best Practices
1. **Keep It Simple**: Single file is fine for simple tools
2. **Use RunE**: Always return errors instead of os.Exit
3. **Provide Defaults**: Set sensible default flag values
4. **Add Examples**: Include usage examples in Long description
5. **Version Info**: Always set a version
6. **Test Thoroughly**: Write tests for command execution and flags
7. **Document Flags**: Provide clear flag descriptions
This example provides a solid foundation for building simple, production-ready CLI tools with Cobra.

View File

@@ -0,0 +1,113 @@
#!/bin/bash
# Add a new command to existing Cobra CLI
# Usage: ./add-command.sh <command-name> [--parent parent-command]
set -euo pipefail
COMMAND_NAME="${1:-}"
PARENT_CMD=""
# Parse arguments
shift || true
while [ $# -gt 0 ]; do
case "$1" in
--parent)
PARENT_CMD="$2"
shift 2
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
if [ -z "$COMMAND_NAME" ]; then
echo "Error: Command name required"
echo "Usage: $0 <command-name> [--parent parent-command]"
exit 1
fi
if [ ! -d "cmd" ]; then
echo "Error: cmd/ directory not found. Run from CLI root directory."
exit 1
fi
# Determine file location
if [ -n "$PARENT_CMD" ]; then
CMD_DIR="cmd/$PARENT_CMD"
mkdir -p "$CMD_DIR"
CMD_FILE="$CMD_DIR/$COMMAND_NAME.go"
PACKAGE_NAME="$PARENT_CMD"
else
CMD_FILE="cmd/$COMMAND_NAME.go"
PACKAGE_NAME="cmd"
fi
if [ -f "$CMD_FILE" ]; then
echo "Error: Command file already exists: $CMD_FILE"
exit 1
fi
# Create command file
cat > "$CMD_FILE" << EOF
package $PACKAGE_NAME
import (
"fmt"
"github.com/spf13/cobra"
)
var (
// Add command-specific flags here
${COMMAND_NAME}Example string
)
var ${COMMAND_NAME}Cmd = &cobra.Command{
Use: "$COMMAND_NAME",
Short: "Short description of $COMMAND_NAME",
Long: \`Detailed description of the $COMMAND_NAME command.
This command does something useful. Add more details here.
Examples:
mycli $COMMAND_NAME --example value\`,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("Executing $COMMAND_NAME command\n")
// Add command logic here
return nil
},
}
func init() {
// Define flags
${COMMAND_NAME}Cmd.Flags().StringVar(&${COMMAND_NAME}Example, "example", "", "example flag")
// Register command
EOF
if [ -n "$PARENT_CMD" ]; then
cat >> "$CMD_FILE" << EOF
${PARENT_CMD}Cmd.AddCommand(${COMMAND_NAME}Cmd)
EOF
else
cat >> "$CMD_FILE" << EOF
rootCmd.AddCommand(${COMMAND_NAME}Cmd)
EOF
fi
cat >> "$CMD_FILE" << EOF
}
EOF
echo "✓ Created command file: $CMD_FILE"
echo ""
echo "Next steps:"
echo "1. Update the command logic in $CMD_FILE"
echo "2. Add any required flags"
echo "3. Build and test: go build"
echo ""

View File

@@ -0,0 +1,82 @@
#!/bin/bash
# Generate shell completion scripts for Cobra CLI
# Usage: ./generate-completions.sh <cli-binary> [output-dir]
set -euo pipefail
CLI_BINARY="${1:-}"
OUTPUT_DIR="${2:-./completions}"
if [ -z "$CLI_BINARY" ]; then
echo "Error: CLI binary path required"
echo "Usage: $0 <cli-binary> [output-dir]"
exit 1
fi
if [ ! -f "$CLI_BINARY" ]; then
echo "Error: Binary not found: $CLI_BINARY"
exit 1
fi
if [ ! -x "$CLI_BINARY" ]; then
echo "Error: Binary is not executable: $CLI_BINARY"
exit 1
fi
mkdir -p "$OUTPUT_DIR"
CLI_NAME=$(basename "$CLI_BINARY")
echo "Generating shell completions for $CLI_NAME..."
echo ""
# Generate Bash completion
if "$CLI_BINARY" completion bash > "$OUTPUT_DIR/$CLI_NAME.bash" 2>/dev/null; then
echo "✓ Bash completion: $OUTPUT_DIR/$CLI_NAME.bash"
echo " Install: source $OUTPUT_DIR/$CLI_NAME.bash"
else
echo "⚠ Bash completion not available"
fi
# Generate Zsh completion
if "$CLI_BINARY" completion zsh > "$OUTPUT_DIR/_$CLI_NAME" 2>/dev/null; then
echo "✓ Zsh completion: $OUTPUT_DIR/_$CLI_NAME"
echo " Install: Place in \$fpath directory"
else
echo "⚠ Zsh completion not available"
fi
# Generate Fish completion
if "$CLI_BINARY" completion fish > "$OUTPUT_DIR/$CLI_NAME.fish" 2>/dev/null; then
echo "✓ Fish completion: $OUTPUT_DIR/$CLI_NAME.fish"
echo " Install: source $OUTPUT_DIR/$CLI_NAME.fish"
else
echo "⚠ Fish completion not available"
fi
# Generate PowerShell completion
if "$CLI_BINARY" completion powershell > "$OUTPUT_DIR/$CLI_NAME.ps1" 2>/dev/null; then
echo "✓ PowerShell completion: $OUTPUT_DIR/$CLI_NAME.ps1"
echo " Install: & $OUTPUT_DIR/$CLI_NAME.ps1"
else
echo "⚠ PowerShell completion not available"
fi
echo ""
echo "Completions generated in: $OUTPUT_DIR"
echo ""
echo "Installation instructions:"
echo ""
echo "Bash:"
echo " echo 'source $OUTPUT_DIR/$CLI_NAME.bash' >> ~/.bashrc"
echo ""
echo "Zsh:"
echo " mkdir -p ~/.zsh/completions"
echo " cp $OUTPUT_DIR/_$CLI_NAME ~/.zsh/completions/"
echo " Add to ~/.zshrc: fpath=(~/.zsh/completions \$fpath)"
echo ""
echo "Fish:"
echo " mkdir -p ~/.config/fish/completions"
echo " cp $OUTPUT_DIR/$CLI_NAME.fish ~/.config/fish/completions/"
echo ""

View File

@@ -0,0 +1,566 @@
#!/bin/bash
# Setup Cobra CLI with chosen structure pattern
# Usage: ./setup-cobra-cli.sh <cli-name> <structure-type>
set -euo pipefail
CLI_NAME="${1:-}"
STRUCTURE_TYPE="${2:-flat}"
if [ -z "$CLI_NAME" ]; then
echo "Error: CLI name required"
echo "Usage: $0 <cli-name> <structure-type>"
echo "Structure types: simple, flat, nested, plugin, hybrid"
exit 1
fi
# Validate structure type
case "$STRUCTURE_TYPE" in
simple|flat|nested|plugin|hybrid)
;;
*)
echo "Error: Invalid structure type: $STRUCTURE_TYPE"
echo "Valid types: simple, flat, nested, plugin, hybrid"
exit 1
;;
esac
echo "Creating Cobra CLI: $CLI_NAME with $STRUCTURE_TYPE structure..."
# Create directory structure
mkdir -p "$CLI_NAME"
cd "$CLI_NAME"
# Initialize Go module
go mod init "$CLI_NAME" 2>/dev/null || echo "Go module already initialized"
# Create base directories
mkdir -p cmd
mkdir -p internal
# Install Cobra
echo "Installing Cobra dependency..."
go get -u github.com/spf13/cobra@latest
case "$STRUCTURE_TYPE" in
simple)
# Single command CLI
cat > main.go << 'EOF'
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var (
verbose bool
)
var rootCmd = &cobra.Command{
Use: "CLI_NAME",
Short: "A simple CLI tool",
Long: `A simple command-line tool built with Cobra.`,
RunE: func(cmd *cobra.Command, args []string) error {
if verbose {
fmt.Println("Running in verbose mode")
}
fmt.Println("Hello from CLI_NAME!")
return nil
},
}
func init() {
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
}
func main() {
if err := rootCmd.Execute(); err != nil {
os.Exit(1)
}
}
EOF
sed -i "s/CLI_NAME/$CLI_NAME/g" main.go
;;
flat)
# Root with subcommands at one level
cat > cmd/root.go << 'EOF'
package cmd
import (
"os"
"github.com/spf13/cobra"
)
var (
cfgFile string
verbose bool
)
var rootCmd = &cobra.Command{
Use: "CLI_NAME",
Short: "A CLI tool with flat command structure",
Long: `A command-line tool with subcommands at a single level.`,
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
}
EOF
cat > cmd/get.go << 'EOF'
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var getCmd = &cobra.Command{
Use: "get [resource]",
Short: "Get resources",
Long: `Retrieve and display resources`,
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Printf("Getting resource: %s\n", args[0])
return nil
},
}
func init() {
rootCmd.AddCommand(getCmd)
}
EOF
cat > cmd/create.go << 'EOF'
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
var (
createName string
)
var createCmd = &cobra.Command{
Use: "create",
Short: "Create resources",
Long: `Create new resources`,
RunE: func(cmd *cobra.Command, args []string) error {
if createName == "" {
return fmt.Errorf("name is required")
}
fmt.Printf("Creating resource: %s\n", createName)
return nil
},
}
func init() {
createCmd.Flags().StringVarP(&createName, "name", "n", "", "resource name (required)")
createCmd.MarkFlagRequired("name")
rootCmd.AddCommand(createCmd)
}
EOF
cat > main.go << 'EOF'
package main
import (
"os"
"CLI_NAME/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
EOF
sed -i "s/CLI_NAME/$CLI_NAME/g" main.go cmd/root.go
;;
nested)
# kubectl-style nested commands
mkdir -p cmd/get cmd/create cmd/delete
cat > cmd/root.go << 'EOF'
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
var (
cfgFile string
verbose bool
output string
)
var rootCmd = &cobra.Command{
Use: "CLI_NAME",
Short: "A production-grade CLI tool",
Long: `A complete CLI application with nested command structure.
This CLI demonstrates kubectl-style command organization with
hierarchical commands and consistent flag handling.`,
}
func Execute() error {
return rootCmd.Execute()
}
func init() {
cobra.OnInitialize(initConfig)
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "text", "output format (text|json|yaml)")
// Command groups
rootCmd.AddGroup(&cobra.Group{
ID: "basic",
Title: "Basic Commands:",
})
rootCmd.AddGroup(&cobra.Group{
ID: "management",
Title: "Management Commands:",
})
}
func initConfig() {
if verbose {
fmt.Fprintln(os.Stderr, "Verbose mode enabled")
}
if cfgFile != "" {
fmt.Fprintf(os.Stderr, "Using config file: %s\n", cfgFile)
}
}
EOF
cat > cmd/get/get.go << 'EOF'
package get
import (
"github.com/spf13/cobra"
)
var GetCmd = &cobra.Command{
Use: "get",
Short: "Display resources",
Long: `Display one or many resources`,
GroupID: "basic",
}
func init() {
GetCmd.AddCommand(podsCmd)
GetCmd.AddCommand(servicesCmd)
}
EOF
cat > cmd/get/pods.go << 'EOF'
package get
import (
"fmt"
"github.com/spf13/cobra"
)
var (
namespace string
allNamespaces bool
)
var podsCmd = &cobra.Command{
Use: "pods [NAME]",
Short: "Display pods",
Long: `Display one or many pods`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if allNamespaces {
fmt.Println("Listing pods in all namespaces")
} else {
fmt.Printf("Listing pods in namespace: %s\n", namespace)
}
if len(args) > 0 {
fmt.Printf("Showing pod: %s\n", args[0])
}
return nil
},
}
func init() {
podsCmd.Flags().StringVarP(&namespace, "namespace", "n", "default", "namespace")
podsCmd.Flags().BoolVarP(&allNamespaces, "all-namespaces", "A", false, "list across all namespaces")
}
EOF
cat > cmd/get/services.go << 'EOF'
package get
import (
"fmt"
"github.com/spf13/cobra"
)
var servicesCmd = &cobra.Command{
Use: "services [NAME]",
Short: "Display services",
Long: `Display one or many services`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Listing services")
if len(args) > 0 {
fmt.Printf("Showing service: %s\n", args[0])
}
return nil
},
}
EOF
cat > cmd/create/create.go << 'EOF'
package create
import (
"github.com/spf13/cobra"
)
var CreateCmd = &cobra.Command{
Use: "create",
Short: "Create resources",
Long: `Create resources from files or stdin`,
GroupID: "management",
}
func init() {
CreateCmd.AddCommand(deploymentCmd)
}
EOF
cat > cmd/create/deployment.go << 'EOF'
package create
import (
"fmt"
"github.com/spf13/cobra"
)
var (
image string
replicas int
)
var deploymentCmd = &cobra.Command{
Use: "deployment NAME",
Short: "Create a deployment",
Long: `Create a deployment with the specified name`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
fmt.Printf("Creating deployment: %s\n", name)
fmt.Printf(" Image: %s\n", image)
fmt.Printf(" Replicas: %d\n", replicas)
return nil
},
}
func init() {
deploymentCmd.Flags().StringVar(&image, "image", "", "container image (required)")
deploymentCmd.Flags().IntVar(&replicas, "replicas", 1, "number of replicas")
deploymentCmd.MarkFlagRequired("image")
}
EOF
cat > cmd/delete/delete.go << 'EOF'
package delete
import (
"fmt"
"github.com/spf13/cobra"
)
var (
force bool
)
var DeleteCmd = &cobra.Command{
Use: "delete",
Short: "Delete resources",
Long: `Delete resources by names, stdin, or resources`,
GroupID: "management",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
for _, resource := range args {
if force {
fmt.Printf("Force deleting: %s\n", resource)
} else {
fmt.Printf("Deleting: %s\n", resource)
}
}
return nil
},
}
func init() {
DeleteCmd.Flags().BoolVarP(&force, "force", "f", false, "force deletion")
}
EOF
# Update root to add nested commands
cat >> cmd/root.go << 'EOF'
func init() {
// Add command imports at the top of your root.go:
// import (
// "CLI_NAME/cmd/get"
// "CLI_NAME/cmd/create"
// "CLI_NAME/cmd/delete"
// )
// Uncomment after fixing imports:
// rootCmd.AddCommand(get.GetCmd)
// rootCmd.AddCommand(create.CreateCmd)
// rootCmd.AddCommand(delete.DeleteCmd)
}
EOF
cat > main.go << 'EOF'
package main
import (
"os"
"CLI_NAME/cmd"
_ "CLI_NAME/cmd/get"
_ "CLI_NAME/cmd/create"
_ "CLI_NAME/cmd/delete"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}
EOF
sed -i "s/CLI_NAME/$CLI_NAME/g" main.go cmd/root.go
;;
plugin)
echo "Plugin structure not yet implemented"
exit 1
;;
hybrid)
echo "Hybrid structure not yet implemented"
exit 1
;;
esac
# Create .gitignore
cat > .gitignore << 'EOF'
# Binaries
*.exe
*.exe~
*.dll
*.so
*.dylib
/CLI_NAME
# Test binary
*.test
# Coverage
*.out
*.prof
# Go workspace
go.work
go.work.sum
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
EOF
sed -i "s/CLI_NAME/$CLI_NAME/g" .gitignore
# Create README
cat > README.md << 'EOF'
# CLI_NAME
A CLI tool built with Cobra.
## Installation
```bash
go install
```
## Usage
```bash
CLI_NAME --help
```
## Development
Build:
```bash
go build -o CLI_NAME
```
Run:
```bash
./CLI_NAME
```
Test:
```bash
go test ./...
```
## Structure
This CLI uses STRUCTURE_TYPE command structure.
EOF
sed -i "s/CLI_NAME/$CLI_NAME/g" README.md
sed -i "s/STRUCTURE_TYPE/$STRUCTURE_TYPE/g" README.md
# Initialize dependencies
echo "Downloading dependencies..."
go mod tidy
echo ""
echo "✓ CLI created successfully: $CLI_NAME"
echo ""
echo "Next steps:"
echo " cd $CLI_NAME"
echo " go build -o $CLI_NAME"
echo " ./$CLI_NAME --help"
echo ""

View File

@@ -0,0 +1,181 @@
#!/bin/bash
# Validate Cobra CLI structure and patterns
# Usage: ./validate-cobra-cli.sh <cli-directory>
set -euo pipefail
CLI_DIR="${1:-.}"
if [ ! -d "$CLI_DIR" ]; then
echo "Error: Directory not found: $CLI_DIR"
exit 1
fi
echo "Validating Cobra CLI structure in: $CLI_DIR"
echo ""
ERRORS=0
WARNINGS=0
# Check Go module
if [ ! -f "$CLI_DIR/go.mod" ]; then
echo "❌ ERROR: go.mod not found"
((ERRORS++))
else
echo "✓ go.mod found"
fi
# Check main.go
if [ ! -f "$CLI_DIR/main.go" ]; then
echo "❌ ERROR: main.go not found"
((ERRORS++))
else
echo "✓ main.go found"
# Check if main.go has proper structure
if ! grep -q "func main()" "$CLI_DIR/main.go"; then
echo "❌ ERROR: main() function not found in main.go"
((ERRORS++))
fi
fi
# Check cmd directory
if [ ! -d "$CLI_DIR/cmd" ]; then
echo "⚠ WARNING: cmd/ directory not found (acceptable for simple CLIs)"
((WARNINGS++))
else
echo "✓ cmd/ directory found"
# Check root command
if [ -f "$CLI_DIR/cmd/root.go" ]; then
echo "✓ cmd/root.go found"
# Validate root command structure
if ! grep -q "var rootCmd" "$CLI_DIR/cmd/root.go"; then
echo "❌ ERROR: rootCmd variable not found in root.go"
((ERRORS++))
fi
if ! grep -q "func Execute()" "$CLI_DIR/cmd/root.go"; then
echo "❌ ERROR: Execute() function not found in root.go"
((ERRORS++))
fi
else
echo "⚠ WARNING: cmd/root.go not found"
((WARNINGS++))
fi
fi
# Check for Cobra dependency
if [ -f "$CLI_DIR/go.mod" ]; then
if ! grep -q "github.com/spf13/cobra" "$CLI_DIR/go.mod"; then
echo "❌ ERROR: Cobra dependency not found in go.mod"
((ERRORS++))
else
echo "✓ Cobra dependency found"
fi
fi
# Validate command files have proper structure
if [ -d "$CLI_DIR/cmd" ]; then
for cmd_file in "$CLI_DIR/cmd"/*.go; do
if [ -f "$cmd_file" ]; then
filename=$(basename "$cmd_file")
# Check for command variable
if grep -q "var.*Cmd = &cobra.Command" "$cmd_file"; then
echo "✓ Command structure found in $filename"
# Check for Use field
if ! grep -A5 "var.*Cmd = &cobra.Command" "$cmd_file" | grep -q "Use:"; then
echo "⚠ WARNING: Use field missing in $filename"
((WARNINGS++))
fi
# Check for Short description
if ! grep -A10 "var.*Cmd = &cobra.Command" "$cmd_file" | grep -q "Short:"; then
echo "⚠ WARNING: Short description missing in $filename"
((WARNINGS++))
fi
# Check for Run or RunE
if ! grep -A15 "var.*Cmd = &cobra.Command" "$cmd_file" | grep -qE "Run:|RunE:"; then
echo "⚠ WARNING: Run/RunE function missing in $filename"
((WARNINGS++))
fi
# Prefer RunE over Run for error handling
if grep -A15 "var.*Cmd = &cobra.Command" "$cmd_file" | grep -q "Run:" && \
! grep -A15 "var.*Cmd = &cobra.Command" "$cmd_file" | grep -q "RunE:"; then
echo "⚠ WARNING: Consider using RunE instead of Run in $filename for better error handling"
((WARNINGS++))
fi
fi
fi
done
fi
# Check for .gitignore
if [ ! -f "$CLI_DIR/.gitignore" ]; then
echo "⚠ WARNING: .gitignore not found"
((WARNINGS++))
else
echo "✓ .gitignore found"
fi
# Check for README
if [ ! -f "$CLI_DIR/README.md" ]; then
echo "⚠ WARNING: README.md not found"
((WARNINGS++))
else
echo "✓ README.md found"
fi
# Check if Go code compiles
echo ""
echo "Checking if code compiles..."
cd "$CLI_DIR"
if go build -o /tmp/cobra-cli-test 2>&1 | head -20; then
echo "✓ Code compiles successfully"
rm -f /tmp/cobra-cli-test
else
echo "❌ ERROR: Code does not compile"
((ERRORS++))
fi
# Check for common anti-patterns
echo ""
echo "Checking for anti-patterns..."
# Check for os.Exit in command handlers
if grep -r "os.Exit" "$CLI_DIR/cmd" 2>/dev/null | grep -v "import" | grep -v "//"; then
echo "⚠ WARNING: Found os.Exit() in command handlers - prefer returning errors"
((WARNINGS++))
fi
# Check for panic in command handlers
if grep -r "panic(" "$CLI_DIR/cmd" 2>/dev/null | grep -v "import" | grep -v "//"; then
echo "⚠ WARNING: Found panic() in command handlers - prefer returning errors"
((WARNINGS++))
fi
# Summary
echo ""
echo "================================"
echo "Validation Summary"
echo "================================"
echo "Errors: $ERRORS"
echo "Warnings: $WARNINGS"
echo ""
if [ $ERRORS -eq 0 ]; then
echo "✓ Validation passed!"
if [ $WARNINGS -gt 0 ]; then
echo " ($WARNINGS warnings to review)"
fi
exit 0
else
echo "❌ Validation failed with $ERRORS errors"
exit 1
fi

View File

@@ -0,0 +1,71 @@
package {{.PackageName}}
import (
"fmt"
"github.com/spf13/cobra"
)
var (
// Command-specific flags
{{.CommandName}}Name string
{{.CommandName}}Force bool
{{.CommandName}}DryRun bool
)
// {{.CommandName}}Cmd represents the {{.CommandName}} command
var {{.CommandName}}Cmd = &cobra.Command{
Use: "{{.CommandName}} [flags]",
Short: "{{.ShortDescription}}",
Long: `{{.LongDescription}}
This command provides {{.CommandName}} functionality with proper
error handling and validation.
Examples:
{{.CLIName}} {{.CommandName}} --name example
{{.CLIName}} {{.CommandName}} --force
{{.CLIName}} {{.CommandName}} --dry-run`,
Args: cobra.NoArgs,
GroupID: "{{.GroupID}}",
RunE: func(cmd *cobra.Command, args []string) error {
// Validate required flags
if {{.CommandName}}Name == "" {
return fmt.Errorf("--name is required")
}
// Check dry-run mode
if {{.CommandName}}DryRun {
fmt.Printf("DRY RUN: Would execute {{.CommandName}} with name: %s\n", {{.CommandName}}Name)
return nil
}
// Execute command logic
if cmd.Root().PersistentFlags().Lookup("verbose").Changed {
fmt.Printf("Executing {{.CommandName}} in verbose mode...\n")
}
if err := execute{{.CommandName}}({{.CommandName}}Name, {{.CommandName}}Force); err != nil {
return fmt.Errorf("{{.CommandName}} failed: %w", err)
}
fmt.Printf("Successfully executed {{.CommandName}}: %s\n", {{.CommandName}}Name)
return nil
},
}
func init() {
// Define flags
{{.CommandName}}Cmd.Flags().StringVarP(&{{.CommandName}}Name, "name", "n", "", "resource name (required)")
{{.CommandName}}Cmd.Flags().BoolVarP(&{{.CommandName}}Force, "force", "f", false, "force operation")
{{.CommandName}}Cmd.Flags().BoolVar(&{{.CommandName}}DryRun, "dry-run", false, "simulate operation without making changes")
// Mark required flags
{{.CommandName}}Cmd.MarkFlagRequired("name")
}
// execute{{.CommandName}} performs the actual operation
func execute{{.CommandName}}(name string, force bool) error {
// Implementation goes here
return nil
}

View File

@@ -0,0 +1,71 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// completionCmd represents the completion command
var completionCmd = &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate completion script",
Long: `Generate shell completion script for {{.CLIName}}.
The completion script must be evaluated to provide interactive
completion. This can be done by sourcing it from your shell profile.
Bash:
source <({{.CLIName}} completion bash)
# To load completions for each session, execute once:
# Linux:
{{.CLIName}} completion bash > /etc/bash_completion.d/{{.CLIName}}
# macOS:
{{.CLIName}} completion bash > /usr/local/etc/bash_completion.d/{{.CLIName}}
Zsh:
# If shell completion is not already enabled, enable it:
echo "autoload -U compinit; compinit" >> ~/.zshrc
# To load completions for each session, execute once:
{{.CLIName}} completion zsh > "${fpath[1]}/_{{.CLIName}}"
# You will need to start a new shell for this setup to take effect.
Fish:
{{.CLIName}} completion fish | source
# To load completions for each session, execute once:
{{.CLIName}} completion fish > ~/.config/fish/completions/{{.CLIName}}.fish
PowerShell:
{{.CLIName}} completion powershell | Out-String | Invoke-Expression
# To load completions for every new session:
{{.CLIName}} completion powershell > {{.CLIName}}.ps1
# and source this file from your PowerShell profile.
`,
DisableFlagsInUseLine: true,
ValidArgs: []string{"bash", "zsh", "fish", "powershell"},
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
switch args[0] {
case "bash":
return cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
return cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
return cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
return cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
default:
return fmt.Errorf("unsupported shell type: %s", args[0])
}
},
}
func init() {
rootCmd.AddCommand(completionCmd)
}

View File

@@ -0,0 +1,13 @@
package main
import (
"os"
"{{.ModulePath}}/cmd"
)
func main() {
if err := cmd.Execute(); err != nil {
os.Exit(1)
}
}

View File

@@ -0,0 +1,20 @@
package {{.PackageName}}
import (
"github.com/spf13/cobra"
)
// {{.CommandName}}Cmd represents the {{.CommandName}} parent command
var {{.CommandName}}Cmd = &cobra.Command{
Use: "{{.CommandName}}",
Short: "{{.ShortDescription}}",
Long: `{{.LongDescription}}`,
GroupID: "{{.GroupID}}",
}
func init() {
// Add subcommands here
// {{.CommandName}}Cmd.AddCommand({{.CommandName}}ListCmd)
// {{.CommandName}}Cmd.AddCommand({{.CommandName}}CreateCmd)
// {{.CommandName}}Cmd.AddCommand({{.CommandName}}DeleteCmd)
}

View File

@@ -0,0 +1,95 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
cfgFile string
verbose bool
output string
)
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "{{.CLIName}}",
Short: "{{.ShortDescription}}",
Long: `{{.LongDescription}}
This is a production-grade CLI application built with Cobra.
It provides a complete command-line interface with proper error
handling, configuration management, and extensibility.`,
Version: "{{.Version}}",
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() error {
return rootCmd.Execute()
}
func init() {
cobra.OnInitialize(initConfig)
// Global flags
rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.{{.CLIName}}.yaml)")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringVarP(&output, "output", "o", "text", "output format (text|json|yaml)")
// Bind flags to viper
viper.BindPFlag("verbose", rootCmd.PersistentFlags().Lookup("verbose"))
viper.BindPFlag("output", rootCmd.PersistentFlags().Lookup("output"))
// Command groups for organized help
rootCmd.AddGroup(&cobra.Group{
ID: "basic",
Title: "Basic Commands:",
})
rootCmd.AddGroup(&cobra.Group{
ID: "management",
Title: "Management Commands:",
})
rootCmd.AddGroup(&cobra.Group{
ID: "other",
Title: "Other Commands:",
})
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if cfgFile != "" {
// Use config file from the flag.
viper.SetConfigFile(cfgFile)
} else {
// Find home directory.
home, err := os.UserHomeDir()
cobra.CheckErr(err)
// Search config in home directory with name ".{{.CLIName}}" (without extension).
viper.AddConfigPath(home)
viper.AddConfigPath(".")
viper.SetConfigType("yaml")
viper.SetConfigName(".{{.CLIName}}")
}
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
if err := viper.ReadInConfig(); err == nil && verbose {
fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed())
}
}
// GetVerbose returns whether verbose mode is enabled
func GetVerbose() bool {
return viper.GetBool("verbose")
}
// GetOutput returns the output format
func GetOutput() string {
return viper.GetString("output")
}

View File

@@ -0,0 +1,435 @@
---
name: Commander.js Patterns
description: Commander.js CLI framework patterns including Command class, options, arguments, nested subcommands, and Option class usage. Use when building Node.js CLIs, implementing Commander.js commands, creating TypeScript CLI tools, adding command options/arguments, or when user mentions Commander.js, CLI commands, command options, or nested subcommands.
allowed-tools: Read, Write, Bash, Edit
---
# Commander.js Patterns Skill
Provides comprehensive Commander.js patterns, templates, and examples for building robust Node.js CLI applications with TypeScript support.
## Overview
Commander.js is the complete solution for Node.js command-line interfaces. This skill provides battle-tested patterns for:
- Command class instantiation and configuration
- Options with flags, choices, and defaults
- Arguments (required, optional, variadic)
- Nested subcommands and command hierarchies
- Option class with advanced validation
- Action handlers and middleware
- Error handling and validation
## Instructions
### Basic Command Setup
1. **Create program instance:**
```typescript
import { Command } from 'commander';
const program = new Command();
program
.name('mycli')
.description('CLI description')
.version('1.0.0');
```
2. **Add simple command:**
```typescript
program
.command('init')
.description('Initialize project')
.action(() => {
// Command logic
});
```
3. **Parse arguments:**
```typescript
program.parse();
```
### Command with Options
Use options for named flags with values:
```typescript
program
.command('deploy')
.description('Deploy application')
.option('-e, --env <environment>', 'target environment', 'dev')
.option('-f, --force', 'force deployment', false)
.option('-v, --verbose', 'verbose output')
.action((options) => {
console.log('Environment:', options.env);
console.log('Force:', options.force);
console.log('Verbose:', options.verbose);
});
```
### Command with Arguments
Use arguments for positional parameters:
```typescript
program
.command('deploy <environment>')
.description('Deploy to environment')
.argument('<environment>', 'target environment')
.argument('[region]', 'optional region', 'us-east-1')
.action((environment, region, options) => {
console.log(`Deploying to ${environment} in ${region}`);
});
```
### Option Class Usage
For advanced option configuration:
```typescript
import { Command, Option } from 'commander';
program
.command('deploy')
.addOption(
new Option('-m, --mode <mode>', 'deployment mode')
.choices(['fast', 'safe', 'rollback'])
.default('safe')
.makeOptionMandatory()
)
.addOption(
new Option('-r, --replicas <count>', 'replica count')
.argParser(parseInt)
.default(3)
)
.action((options) => {
console.log(`Mode: ${options.mode}, Replicas: ${options.replicas}`);
});
```
### Nested Subcommands
Create command hierarchies:
```typescript
const config = program
.command('config')
.description('Manage configuration');
config
.command('get <key>')
.description('Get config value')
.action((key) => {
console.log(`Config ${key}:`, getConfig(key));
});
config
.command('set <key> <value>')
.description('Set config value')
.action((key, value) => {
setConfig(key, value);
console.log(`✓ Set ${key} = ${value}`);
});
config
.command('list')
.description('List all config')
.action(() => {
console.log(getAllConfig());
});
```
### Variadic Arguments
Accept multiple values:
```typescript
program
.command('add <items...>')
.description('Add multiple items')
.action((items) => {
console.log('Adding items:', items);
});
// Usage: mycli add item1 item2 item3
```
### Custom Argument Parsing
Transform argument values:
```typescript
program
.command('wait <delay>')
.description('Wait for specified time')
.argument('<delay>', 'delay in seconds', parseFloat)
.action((delay) => {
console.log(`Waiting ${delay} seconds...`);
});
```
### Global Options
Options available to all commands:
```typescript
program
.option('-c, --config <path>', 'config file path')
.option('-v, --verbose', 'verbose output')
.option('--no-color', 'disable colors');
program
.command('deploy')
.action((options, command) => {
const globalOpts = command.parent?.opts();
console.log('Config:', globalOpts?.config);
console.log('Verbose:', globalOpts?.verbose);
});
```
### Error Handling
```typescript
program
.command('deploy <environment>')
.action((environment) => {
if (!['dev', 'staging', 'prod'].includes(environment)) {
throw new Error(`Invalid environment: ${environment}`);
}
// Deploy logic
});
program.exitOverride();
try {
program.parse();
} catch (err) {
console.error('Error:', err.message);
process.exit(1);
}
```
## Available Scripts
- **validate-commander-structure.sh**: Validates Commander.js CLI structure and patterns
- **generate-command.sh**: Scaffolds new command with options and arguments
- **generate-subcommand.sh**: Creates nested subcommand structure
- **test-commander-cli.sh**: Tests CLI commands with various inputs
- **extract-command-help.sh**: Extracts help text from CLI for documentation
## Templates
### TypeScript Templates
- **basic-commander.ts**: Minimal Commander.js setup
- **command-with-options.ts**: Command with various option types
- **command-with-arguments.ts**: Command with required/optional arguments
- **nested-subcommands.ts**: Multi-level command hierarchy
- **option-class-advanced.ts**: Advanced Option class usage
- **full-cli-example.ts**: Complete CLI with all patterns
- **commander-with-inquirer.ts**: Interactive prompts integration
- **commander-with-validation.ts**: Input validation patterns
### JavaScript Templates
- **basic-commander.js**: ES modules Commander.js setup
- **commonjs-commander.js**: CommonJS Commander.js setup
### Configuration Templates
- **tsconfig.commander.json**: TypeScript config for Commander.js projects
- **package.json.template**: Package.json with Commander.js dependencies
## Examples
- **basic-usage.md**: Simple CLI with 2-3 commands
- **options-arguments-demo.md**: Comprehensive options and arguments examples
- **nested-commands-demo.md**: Building command hierarchies
- **advanced-option-class.md**: Option class validation and parsing
- **interactive-cli.md**: Combining Commander.js with Inquirer.js
- **error-handling-patterns.md**: Robust error handling strategies
- **testing-commander-cli.md**: Unit and integration testing patterns
## Commander.js Key Concepts
### Command Class
```typescript
new Command()
.name('cli-name')
.description('CLI description')
.version('1.0.0')
.command('subcommand')
```
### Option Types
- **Flag option**: `-v, --verbose` (boolean)
- **Value option**: `-p, --port <port>` (required value)
- **Optional value**: `-p, --port [port]` (optional value)
- **Negatable**: `--no-color` (inverse boolean)
- **Variadic**: `--files <files...>` (multiple values)
### Argument Types
- **Required**: `<name>`
- **Optional**: `[name]`
- **Variadic**: `<items...>` or `[items...]`
### Option Class Methods
- `.choices(['a', 'b', 'c'])`: Restrict to specific values
- `.default(value)`: Set default value
- `.argParser(fn)`: Custom parsing function
- `.makeOptionMandatory()`: Require option
- `.conflicts(option)`: Mutually exclusive options
- `.implies(option)`: Implies another option
- `.env(name)`: Read from environment variable
### Action Handler Signatures
```typescript
// No arguments
.action(() => {})
// With options only
.action((options) => {})
// With arguments
.action((arg1, arg2, options) => {})
// With command reference
.action((options, command) => {})
```
## Pattern Recipes
### Pattern 1: Simple CLI with Subcommands
Use template: `templates/basic-commander.ts`
### Pattern 2: CLI with Rich Options
Use template: `templates/option-class-advanced.ts`
### Pattern 3: Interactive CLI
Use template: `templates/commander-with-inquirer.ts`
### Pattern 4: CLI with Validation
Use template: `templates/commander-with-validation.ts`
### Pattern 5: Multi-level Commands
Use template: `templates/nested-subcommands.ts`
## Integration with Other Tools
### With Inquirer.js (Interactive Prompts)
```typescript
import inquirer from 'inquirer';
program
.command('setup')
.action(async () => {
const answers = await inquirer.prompt([
{ type: 'input', name: 'name', message: 'Project name:' },
{ type: 'list', name: 'template', message: 'Template:', choices: ['basic', 'advanced'] }
]);
// Use answers
});
```
### With Chalk (Colored Output)
```typescript
import chalk from 'chalk';
program
.command('deploy')
.action(() => {
console.log(chalk.green('✓ Deployment successful'));
console.log(chalk.red('✗ Deployment failed'));
});
```
### With Ora (Spinners)
```typescript
import ora from 'ora';
program
.command('build')
.action(async () => {
const spinner = ora('Building...').start();
await build();
spinner.succeed('Build complete');
});
```
## Best Practices
1. **Use Option class for complex options**: Provides better validation and type safety
2. **Keep action handlers thin**: Delegate to separate functions
3. **Provide clear descriptions**: Help users understand commands
4. **Set sensible defaults**: Reduce required options
5. **Validate early**: Check inputs before processing
6. **Handle errors gracefully**: Provide helpful error messages
7. **Use TypeScript**: Better type safety and IDE support
8. **Test thoroughly**: Unit test commands and options
9. **Document examples**: Show common usage patterns
10. **Version your CLI**: Use semantic versioning
## Common Patterns
### Pattern: Config Command Group
```typescript
const config = program.command('config');
config.command('get <key>').action(getConfig);
config.command('set <key> <value>').action(setConfig);
config.command('list').action(listConfig);
config.command('delete <key>').action(deleteConfig);
```
### Pattern: CRUD Commands
```typescript
program.command('create <name>').action(create);
program.command('read <id>').action(read);
program.command('update <id>').action(update);
program.command('delete <id>').action(deleteItem);
program.command('list').action(list);
```
### Pattern: Deploy with Environments
```typescript
program
.command('deploy')
.addOption(new Option('-e, --env <env>').choices(['dev', 'staging', 'prod']))
.option('-f, --force', 'force deployment')
.action(deploy);
```
## Troubleshooting
### Issue: Options not parsed
**Solution**: Ensure `program.parse()` is called
### Issue: Arguments not received
**Solution**: Check action handler signature matches argument count
### Issue: Subcommands not working
**Solution**: Verify subcommand is attached before `parse()`
### Issue: TypeScript errors
**Solution**: Install `@types/node` and configure tsconfig
### Issue: Help not showing
**Solution**: Commander.js auto-generates help from descriptions
## Success Criteria
✅ Command structure follows Commander.js conventions
✅ Options and arguments properly typed
✅ Help text is clear and descriptive
✅ Error handling covers edge cases
✅ CLI tested with various inputs
✅ TypeScript compiles without errors
✅ Commands execute as expected
## Related Skills
- `click-patterns` - Python Click framework patterns
- `typer-patterns` - Python Typer framework patterns
- `clap-patterns` - Rust Clap framework patterns
---
**Skill Type**: Framework Patterns + Code Templates
**Language**: TypeScript/JavaScript (Node.js)
**Framework**: Commander.js v12+
**Auto-invocation**: Yes (via description matching)

Some files were not shown because too many files have changed in this diff Show More