Initial commit
This commit is contained in:
248
skills/clap-patterns/SKILL.md
Normal file
248
skills/clap-patterns/SKILL.md
Normal file
@@ -0,0 +1,248 @@
|
||||
---
|
||||
name: clap-patterns
|
||||
description: Common Clap patterns and idioms for argument parsing, validation, and CLI design. Use when implementing CLI arguments with Clap v4+.
|
||||
---
|
||||
|
||||
# Clap Patterns Skill
|
||||
|
||||
Common patterns and idioms for using Clap v4+ effectively in Rust CLI applications.
|
||||
|
||||
## Derive API vs Builder API
|
||||
|
||||
### When to Use Derive API
|
||||
|
||||
- CLI structure known at compile time
|
||||
- Want type safety and compile-time validation
|
||||
- Prefer declarative style
|
||||
- Standard CLI patterns are sufficient
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
#[command(version, about)]
|
||||
struct Cli {
|
||||
#[arg(short, long)]
|
||||
input: PathBuf,
|
||||
}
|
||||
```
|
||||
|
||||
### When to Use Builder API
|
||||
|
||||
- CLI needs to be built dynamically at runtime
|
||||
- Building plugin systems
|
||||
- Arguments depend on configuration
|
||||
- Need maximum flexibility
|
||||
|
||||
```rust
|
||||
fn build_cli() -> Command {
|
||||
Command::new("app")
|
||||
.arg(Arg::new("input").short('i'))
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Global Options with Subcommands
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
#[arg(short, long, global = true, action = ArgAction::Count)]
|
||||
verbose: u8,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
```
|
||||
|
||||
### Argument Groups for Mutual Exclusivity
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
#[command(group(
|
||||
ArgGroup::new("format")
|
||||
.required(true)
|
||||
.args(&["json", "yaml", "toml"])
|
||||
))]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
#[arg(long)]
|
||||
yaml: bool,
|
||||
#[arg(long)]
|
||||
toml: bool,
|
||||
}
|
||||
```
|
||||
|
||||
### Custom Value Parsers
|
||||
|
||||
```rust
|
||||
fn parse_port(s: &str) -> Result<u16, String> {
|
||||
let port: u16 = s.parse()
|
||||
.map_err(|_| format!("`{s}` isn't a valid port"))?;
|
||||
if (1024..=65535).contains(&port) {
|
||||
Ok(port)
|
||||
} else {
|
||||
Err(format!("port not in range 1024-65535"))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
#[arg(long, value_parser = parse_port)]
|
||||
port: u16,
|
||||
}
|
||||
```
|
||||
|
||||
### Environment Variable Fallbacks
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
#[arg(long, env = "API_TOKEN")]
|
||||
token: String,
|
||||
|
||||
#[arg(long, env = "API_ENDPOINT", default_value = "https://api.example.com")]
|
||||
endpoint: String,
|
||||
}
|
||||
```
|
||||
|
||||
### Flattening Shared Options
|
||||
|
||||
```rust
|
||||
#[derive(Args)]
|
||||
struct CommonOpts {
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
|
||||
#[arg(short, long)]
|
||||
config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
#[command(flatten)]
|
||||
common: CommonOpts,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Values
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
/// Tags (can be specified multiple times)
|
||||
#[arg(short, long)]
|
||||
tag: Vec<String>,
|
||||
|
||||
/// Files to process
|
||||
files: Vec<PathBuf>,
|
||||
}
|
||||
// Usage: myapp --tag rust --tag cli file1.txt file2.txt
|
||||
```
|
||||
|
||||
### Subcommand with Shared Arguments
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
Build(BuildArgs),
|
||||
Test(TestArgs),
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
struct BuildArgs {
|
||||
#[command(flatten)]
|
||||
common: CommonOpts,
|
||||
|
||||
#[arg(short, long)]
|
||||
release: bool,
|
||||
}
|
||||
```
|
||||
|
||||
### Argument Counting (Verbosity Levels)
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
/// Verbosity (-v, -vv, -vvv)
|
||||
#[arg(short, long, action = ArgAction::Count)]
|
||||
verbose: u8,
|
||||
}
|
||||
// Usage: -v (1), -vv (2), -vvv (3)
|
||||
```
|
||||
|
||||
### Help Template Customization
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
after_help = "EXAMPLES:\n \
|
||||
myapp --input file.txt\n \
|
||||
myapp -i file.txt -vv\n\n\
|
||||
For more info: https://example.com"
|
||||
)]
|
||||
struct Cli {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Value Hints
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
#[arg(short, long, value_name = "FILE", value_hint = ValueHint::FilePath)]
|
||||
input: PathBuf,
|
||||
|
||||
#[arg(short, long, value_name = "DIR", value_hint = ValueHint::DirPath)]
|
||||
output: PathBuf,
|
||||
|
||||
#[arg(short, long, value_name = "URL", value_hint = ValueHint::Url)]
|
||||
endpoint: String,
|
||||
}
|
||||
```
|
||||
|
||||
### Default Values with Functions
|
||||
|
||||
```rust
|
||||
fn default_config_path() -> PathBuf {
|
||||
dirs::config_dir()
|
||||
.unwrap()
|
||||
.join("myapp")
|
||||
.join("config.toml")
|
||||
}
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
#[arg(long, default_value_os_t = default_config_path())]
|
||||
config: PathBuf,
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use `value_name`** for clearer help text
|
||||
2. **Provide both short and long flags** where appropriate
|
||||
3. **Add help text** to all arguments
|
||||
4. **Use `ValueEnum`** for fixed set of choices
|
||||
5. **Validate early** with custom parsers
|
||||
6. **Support environment variables** for sensitive data
|
||||
7. **Use argument groups** for mutually exclusive options
|
||||
8. **Document with examples** in `after_help`
|
||||
9. **Use semantic types** (PathBuf, not String for paths)
|
||||
10. **Test CLI parsing** with integration tests
|
||||
|
||||
## References
|
||||
|
||||
- [Clap Documentation](https://docs.rs/clap/)
|
||||
- [Clap Derive Reference](https://docs.rs/clap/latest/clap/_derive/index.html)
|
||||
- [Clap Examples](https://github.com/clap-rs/clap/tree/master/examples)
|
||||
471
skills/cli-configuration/SKILL.md
Normal file
471
skills/cli-configuration/SKILL.md
Normal file
@@ -0,0 +1,471 @@
|
||||
---
|
||||
name: cli-configuration
|
||||
description: Configuration management patterns including file formats, precedence, environment variables, and XDG directories. Use when implementing configuration systems for CLI applications.
|
||||
---
|
||||
|
||||
# CLI Configuration Skill
|
||||
|
||||
Patterns and best practices for managing configuration in command-line applications.
|
||||
|
||||
## Configuration Precedence
|
||||
|
||||
The standard precedence order (lowest to highest priority):
|
||||
|
||||
1. **Compiled defaults** - Hard-coded sensible defaults
|
||||
2. **System config** - /etc/myapp/config.toml
|
||||
3. **User config** - ~/.config/myapp/config.toml
|
||||
4. **Project config** - ./myapp.toml or ./.myapp.toml
|
||||
5. **Environment variables** - MYAPP_KEY=value
|
||||
6. **CLI arguments** - --key value (highest priority)
|
||||
|
||||
```rust
|
||||
use config::{Config as ConfigBuilder, Environment, File};
|
||||
|
||||
pub fn load_config(cli: &Cli) -> Result<Config> {
|
||||
let mut builder = ConfigBuilder::builder()
|
||||
// 1. Defaults
|
||||
.set_default("port", 8080)?
|
||||
.set_default("host", "localhost")?
|
||||
.set_default("log_level", "info")?;
|
||||
|
||||
// 2. System config (if exists)
|
||||
builder = builder
|
||||
.add_source(File::with_name("/etc/myapp/config").required(false));
|
||||
|
||||
// 3. User config (if exists)
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
builder = builder.add_source(
|
||||
File::from(config_dir.join("myapp/config.toml")).required(false)
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Project config (if exists)
|
||||
builder = builder
|
||||
.add_source(File::with_name("myapp").required(false))
|
||||
.add_source(File::with_name(".myapp").required(false));
|
||||
|
||||
// 5. CLI-specified config (if provided)
|
||||
if let Some(config_path) = &cli.config {
|
||||
builder = builder.add_source(File::from(config_path.as_ref()));
|
||||
}
|
||||
|
||||
// 6. Environment variables
|
||||
builder = builder.add_source(
|
||||
Environment::with_prefix("MYAPP")
|
||||
.separator("_")
|
||||
.try_parsing(true)
|
||||
);
|
||||
|
||||
// 7. CLI arguments (highest priority)
|
||||
if let Some(port) = cli.port {
|
||||
builder = builder.set_override("port", port)?;
|
||||
}
|
||||
|
||||
Ok(builder.build()?.try_deserialize()?)
|
||||
}
|
||||
```
|
||||
|
||||
## Config File Formats
|
||||
|
||||
### TOML (Recommended)
|
||||
|
||||
Clear, human-readable, good error messages.
|
||||
|
||||
```toml
|
||||
# config.toml
|
||||
[general]
|
||||
port = 8080
|
||||
host = "localhost"
|
||||
log_level = "info"
|
||||
|
||||
[database]
|
||||
url = "postgresql://localhost/mydb"
|
||||
pool_size = 10
|
||||
|
||||
[features]
|
||||
caching = true
|
||||
metrics = false
|
||||
|
||||
[[servers]]
|
||||
name = "primary"
|
||||
address = "192.168.1.1"
|
||||
|
||||
[[servers]]
|
||||
name = "backup"
|
||||
address = "192.168.1.2"
|
||||
```
|
||||
|
||||
```rust
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct Config {
|
||||
general: General,
|
||||
database: Database,
|
||||
features: Features,
|
||||
servers: Vec<Server>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
struct General {
|
||||
port: u16,
|
||||
host: String,
|
||||
log_level: String,
|
||||
}
|
||||
```
|
||||
|
||||
### YAML (Alternative)
|
||||
|
||||
More concise, supports comments, complex structures.
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
general:
|
||||
port: 8080
|
||||
host: localhost
|
||||
log_level: info
|
||||
|
||||
database:
|
||||
url: postgresql://localhost/mydb
|
||||
pool_size: 10
|
||||
|
||||
features:
|
||||
caching: true
|
||||
metrics: false
|
||||
|
||||
servers:
|
||||
- name: primary
|
||||
address: 192.168.1.1
|
||||
- name: backup
|
||||
address: 192.168.1.2
|
||||
```
|
||||
|
||||
### JSON (Machine-Readable)
|
||||
|
||||
Good for programmatic generation, less human-friendly.
|
||||
|
||||
```json
|
||||
{
|
||||
"general": {
|
||||
"port": 8080,
|
||||
"host": "localhost",
|
||||
"log_level": "info"
|
||||
},
|
||||
"database": {
|
||||
"url": "postgresql://localhost/mydb",
|
||||
"pool_size": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## XDG Base Directory Support
|
||||
|
||||
Follow the XDG Base Directory specification for cross-platform compatibility.
|
||||
|
||||
```rust
|
||||
use directories::ProjectDirs;
|
||||
|
||||
pub struct AppPaths {
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub cache_dir: PathBuf,
|
||||
pub state_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl AppPaths {
|
||||
pub fn new(app_name: &str) -> Result<Self> {
|
||||
let proj_dirs = ProjectDirs::from("com", "example", app_name)
|
||||
.ok_or_else(|| anyhow!("Could not determine project directories"))?;
|
||||
|
||||
Ok(Self {
|
||||
config_dir: proj_dirs.config_dir().to_path_buf(),
|
||||
data_dir: proj_dirs.data_dir().to_path_buf(),
|
||||
cache_dir: proj_dirs.cache_dir().to_path_buf(),
|
||||
state_dir: proj_dirs.state_dir()
|
||||
.unwrap_or_else(|| proj_dirs.data_dir())
|
||||
.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn config_file(&self) -> PathBuf {
|
||||
self.config_dir.join("config.toml")
|
||||
}
|
||||
|
||||
pub fn ensure_dirs(&self) -> Result<()> {
|
||||
fs::create_dir_all(&self.config_dir)?;
|
||||
fs::create_dir_all(&self.data_dir)?;
|
||||
fs::create_dir_all(&self.cache_dir)?;
|
||||
fs::create_dir_all(&self.state_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Directory locations by platform:**
|
||||
|
||||
| Platform | Config | Data | Cache |
|
||||
|----------|--------|------|-------|
|
||||
| Linux | ~/.config/myapp | ~/.local/share/myapp | ~/.cache/myapp |
|
||||
| macOS | ~/Library/Application Support/myapp | ~/Library/Application Support/myapp | ~/Library/Caches/myapp |
|
||||
| Windows | %APPDATA%\example\myapp | %APPDATA%\example\myapp | %LOCALAPPDATA%\example\myapp |
|
||||
|
||||
## Environment Variable Patterns
|
||||
|
||||
### Naming Convention
|
||||
|
||||
Use `APPNAME_SECTION_KEY` format:
|
||||
|
||||
```bash
|
||||
MYAPP_DATABASE_URL=postgresql://localhost/db
|
||||
MYAPP_LOG_LEVEL=debug
|
||||
MYAPP_FEATURES_CACHING=true
|
||||
MYAPP_PORT=9000
|
||||
```
|
||||
|
||||
### Integration with Clap
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
/// Database URL (env: MYAPP_DATABASE_URL)
|
||||
#[arg(long, env = "MYAPP_DATABASE_URL")]
|
||||
database_url: Option<String>,
|
||||
|
||||
/// Log level (env: MYAPP_LOG_LEVEL)
|
||||
#[arg(long, env = "MYAPP_LOG_LEVEL", default_value = "info")]
|
||||
log_level: String,
|
||||
|
||||
/// Port (env: MYAPP_PORT)
|
||||
#[arg(long, env = "MYAPP_PORT", default_value = "8080")]
|
||||
port: u16,
|
||||
}
|
||||
```
|
||||
|
||||
### Sensitive Data Pattern
|
||||
|
||||
**Never** put secrets in config files. Use environment variables instead.
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Config {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
|
||||
// Loaded from environment only
|
||||
#[serde(skip)]
|
||||
pub api_token: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let mut config: Config = /* load from file */;
|
||||
|
||||
// Sensitive data from env only
|
||||
config.api_token = env::var("MYAPP_API_TOKEN")
|
||||
.context("MYAPP_API_TOKEN environment variable required")?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Validation
|
||||
|
||||
Validate configuration early at load time:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Config {
|
||||
pub port: u16,
|
||||
pub host: String,
|
||||
pub workers: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
// Port range
|
||||
if !(1024..=65535).contains(&self.port) {
|
||||
bail!("Port must be between 1024 and 65535, got {}", self.port);
|
||||
}
|
||||
|
||||
// Workers
|
||||
if self.workers == 0 {
|
||||
bail!("Workers must be at least 1");
|
||||
}
|
||||
|
||||
let max_workers = num_cpus::get() * 2;
|
||||
if self.workers > max_workers {
|
||||
bail!(
|
||||
"Workers ({}) exceeds recommended maximum ({})",
|
||||
self.workers,
|
||||
max_workers
|
||||
);
|
||||
}
|
||||
|
||||
// Host validation
|
||||
if self.host.is_empty() {
|
||||
bail!("Host cannot be empty");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Generating Default Config
|
||||
|
||||
Provide a command to generate a default configuration file:
|
||||
|
||||
```rust
|
||||
impl Config {
|
||||
pub fn default_config() -> Self {
|
||||
Self {
|
||||
general: General {
|
||||
port: 8080,
|
||||
host: "localhost".to_string(),
|
||||
log_level: "info".to_string(),
|
||||
},
|
||||
database: Database {
|
||||
url: "postgresql://localhost/mydb".to_string(),
|
||||
pool_size: 10,
|
||||
},
|
||||
features: Features {
|
||||
caching: true,
|
||||
metrics: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_default(path: &Path) -> Result<()> {
|
||||
let config = Self::default_config();
|
||||
let toml = toml::to_string_pretty(&config)?;
|
||||
|
||||
// Add helpful comments
|
||||
let content = format!(
|
||||
"# Configuration file for myapp\n\
|
||||
# See: https://example.com/docs/config\n\n\
|
||||
{toml}"
|
||||
);
|
||||
|
||||
fs::write(path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**CLI Command:**
|
||||
|
||||
```rust
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Generate a default configuration file
|
||||
InitConfig {
|
||||
/// Output path (default: ~/.config/myapp/config.toml)
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
fn handle_init_config(output: Option<PathBuf>) -> Result<()> {
|
||||
let path = output.unwrap_or_else(|| {
|
||||
AppPaths::new("myapp")
|
||||
.unwrap()
|
||||
.config_file()
|
||||
});
|
||||
|
||||
if path.exists() {
|
||||
bail!("Config file already exists: {}", path.display());
|
||||
}
|
||||
|
||||
Config::write_default(&path)?;
|
||||
println!("Created config file: {}", path.display());
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Config Migration Pattern
|
||||
|
||||
Handle breaking changes in config format:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ConfigV2 {
|
||||
version: u32,
|
||||
#[serde(flatten)]
|
||||
data: ConfigData,
|
||||
}
|
||||
|
||||
impl ConfigV2 {
|
||||
pub fn load(path: &Path) -> Result<Self> {
|
||||
let content = fs::read_to_string(path)?;
|
||||
let mut config: ConfigV2 = toml::from_str(&content)?;
|
||||
|
||||
// Migrate from older versions
|
||||
match config.version {
|
||||
1 => {
|
||||
eprintln!("Migrating config from v1 to v2...");
|
||||
config = migrate_v1_to_v2(config)?;
|
||||
// Optionally save migrated config
|
||||
config.save(path)?;
|
||||
}
|
||||
2 => {}, // Current version
|
||||
v => bail!("Unsupported config version: {}", v),
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Examples Command
|
||||
|
||||
Provide examples in help text:
|
||||
|
||||
```rust
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Show configuration examples
|
||||
ConfigExamples,
|
||||
}
|
||||
|
||||
fn show_config_examples() {
|
||||
println!("Configuration Examples:\n");
|
||||
|
||||
println!("1. Basic configuration (config.toml):");
|
||||
println!("{}", r#"
|
||||
[general]
|
||||
port = 8080
|
||||
host = "localhost"
|
||||
"#);
|
||||
|
||||
println!("\n2. Environment variables:");
|
||||
println!(" MYAPP_PORT=9000");
|
||||
println!(" MYAPP_DATABASE_URL=postgresql://localhost/db");
|
||||
|
||||
println!("\n3. CLI override:");
|
||||
println!(" myapp --port 9000 --host 0.0.0.0");
|
||||
|
||||
println!("\n4. Precedence (highest to lowest):");
|
||||
println!(" CLI args > Env vars > Config file > Defaults");
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Provide sensible defaults** - App should work out-of-box
|
||||
2. **Document precedence** - Make override behavior clear
|
||||
3. **Validate early** - Catch config errors at startup
|
||||
4. **Use XDG directories** - Follow platform conventions
|
||||
5. **Support env vars** - Essential for containers/CI
|
||||
6. **Generate defaults** - Help users get started
|
||||
7. **Version config format** - Enable migrations
|
||||
8. **Keep secrets out** - Use env vars for sensitive data
|
||||
9. **Clear error messages** - Help users fix config issues
|
||||
10. **Document all options** - With examples and defaults
|
||||
|
||||
## References
|
||||
|
||||
- [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html)
|
||||
- [The Twelve-Factor App: Config](https://12factor.net/config)
|
||||
- [directories crate](https://docs.rs/directories/)
|
||||
- [config crate](https://docs.rs/config/)
|
||||
550
skills/cli-distribution/SKILL.md
Normal file
550
skills/cli-distribution/SKILL.md
Normal file
@@ -0,0 +1,550 @@
|
||||
---
|
||||
name: cli-distribution
|
||||
description: Distribution and packaging patterns including shell completions, man pages, cross-compilation, and release automation. Use when preparing CLI tools for distribution.
|
||||
---
|
||||
|
||||
# CLI Distribution Skill
|
||||
|
||||
Patterns and best practices for distributing Rust CLI applications to users.
|
||||
|
||||
## Shell Completion Generation
|
||||
|
||||
### Using clap_complete
|
||||
|
||||
```rust
|
||||
use clap::{CommandFactory, Parser};
|
||||
use clap_complete::{generate, Generator, Shell};
|
||||
use std::io;
|
||||
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
/// Generate shell completions
|
||||
#[arg(long = "generate", value_enum)]
|
||||
generator: Option<Shell>,
|
||||
|
||||
// ... other fields
|
||||
}
|
||||
|
||||
fn print_completions<G: Generator>(gen: G, cmd: &mut clap::Command) {
|
||||
generate(gen, cmd, cmd.get_name().to_string(), &mut io::stdout());
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
if let Some(generator) = cli.generator {
|
||||
let mut cmd = Cli::command();
|
||||
print_completions(generator, &mut cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
// ... rest of application
|
||||
}
|
||||
```
|
||||
|
||||
### Installation Instructions by Shell
|
||||
|
||||
**Bash:**
|
||||
```bash
|
||||
# Generate and save
|
||||
myapp --generate bash > /etc/bash_completion.d/myapp
|
||||
|
||||
# Or add to ~/.bashrc
|
||||
eval "$(myapp --generate bash)"
|
||||
```
|
||||
|
||||
**Zsh:**
|
||||
```bash
|
||||
# Generate and save
|
||||
myapp --generate zsh > ~/.zfunc/_myapp
|
||||
|
||||
# Add to ~/.zshrc
|
||||
fpath=(~/.zfunc $fpath)
|
||||
autoload -Uz compinit && compinit
|
||||
```
|
||||
|
||||
**Fish:**
|
||||
```bash
|
||||
# Generate and save
|
||||
myapp --generate fish > ~/.config/fish/completions/myapp.fish
|
||||
|
||||
# Or load directly
|
||||
myapp --generate fish | source
|
||||
```
|
||||
|
||||
**PowerShell:**
|
||||
```powershell
|
||||
# Add to $PROFILE
|
||||
Invoke-Expression (& myapp --generate powershell)
|
||||
```
|
||||
|
||||
### Dynamic Completions
|
||||
|
||||
For commands with dynamic values (like listing resources):
|
||||
|
||||
```rust
|
||||
use clap::CommandFactory;
|
||||
use clap_complete::{generate, Generator};
|
||||
|
||||
pub fn generate_with_values<G: Generator>(
|
||||
gen: G,
|
||||
resources: &[String],
|
||||
) -> String {
|
||||
let mut cmd = Cli::command();
|
||||
|
||||
// Add dynamic values to completion
|
||||
if let Some(subcommand) = cmd.find_subcommand_mut("get") {
|
||||
for resource in resources {
|
||||
subcommand = subcommand.arg(
|
||||
clap::Arg::new("resource")
|
||||
.value_parser(clap::builder::PossibleValuesParser::new(resource))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let mut buf = Vec::new();
|
||||
generate(gen, &mut cmd, "myapp", &mut buf);
|
||||
String::from_utf8(buf).unwrap()
|
||||
}
|
||||
```
|
||||
|
||||
## Man Page Generation
|
||||
|
||||
### Using clap_mangen
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
clap_mangen = "0.2"
|
||||
```
|
||||
|
||||
```rust
|
||||
use clap::CommandFactory;
|
||||
use clap_mangen::Man;
|
||||
use std::io;
|
||||
|
||||
fn generate_man_page() {
|
||||
let cmd = Cli::command();
|
||||
let man = Man::new(cmd);
|
||||
man.render(&mut io::stdout()).unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
### Build Script for Man Pages
|
||||
|
||||
```rust
|
||||
// build.rs
|
||||
use clap::CommandFactory;
|
||||
use clap_mangen::Man;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
include!("src/cli.rs");
|
||||
|
||||
fn main() {
|
||||
let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("target/man");
|
||||
fs::create_dir_all(&out_dir).unwrap();
|
||||
|
||||
let cmd = Cli::command();
|
||||
let man = Man::new(cmd);
|
||||
let mut buffer = Vec::new();
|
||||
man.render(&mut buffer).unwrap();
|
||||
|
||||
fs::write(out_dir.join("myapp.1"), buffer).unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
### Install Man Page
|
||||
|
||||
```bash
|
||||
# System-wide
|
||||
sudo cp target/man/myapp.1 /usr/local/share/man/man1/
|
||||
|
||||
# User-local
|
||||
mkdir -p ~/.local/share/man/man1
|
||||
cp target/man/myapp.1 ~/.local/share/man/man1/
|
||||
```
|
||||
|
||||
## Cross-Compilation
|
||||
|
||||
### Target Triples
|
||||
|
||||
Common targets for CLI distribution:
|
||||
|
||||
```bash
|
||||
# Linux
|
||||
x86_64-unknown-linux-gnu # GNU Linux
|
||||
x86_64-unknown-linux-musl # MUSL Linux (static)
|
||||
aarch64-unknown-linux-gnu # ARM64 Linux
|
||||
|
||||
# macOS
|
||||
x86_64-apple-darwin # Intel Mac
|
||||
aarch64-apple-darwin # Apple Silicon
|
||||
|
||||
# Windows
|
||||
x86_64-pc-windows-msvc # Windows MSVC
|
||||
x86_64-pc-windows-gnu # Windows GNU
|
||||
```
|
||||
|
||||
### Cross-Compilation with cross
|
||||
|
||||
```bash
|
||||
# Install cross
|
||||
cargo install cross
|
||||
|
||||
# Build for Linux from any platform
|
||||
cross build --release --target x86_64-unknown-linux-gnu
|
||||
|
||||
# Build static binary with MUSL
|
||||
cross build --release --target x86_64-unknown-linux-musl
|
||||
```
|
||||
|
||||
### GitHub Actions for Cross-Compilation
|
||||
|
||||
```yaml
|
||||
# .github/workflows/release.yml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
artifact_name: myapp
|
||||
asset_name: myapp-linux-amd64
|
||||
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
artifact_name: myapp
|
||||
asset_name: myapp-linux-musl-amd64
|
||||
|
||||
- os: macos-latest
|
||||
target: x86_64-apple-darwin
|
||||
artifact_name: myapp
|
||||
asset_name: myapp-macos-amd64
|
||||
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
artifact_name: myapp
|
||||
asset_name: myapp-macos-arm64
|
||||
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
artifact_name: myapp.exe
|
||||
asset_name: myapp-windows-amd64.exe
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Build
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Upload binaries
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.asset_name }}
|
||||
path: target/${{ matrix.target }}/release/${{ matrix.artifact_name }}
|
||||
```
|
||||
|
||||
## Binary Size Optimization
|
||||
|
||||
### Cargo.toml optimizations
|
||||
|
||||
```toml
|
||||
[profile.release]
|
||||
opt-level = "z" # Optimize for size
|
||||
lto = true # Link-time optimization
|
||||
codegen-units = 1 # Better optimization
|
||||
strip = true # Strip symbols
|
||||
panic = "abort" # Smaller panic handler
|
||||
```
|
||||
|
||||
### Additional size reduction
|
||||
|
||||
```bash
|
||||
# Install upx
|
||||
brew install upx # macOS
|
||||
apt install upx # Linux
|
||||
|
||||
# Compress binary
|
||||
upx --best --lzma target/release/myapp
|
||||
```
|
||||
|
||||
**Before/After example:**
|
||||
```
|
||||
Original: 2.5 MB
|
||||
Optimized: 1.2 MB (strip = true)
|
||||
UPX: 400 KB (upx --best --lzma)
|
||||
```
|
||||
|
||||
## Package Distribution
|
||||
|
||||
### Homebrew (macOS/Linux)
|
||||
|
||||
Create a formula:
|
||||
|
||||
```ruby
|
||||
# Formula/myapp.rb
|
||||
class Myapp < Formula
|
||||
desc "Description of your CLI tool"
|
||||
homepage "https://github.com/username/myapp"
|
||||
url "https://github.com/username/myapp/archive/v1.0.0.tar.gz"
|
||||
sha256 "abc123..."
|
||||
license "MIT"
|
||||
|
||||
depends_on "rust" => :build
|
||||
|
||||
def install
|
||||
system "cargo", "install", "--locked", "--root", prefix, "--path", "."
|
||||
|
||||
# Install shell completions
|
||||
generate_completions_from_executable(bin/"myapp", "--generate")
|
||||
|
||||
# Install man page
|
||||
man1.install "target/man/myapp.1"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match "myapp 1.0.0", shell_output("#{bin}/myapp --version")
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Debian Package (.deb)
|
||||
|
||||
Using `cargo-deb`:
|
||||
|
||||
```bash
|
||||
cargo install cargo-deb
|
||||
|
||||
# Create debian package
|
||||
cargo deb
|
||||
|
||||
# Package will be in target/debian/myapp_1.0.0_amd64.deb
|
||||
```
|
||||
|
||||
**Cargo.toml metadata:**
|
||||
|
||||
```toml
|
||||
[package.metadata.deb]
|
||||
maintainer = "Your Name <you@example.com>"
|
||||
copyright = "2024, Your Name"
|
||||
license-file = ["LICENSE", "4"]
|
||||
extended-description = """
|
||||
A longer description of your CLI tool
|
||||
that spans multiple lines."""
|
||||
depends = "$auto"
|
||||
section = "utility"
|
||||
priority = "optional"
|
||||
assets = [
|
||||
["target/release/myapp", "usr/bin/", "755"],
|
||||
["README.md", "usr/share/doc/myapp/", "644"],
|
||||
["target/completions/myapp.bash", "usr/share/bash-completion/completions/", "644"],
|
||||
["target/man/myapp.1", "usr/share/man/man1/", "644"],
|
||||
]
|
||||
```
|
||||
|
||||
### Docker Distribution
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM rust:1.75 as builder
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /app/target/release/myapp /usr/local/bin/myapp
|
||||
ENTRYPOINT ["myapp"]
|
||||
```
|
||||
|
||||
**Multi-stage with MUSL (smaller image):**
|
||||
|
||||
```dockerfile
|
||||
FROM rust:1.75-alpine as builder
|
||||
RUN apk add --no-cache musl-dev
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release --target x86_64-unknown-linux-musl
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/myapp /myapp
|
||||
ENTRYPOINT ["/myapp"]
|
||||
```
|
||||
|
||||
### Cargo-binstall Support
|
||||
|
||||
Add metadata for faster installation:
|
||||
|
||||
```toml
|
||||
[package.metadata.binstall]
|
||||
pkg-url = "{ repo }/releases/download/v{ version }/{ name }-{ target }{ binary-ext }"
|
||||
bin-dir = "{ bin }{ binary-ext }"
|
||||
pkg-fmt = "bin"
|
||||
```
|
||||
|
||||
Users can then install with:
|
||||
```bash
|
||||
cargo binstall myapp
|
||||
```
|
||||
|
||||
## Auto-Update
|
||||
|
||||
### Using self_update crate
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
self_update = "0.39"
|
||||
```
|
||||
|
||||
```rust
|
||||
use self_update::cargo_crate_version;
|
||||
|
||||
fn update() -> Result<()> {
|
||||
let status = self_update::backends::github::Update::configure()
|
||||
.repo_owner("username")
|
||||
.repo_name("myapp")
|
||||
.bin_name("myapp")
|
||||
.show_download_progress(true)
|
||||
.current_version(cargo_crate_version!())
|
||||
.build()?
|
||||
.update()?;
|
||||
|
||||
println!("Update status: `{}`!", status.version());
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Update Command
|
||||
|
||||
```rust
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Update to the latest version
|
||||
Update,
|
||||
}
|
||||
|
||||
fn handle_update() -> Result<()> {
|
||||
println!("Checking for updates...");
|
||||
|
||||
match update() {
|
||||
Ok(_) => {
|
||||
println!("Updated successfully! Please restart the application.");
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Update failed: {}", e);
|
||||
eprintln!("Download manually: https://github.com/username/myapp/releases");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Release Automation
|
||||
|
||||
### Cargo-release
|
||||
|
||||
```bash
|
||||
cargo install cargo-release
|
||||
|
||||
# Dry run
|
||||
cargo release --dry-run
|
||||
|
||||
# Release patch version
|
||||
cargo release patch --execute
|
||||
|
||||
# Release minor version
|
||||
cargo release minor --execute
|
||||
```
|
||||
|
||||
### GitHub Release Action
|
||||
|
||||
```yaml
|
||||
# .github/workflows/release.yml
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: taiki-e/create-gh-release-action@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
changelog: CHANGELOG.md
|
||||
|
||||
upload-assets:
|
||||
needs: release
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
- target: x86_64-apple-darwin
|
||||
- target: x86_64-pc-windows-msvc
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: taiki-e/upload-rust-binary-action@v1
|
||||
with:
|
||||
bin: myapp
|
||||
target: ${{ matrix.target }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Provide multiple installation methods** - Cargo, Homebrew, apt, etc.
|
||||
2. **Generate completions** - Essential for good UX
|
||||
3. **Create man pages** - Professional documentation
|
||||
4. **Test cross-platform** - Build for all major platforms
|
||||
5. **Optimize binary size** - Users appreciate smaller downloads
|
||||
6. **Automate releases** - Use CI/CD for consistent builds
|
||||
7. **Version clearly** - Semantic versioning
|
||||
8. **Sign binaries** - Build trust (especially on macOS)
|
||||
9. **Provide checksums** - Verify download integrity
|
||||
10. **Document installation** - Clear, platform-specific instructions
|
||||
|
||||
## Distribution Checklist
|
||||
|
||||
- [ ] Shell completions generated (bash, zsh, fish, powershell)
|
||||
- [ ] Man pages created
|
||||
- [ ] Cross-compiled for major platforms
|
||||
- [ ] Binary size optimized
|
||||
- [ ] Release artifacts uploaded to GitHub
|
||||
- [ ] Installation instructions in README
|
||||
- [ ] Homebrew formula (if applicable)
|
||||
- [ ] Debian package (if applicable)
|
||||
- [ ] Docker image (if applicable)
|
||||
- [ ] Checksums provided
|
||||
- [ ] Changelog maintained
|
||||
- [ ] Version bumped properly
|
||||
|
||||
## References
|
||||
|
||||
- [clap_complete Documentation](https://docs.rs/clap_complete/)
|
||||
- [clap_mangen Documentation](https://docs.rs/clap_mangen/)
|
||||
- [cargo-deb](https://github.com/kornelski/cargo-deb)
|
||||
- [cross](https://github.com/cross-rs/cross)
|
||||
- [Rust Platform Support](https://doc.rust-lang.org/nightly/rustc/platform-support.html)
|
||||
366
skills/cli-ux-patterns/SKILL.md
Normal file
366
skills/cli-ux-patterns/SKILL.md
Normal file
@@ -0,0 +1,366 @@
|
||||
---
|
||||
name: cli-ux-patterns
|
||||
description: CLI user experience best practices for error messages, colors, progress indicators, and output formatting. Use when improving CLI usability and user experience.
|
||||
---
|
||||
|
||||
# CLI UX Patterns Skill
|
||||
|
||||
Best practices and patterns for creating delightful command-line user experiences.
|
||||
|
||||
## Error Message Patterns
|
||||
|
||||
### The Three Parts of Good Error Messages
|
||||
|
||||
1. **What went wrong** - Clear description of the error
|
||||
2. **Why it matters** - Context about the operation
|
||||
3. **How to fix it** - Actionable suggestions
|
||||
|
||||
```rust
|
||||
bail!(
|
||||
"Failed to read config file: {}\n\n\
|
||||
The application needs a valid configuration to start.\n\n\
|
||||
To fix this:\n\
|
||||
1. Create a config file: myapp init\n\
|
||||
2. Or specify a different path: --config /path/to/config.toml\n\
|
||||
3. Check file permissions: ls -l {}",
|
||||
path.display(),
|
||||
path.display()
|
||||
);
|
||||
```
|
||||
|
||||
### Using miette for Rich Diagnostics
|
||||
|
||||
```rust
|
||||
#[derive(Error, Debug, Diagnostic)]
|
||||
#[error("Configuration error")]
|
||||
#[diagnostic(
|
||||
code(config::invalid),
|
||||
url("https://docs.example.com/config"),
|
||||
help("Check the syntax of your configuration file")
|
||||
)]
|
||||
struct ConfigError {
|
||||
#[source_code]
|
||||
src: String,
|
||||
|
||||
#[label("invalid value here")]
|
||||
span: SourceSpan,
|
||||
}
|
||||
```
|
||||
|
||||
## Color Usage Patterns
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
- **Red** - Errors, failures, destructive actions
|
||||
- **Yellow** - Warnings, cautions
|
||||
- **Green** - Success, completion, safe operations
|
||||
- **Blue** - Information, hints, links
|
||||
- **Cyan** - Highlights, emphasis
|
||||
- **Dim/Gray** - Less important info, metadata
|
||||
|
||||
```rust
|
||||
use owo_colors::OwoColorize;
|
||||
|
||||
// Status indicators with colors
|
||||
println!("{} Build succeeded", "✓".green().bold());
|
||||
println!("{} Warning: using default", "⚠".yellow().bold());
|
||||
println!("{} Error: file not found", "✗".red().bold());
|
||||
println!("{} Info: processing 10 files", "ℹ".blue().bold());
|
||||
```
|
||||
|
||||
### Respecting NO_COLOR
|
||||
|
||||
```rust
|
||||
use owo_colors::{OwoColorize, Stream};
|
||||
|
||||
fn print_status(message: &str, is_error: bool) {
|
||||
let stream = if is_error { Stream::Stderr } else { Stream::Stdout };
|
||||
|
||||
if is_error {
|
||||
eprintln!("{}", message.if_supports_color(stream, |text| text.red()));
|
||||
} else {
|
||||
println!("{}", message.if_supports_color(stream, |text| text.green()));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Progress Indication Patterns
|
||||
|
||||
### When to Use Progress Bars
|
||||
|
||||
- File downloads/uploads
|
||||
- Bulk processing with known count
|
||||
- Multi-step processes
|
||||
- Any operation > 2 seconds with known total
|
||||
|
||||
```rust
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
|
||||
let pb = ProgressBar::new(items.len() as u64);
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("{spinner:.green} [{bar:40}] {pos}/{len} {msg}")?
|
||||
.progress_chars("=>-")
|
||||
);
|
||||
|
||||
for item in items {
|
||||
pb.set_message(format!("Processing {}", item.name));
|
||||
process(item)?;
|
||||
pb.inc(1);
|
||||
}
|
||||
|
||||
pb.finish_with_message("Complete!");
|
||||
```
|
||||
|
||||
### When to Use Spinners
|
||||
|
||||
- Unknown duration operations
|
||||
- Waiting for external resources
|
||||
- Operations < 2 seconds
|
||||
- Indeterminate progress
|
||||
|
||||
```rust
|
||||
let spinner = ProgressBar::new_spinner();
|
||||
spinner.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.template("{spinner:.green} {msg}")?
|
||||
);
|
||||
|
||||
spinner.set_message("Connecting to server...");
|
||||
// Do work
|
||||
spinner.finish_with_message("Connected!");
|
||||
```
|
||||
|
||||
## Interactive Prompt Patterns
|
||||
|
||||
### When to Prompt vs When to Fail
|
||||
|
||||
**Prompt when:**
|
||||
- Optional information for better UX
|
||||
- Choosing from known options
|
||||
- Confirmation for destructive operations
|
||||
- First-time setup/initialization
|
||||
|
||||
**Fail with error when:**
|
||||
- Required information
|
||||
- Non-interactive environment (CI/CD)
|
||||
- Piped input/output
|
||||
- --yes flag provided
|
||||
|
||||
```rust
|
||||
use dialoguer::Confirm;
|
||||
|
||||
fn delete_resource(name: &str, force: bool) -> Result<()> {
|
||||
if !force && atty::is(atty::Stream::Stdin) {
|
||||
let confirmed = Confirm::new()
|
||||
.with_prompt(format!("Delete {}? This cannot be undone", name))
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirmed {
|
||||
println!("Cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Perform deletion
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Smart Defaults
|
||||
|
||||
```rust
|
||||
use dialoguer::Input;
|
||||
|
||||
fn get_project_name(current_dir: &Path) -> Result<String> {
|
||||
let default = current_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("my-project");
|
||||
|
||||
Input::new()
|
||||
.with_prompt("Project name")
|
||||
.default(default.to_string())
|
||||
.interact_text()
|
||||
}
|
||||
```
|
||||
|
||||
## Output Formatting Patterns
|
||||
|
||||
### Human-Readable vs Machine-Readable
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
}
|
||||
|
||||
fn print_results(results: &[Item], cli: &Cli) {
|
||||
if cli.json {
|
||||
// Machine-readable
|
||||
println!("{}", serde_json::to_string_pretty(&results).unwrap());
|
||||
} else {
|
||||
// Human-readable
|
||||
for item in results {
|
||||
println!("{} {} - {}",
|
||||
if item.active { "✓".green() } else { "✗".red() },
|
||||
item.name.bold(),
|
||||
item.description.dimmed()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Table Output
|
||||
|
||||
```rust
|
||||
use comfy_table::{Table, Cell, Color};
|
||||
|
||||
fn print_table(items: &[Item]) {
|
||||
let mut table = Table::new();
|
||||
table.set_header(vec!["Name", "Status", "Created"]);
|
||||
|
||||
for item in items {
|
||||
let status_color = if item.active { Color::Green } else { Color::Red };
|
||||
table.add_row(vec![
|
||||
Cell::new(&item.name),
|
||||
Cell::new(&item.status).fg(status_color),
|
||||
Cell::new(&item.created),
|
||||
]);
|
||||
}
|
||||
|
||||
println!("{table}");
|
||||
}
|
||||
```
|
||||
|
||||
## Verbosity Patterns
|
||||
|
||||
### Progressive Disclosure
|
||||
|
||||
```rust
|
||||
fn log_message(level: u8, quiet: bool, message: &str) {
|
||||
match (level, quiet) {
|
||||
(_, true) => {}, // Quiet mode: no output
|
||||
(0, false) => {}, // Default: only errors
|
||||
(1, false) => println!("{}", message), // -v: basic info
|
||||
(2, false) => println!("INFO: {}", message), // -vv: detailed
|
||||
_ => println!("[DEBUG] {}", message), // -vvv: everything
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Quiet Mode
|
||||
|
||||
```rust
|
||||
#[derive(Parser)]
|
||||
struct Cli {
|
||||
#[arg(short, long)]
|
||||
quiet: bool,
|
||||
|
||||
#[arg(short, long, action = ArgAction::Count, conflicts_with = "quiet")]
|
||||
verbose: u8,
|
||||
}
|
||||
```
|
||||
|
||||
## Confirmation Patterns
|
||||
|
||||
### Destructive Operations
|
||||
|
||||
```rust
|
||||
// Always require confirmation for:
|
||||
// - Deleting data
|
||||
// - Overwriting files
|
||||
// - Production deployments
|
||||
// - Irreversible operations
|
||||
|
||||
fn deploy_to_production(force: bool) -> Result<()> {
|
||||
if !force {
|
||||
println!("{}", "WARNING: Deploying to PRODUCTION".red().bold());
|
||||
println!("This will affect live users.");
|
||||
|
||||
let confirmed = Confirm::new()
|
||||
.with_prompt("Are you absolutely sure?")
|
||||
.default(false)
|
||||
.interact()?;
|
||||
|
||||
if !confirmed {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
// Deploy
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
## Stdout vs Stderr
|
||||
|
||||
### Best Practices
|
||||
|
||||
- **stdout** - Program output, data, results
|
||||
- **stderr** - Errors, warnings, progress, diagnostics
|
||||
|
||||
```rust
|
||||
// Correct usage
|
||||
println!("result: {}", data); // stdout - actual output
|
||||
eprintln!("Error: {}", error); // stderr - error message
|
||||
eprintln!("Processing..."); // stderr - progress update
|
||||
|
||||
// This allows piping output while seeing progress:
|
||||
// myapp process file.txt | other_command
|
||||
// (progress messages don't interfere with piped data)
|
||||
```
|
||||
|
||||
## Accessibility Considerations
|
||||
|
||||
### Screen Reader Friendly
|
||||
|
||||
```rust
|
||||
// Always include text prefixes, not just symbols
|
||||
fn print_status(level: Level, message: &str) {
|
||||
let (symbol, prefix) = match level {
|
||||
Level::Success => ("✓", "SUCCESS:"),
|
||||
Level::Error => ("✗", "ERROR:"),
|
||||
Level::Warning => ("⚠", "WARNING:"),
|
||||
Level::Info => ("ℹ", "INFO:"),
|
||||
};
|
||||
|
||||
// Both symbol and text for accessibility
|
||||
println!("{} {} {}", symbol, prefix, message);
|
||||
}
|
||||
```
|
||||
|
||||
### Color Blindness Considerations
|
||||
|
||||
- Don't rely on color alone
|
||||
- Use symbols/icons with colors
|
||||
- Test with color blindness simulators
|
||||
- Provide text alternatives
|
||||
|
||||
## The 12-Factor CLI Principles
|
||||
|
||||
1. **Great help** - Comprehensive, discoverable
|
||||
2. **Prefer flags to args** - More explicit
|
||||
3. **Respect POSIX** - Follow conventions
|
||||
4. **Use stdout for output** - Enable piping
|
||||
5. **Use stderr for messaging** - Keep output clean
|
||||
6. **Handle signals** - Respond to Ctrl+C gracefully
|
||||
7. **Be quiet by default** - User controls verbosity
|
||||
8. **Fail fast** - Validate early
|
||||
9. **Support --help and --version** - Always
|
||||
10. **Be explicit** - Avoid surprising behavior
|
||||
11. **Be consistent** - Follow patterns
|
||||
12. **Make it easy** - Good defaults, clear errors
|
||||
|
||||
## References
|
||||
|
||||
- [CLI Guidelines](https://clig.dev/)
|
||||
- [12 Factor CLI Apps](https://medium.com/@jdxcode/12-factor-cli-apps-dd3c227a0e46)
|
||||
- [NO_COLOR](https://no-color.org/)
|
||||
- [Human-First CLI Design](https://uxdesign.cc/human-first-cli-design-principles-b2b4b4e7e7c1)
|
||||
Reference in New Issue
Block a user