Initial commit
This commit is contained in:
201
skills/argparse-patterns/SKILL.md
Normal file
201
skills/argparse-patterns/SKILL.md
Normal 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
|
||||
473
skills/argparse-patterns/examples/advanced-parsing.md
Normal file
473
skills/argparse-patterns/examples/advanced-parsing.md
Normal 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
|
||||
230
skills/argparse-patterns/examples/basic-usage.md
Normal file
230
skills/argparse-patterns/examples/basic-usage.md
Normal 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`
|
||||
370
skills/argparse-patterns/examples/nested-commands.md
Normal file
370
skills/argparse-patterns/examples/nested-commands.md
Normal 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
|
||||
283
skills/argparse-patterns/examples/subcommands.md
Normal file
283
skills/argparse-patterns/examples/subcommands.md
Normal 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`
|
||||
424
skills/argparse-patterns/examples/validation-patterns.md
Normal file
424
skills/argparse-patterns/examples/validation-patterns.md
Normal 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`
|
||||
151
skills/argparse-patterns/scripts/convert-to-click.sh
Executable file
151
skills/argparse-patterns/scripts/convert-to-click.sh
Executable 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
|
||||
213
skills/argparse-patterns/scripts/generate-parser.sh
Executable file
213
skills/argparse-patterns/scripts/generate-parser.sh
Executable 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
|
||||
149
skills/argparse-patterns/scripts/test-parser.sh
Executable file
149
skills/argparse-patterns/scripts/test-parser.sh
Executable 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
|
||||
173
skills/argparse-patterns/scripts/validate-parser.sh
Executable file
173
skills/argparse-patterns/scripts/validate-parser.sh
Executable 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
|
||||
201
skills/argparse-patterns/templates/argparse-to-commander.ts
Normal file
201
skills/argparse-patterns/templates/argparse-to-commander.ts
Normal 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
|
||||
*/
|
||||
243
skills/argparse-patterns/templates/argument-groups.py
Executable file
243
skills/argparse-patterns/templates/argument-groups.py
Executable 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())
|
||||
93
skills/argparse-patterns/templates/basic-parser.py
Executable file
93
skills/argparse-patterns/templates/basic-parser.py
Executable 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())
|
||||
162
skills/argparse-patterns/templates/boolean-flags.py
Executable file
162
skills/argparse-patterns/templates/boolean-flags.py
Executable 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())
|
||||
197
skills/argparse-patterns/templates/choices-validation.py
Executable file
197
skills/argparse-patterns/templates/choices-validation.py
Executable 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())
|
||||
188
skills/argparse-patterns/templates/custom-actions.py
Executable file
188
skills/argparse-patterns/templates/custom-actions.py
Executable 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())
|
||||
175
skills/argparse-patterns/templates/mutually-exclusive.py
Executable file
175
skills/argparse-patterns/templates/mutually-exclusive.py
Executable 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())
|
||||
219
skills/argparse-patterns/templates/nested-subparser.py
Executable file
219
skills/argparse-patterns/templates/nested-subparser.py
Executable 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)
|
||||
123
skills/argparse-patterns/templates/subparser-pattern.py
Executable file
123
skills/argparse-patterns/templates/subparser-pattern.py
Executable 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)
|
||||
257
skills/argparse-patterns/templates/type-coercion.py
Executable file
257
skills/argparse-patterns/templates/type-coercion.py
Executable 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())
|
||||
164
skills/argparse-patterns/templates/variadic-args.py
Executable file
164
skills/argparse-patterns/templates/variadic-args.py
Executable 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())
|
||||
249
skills/clap-patterns/SKILL.md
Normal file
249
skills/clap-patterns/SKILL.md
Normal 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/
|
||||
164
skills/clap-patterns/examples/quick-start.md
Normal file
164
skills/clap-patterns/examples/quick-start.md
Normal 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/
|
||||
474
skills/clap-patterns/examples/real-world-cli.md
Normal file
474
skills/clap-patterns/examples/real-world-cli.md
Normal 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`
|
||||
300
skills/clap-patterns/examples/validation-examples.md
Normal file
300
skills/clap-patterns/examples/validation-examples.md
Normal 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/
|
||||
94
skills/clap-patterns/scripts/generate-completions.sh
Executable file
94
skills/clap-patterns/scripts/generate-completions.sh
Executable 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"
|
||||
159
skills/clap-patterns/scripts/test-cli.sh
Executable file
159
skills/clap-patterns/scripts/test-cli.sh
Executable 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
|
||||
113
skills/clap-patterns/scripts/validate-cargo.sh
Executable file
113
skills/clap-patterns/scripts/validate-cargo.sh
Executable 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"] }'
|
||||
66
skills/clap-patterns/templates/basic-parser.rs
Normal file
66
skills/clap-patterns/templates/basic-parser.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
112
skills/clap-patterns/templates/builder-pattern.rs
Normal file
112
skills/clap-patterns/templates/builder-pattern.rs
Normal 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
|
||||
99
skills/clap-patterns/templates/env-variables.rs
Normal file
99
skills/clap-patterns/templates/env-variables.rs
Normal 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
|
||||
290
skills/clap-patterns/templates/full-featured-cli.rs
Normal file
290
skills/clap-patterns/templates/full-featured-cli.rs
Normal 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
|
||||
139
skills/clap-patterns/templates/subcommands.rs
Normal file
139
skills/clap-patterns/templates/subcommands.rs
Normal 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 { "" });
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
143
skills/clap-patterns/templates/value-enum.rs
Normal file
143
skills/clap-patterns/templates/value-enum.rs
Normal 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
|
||||
109
skills/clap-patterns/templates/value-parser.rs
Normal file
109
skills/clap-patterns/templates/value-parser.rs
Normal 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.");
|
||||
}
|
||||
334
skills/cli-patterns/SKILL.md
Normal file
334
skills/cli-patterns/SKILL.md
Normal 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)
|
||||
212
skills/cli-patterns/examples/EXAMPLES-INDEX.md
Normal file
212
skills/cli-patterns/examples/EXAMPLES-INDEX.md
Normal 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.
|
||||
69
skills/cli-patterns/examples/api-cli/README.md
Normal file
69
skills/cli-patterns/examples/api-cli/README.md
Normal 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)
|
||||
```
|
||||
205
skills/cli-patterns/examples/api-cli/main.go
Normal file
205
skills/cli-patterns/examples/api-cli/main.go
Normal 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:]
|
||||
}
|
||||
46
skills/cli-patterns/examples/db-cli/README.md
Normal file
46
skills/cli-patterns/examples/db-cli/README.md
Normal 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
|
||||
183
skills/cli-patterns/examples/db-cli/main.go
Normal file
183
skills/cli-patterns/examples/db-cli/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
60
skills/cli-patterns/examples/deploy-cli/README.md
Normal file
60
skills/cli-patterns/examples/deploy-cli/README.md
Normal 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)
|
||||
```
|
||||
192
skills/cli-patterns/examples/deploy-cli/main.go
Normal file
192
skills/cli-patterns/examples/deploy-cli/main.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
skills/cli-patterns/scripts/add-command.sh
Executable file
52
skills/cli-patterns/scripts/add-command.sh
Executable 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"
|
||||
109
skills/cli-patterns/scripts/generate-basic.sh
Executable file
109
skills/cli-patterns/scripts/generate-basic.sh
Executable 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"
|
||||
313
skills/cli-patterns/scripts/generate-full.sh
Executable file
313
skills/cli-patterns/scripts/generate-full.sh
Executable 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"
|
||||
174
skills/cli-patterns/scripts/generate-subcommands.sh
Executable file
174
skills/cli-patterns/scripts/generate-subcommands.sh
Executable 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"
|
||||
103
skills/cli-patterns/scripts/validate-cli.sh
Executable file
103
skills/cli-patterns/scripts/validate-cli.sh
Executable 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
|
||||
52
skills/cli-patterns/templates/basic-cli.go
Normal file
52
skills/cli-patterns/templates/basic-cli.go
Normal 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)
|
||||
}
|
||||
}
|
||||
141
skills/cli-patterns/templates/categories-cli.go
Normal file
141
skills/cli-patterns/templates/categories-cli.go
Normal 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)
|
||||
}
|
||||
}
|
||||
52
skills/cli-patterns/templates/click-basic.py
Normal file
52
skills/cli-patterns/templates/click-basic.py
Normal 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={})
|
||||
51
skills/cli-patterns/templates/commander-basic.ts
Normal file
51
skills/cli-patterns/templates/commander-basic.ts
Normal 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();
|
||||
152
skills/cli-patterns/templates/context-cli.go
Normal file
152
skills/cli-patterns/templates/context-cli.go
Normal 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)
|
||||
}
|
||||
}
|
||||
172
skills/cli-patterns/templates/flags-demo.go
Normal file
172
skills/cli-patterns/templates/flags-demo.go
Normal 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)
|
||||
}
|
||||
}
|
||||
94
skills/cli-patterns/templates/hooks-cli.go
Normal file
94
skills/cli-patterns/templates/hooks-cli.go
Normal 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
|
||||
116
skills/cli-patterns/templates/subcommands-cli.go
Normal file
116
skills/cli-patterns/templates/subcommands-cli.go
Normal 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)
|
||||
}
|
||||
}
|
||||
58
skills/cli-patterns/templates/typer-basic.py
Normal file
58
skills/cli-patterns/templates/typer-basic.py
Normal 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()
|
||||
155
skills/cli-testing-patterns/SKILL.md
Normal file
155
skills/cli-testing-patterns/SKILL.md
Normal 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
|
||||
406
skills/cli-testing-patterns/examples/exit-code-testing/README.md
Normal file
406
skills/cli-testing-patterns/examples/exit-code-testing/README.md
Normal 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)
|
||||
@@ -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)
|
||||
277
skills/cli-testing-patterns/examples/jest-advanced/README.md
Normal file
277
skills/cli-testing-patterns/examples/jest-advanced/README.md
Normal 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)
|
||||
145
skills/cli-testing-patterns/examples/jest-basic/README.md
Normal file
145
skills/cli-testing-patterns/examples/jest-basic/README.md
Normal 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)
|
||||
353
skills/cli-testing-patterns/examples/pytest-click/README.md
Normal file
353
skills/cli-testing-patterns/examples/pytest-click/README.md
Normal 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)
|
||||
82
skills/cli-testing-patterns/scripts/run-cli-tests.sh
Executable file
82
skills/cli-testing-patterns/scripts/run-cli-tests.sh
Executable 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!"
|
||||
235
skills/cli-testing-patterns/scripts/setup-jest-testing.sh
Executable file
235
skills/cli-testing-patterns/scripts/setup-jest-testing.sh
Executable 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"
|
||||
448
skills/cli-testing-patterns/scripts/setup-pytest-testing.sh
Executable file
448
skills/cli-testing-patterns/scripts/setup-pytest-testing.sh
Executable 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"
|
||||
127
skills/cli-testing-patterns/scripts/validate-test-coverage.sh
Executable file
127
skills/cli-testing-patterns/scripts/validate-test-coverage.sh
Executable 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!"
|
||||
175
skills/cli-testing-patterns/templates/jest-cli-test.ts
Normal file
175
skills/cli-testing-patterns/templates/jest-cli-test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
198
skills/cli-testing-patterns/templates/jest-config-test.ts
Normal file
198
skills/cli-testing-patterns/templates/jest-config-test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
223
skills/cli-testing-patterns/templates/jest-integration-test.ts
Normal file
223
skills/cli-testing-patterns/templates/jest-integration-test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
270
skills/cli-testing-patterns/templates/pytest-click-test.py
Normal file
270
skills/cli-testing-patterns/templates/pytest-click-test.py
Normal 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')
|
||||
346
skills/cli-testing-patterns/templates/pytest-fixtures.py
Normal file
346
skills/cli-testing-patterns/templates/pytest-fixtures.py
Normal 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
|
||||
378
skills/cli-testing-patterns/templates/pytest-integration-test.py
Normal file
378
skills/cli-testing-patterns/templates/pytest-integration-test.py
Normal 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()
|
||||
509
skills/cli-testing-patterns/templates/test-helpers.py
Normal file
509
skills/cli-testing-patterns/templates/test-helpers.py
Normal 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
|
||||
362
skills/cli-testing-patterns/templates/test-helpers.ts
Normal file
362
skills/cli-testing-patterns/templates/test-helpers.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
126
skills/click-patterns/SKILL.md
Normal file
126
skills/click-patterns/SKILL.md
Normal 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
|
||||
405
skills/click-patterns/examples/complete-example.md
Normal file
405
skills/click-patterns/examples/complete-example.md
Normal 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
|
||||
482
skills/click-patterns/examples/edge-cases.md
Normal file
482
skills/click-patterns/examples/edge-cases.md
Normal 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).
|
||||
521
skills/click-patterns/examples/patterns.md
Normal file
521
skills/click-patterns/examples/patterns.md
Normal 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/).
|
||||
334
skills/click-patterns/scripts/generate-click-cli.sh
Executable file
334
skills/click-patterns/scripts/generate-click-cli.sh
Executable 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"
|
||||
108
skills/click-patterns/scripts/setup-click-project.sh
Executable file
108
skills/click-patterns/scripts/setup-click-project.sh
Executable 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/"
|
||||
162
skills/click-patterns/scripts/validate-click.sh
Executable file
162
skills/click-patterns/scripts/validate-click.sh
Executable 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
|
||||
310
skills/click-patterns/templates/advanced-cli.py
Normal file
310
skills/click-patterns/templates/advanced-cli.py
Normal 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()
|
||||
37
skills/click-patterns/templates/basic-cli.py
Normal file
37
skills/click-patterns/templates/basic-cli.py
Normal 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()
|
||||
126
skills/click-patterns/templates/nested-commands.py
Normal file
126
skills/click-patterns/templates/nested-commands.py
Normal 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={})
|
||||
169
skills/click-patterns/templates/validators.py
Normal file
169
skills/click-patterns/templates/validators.py
Normal 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()
|
||||
693
skills/cobra-patterns/SKILL.md
Normal file
693
skills/cobra-patterns/SKILL.md
Normal 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
|
||||
366
skills/cobra-patterns/examples/kubectl-style-cli.md
Normal file
366
skills/cobra-patterns/examples/kubectl-style-cli.md
Normal 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.
|
||||
538
skills/cobra-patterns/examples/production-cli-complete.md
Normal file
538
skills/cobra-patterns/examples/production-cli-complete.md
Normal 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.
|
||||
381
skills/cobra-patterns/examples/simple-cli-basic.md
Normal file
381
skills/cobra-patterns/examples/simple-cli-basic.md
Normal 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.
|
||||
113
skills/cobra-patterns/scripts/add-command.sh
Executable file
113
skills/cobra-patterns/scripts/add-command.sh
Executable 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 ""
|
||||
82
skills/cobra-patterns/scripts/generate-completions.sh
Executable file
82
skills/cobra-patterns/scripts/generate-completions.sh
Executable 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 ""
|
||||
566
skills/cobra-patterns/scripts/setup-cobra-cli.sh
Executable file
566
skills/cobra-patterns/scripts/setup-cobra-cli.sh
Executable 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 ""
|
||||
181
skills/cobra-patterns/scripts/validate-cobra-cli.sh
Executable file
181
skills/cobra-patterns/scripts/validate-cobra-cli.sh
Executable 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
|
||||
71
skills/cobra-patterns/templates/command.go.template
Normal file
71
skills/cobra-patterns/templates/command.go.template
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
13
skills/cobra-patterns/templates/main.go.template
Normal file
13
skills/cobra-patterns/templates/main.go.template
Normal file
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"{{.ModulePath}}/cmd"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
20
skills/cobra-patterns/templates/nested-command.go.template
Normal file
20
skills/cobra-patterns/templates/nested-command.go.template
Normal 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)
|
||||
}
|
||||
95
skills/cobra-patterns/templates/root.go.template
Normal file
95
skills/cobra-patterns/templates/root.go.template
Normal 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")
|
||||
}
|
||||
435
skills/commander-patterns/SKILL.md
Normal file
435
skills/commander-patterns/SKILL.md
Normal 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
Reference in New Issue
Block a user