From c31e3b30d9c807ca5f0916fe1b310779b2ffc733 Mon Sep 17 00:00:00 2001 From: lognarly Date: Tue, 12 May 2026 10:05:39 -0400 Subject: [PATCH 1/2] feat(alias): implement alias execution and builtin name protection Add runtime expansion of stored aliases so that user-defined shorthand commands are resolved to their full pup command before clap parsing. - Expand alias tokens in args before clap sees them, enabling pup to dispatch as pup - Guard set and apply_expansion against builtin command names to prevent aliases from shadowing built-in commands - Add docs/ALIASES.md covering storage locations, commands, expansion behavior, and examples - Update CLAUDE.md documentation index to reference ALIASES.md Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 1 + docs/ALIASES.md | 124 ++++++++++++++++++++++++++++++++++++++++++ src/commands/alias.rs | 100 ++++++++++++++++++++++++++++++++++ src/main.rs | 15 ++++- 4 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 docs/ALIASES.md diff --git a/CLAUDE.md b/CLAUDE.md index 1cc2a567..5cea10a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,7 @@ Rust-based CLI wrapper for Datadog APIs. Provides OAuth2 + API key authenticatio ## Documentation Index +- **[ALIASES.md](docs/ALIASES.md)** - Alias management (storage, commands, expansion) - **[COMMANDS.md](docs/COMMANDS.md)** - Complete command reference - **[CONTRIBUTING.md](docs/CONTRIBUTING.md)** - Git workflow, PR process, commit format - **[TESTING.md](docs/TESTING.md)** - Test strategy, coverage requirements, CI/CD diff --git a/docs/ALIASES.md b/docs/ALIASES.md new file mode 100644 index 00000000..0170c722 --- /dev/null +++ b/docs/ALIASES.md @@ -0,0 +1,124 @@ +# Aliases + +## Overview + +Aliases let you define short names for any `pup` command. Once set, an alias +can be used exactly like a built-in command — with the same global flags and +any extra arguments appended after the alias name. + +```bash +pup alias set infra-list "infrastructure hosts list" +pup infra-list # same as: pup infrastructure hosts list +pup infra-list --filter env:production # extra args are appended to the expansion +pup --output json infra-list # global flags before the alias still work +``` + +## Managing Aliases + +```bash +# Create or update an alias +pup alias set infra-list "infrastructure hosts list" +pup alias set prod-errors "logs search --query='status:error' --tag='env:prod'" + +# List all configured aliases +pup alias list +pup --output json alias list + +# Delete one or more aliases +pup alias delete infra-list +pup alias delete infra-list prod-errors + +# Bulk-import from a YAML or JSON file +pup alias import my-aliases.yaml +pup alias import my-aliases.json +``` + +### Import file format + +YAML: + +```yaml +infra-list: infrastructure hosts list +prod-errors: logs search --query='status:error' --tag='env:prod' +``` + +JSON: + +```json +{ + "infra-list": "infrastructure hosts list", + "prod-errors": "logs search --query='status:error' --tag='env:prod'" +} +``` + +## Storage + +Aliases are stored in a YAML file named `aliases.yaml` inside pup's config +directory. The location depends on your platform: + +| Operating System | Path | +|------------------|------| +| macOS | `~/Library/Application Support/pup/aliases.yaml` | +| Linux | `~/.config/pup/aliases.yaml` (or `$XDG_CONFIG_HOME/pup/aliases.yaml`) | +| Windows | `%APPDATA%\pup\aliases.yaml` | + +The file is created automatically on the first `pup alias set`. It is a plain +key/value map and can be edited directly in any text editor. Importing merges +into the existing file — aliases not present in the import file are left +untouched. + +## How Expansion Works + +When `pup` starts it reads `~/.config/pup/aliases.yaml` and checks whether the +first positional argument (the subcommand) matches a known alias. If it does, +the alias token is replaced with the stored command tokens **before** the +argument list is handed to the command parser. + +This means: + +- **Global flags before the alias are preserved.** `pup --output json infra-list` + expands to `pup --output json infrastructure hosts list`. +- **Extra arguments after the alias are appended.** `pup infra-list --filter env:prod` + expands to `pup infrastructure hosts list --filter env:prod`. +- **Extensions take priority over aliases.** If a pup extension binary matches + the alias name, the extension is dispatched instead. +- **Built-in commands cannot be aliased over.** An alias named `monitors` or + `logs` will never shadow the built-in of the same name. + +## Implementation + +| File | Role | +|------|------| +| `src/commands/alias.rs` | CRUD commands (`list`, `set`, `delete`, `import`) and expansion logic (`expand`, `apply_expansion`) | +| `src/main.rs` | Calls `commands::alias::expand` on startup, before clap parses the argument list | + +The core expansion function (`apply_expansion`) operates on an in-memory alias +map, making it straightforward to unit-test without filesystem access. The +public `expand` function wraps it with a disk read from `aliases.yaml`. + +## Examples + +```bash +# Shorten a frequently used infrastructure command +pup alias set infra-list "infrastructure hosts list" +pup infra-list +pup infra-list --filter env:production --count 50 + +# Bookmark a common log search +pup alias set prod-errors "logs search --query='status:error' --tag='env:prod'" +pup prod-errors +pup prod-errors --from 30m # append extra flags on the fly + +# Snapshot a metrics query +pup alias set cpu "metrics query --query='avg:system.cpu.user{*}' --from=1h" +pup cpu + +# Review all aliases +pup alias list + +# Remove an alias you no longer need +pup alias delete cpu + +# Share a set of aliases with your team via a checked-in file +pup alias import team-aliases.yaml +``` diff --git a/src/commands/alias.rs b/src/commands/alias.rs index a0f93fb9..96096b31 100644 --- a/src/commands/alias.rs +++ b/src/commands/alias.rs @@ -52,6 +52,9 @@ pub fn list(cfg: &crate::config::Config) -> Result<()> { } pub fn set(name: String, command: String) -> Result<()> { + if crate::extensions::is_builtin_command(&name) { + bail!("'{name}' is a built-in command and cannot be used as an alias name"); + } let mut aliases = load_aliases()?; aliases.insert(name.clone(), command.clone()); save_aliases(&aliases)?; @@ -71,6 +74,38 @@ pub fn delete(names: Vec) -> Result<()> { Ok(()) } +/// Core expansion logic, operating on an already-loaded alias map. +/// Exposed separately so unit tests can exercise it without touching the +/// filesystem. +fn apply_expansion(args: &[String], name: &str, aliases: &BTreeMap) -> Vec { + // Built-in commands cannot be shadowed by aliases. + if crate::extensions::is_builtin_command(name) { + return args.to_vec(); + } + let Some(expansion) = aliases.get(name) else { + return args.to_vec(); + }; + let expansion_tokens: Vec = expansion.split_whitespace().map(String::from).collect(); + let Some(idx) = args.iter().position(|a| a == name) else { + return args.to_vec(); + }; + let mut out = args[..idx].to_vec(); + out.extend(expansion_tokens); + out.extend_from_slice(&args[idx + 1..]); + out +} + +/// Rewrite `args` by replacing the `name` token with its stored alias expansion. +/// Returns a clone of `args` unchanged if `name` is not a known alias or on +/// any I/O error. +pub fn expand(args: &[String], name: &str) -> Vec { + let aliases = match load_aliases() { + Ok(m) => m, + Err(_) => return args.to_vec(), + }; + apply_expansion(args, name, &aliases) +} + pub fn import(file: &str) -> Result<()> { let contents = std::fs::read_to_string(file) .with_context(|| format!("failed to read alias file: {file}"))?; @@ -97,3 +132,68 @@ pub fn import(file: &str) -> Result<()> { println!("Imported {count} alias(es) from {file}."); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn args(s: &str) -> Vec { + s.split_whitespace().map(String::from).collect() + } + + fn map(pairs: &[(&str, &str)]) -> BTreeMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn test_apply_expansion_no_match() { + let aliases = map(&[]); + let result = apply_expansion(&args("pup infra-list"), "infra-list", &aliases); + assert_eq!(result, args("pup infra-list")); + } + + #[test] + fn test_apply_expansion_simple() { + let aliases = map(&[("infra-list", "infrastructure hosts list")]); + let result = apply_expansion(&args("pup infra-list"), "infra-list", &aliases); + assert_eq!(result, args("pup infrastructure hosts list")); + } + + #[test] + fn test_apply_expansion_preserves_trailing_args() { + let aliases = map(&[("infra-list", "infrastructure hosts list")]); + let result = apply_expansion( + &args("pup infra-list --filter env:prod"), + "infra-list", + &aliases, + ); + assert_eq!( + result, + args("pup infrastructure hosts list --filter env:prod") + ); + } + + #[test] + fn test_apply_expansion_preserves_leading_flags() { + let aliases = map(&[("infra-list", "infrastructure hosts list")]); + let result = apply_expansion( + &args("pup --output json infra-list"), + "infra-list", + &aliases, + ); + assert_eq!(result, args("pup --output json infrastructure hosts list")); + } + + #[test] + fn test_set_rejects_builtin_name() { + let result = super::set( + "infrastructure".to_string(), + "infrastructure hosts list".to_string(), + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("built-in command")); + } +} diff --git a/src/main.rs b/src/main.rs index e40bf27e..dec75b27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10298,7 +10298,7 @@ mod resolve_callback_port_tests { async fn main_inner() -> anyhow::Result<()> { // In agent mode, intercept --help to return a JSON schema instead of plain text. - let args: Vec = std::env::args().collect(); + let mut args: Vec = std::env::args().collect(); let has_help = args.iter().any(|a| a == "--help" || a == "-h"); let has_agent_flag = args.iter().any(|a| a == "--agent"); let has_no_agent_flag = args.iter().any(|a| a == "--no-agent"); @@ -10342,6 +10342,17 @@ async fn main_inner() -> anyhow::Result<()> { } } + // --- Alias expansion (before clap parsing) --- + // If the first positional arg matches a stored alias, rewrite args so + // that clap sees the expanded command instead of the alias name. + #[cfg(not(target_arch = "wasm32"))] + { + let parsed = extensions::parse_extension_args(&args); + if let Some(ref candidate) = parsed.candidate { + args = commands::alias::expand(&args, candidate); + } + } + // Build the clap Command and, when extensions are installed, append an // "EXTENSIONS:" section to the help output so they are visible in // `pup --help` / `pup help`, similar to how `gh` lists extensions. @@ -10353,7 +10364,7 @@ async fn main_inner() -> anyhow::Result<()> { cmd = cmd.after_help(section); } } - let matches = cmd.get_matches(); + let matches = cmd.get_matches_from(&args); let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()); // Handle commands that do not require authentication before Config::from_env() so From 4e6247cb95d6988843e255f6c15edefb84771d36 Mon Sep 17 00:00:00 2001 From: lognarly Date: Tue, 12 May 2026 10:59:41 -0400 Subject: [PATCH 2/2] fix(alias): guard extensions calls behind wasm32 cfg and remove unused mut - Wrap is_builtin_command calls in alias.rs with #[cfg(not(target_arch = "wasm32"))] to match the extensions module gate - Replace `let mut args` mutation with a let-shadowing pattern in the alias expansion block so args is never declared mut on wasm32 Co-Authored-By: Claude Sonnet 4.6 --- src/commands/alias.rs | 2 ++ src/main.rs | 10 ++++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/commands/alias.rs b/src/commands/alias.rs index 96096b31..f0c3c66a 100644 --- a/src/commands/alias.rs +++ b/src/commands/alias.rs @@ -52,6 +52,7 @@ pub fn list(cfg: &crate::config::Config) -> Result<()> { } pub fn set(name: String, command: String) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] if crate::extensions::is_builtin_command(&name) { bail!("'{name}' is a built-in command and cannot be used as an alias name"); } @@ -79,6 +80,7 @@ pub fn delete(names: Vec) -> Result<()> { /// filesystem. fn apply_expansion(args: &[String], name: &str, aliases: &BTreeMap) -> Vec { // Built-in commands cannot be shadowed by aliases. + #[cfg(not(target_arch = "wasm32"))] if crate::extensions::is_builtin_command(name) { return args.to_vec(); } diff --git a/src/main.rs b/src/main.rs index dec75b27..d45f6a90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10298,7 +10298,7 @@ mod resolve_callback_port_tests { async fn main_inner() -> anyhow::Result<()> { // In agent mode, intercept --help to return a JSON schema instead of plain text. - let mut args: Vec = std::env::args().collect(); + let args: Vec = std::env::args().collect(); let has_help = args.iter().any(|a| a == "--help" || a == "-h"); let has_agent_flag = args.iter().any(|a| a == "--agent"); let has_no_agent_flag = args.iter().any(|a| a == "--no-agent"); @@ -10346,12 +10346,14 @@ async fn main_inner() -> anyhow::Result<()> { // If the first positional arg matches a stored alias, rewrite args so // that clap sees the expanded command instead of the alias name. #[cfg(not(target_arch = "wasm32"))] - { + let args = { let parsed = extensions::parse_extension_args(&args); if let Some(ref candidate) = parsed.candidate { - args = commands::alias::expand(&args, candidate); + commands::alias::expand(&args, candidate) + } else { + args } - } + }; // Build the clap Command and, when extensions are installed, append an // "EXTENSIONS:" section to the help output so they are visible in