Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
124 changes: 124 additions & 0 deletions docs/ALIASES.md
Original file line number Diff line number Diff line change
@@ -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
```
102 changes: 102 additions & 0 deletions src/commands/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ 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");
}
let mut aliases = load_aliases()?;
aliases.insert(name.clone(), command.clone());
save_aliases(&aliases)?;
Expand All @@ -71,6 +75,39 @@ pub fn delete(names: Vec<String>) -> 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<String, String>) -> Vec<String> {
// Built-in commands cannot be shadowed by aliases.
#[cfg(not(target_arch = "wasm32"))]
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<String> = 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<String> {
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}"))?;
Expand All @@ -97,3 +134,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<String> {
s.split_whitespace().map(String::from).collect()
}

fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
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"));
}
}
15 changes: 14 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10342,6 +10342,19 @@ 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 args = {
let parsed = extensions::parse_extension_args(&args);
if let Some(ref candidate) = parsed.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
// `pup --help` / `pup help`, similar to how `gh` lists extensions.
Expand All @@ -10353,7 +10366,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
Expand Down
Loading