diff --git a/CHANGELOG.md b/CHANGELOG.md index b119bb1..72b7202 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ### 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. - **`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`. - **`dispatch watch --exec `**: run a command on each session attention transition while streaming. Session context is passed through `DISPATCH_SESSION_ID`, `DISPATCH_SESSION_STATE`, `DISPATCH_SESSION_PREV_STATE`, `DISPATCH_SESSION_REPO`, `DISPATCH_SESSION_BRANCH`, `DISPATCH_SESSION_FOLDER`, and `DISPATCH_SESSION_SUMMARY` environment variables. Hook output goes to stderr and a failing hook never stops the watch loop. diff --git a/README.md b/README.md index ed363db..0c65b90 100644 --- a/README.md +++ b/README.md @@ -356,6 +356,19 @@ dispatch info 0a1b2c3d --refs The summary covers the session's repository, branch, working directory, host type, turn and file counts, timestamps, and linked ref counts. Use `--refs` to include linked commit, PR, and issue values, or `--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 3ac4033..b1038cf 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) @@ -400,7 +407,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 notes views aliases alias compare prune tag watch config export info man" + local commands="help version open new doctor update completion stats search tags notes views aliases alias 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 @@ -438,7 +445,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 notes views aliases alias compare prune tag watch config export info man) + commands=(help version open new doctor update completion stats search tags notes views aliases alias 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) @@ -507,7 +514,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 notes views aliases alias 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 notes views aliases alias 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)" @@ -518,7 +525,7 @@ end ` const powershellCompletionScript = `# PowerShell completion for dispatch -$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'search', 'tags', 'aliases', 'alias', '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', '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 9e31c0a..d689ff5 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -138,6 +138,8 @@ Commands: export --repo R [flags] Export all sessions matching a scope filter (batch mode) info [--json] [--refs] Print a concise session summary + 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 34d3b81..c9d7a3a 100644 --- a/cmd/dispatch/man.go +++ b/cmd/dispatch/man.go @@ -142,6 +142,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: "Give a session a memorable alias for quick resume:", cmd: "dispatch alias review"}, {desc: "Run a command whenever a session changes attention state:", cmd: "dispatch watch --exec 'notify-send \"$DISPATCH_SESSION_STATE\"'"}, 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") + } +}