From bf31e7231b1cc16d7b20744cf06ea4ab043a370f Mon Sep 17 00:00:00 2001 From: Jon Gallant <2163001+jongio@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:30:59 -0700 Subject: [PATCH] feat(cli): add dispatch path to print a session directory Add a `dispatch path ` command that prints only a session's working directory, so you can `cd "$(dispatch path x)"`. It resolves the session the same way `dispatch open` does (full ID, alias, short ID prefix, --last, --current) by reusing open's resolver seams. It errors when the session has no recorded directory or when that directory no longer exists on disk, so `cd "$(...)"` fails loudly instead of landing in the wrong place. Wires the command into the CLI dispatch switch, the four shell completion lists, the usage banner, the man examples, the README, and the changelog. Closes #330 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/cli.go | 15 ++- cmd/dispatch/main.go | 2 + cmd/dispatch/man.go | 1 + cmd/dispatch/path.go | 166 +++++++++++++++++++++++++++++++++ cmd/dispatch/path_test.go | 188 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 385 insertions(+), 4 deletions(-) create mode 100644 cmd/dispatch/path.go create mode 100644 cmd/dispatch/path_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index cf07f27..e173ea1 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 path`** — print only a session's working directory so you can `cd "$(dispatch path )"`. Resolves the session by full ID, alias, short ID prefix, `--last`, or `--current` the same way `dispatch open` does. Errors if the session has no recorded directory or the directory no longer exists. + ## [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..97aae40 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,19 @@ dispatch info 0a1b2c3d --json The summary covers the session's repository, branch, working directory, host type, turn and file counts, timestamps, tags, alias, and any linked refs. Use `--json` for scripting. The session ID accepts the same prefix shorthand as `open`. +### Path + +Print only a session's working directory so you can change into it from a subshell: + +```sh +cd "$(dispatch path 0a1b2c3d)" # by ID or short prefix +cd "$(dispatch path my-alias)" # by alias +cd "$(dispatch path --last)" # most recently active session +cd "$(dispatch path --current)" # match the current repo and branch +``` + +`path` writes a single line (the absolute directory) and nothing else, so it drops straight into `cd`. It resolves the session the same way `open` does. It exits with an error if the session has no recorded directory or if that directory no longer exists, so `cd "$(...)"` fails loudly instead of landing somewhere wrong. + ### Aliases List every session alias with `dispatch aliases`: diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index a40a730..e91b55a 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -198,6 +198,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, startupOptions{}, nil + case "path": + if pErr := runPath(os.Stdout, args); pErr != nil { + fmt.Fprintf(os.Stderr, "path: %v\n", pErr) + return true, cleanup, startupOptions{}, pErr + } + return true, cleanup, startupOptions{}, nil + case "compare": if cErr := runCompare(os.Stdout, args); cErr != nil { fmt.Fprintf(os.Stderr, "compare: %v\n", cErr) @@ -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 compare prune tag watch config export info path 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 compare prune tag watch config export info path 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 compare prune tag watch config export info path 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', 'compare', 'prune', 'tag', 'watch', 'config', 'export', 'info', 'path', '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..511242a 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -136,6 +136,8 @@ Commands: export [flags] Export a session as Markdown, JSON, or HTML export --repo R [flags] Export all sessions matching a scope filter (batch mode) info [--json] Print a concise session summary (--json for machine-readable output) + path + Print only a session's working directory (for cd "$(dispatch path x)") compare [--json] Compare two sessions side by side tag [flags] Add, remove, set, or list tags on a session diff --git a/cmd/dispatch/man.go b/cmd/dispatch/man.go index b5b0862..4121ef8 100644 --- a/cmd/dispatch/man.go +++ b/cmd/dispatch/man.go @@ -141,6 +141,7 @@ type manExample struct { // manExamples gives a few runnable command lines. var manExamples = []manExample{ {desc: "Launch the TUI filtered to the current repo and branch:", cmd: "dispatch --current"}, + {desc: "Change into a session's working directory:", cmd: "cd \"$(dispatch path my-alias)\""}, {desc: "Export a session as JSON to standard output:", cmd: "dispatch export --format json --stdout"}, {desc: "Install the man page for the current user:", cmd: "dispatch man > ~/.local/share/man/man1/dispatch.1"}, } diff --git a/cmd/dispatch/path.go b/cmd/dispatch/path.go new file mode 100644 index 0000000..38e778b --- /dev/null +++ b/cmd/dispatch/path.go @@ -0,0 +1,166 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" +) + +// pathDirExistsFn reports whether dir exists on disk and is a directory. It is +// a package variable so tests can exercise the missing-directory branch without +// touching the filesystem, matching the seam pattern used elsewhere in this +// package (see open.go). +var pathDirExistsFn = defaultPathDirExists + +// runPath prints a single session's working directory and nothing else, so it +// drops straight into a subshell: +// +// cd "$(dispatch path my-alias)" +// +// It resolves the session the same way `dispatch open` does (full ID, alias, +// short ID prefix, --last, or --current) by reusing open's resolver seams, so +// the matching rules stay identical. The absolute path is written to w with a +// trailing newline. A session with no recorded directory, or a directory that +// no longer exists on disk, is reported to the caller as an error so +// `cd "$(...)"` fails loudly instead of landing in the wrong place. args[0] is +// expected to be "path". +func runPath(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + id, last, current, err := parsePathArgs(args) + if err != nil { + return err + } + + cfg, err := openLoadConfigFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + sess, err := resolvePathSession(cfg, id, last, current) + if err != nil { + return err + } + + dir := strings.TrimSpace(sess.Cwd) + if dir == "" { + return fmt.Errorf("session %s has no recorded directory", shortID(sess.ID)) + } + if !pathDirExistsFn(dir) { + return fmt.Errorf("directory %q for session %s no longer exists", dir, shortID(sess.ID)) + } + + fmt.Fprintln(w, dir) + return nil +} + +// parsePathArgs extracts the session ID and the --last / --current selectors +// from the path subcommand arguments. Exactly one selector is required: a +// positional ID, --last, or --current. args[0] is expected to be "path". +func parsePathArgs(args []string) (id string, last, current bool, err error) { + rest := args + if len(rest) > 0 { + rest = rest[1:] // drop the "path" token + } + + var positionals []string + for _, arg := range rest { + switch { + case arg == "--last" || arg == "-l": + last = true + case arg == "--current": + current = true + case strings.HasPrefix(arg, "-"): + return "", false, false, fmt.Errorf("unknown flag: %s", arg) + default: + positionals = append(positionals, arg) + } + } + + selectors := 0 + if last { + selectors++ + } + if current { + selectors++ + } + if len(positionals) > 0 { + selectors++ + } + + switch { + case selectors == 0: + return "", false, false, errors.New("path requires a session ID (or use --last or --current)") + case selectors > 1: + return "", false, false, errors.New("path takes a single session ID, --last, or --current, not a combination") + case len(positionals) > 1: + return "", false, false, fmt.Errorf("path accepts a single session ID, got %d arguments", len(positionals)) + } + + if len(positionals) == 1 { + id = positionals[0] + } + return id, last, current, nil +} + +// resolvePathSession loads the session named by the parsed selectors, reusing +// the same store seams as open so ID, alias, prefix, --last, and --current all +// resolve identically. +func resolvePathSession(cfg *config.Config, id string, last, current bool) (*data.Session, error) { + switch { + case last: + sess, err := openGetLastSessionFn() + if err != nil { + return nil, err + } + if sess == nil { + return nil, errors.New("no sessions found") + } + return sess, nil + + case current: + repo, branch, err := openDetectGitFn() + if err != nil { + return nil, fmt.Errorf("--current: %w", err) + } + if repo == "" && branch == "" { + return nil, errors.New("--current: could not detect a repository or branch from the current directory") + } + sessions, err := openListSessionsFn(data.FilterOptions{Repository: repo, Branch: branch}) + if err != nil { + return nil, err + } + if len(sessions) == 0 { + return nil, errors.New("no sessions found matching the current repository and branch") + } + return &sessions[0], nil + + default: + // Resolve the argument as an alias first, then fall back to treating it + // as a session ID or prefix, mirroring open. + if resolved := cfg.SessionIDForAlias(id); resolved != "" { + id = resolved + } + sess, err := openGetSessionFn(id) + if err != nil { + return nil, err + } + if sess == nil { + return nil, fmt.Errorf("session %q not found", id) + } + return sess, nil + } +} + +// defaultPathDirExists reports whether dir exists on disk and is a directory. +func defaultPathDirExists(dir string) bool { + info, err := os.Stat(dir) + return err == nil && info.IsDir() +} diff --git a/cmd/dispatch/path_test.go b/cmd/dispatch/path_test.go new file mode 100644 index 0000000..7b29d34 --- /dev/null +++ b/cmd/dispatch/path_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "bytes" + "errors" + "strings" + "testing" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/data" +) + +func TestParsePathArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantID string + wantLast bool + wantCurrent bool + wantErr bool + }{ + {name: "id only", args: []string{"path", "abc123"}, wantID: "abc123"}, + {name: "alias", args: []string{"path", "authfix"}, wantID: "authfix"}, + {name: "last", args: []string{"path", "--last"}, wantLast: true}, + {name: "last short", args: []string{"path", "-l"}, wantLast: true}, + {name: "current", args: []string{"path", "--current"}, wantCurrent: true}, + {name: "no selector", args: []string{"path"}, wantErr: true}, + {name: "id and last", args: []string{"path", "abc", "--last"}, wantErr: true}, + {name: "last and current", args: []string{"path", "--last", "--current"}, wantErr: true}, + {name: "two ids", args: []string{"path", "abc", "def"}, wantErr: true}, + {name: "unknown flag", args: []string{"path", "--nope"}, wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + id, last, current, err := parsePathArgs(tt.args) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got id=%q last=%v current=%v", id, last, current) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != tt.wantID { + t.Errorf("id = %q, want %q", id, tt.wantID) + } + if last != tt.wantLast { + t.Errorf("last = %v, want %v", last, tt.wantLast) + } + if current != tt.wantCurrent { + t.Errorf("current = %v, want %v", current, tt.wantCurrent) + } + }) + } +} + +// withPathStubs swaps the config/session/dir seams runPath depends on and +// restores them on cleanup. dirExists controls the pathDirExistsFn result. +func withPathStubs(t *testing.T, cfg *config.Config, sess *data.Session, getErr error, dirExists bool) *string { + t.Helper() + origCfg, origGet, origDir := openLoadConfigFn, openGetSessionFn, pathDirExistsFn + gotID := new(string) + openLoadConfigFn = func() (*config.Config, error) { return cfg, nil } + openGetSessionFn = func(id string) (*data.Session, error) { + *gotID = id + return sess, getErr + } + pathDirExistsFn = func(string) bool { return dirExists } + t.Cleanup(func() { + openLoadConfigFn, openGetSessionFn, pathDirExistsFn = origCfg, origGet, origDir + }) + return gotID +} + +func TestRunPath_ByID(t *testing.T) { + cfg := config.Default() + sess := &data.Session{ID: "sess-1", Cwd: "/tmp/project"} + gotID := withPathStubs(t, cfg, sess, nil, true) + + var buf bytes.Buffer + if err := runPath(&buf, []string{"path", "sess-1"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if *gotID != "sess-1" { + t.Errorf("looked up id %q, want sess-1", *gotID) + } + if got := strings.TrimSpace(buf.String()); got != "/tmp/project" { + t.Errorf("output = %q, want /tmp/project", got) + } +} + +func TestRunPath_ResolvesAlias(t *testing.T) { + cfg := config.Default() + cfg.SessionAliases = map[string]string{"sess-1": "authfix"} + sess := &data.Session{ID: "sess-1", Cwd: "/tmp/project"} + gotID := withPathStubs(t, cfg, sess, nil, true) + + if err := runPath(&bytes.Buffer{}, []string{"path", "authfix"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if *gotID != "sess-1" { + t.Errorf("resolved id = %q, want sess-1", *gotID) + } +} + +func TestRunPath_Last(t *testing.T) { + cfg := config.Default() + sess := &data.Session{ID: "sess-9", Cwd: "/tmp/last"} + origCfg, origLast, origDir := openLoadConfigFn, openGetLastSessionFn, pathDirExistsFn + openLoadConfigFn = func() (*config.Config, error) { return cfg, nil } + openGetLastSessionFn = func() (*data.Session, error) { return sess, nil } + pathDirExistsFn = func(string) bool { return true } + t.Cleanup(func() { + openLoadConfigFn, openGetLastSessionFn, pathDirExistsFn = origCfg, origLast, origDir + }) + + var buf bytes.Buffer + if err := runPath(&buf, []string{"path", "--last"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.TrimSpace(buf.String()); got != "/tmp/last" { + t.Errorf("output = %q, want /tmp/last", got) + } +} + +func TestRunPath_Current(t *testing.T) { + cfg := config.Default() + origCfg, origDetect, origList, origDir := openLoadConfigFn, openDetectGitFn, openListSessionsFn, pathDirExistsFn + openLoadConfigFn = func() (*config.Config, error) { return cfg, nil } + openDetectGitFn = func() (string, string, error) { return "owner/repo", "main", nil } + openListSessionsFn = func(f data.FilterOptions) ([]data.Session, error) { + if f.Repository != "owner/repo" || f.Branch != "main" { + t.Errorf("filter = %+v, want repo owner/repo branch main", f) + } + return []data.Session{{ID: "sess-c", Cwd: "/tmp/current"}}, nil + } + pathDirExistsFn = func(string) bool { return true } + t.Cleanup(func() { + openLoadConfigFn, openDetectGitFn, openListSessionsFn, pathDirExistsFn = origCfg, origDetect, origList, origDir + }) + + var buf bytes.Buffer + if err := runPath(&buf, []string{"path", "--current"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.TrimSpace(buf.String()); got != "/tmp/current" { + t.Errorf("output = %q, want /tmp/current", got) + } +} + +func TestRunPath_EmptyCwd(t *testing.T) { + cfg := config.Default() + sess := &data.Session{ID: "sess-1", Cwd: " "} + withPathStubs(t, cfg, sess, nil, true) + + err := runPath(&bytes.Buffer{}, []string{"path", "sess-1"}) + if err == nil || !strings.Contains(err.Error(), "no recorded directory") { + t.Fatalf("expected no-recorded-directory error, got %v", err) + } +} + +func TestRunPath_MissingDir(t *testing.T) { + cfg := config.Default() + sess := &data.Session{ID: "sess-1", Cwd: "/tmp/gone"} + withPathStubs(t, cfg, sess, nil, false) + + err := runPath(&bytes.Buffer{}, []string{"path", "sess-1"}) + if err == nil || !strings.Contains(err.Error(), "no longer exists") { + t.Fatalf("expected missing-directory error, got %v", err) + } +} + +func TestRunPath_UnknownID(t *testing.T) { + cfg := config.Default() + withPathStubs(t, cfg, nil, errors.New("not found"), true) + + if err := runPath(&bytes.Buffer{}, []string{"path", "nope"}); err == nil { + t.Fatal("expected error for unknown id") + } +} + +func TestRunPath_BadArgs(t *testing.T) { + if err := runPath(&bytes.Buffer{}, []string{"path"}); err == nil { + t.Fatal("expected error when no selector is given") + } +}