Skip to content

Latest commit

 

History

History
1037 lines (851 loc) · 29.8 KB

File metadata and controls

1037 lines (851 loc) · 29.8 KB

Command Alias Design

Status: Design Proposal Created: 2026-02-16 Author: dtctl team

Overview

Command aliases let users define shorthand names for frequently used dtctl commands. An alias expands to a full dtctl command string before execution, supporting positional parameters and shell pipelines. Aliases are stored in the user's config file and managed through a dedicated dtctl alias command group.

Aliases are a well-established CLI pattern (git, gh) that reduces repetitive typing and lets teams share standardized shortcuts.

Goals

  1. Reduce repetition -- One-word shortcuts for common multi-flag commands
  2. Parameterization -- Positional placeholders ($1, $2) for reusable templates
  3. Shell expansion -- Pipe dtctl output through jq, grep, etc. via ! prefix
  4. Team sharing -- Import/export aliases as YAML for team-wide consistency
  5. Safety -- Aliases cannot shadow built-in commands

Non-Goals

  • Alias chaining (alias referencing another alias)
  • Context-scoped aliases (all aliases are global)
  • Interactive alias builder / wizard
  • Auto-generated aliases from usage patterns

User Experience

Managing Aliases

# Set a simple alias
dtctl alias set my-wf "get workflows --context=production"

# Use it
dtctl my-wf
# => dtctl get workflows --context=production

# Set an alias with positional parameters
dtctl alias set wf-logs 'query "fetch logs | filter workflow.id == \"$1\"" --context=production'

# Use it -- $1 is replaced with the argument
dtctl wf-logs abc-123
# => dtctl query "fetch logs | filter workflow.id == \"abc-123\"" --context=production

# Set a shell alias (! prefix)
dtctl alias set wf-count '!dtctl get workflows -o json | jq length'

# Use it -- executed through sh
dtctl wf-count
# => 42

# List all aliases
dtctl alias list

# Delete an alias
dtctl alias delete my-wf

# Delete multiple aliases
dtctl alias delete my-wf wf-logs wf-count

Import and Export

# Export all aliases to a file
dtctl alias export -f team-aliases.yaml

# Import aliases from a file (merges with existing)
dtctl alias import -f team-aliases.yaml

# Import with overwrite confirmation
dtctl alias import -f team-aliases.yaml
# Alias "deploy" already exists. Overwrite? [y/N]

# Force overwrite without prompting
dtctl alias import -f team-aliases.yaml --overwrite

Example alias file (team-aliases.yaml)

aliases:
  prod-wf: "get workflows --context=production"
  staging-dash: "get dashboards --context=staging"
  deploy: 'apply -f deployments/ --context=$1'
  wf-count: "!dtctl get workflows -o json | jq length"

List output

$ dtctl alias list
NAME          EXPANSION
deploy        apply -f deployments/ --context=$1
prod-wf       get workflows --context=production
staging-dash  get dashboards --context=staging
wf-count      !dtctl get workflows -o json | jq length

Error Cases

# Cannot shadow a built-in command
$ dtctl alias set get "describe workflows"
Error: "get" is a built-in command and cannot be used as an alias name

# Cannot shadow a built-in subcommand path
$ dtctl alias set config "get workflows"
Error: "config" is a built-in command and cannot be used as an alias name

# Unknown alias
$ dtctl nonexistent
Error: unknown command "nonexistent"

Did you mean one of these?
  get
  describe

# Missing required positional argument
$ dtctl alias set greet 'query "hello $1"'
$ dtctl greet
Error: alias "greet" requires at least 1 argument ($1), got 0

Technical Design

Storage

Aliases are stored in the existing config file under a top-level aliases key:

apiVersion: v1
kind: Config
current-context: production
contexts: [...]
tokens: [...]
preferences:
  output: table
  editor: vim
aliases:
  prod-wf: "get workflows --context=production"
  deploy: "apply -f deployments/ --context=$1"
  wf-count: "!dtctl get workflows -o json | jq length"

This keeps aliases co-located with the rest of the configuration and means they benefit from the existing local-config (.dtctl.yaml) vs global-config precedence. Project-local .dtctl.yaml files can define project-specific aliases.

Config Struct Change

// pkg/config/config.go

type Config struct {
    APIVersion     string            `yaml:"apiVersion"`
    Kind           string            `yaml:"kind"`
    CurrentContext string            `yaml:"current-context"`
    Contexts       []NamedContext    `yaml:"contexts"`
    Tokens         []NamedToken      `yaml:"tokens"`
    Preferences    Preferences       `yaml:"preferences"`
    Aliases        map[string]string `yaml:"aliases,omitempty"`
}

Alias Package

// pkg/config/alias.go
package config

import (
    "fmt"
    "os"
    "regexp"
    "sort"
    "strings"

    "gopkg.in/yaml.v3"
)

// aliasNameRegex validates alias names: letters, numbers, hyphens, underscores
var aliasNameRegex = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_-]*$`)

// maxPositionalParam is the highest $N we scan for when validating argument count
const maxPositionalParam = 9

// ValidateAliasName checks that an alias name is syntactically valid.
func ValidateAliasName(name string) error {
    if name == "" {
        return fmt.Errorf("alias name cannot be empty")
    }
    if !aliasNameRegex.MatchString(name) {
        return fmt.Errorf("alias name %q is invalid: use only letters, numbers, hyphens, and underscores", name)
    }
    return nil
}

// SetAlias adds or updates an alias. Returns an error if the name is invalid.
// builtinCheck is called to verify the name does not shadow a built-in command.
func (c *Config) SetAlias(name, expansion string, builtinCheck func(string) bool) error {
    if err := ValidateAliasName(name); err != nil {
        return err
    }
    if builtinCheck != nil && builtinCheck(name) {
        return fmt.Errorf("%q is a built-in command and cannot be used as an alias name", name)
    }
    if expansion == "" {
        return fmt.Errorf("alias expansion cannot be empty")
    }
    if c.Aliases == nil {
        c.Aliases = make(map[string]string)
    }
    c.Aliases[name] = expansion
    return nil
}

// DeleteAlias removes an alias by name. Returns an error if it does not exist.
func (c *Config) DeleteAlias(name string) error {
    if c.Aliases == nil {
        return fmt.Errorf("alias %q not found", name)
    }
    if _, ok := c.Aliases[name]; !ok {
        return fmt.Errorf("alias %q not found", name)
    }
    delete(c.Aliases, name)
    return nil
}

// GetAlias returns the expansion for an alias, or empty string if not found.
func (c *Config) GetAlias(name string) (string, bool) {
    if c.Aliases == nil {
        return "", false
    }
    exp, ok := c.Aliases[name]
    return exp, ok
}

// ListAliases returns all aliases sorted alphabetically by name.
func (c *Config) ListAliases() []AliasEntry {
    entries := make([]AliasEntry, 0, len(c.Aliases))
    for name, expansion := range c.Aliases {
        entries = append(entries, AliasEntry{Name: name, Expansion: expansion})
    }
    sort.Slice(entries, func(i, j int) bool {
        return entries[i].Name < entries[j].Name
    })
    return entries
}

// AliasEntry is a single alias for display purposes.
type AliasEntry struct {
    Name      string `table:"NAME"`
    Expansion string `table:"EXPANSION"`
}

// AliasFile represents the YAML structure for import/export.
type AliasFile struct {
    Aliases map[string]string `yaml:"aliases"`
}

// ExportAliases writes aliases to a file in YAML format.
func (c *Config) ExportAliases(path string) error {
    af := AliasFile{Aliases: c.Aliases}
    data, err := yaml.Marshal(af)
    if err != nil {
        return fmt.Errorf("failed to marshal aliases: %w", err)
    }
    return os.WriteFile(path, data, 0600)
}

// ImportAliases reads aliases from a YAML file and merges them into the config.
// If overwrite is false, existing aliases are not replaced and conflicts are
// returned as a list of names.
func (c *Config) ImportAliases(path string, overwrite bool, builtinCheck func(string) bool) ([]string, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, fmt.Errorf("failed to read alias file: %w", err)
    }

    var af AliasFile
    if err := yaml.Unmarshal(data, &af); err != nil {
        return nil, fmt.Errorf("failed to parse alias file: %w", err)
    }

    if c.Aliases == nil {
        c.Aliases = make(map[string]string)
    }

    var conflicts []string
    for name, expansion := range af.Aliases {
        if err := ValidateAliasName(name); err != nil {
            return nil, fmt.Errorf("invalid alias in file: %w", err)
        }
        if builtinCheck != nil && builtinCheck(name) {
            return nil, fmt.Errorf("alias %q in file shadows a built-in command", name)
        }
        if _, exists := c.Aliases[name]; exists && !overwrite {
            conflicts = append(conflicts, name)
            continue
        }
        c.Aliases[name] = expansion
    }

    return conflicts, nil
}

Alias Resolution

Resolution happens in cmd/root.go before Cobra parses args. This is the same approach used by gh -- intercept os.Args, check if the first non-flag argument matches an alias, expand it, and either re-invoke Cobra or exec a shell.

// cmd/alias_resolve.go
package cmd

import (
    "fmt"
    "os"
    "os/exec"
    "regexp"
    "strconv"
    "strings"

    "github.com/dynatrace-oss/dtctl/pkg/config"
)

// resolveAlias checks if the first argument is an alias and expands it.
// Returns (expanded args, isShellAlias, error).
// If no alias matches, returns (nil, false, nil).
func resolveAlias(args []string, cfg *config.Config) ([]string, bool, error) {
    if len(args) == 0 || cfg == nil {
        return nil, false, nil
    }

    // Skip if the first arg is a flag
    if strings.HasPrefix(args[0], "-") {
        return nil, false, nil
    }

    name := args[0]
    expansion, ok := cfg.GetAlias(name)
    if !ok {
        return nil, false, nil
    }

    // Shell alias: starts with !
    if strings.HasPrefix(expansion, "!") {
        shellCmd := expansion[1:]
        // Append extra args
        if len(args) > 1 {
            shellCmd += " " + strings.Join(args[1:], " ")
        }
        return []string{shellCmd}, true, nil
    }

    // Regular alias: split and substitute positional params
    parts := splitCommand(expansion)
    extraArgs := args[1:]

    // Substitute $1..$9
    maxUsed := 0
    for i, part := range parts {
        parts[i] = substituteParams(part, extraArgs, &maxUsed)
    }

    // Append unconsumed args (those beyond the highest $N used)
    if maxUsed < len(extraArgs) {
        parts = append(parts, extraArgs[maxUsed:]...)
    }

    // Validate: if $N was used, require that many args
    if maxUsed > len(extraArgs) {
        return nil, false, fmt.Errorf(
            "alias %q requires at least %d argument(s) ($1-$%d), got %d",
            name, maxUsed, maxUsed, len(extraArgs))
    }

    return parts, false, nil
}

// substituteParams replaces $1..$9 in s with values from args.
// Tracks the highest parameter index used.
func substituteParams(s string, args []string, maxUsed *int) string {
    re := regexp.MustCompile(`\$(\d)`)
    return re.ReplaceAllStringFunc(s, func(match string) string {
        idx, _ := strconv.Atoi(match[1:])
        if idx > *maxUsed {
            *maxUsed = idx
        }
        if idx >= 1 && idx <= len(args) {
            return args[idx-1]
        }
        return match // leave unreplaced if not enough args
    })
}

// splitCommand splits a command string respecting quotes.
func splitCommand(s string) []string {
    var parts []string
    var current strings.Builder
    inSingle := false
    inDouble := false

    for i := 0; i < len(s); i++ {
        ch := s[i]
        switch {
        case ch == '\'' && !inDouble:
            inSingle = !inSingle
        case ch == '"' && !inSingle:
            inDouble = !inDouble
        case ch == ' ' && !inSingle && !inDouble:
            if current.Len() > 0 {
                parts = append(parts, current.String())
                current.Reset()
            }
        default:
            current.WriteByte(ch)
        }
    }
    if current.Len() > 0 {
        parts = append(parts, current.String())
    }
    return parts
}

// execShellAlias runs a shell alias via sh -c.
func execShellAlias(shellCmd string) error {
    cmd := exec.Command("sh", "-c", shellCmd)
    cmd.Stdin = os.Stdin
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr
    return cmd.Run()
}

Integration into Execute()

// cmd/root.go (modified)

func Execute() {
    setupErrorHandlers(rootCmd)

    // --- Alias resolution (before Cobra parses args) ---
    // Load config quietly; if it fails, skip alias resolution (the real
    // command will produce the proper error later).
    if cfg, err := config.Load(); err == nil {
        // os.Args[0] is the binary name; work with os.Args[1:]
        expanded, isShell, err := resolveAlias(os.Args[1:], cfg)
        if err != nil {
            fmt.Fprintf(os.Stderr, "Error: %s\n", err)
            os.Exit(1)
        }

        if isShell {
            if err := execShellAlias(expanded[0]); err != nil {
                os.Exit(1)
            }
            return
        }

        if expanded != nil {
            rootCmd.SetArgs(expanded)
        }
    }
    // --- End alias resolution ---

    if err := rootCmd.Execute(); err != nil {
        // ... existing error handling ...
    }
}

Builtin Check

The builtin check is passed as a function so the config package doesn't depend on Cobra:

// cmd/alias.go

// isBuiltinCommand returns true if name matches any registered Cobra command.
func isBuiltinCommand(name string) bool {
    for _, cmd := range rootCmd.Commands() {
        if cmd.Name() == name {
            return true
        }
        for _, alias := range cmd.Aliases {
            if alias == name {
                return true
            }
        }
    }
    return false
}

Command Definitions

// cmd/alias.go
package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

var aliasCmd = &cobra.Command{
    Use:   "alias",
    Short: "Manage command aliases",
    Long: `Create, list, and delete shorthand names for dtctl commands.

Aliases expand before command parsing, so they work exactly like typing
the full command. Use positional parameters ($1, $2, ...) for reusable
templates, or prefix with ! for shell expansion.

Examples:
  # Simple alias
  dtctl alias set prod-wf "get workflows --context=production"
  dtctl prod-wf

  # Parameterized alias
  dtctl alias set wf 'get workflow $1 --context=production'
  dtctl wf my-workflow-id

  # Shell alias (pipes, jq, etc.)
  dtctl alias set wf-count '!dtctl get workflows -o json | jq length'
  dtctl wf-count`,
}

var aliasSetCmd = &cobra.Command{
    Use:   "set <name> <expansion>",
    Short: "Create or update an alias",
    Args:  cobra.ExactArgs(2),
    RunE: func(cmd *cobra.Command, args []string) error {
        name, expansion := args[0], args[1]

        cfg, err := loadConfigRaw()
        if err != nil {
            return err
        }

        if err := cfg.SetAlias(name, expansion, isBuiltinCommand); err != nil {
            return err
        }

        if err := saveConfig(cfg); err != nil {
            return err
        }

        fmt.Printf("Alias %q set to %q\n", name, expansion)
        return nil
    },
}

var aliasListCmd = &cobra.Command{
    Use:   "list",
    Short: "List all aliases",
    Aliases: []string{"ls"},
    RunE: func(cmd *cobra.Command, args []string) error {
        cfg, err := loadConfigRaw()
        if err != nil {
            return err
        }

        entries := cfg.ListAliases()
        if len(entries) == 0 {
            fmt.Println("No aliases configured.")
            fmt.Println("Use 'dtctl alias set <name> <command>' to create one.")
            return nil
        }

        printer := NewPrinter()
        return printer.PrintList(entries)
    },
}

var aliasDeleteCmd = &cobra.Command{
    Use:   "delete <name> [name...]",
    Short: "Delete one or more aliases",
    Aliases: []string{"rm"},
    Args:  cobra.MinimumNArgs(1),
    RunE: func(cmd *cobra.Command, args []string) error {
        cfg, err := loadConfigRaw()
        if err != nil {
            return err
        }

        for _, name := range args {
            if err := cfg.DeleteAlias(name); err != nil {
                return err
            }
            fmt.Printf("Alias %q deleted\n", name)
        }

        return saveConfig(cfg)
    },
}

var aliasExportCmd = &cobra.Command{
    Use:   "export",
    Short: "Export aliases to a YAML file",
    RunE: func(cmd *cobra.Command, args []string) error {
        file, _ := cmd.Flags().GetString("file")
        if file == "" {
            return fmt.Errorf("--file is required")
        }

        cfg, err := loadConfigRaw()
        if err != nil {
            return err
        }

        if len(cfg.Aliases) == 0 {
            return fmt.Errorf("no aliases to export")
        }

        if err := cfg.ExportAliases(file); err != nil {
            return err
        }

        fmt.Printf("Exported %d alias(es) to %s\n", len(cfg.Aliases), file)
        return nil
    },
}

var aliasImportCmd = &cobra.Command{
    Use:   "import",
    Short: "Import aliases from a YAML file",
    RunE: func(cmd *cobra.Command, args []string) error {
        file, _ := cmd.Flags().GetString("file")
        overwrite, _ := cmd.Flags().GetBool("overwrite")

        if file == "" {
            return fmt.Errorf("--file is required")
        }

        cfg, err := loadConfigRaw()
        if err != nil {
            return err
        }

        conflicts, err := cfg.ImportAliases(file, overwrite, isBuiltinCommand)
        if err != nil {
            return err
        }

        if len(conflicts) > 0 && !overwrite {
            fmt.Printf("Skipped %d existing alias(es): %s\n",
                len(conflicts), strings.Join(conflicts, ", "))
            fmt.Println("Use --overwrite to replace existing aliases.")
        }

        if err := saveConfig(cfg); err != nil {
            return err
        }

        fmt.Println("Aliases imported successfully.")
        return nil
    },
}

func init() {
    rootCmd.AddCommand(aliasCmd)

    aliasCmd.AddCommand(aliasSetCmd)
    aliasCmd.AddCommand(aliasListCmd)
    aliasCmd.AddCommand(aliasDeleteCmd)
    aliasCmd.AddCommand(aliasExportCmd)
    aliasCmd.AddCommand(aliasImportCmd)

    aliasExportCmd.Flags().StringP("file", "f", "", "output file path")
    aliasImportCmd.Flags().StringP("file", "f", "", "input file path")
    aliasImportCmd.Flags().Bool("overwrite", false, "overwrite existing aliases")
}

Alias Resolution Order

When dtctl receives a command, resolution follows this order:

  1. Global flags -- If the first arg starts with -, skip alias lookup entirely
  2. Built-in commands -- Cobra matches registered commands first (get, describe, config, alias, ctx, doctor, etc.). Built-ins always win.
  3. Aliases -- If no built-in matches, check the alias map. Expand and re-parse.
  4. Unknown command -- If no alias matches either, fall through to Cobra's error handling (which already provides "did you mean?" suggestions via pkg/suggest).

This means aliases can never accidentally override built-in commands, even if a future dtctl release adds a command with the same name as an existing alias. In that case, the built-in wins silently. Users can run dtctl alias list to audit.


Use Cases

1. Environment Shortcuts

# Quick access to production resources
dtctl alias set prod-wf "get workflows --context=production"
dtctl alias set staging-dash "get dashboards --context=staging -o wide"

dtctl prod-wf          # => dtctl get workflows --context=production
dtctl staging-dash     # => dtctl get dashboards --context=staging -o wide

2. Parameterized Lookups

# Look up a workflow by name in production
dtctl alias set pw 'get workflow $1 --context=production'
dtctl pw error-handler
# => dtctl get workflow error-handler --context=production

# Deploy a specific file to a specific context
dtctl alias set deploy 'apply -f $1 --context=$2'
dtctl deploy workflows/handler.yaml production
# => dtctl apply -f workflows/handler.yaml --context=production

3. Shell Pipelines

# Count workflows
dtctl alias set wf-count '!dtctl get workflows -o json | jq length'

# Get workflow names only
dtctl alias set wf-names '!dtctl get workflows -o json | jq -r ".[].title"'

# Find workflows modified today
dtctl alias set wf-today '!dtctl get workflows -o json | jq "[.[] | select(.lastModified > \"$(date -u +%Y-%m-%dT00:00:00Z)\")]"'

4. Team Standardization

# Team lead creates standard aliases
cat > team-aliases.yaml <<EOF
aliases:
  prod-wf: "get workflows --context=production"
  staging-wf: "get workflows --context=staging"
  deploy: "apply -f $1 --context=$2"
  check: "diff -f $1 --context=production"
EOF

# Distribute to team
dtctl alias import -f team-aliases.yaml

5. CI/CD Shortcuts

# .dtctl.yaml in project root
apiVersion: v1
kind: Config
current-context: ci
contexts:
  - name: ci
    context:
      environment: https://ci.dynatrace.com
      token-ref: ci-token
aliases:
  deploy: "apply -f ./dynatrace/ --plain"
  drift-check: "diff -f ./dynatrace/ --plain --quiet"
  validate: "apply -f ./dynatrace/ --dry-run --plain"

Testing Strategy

Unit Tests (pkg/config/alias_test.go)

func TestSetAlias(t *testing.T) {
    tests := []struct {
        name      string
        aliasName string
        expansion string
        builtin   func(string) bool
        wantErr   string
    }{
        {
            name:      "simple alias",
            aliasName: "wf",
            expansion: "get workflows",
        },
        {
            name:      "alias with hyphens and underscores",
            aliasName: "prod-wf_v2",
            expansion: "get workflows --context=production",
        },
        {
            name:      "rejects empty name",
            aliasName: "",
            expansion: "get workflows",
            wantErr:   "alias name cannot be empty",
        },
        {
            name:      "rejects invalid characters",
            aliasName: "my alias!",
            expansion: "get workflows",
            wantErr:   "invalid",
        },
        {
            name:      "rejects builtin shadow",
            aliasName: "get",
            expansion: "describe workflows",
            builtin:   func(s string) bool { return s == "get" },
            wantErr:   "built-in command",
        },
        {
            name:      "rejects empty expansion",
            aliasName: "wf",
            expansion: "",
            wantErr:   "cannot be empty",
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            cfg := NewConfig()
            err := cfg.SetAlias(tt.aliasName, tt.expansion, tt.builtin)
            if tt.wantErr != "" {
                require.ErrorContains(t, err, tt.wantErr)
            } else {
                require.NoError(t, err)
                got, ok := cfg.GetAlias(tt.aliasName)
                require.True(t, ok)
                require.Equal(t, tt.expansion, got)
            }
        })
    }
}

func TestDeleteAlias(t *testing.T) { /* not found, success */ }
func TestListAliases_Sorted(t *testing.T) { /* alphabetical */ }
func TestImportAliases_MergeAndConflicts(t *testing.T) { /* merge, skip, overwrite */ }
func TestImportAliases_RejectsBuiltinShadow(t *testing.T) { /* shadow in file */ }
func TestExportAliases_RoundTrip(t *testing.T) { /* export then import */ }

Resolution Tests (cmd/alias_resolve_test.go)

func TestResolveAlias(t *testing.T) {
    tests := []struct {
        name      string
        args      []string
        aliases   map[string]string
        wantArgs  []string
        wantShell bool
        wantErr   string
    }{
        {
            name:     "simple expansion",
            args:     []string{"wf"},
            aliases:  map[string]string{"wf": "get workflows"},
            wantArgs: []string{"get", "workflows"},
        },
        {
            name:     "positional params",
            args:     []string{"pw", "my-id"},
            aliases:  map[string]string{"pw": "get workflow $1"},
            wantArgs: []string{"get", "workflow", "my-id"},
        },
        {
            name:     "extra args appended",
            args:     []string{"wf", "--context=prod"},
            aliases:  map[string]string{"wf": "get workflows"},
            wantArgs: []string{"get", "workflows", "--context=prod"},
        },
        {
            name:      "shell alias",
            args:      []string{"count"},
            aliases:   map[string]string{"count": "!dtctl get wf -o json | jq length"},
            wantArgs:  []string{"dtctl get wf -o json | jq length"},
            wantShell: true,
        },
        {
            name:    "missing required arg",
            args:    []string{"pw"},
            aliases: map[string]string{"pw": "get workflow $1"},
            wantErr: "requires at least 1 argument",
        },
        {
            name:     "no match returns nil",
            args:     []string{"unknown"},
            aliases:  map[string]string{"wf": "get workflows"},
            wantArgs: nil,
        },
        {
            name:     "flag as first arg skips lookup",
            args:     []string{"--help"},
            aliases:  map[string]string{"--help": "bad"},
            wantArgs: nil,
        },
    }
    // ...
}

func TestSplitCommand_Quotes(t *testing.T) {
    tests := []struct {
        input string
        want  []string
    }{
        {`get workflows`, []string{"get", "workflows"}},
        {`query "fetch logs | limit 10"`, []string{"query", "fetch logs | limit 10"}},
        {`get workflow 'my workflow'`, []string{"get", "workflow", "my workflow"}},
    }
    // ...
}

Integration Tests (cmd/alias_test.go)

func TestAliasSetAndList(t *testing.T) { /* set, then list, verify output */ }
func TestAliasDelete(t *testing.T) { /* set, delete, verify gone */ }
func TestAliasImportExport(t *testing.T) { /* export, modify, import, verify */ }
func TestAliasExecution(t *testing.T) { /* set alias, execute it, verify args */ }
func TestAliasCannotShadowBuiltin(t *testing.T) { /* try to set "get", verify error */ }

Error Handling

Scenario Error message
Invalid alias name alias name "..." is invalid: use only letters, numbers, hyphens, and underscores
Shadow built-in "get" is a built-in command and cannot be used as an alias name
Empty expansion alias expansion cannot be empty
Delete nonexistent alias "foo" not found
Missing positional arg alias "pw" requires at least 1 argument ($1), got 0
Import invalid YAML failed to parse alias file: ...
Import shadow alias "get" in file shadows a built-in command
Export with no aliases no aliases to export

Alternatives Considered

1. Store aliases in a separate file

Pros: Clean separation, easy to share just aliases. Cons: Another file to manage; config loading becomes more complex; can't use project-local .dtctl.yaml for project aliases without extra logic. Decision: Store in existing config. One file, one source of truth. Export covers the sharing use case.

2. Alias chaining (alias referencing another alias)

Pros: More composable. Cons: Risk of circular references, harder to debug, increases complexity significantly for marginal value. Neither git nor gh support this. Decision: Not supported. Keep it simple. Shell aliases cover complex composition.

3. Aliases as Cobra subcommands registered at startup

Pros: Full Cobra integration (help text, completion). Cons: Requires loading config before Cobra init, complicates startup, aliases would appear in --help output cluttering it. Decision: Resolve before Cobra parses. Aliases are user shortcuts, not first-class commands.

4. Support $@ for all remaining args

Pros: Explicit "pass everything through" syntax. Cons: The current design already appends unused args, so $@ is redundant. Extra syntax to learn for no benefit. Decision: Not needed. Unconsumed args are appended automatically.


Future Enhancements

These are explicitly out of scope for the initial implementation but could be added later if there is demand:

  • Alias descriptions -- Optional --description on alias set for self-documenting aliases
  • Alias history -- Track when aliases were last used for cleanup suggestions
  • Completion for alias names -- Shell completion that includes alias names
  • Alias validation on set -- Parse the expansion and warn if it references unknown commands

Implementation Checklist

  1. Add Aliases map[string]string to Config struct in pkg/config/config.go
  2. Create pkg/config/alias.go with SetAlias, DeleteAlias, GetAlias, ListAliases, ImportAliases, ExportAliases, ValidateAliasName
  3. Create pkg/config/alias_test.go with table-driven tests
  4. Create cmd/alias.go with alias set/list/delete/import/export commands
  5. Create cmd/alias_resolve.go with resolveAlias, splitCommand, substituteParams
  6. Create cmd/alias_resolve_test.go with resolution and quote-parsing tests
  7. Modify cmd/root.go Execute() to call resolveAlias before rootCmd.Execute()
  8. Update examples/config-example.yaml with alias examples
  9. Update docs/dev/IMPLEMENTATION_STATUS.md

Success Metrics

  • Alias CRUD works reliably (set, get, list, delete)
  • Positional parameter substitution handles $1-$9 correctly
  • Shell aliases execute through sh -c and inherit stdin/stdout/stderr
  • Built-in commands can never be shadowed
  • Import/export round-trips without data loss
  • All tests pass with race detection enabled
  • Config file remains valid YAML after alias operations

References