From cfd221cf8298bd8224fd7244e2ebd7b16d74e34b Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:51:32 -0700 Subject: [PATCH] feat(cli): add `dispatch alias` to set and remove session aliases Aliases were list-only from the CLI (`dispatch aliases`); the only way to set or clear one was the TUI. This adds a `dispatch alias` mutator so aliases have the same CLI parity that `tag` and `notes` already have. Forms: dispatch alias assign or reassign an alias dispatch alias --clear remove the alias on a session dispatch alias --remove remove an alias by its name dispatch alias --json The accepts the same short prefix that `dispatch open` does, and names reuse config's normalization and uniqueness rules so `dispatch open ` keeps resolving to exactly one session. Wires the command into handleArgs, all four shell completion lists, usage, the man page examples, README, and CHANGELOG. Adds table-driven parser tests and runAlias coverage for set, reassign, clear, remove-by-name, duplicate rejection, unknown ID, and JSON output. Closes #331 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 732fe147-a68b-4784-8e17-ef089fb70ba3 --- CHANGELOG.md | 4 + README.md | 13 +++ cmd/dispatch/alias.go | 195 ++++++++++++++++++++++++++++++++++ cmd/dispatch/alias_test.go | 212 +++++++++++++++++++++++++++++++++++++ cmd/dispatch/cli.go | 15 ++- cmd/dispatch/main.go | 1 + cmd/dispatch/man.go | 1 + 7 files changed, 437 insertions(+), 4 deletions(-) create mode 100644 cmd/dispatch/alias.go create mode 100644 cmd/dispatch/alias_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cf07f27..29370f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +### Added + +- **`dispatch alias`**: set, reassign, clear (`--clear`), or remove (`--remove`) a session alias from the command line, completing the CLI parity that `tag` and `notes` already have. Supports `--json`. + ## [v0.14.0] — 2026-07-18 This release turns Dispatch from a TUI-only tool into a scriptable CLI: most session operations are now available as non-interactive commands with JSON output, and the TUI gains a git status overlay plus several navigation and organization features. diff --git a/README.md b/README.md index bf2de97..8e11250 100644 --- a/README.md +++ b/README.md @@ -361,6 +361,19 @@ dispatch aliases --json Each entry shows the alias, the target session ID, summary, and repository. Aliases whose target session is no longer in the store are marked as orphaned. Use `--json` for scripting. +### Alias + +Set or remove a single session alias from the command line, completing the parity that `tag` and `notes` already have: + +```sh +dispatch alias review # assign or reassign an alias +dispatch alias --clear # remove the alias on a session +dispatch alias --remove review # remove an alias by its name +dispatch alias review --json +``` + +The `` accepts the same short prefix that `dispatch open` does. Alias names are lowercased and must be unique, so `dispatch open ` keeps resolving to one session. + ### Tag Manage tags on a single session from the command line: diff --git a/cmd/dispatch/alias.go b/cmd/dispatch/alias.go new file mode 100644 index 0000000..8751cf3 --- /dev/null +++ b/cmd/dispatch/alias.go @@ -0,0 +1,195 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "github.com/jongio/dispatch/internal/config" +) + +// aliasResult is the machine-readable output of an alias mutation. Alias is +// empty when the alias was cleared or removed. +type aliasResult struct { + ID string `json:"id"` + Alias string `json:"alias"` +} + +// runAlias sets or removes a session alias from the command line, completing +// the CLI parity that `tag` and `notes` already have for their metadata. It +// supports: +// +// dispatch alias assign or reassign an alias +// dispatch alias --clear remove the alias on a session +// dispatch alias --remove remove an alias by its name +// +// accepts the same full ID or short prefix that `dispatch open` does, so +// aliases resolve to exactly one session. Alias names reuse config's +// normalization and uniqueness rules, so `dispatch open ` keeps +// resolving to one session. args[0] is expected to be "alias". +func runAlias(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + sessionArg, name, clearFlag, remove, jsonOut, err := parseAliasArgs(args) + if err != nil { + return err + } + + cfg, err := configLoadFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + var ( + sessionID string + alias string + action string + ) + + switch { + case remove: + sessionID = cfg.SessionIDForAlias(name) + if sessionID == "" { + return fmt.Errorf("no session uses alias %q", config.NormalizeAlias(name)) + } + if sErr := cfg.SetAlias(sessionID, ""); sErr != nil { + return sErr + } + action = "cleared" + + case clearFlag: + resolved, rErr := resolveAliasSession(sessionArg) + if rErr != nil { + return rErr + } + sessionID = resolved + if sErr := cfg.SetAlias(sessionID, ""); sErr != nil { + return sErr + } + action = "cleared" + + default: + resolved, rErr := resolveAliasSession(sessionArg) + if rErr != nil { + return rErr + } + sessionID = resolved + if sErr := cfg.SetAlias(sessionID, name); sErr != nil { + return sErr + } + alias = cfg.AliasFor(sessionID) + action = "set" + } + + if sErr := configSaveFn(cfg); sErr != nil { + return sErr + } + + if jsonOut { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(aliasResult{ID: sessionID, Alias: alias}) + } + + if action == "cleared" { + fmt.Fprintf(w, "Cleared alias for %s\n", shortID(sessionID)) + } else { + fmt.Fprintf(w, "Set alias %q for %s\n", alias, shortID(sessionID)) + } + return nil +} + +// resolveAliasSession resolves a session ID or short prefix to a full session +// ID using the same seam `dispatch open` uses, so prefix matching is identical. +func resolveAliasSession(idArg string) (string, error) { + if strings.TrimSpace(idArg) == "" { + return "", errors.New("alias requires a session ID") + } + sess, err := openGetSessionFn(idArg) + if err != nil { + return "", err + } + if sess == nil { + return "", fmt.Errorf("session %q not found", idArg) + } + return sess.ID, nil +} + +// parseAliasArgs splits the alias subcommand arguments into the session +// selector, alias name, and mode flags. args[0] is expected to be "alias". +func parseAliasArgs(args []string) (sessionArg, name string, clearFlag, remove, jsonOut bool, err error) { + rest := args + if len(rest) > 0 { + rest = rest[1:] // drop the "alias" token + } + + var positionals []string + for i := 0; i < len(rest); i++ { + arg := rest[i] + switch { + case arg == "--json": + jsonOut = true + case arg == "--clear": + clearFlag = true + case arg == "--remove": + remove = true + if i+1 >= len(rest) { + return "", "", false, false, false, errors.New("--remove requires an alias name") + } + i++ + name = rest[i] + case strings.HasPrefix(arg, "--remove="): + remove = true + name = strings.TrimPrefix(arg, "--remove=") + case strings.HasPrefix(arg, "-"): + return "", "", false, false, false, fmt.Errorf("unknown flag: %s", arg) + default: + positionals = append(positionals, arg) + } + } + + if remove { + switch { + case clearFlag: + return "", "", false, false, false, errors.New("alias --remove cannot be combined with --clear") + case len(positionals) > 0: + return "", "", false, false, false, errors.New("alias --remove takes an alias name, not a session ID") + case strings.TrimSpace(name) == "": + return "", "", false, false, false, errors.New("--remove requires an alias name") + } + return "", name, false, true, jsonOut, nil + } + + if len(positionals) == 0 { + return "", "", false, false, false, errors.New("alias requires a session ID") + } + sessionArg = positionals[0] + + if clearFlag { + if len(positionals) > 1 { + return "", "", false, false, false, errors.New("alias --clear does not take an alias name") + } + return sessionArg, "", true, false, jsonOut, nil + } + + switch len(positionals) { + case 1: + return "", "", false, false, false, errors.New("alias requires an alias name (or use --clear)") + case 2: + name = positionals[1] + default: + return "", "", false, false, false, fmt.Errorf("alias takes a single session ID and a single alias name, got %d arguments", len(positionals)) + } + + if strings.TrimSpace(name) == "" { + return "", "", false, false, false, errors.New("alias name cannot be empty") + } + if strings.ContainsAny(name, " \t") { + return "", "", false, false, false, errors.New("alias name cannot contain whitespace") + } + return sessionArg, name, false, false, jsonOut, nil +} diff --git a/cmd/dispatch/alias_test.go b/cmd/dispatch/alias_test.go new file mode 100644 index 0000000..8845e36 --- /dev/null +++ b/cmd/dispatch/alias_test.go @@ -0,0 +1,212 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" +) + +// withAliasSeams swaps the config and session-resolution seams that runAlias +// depends on and restores them via t.Cleanup. sessions are matched by exact ID. +func withAliasSeams(t *testing.T, cfg *config.Config, sessions []data.Session) { + t.Helper() + + prevLoad := configLoadFn + prevSave := configSaveFn + configLoadFn = func() (*config.Config, error) { return cfg, nil } + configSaveFn = func(*config.Config) error { return nil } + t.Cleanup(func() { configLoadFn = prevLoad; configSaveFn = prevSave }) + + prevGet := openGetSessionFn + openGetSessionFn = func(id string) (*data.Session, error) { + for i := range sessions { + if sessions[i].ID == id { + return &sessions[i], nil + } + } + return nil, nil + } + t.Cleanup(func() { openGetSessionFn = prevGet }) +} + +func TestParseAliasArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantSession string + wantName string + wantClear bool + wantRemove bool + wantJSON bool + wantErr bool + }{ + {name: "set", args: []string{"alias", "ses-1", "review"}, wantSession: "ses-1", wantName: "review"}, + {name: "set with json", args: []string{"alias", "ses-1", "review", "--json"}, wantSession: "ses-1", wantName: "review", wantJSON: true}, + {name: "clear", args: []string{"alias", "ses-1", "--clear"}, wantSession: "ses-1", wantClear: true}, + {name: "remove spaced", args: []string{"alias", "--remove", "review"}, wantName: "review", wantRemove: true}, + {name: "remove equals", args: []string{"alias", "--remove=review"}, wantName: "review", wantRemove: true}, + {name: "no args", args: []string{"alias"}, wantErr: true}, + {name: "id without name", args: []string{"alias", "ses-1"}, wantErr: true}, + {name: "remove without name", args: []string{"alias", "--remove"}, wantErr: true}, + {name: "remove with session", args: []string{"alias", "--remove", "review", "ses-1"}, wantErr: true}, + {name: "clear with extra name", args: []string{"alias", "ses-1", "--clear", "extra"}, wantErr: true}, + {name: "too many positionals", args: []string{"alias", "ses-1", "a", "b"}, wantErr: true}, + {name: "unknown flag", args: []string{"alias", "ses-1", "--bogus"}, wantErr: true}, + {name: "remove and clear", args: []string{"alias", "--remove", "review", "--clear"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sessionArg, name, clearFlag, remove, jsonOut, err := parseAliasArgs(tt.args) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil (session=%q name=%q)", sessionArg, name) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if sessionArg != tt.wantSession { + t.Errorf("sessionArg = %q, want %q", sessionArg, tt.wantSession) + } + if name != tt.wantName { + t.Errorf("name = %q, want %q", name, tt.wantName) + } + if clearFlag != tt.wantClear { + t.Errorf("clear = %v, want %v", clearFlag, tt.wantClear) + } + if remove != tt.wantRemove { + t.Errorf("remove = %v, want %v", remove, tt.wantRemove) + } + if jsonOut != tt.wantJSON { + t.Errorf("jsonOut = %v, want %v", jsonOut, tt.wantJSON) + } + }) + } +} + +func TestRunAlias_Set(t *testing.T) { + cfg := &config.Config{} + withAliasSeams(t, cfg, []data.Session{{ID: "ses-1"}}) + + var buf bytes.Buffer + if err := runAlias(&buf, []string{"alias", "ses-1", "Review"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := cfg.AliasFor("ses-1"); got != "review" { + t.Errorf("alias = %q, want %q (normalized lowercase)", got, "review") + } + if !strings.Contains(buf.String(), "Set alias") { + t.Errorf("output = %q, want it to mention Set alias", buf.String()) + } +} + +func TestRunAlias_Reassign(t *testing.T) { + cfg := &config.Config{SessionAliases: map[string]string{"ses-1": "old"}} + withAliasSeams(t, cfg, []data.Session{{ID: "ses-1"}}) + + if err := runAlias(&bytes.Buffer{}, []string{"alias", "ses-1", "new"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := cfg.AliasFor("ses-1"); got != "new" { + t.Errorf("alias = %q, want %q", got, "new") + } +} + +func TestRunAlias_Clear(t *testing.T) { + cfg := &config.Config{SessionAliases: map[string]string{"ses-1": "review"}} + withAliasSeams(t, cfg, []data.Session{{ID: "ses-1"}}) + + var buf bytes.Buffer + if err := runAlias(&buf, []string{"alias", "ses-1", "--clear"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := cfg.AliasFor("ses-1"); got != "" { + t.Errorf("alias = %q, want empty after clear", got) + } + if !strings.Contains(buf.String(), "Cleared alias") { + t.Errorf("output = %q, want it to mention Cleared alias", buf.String()) + } +} + +func TestRunAlias_RemoveByName(t *testing.T) { + cfg := &config.Config{SessionAliases: map[string]string{"ses-1": "review"}} + withAliasSeams(t, cfg, []data.Session{{ID: "ses-1"}}) + + if err := runAlias(&bytes.Buffer{}, []string{"alias", "--remove", "review"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := cfg.AliasFor("ses-1"); got != "" { + t.Errorf("alias = %q, want empty after remove", got) + } +} + +func TestRunAlias_RemoveUnknownName(t *testing.T) { + cfg := &config.Config{SessionAliases: map[string]string{"ses-1": "review"}} + withAliasSeams(t, cfg, []data.Session{{ID: "ses-1"}}) + + err := runAlias(&bytes.Buffer{}, []string{"alias", "--remove", "nope"}) + if err == nil { + t.Fatal("expected error removing an unknown alias, got nil") + } +} + +func TestRunAlias_DuplicateRejected(t *testing.T) { + cfg := &config.Config{SessionAliases: map[string]string{"ses-1": "review"}} + withAliasSeams(t, cfg, []data.Session{{ID: "ses-1"}, {ID: "ses-2"}}) + + err := runAlias(&bytes.Buffer{}, []string{"alias", "ses-2", "review"}) + if err == nil { + t.Fatal("expected error assigning an alias already in use, got nil") + } + if got := cfg.AliasFor("ses-2"); got != "" { + t.Errorf("ses-2 alias = %q, want empty (assignment should fail)", got) + } +} + +func TestRunAlias_UnknownID(t *testing.T) { + cfg := &config.Config{} + withAliasSeams(t, cfg, []data.Session{{ID: "ses-1"}}) + + err := runAlias(&bytes.Buffer{}, []string{"alias", "missing", "review"}) + if err == nil { + t.Fatal("expected error for unknown session ID, got nil") + } + if !strings.Contains(err.Error(), "not found") { + t.Errorf("error = %q, want it to mention not found", err.Error()) + } +} + +func TestRunAlias_JSON(t *testing.T) { + cfg := &config.Config{} + withAliasSeams(t, cfg, []data.Session{{ID: "ses-1"}}) + + var buf bytes.Buffer + if err := runAlias(&buf, []string{"alias", "ses-1", "review", "--json"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var res aliasResult + if err := json.Unmarshal(buf.Bytes(), &res); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String()) + } + if res.ID != "ses-1" || res.Alias != "review" { + t.Errorf("result = %+v, want {ID:ses-1 Alias:review}", res) + } +} + +func TestRunAlias_LoadError(t *testing.T) { + prevLoad := configLoadFn + configLoadFn = func() (*config.Config, error) { return nil, errors.New("boom") } + t.Cleanup(func() { configLoadFn = prevLoad }) + + err := runAlias(&bytes.Buffer{}, []string{"alias", "ses-1", "review"}) + if err == nil { + t.Fatal("expected error when config load fails, got nil") + } +} diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index a40a730..c7e80be 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -212,6 +212,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "alias": + if aErr := runAlias(os.Stdout, args); aErr != nil { + fmt.Fprintf(os.Stderr, "alias: %v\n", aErr) + return true, cleanup, startupOptions{}, aErr + } + return true, cleanup, startupOptions{}, nil + case "prune": if pErr := runPrune(os.Stdout, args); pErr != nil { fmt.Fprintf(os.Stderr, "prune: %v\n", pErr) @@ -393,7 +400,7 @@ const bashCompletionScript = `# bash completion for dispatch _dispatch_completion() { local cur="${COMP_WORDS[COMP_CWORD]}" local bin="${COMP_WORDS[0]}" - local commands="help version open new doctor update completion stats search tags aliases compare prune tag watch config export info man" + local commands="help version open new doctor update completion stats search tags aliases alias compare prune tag watch config export info man" local flags="-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query" if [[ "${COMP_CWORD}" -eq 1 ]]; then @@ -431,7 +438,7 @@ const zshCompletionScript = `#compdef dispatch disp _dispatch_completion() { local -a commands flags configsubs shells aliases configkeys openflags newflags local bin=${words[1]} - commands=(help version open new doctor update completion stats search tags aliases compare prune tag watch config export info man) + commands=(help version open new doctor update completion stats search tags aliases alias compare prune tag watch config export info man) configsubs=(list get set unset edit path) openflags=(--mode --last --print --agent --model --yolo) newflags=(--mode --agent --model --yolo) @@ -500,7 +507,7 @@ end for bin in dispatch disp complete -c $bin -f - complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags aliases compare prune tag watch config export info man' + complete -c $bin -n '__dispatch_needs_command' -a 'help version open new doctor update completion stats search tags aliases alias compare prune tag watch config export info man' complete -c $bin -n '__dispatch_needs_command' -a '-h --help -v --version --demo --clear-cache --reindex --current --cwd --repo --branch --query' complete -c $bin -n '__dispatch_after completion' -a "($bin __complete shells)" complete -c $bin -n '__dispatch_after open' -a "($bin __complete aliases)" @@ -511,7 +518,7 @@ end ` const powershellCompletionScript = `# PowerShell completion for dispatch -$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'aliases', 'compare', 'prune', 'tag', 'watch', 'config', 'export', 'info', 'man') +$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'aliases', 'alias', 'compare', 'prune', 'tag', 'watch', 'config', 'export', 'info', 'man') $script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex', '--current', '--cwd', '--repo', '--branch', '--query') $script:DispatchConfigSubcommands = @('list', 'get', 'set', 'unset', 'edit', 'path') $script:DispatchOpenFlags = @('--mode', '--last', '--print', '--agent', '--model', '--yolo') diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 8931a16..8561c2d 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -129,6 +129,7 @@ Commands: search [query] [flags] Print matching sessions as JSON, IDs, or a table tags [--json] List tags in use with per-tag session counts aliases [--json] List session aliases with orphan detection + alias Set, reassign, clear (--clear), or remove (--remove) a session alias notes [command] List, get, set, or clear session notes views [command] List named views or set the active view config [get|set|list|edit|path] diff --git a/cmd/dispatch/man.go b/cmd/dispatch/man.go index b5b0862..88f3ce8 100644 --- a/cmd/dispatch/man.go +++ b/cmd/dispatch/man.go @@ -142,6 +142,7 @@ type manExample struct { var manExamples = []manExample{ {desc: "Launch the TUI filtered to the current repo and branch:", cmd: "dispatch --current"}, {desc: "Export a session as JSON to standard output:", cmd: "dispatch export --format json --stdout"}, + {desc: "Give a session a memorable alias for quick resume:", cmd: "dispatch alias review"}, {desc: "Install the man page for the current user:", cmd: "dispatch man > ~/.local/share/man/man1/dispatch.1"}, }