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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>)"`. 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 <cmd>`**: 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.

Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
15 changes: 11 additions & 4 deletions cmd/dispatch/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)"
Expand All @@ -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')
Expand Down
2 changes: 2 additions & 0 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ Commands:
export --repo R [flags] Export all sessions matching a scope filter (batch mode)
info <id> [--json] [--refs]
Print a concise session summary
path <id|--last|--current>
Print only a session's working directory (for cd "$(dispatch path x)")
compare <a> <b> [--json]
Compare two sessions side by side
tag <id> [flags] Add, remove, set, or list tags on a session
Expand Down
1 change: 1 addition & 0 deletions cmd/dispatch/man.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> --format json --stdout"},
{desc: "Give a session a memorable alias for quick resume:", cmd: "dispatch alias <id> review"},
{desc: "Run a command whenever a session changes attention state:", cmd: "dispatch watch --exec 'notify-send \"$DISPATCH_SESSION_STATE\"'"},
Expand Down
166 changes: 166 additions & 0 deletions cmd/dispatch/path.go
Original file line number Diff line number Diff line change
@@ -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()
}
Loading