diff --git a/README.md b/README.md index 6f8ea26..aa5d39c 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,18 @@ Both accept `--mode` to override the launch mode for that one resume (`inplace`, dispatch open --last --mode window ``` +### Start a New Session + +Start a brand-new Copilot session from the command line without opening the TUI: + +```sh +dispatch new # start in the current directory +dispatch new ~/code/app # start in a specific directory +dispatch new --mode tab # override the launch mode (inplace, tab, window, pane) +``` + +The session uses the same agent, model, and launch settings as the TUI. When no directory is given, the current working directory is used. + ### Shell Completion Print completion scripts for supported shells: diff --git a/cmd/dispatch/cli.go b/cmd/dispatch/cli.go index 66d4700..2bd96ff 100644 --- a/cmd/dispatch/cli.go +++ b/cmd/dispatch/cli.go @@ -97,6 +97,13 @@ func handleArgs(args []string, origStderr io.Writer, updateCh <-chan *update.Upd } return true, cleanup, "", nil + case "new": + if nErr := runNew(os.Stdout, args); nErr != nil { + fmt.Fprintf(os.Stderr, "new: %v\n", nErr) + return true, cleanup, "", nErr + } + return true, cleanup, "", nil + case "stats": if sErr := runStats(os.Stdout, args); sErr != nil { fmt.Fprintf(os.Stderr, "stats: %v\n", sErr) @@ -195,7 +202,7 @@ func runCompletion(w io.Writer, shell string) error { const bashCompletionScript = `# bash completion for dispatch _dispatch_completion() { local cur="${COMP_WORDS[COMP_CWORD]}" - local commands="help version open doctor update completion stats config export" + local commands="help version open new doctor update completion stats config export" local flags="-h --help -v --version --demo --clear-cache --reindex" if [[ "${COMP_CWORD}" -eq 1 ]]; then @@ -219,7 +226,7 @@ complete -F _dispatch_completion dispatch disp const zshCompletionScript = `#compdef dispatch disp _dispatch_completion() { local -a commands shells flags configsubs - commands=(help version open doctor update completion stats config export) + commands=(help version open new doctor update completion stats config export) shells=(bash zsh powershell) configsubs=(list get set path) flags=(-h --help -v --version --demo --clear-cache --reindex) @@ -243,7 +250,7 @@ _dispatch_completion "$@" ` const powershellCompletionScript = `# PowerShell completion for dispatch -$script:DispatchCommands = @('help', 'version', 'open', 'doctor', 'update', 'completion', 'stats', 'config', 'export') +$script:DispatchCommands = @('help', 'version', 'open', 'new', 'doctor', 'update', 'completion', 'stats', 'config', 'export') $script:DispatchFlags = @('-h', '--help', '-v', '--version', '--demo', '--clear-cache', '--reindex') $script:DispatchShells = @('bash', 'zsh', 'powershell') $script:DispatchConfigSubcommands = @('list', 'get', 'set', 'path') diff --git a/cmd/dispatch/main.go b/cmd/dispatch/main.go index 48a2666..8b9d157 100644 --- a/cmd/dispatch/main.go +++ b/cmd/dispatch/main.go @@ -93,6 +93,7 @@ Commands: version Print the version open [--mode M] Resume a session by ID (M: inplace, tab, window, pane) open --last [--mode M] Resume the most recently active session + new [dir] [--mode M] Start a new session in a directory (default: current) completion Print shell completion (bash, zsh, powershell) doctor [--json] Print environment diagnostics (--json for machine-readable output) stats [flags] Print session totals and breakdowns diff --git a/cmd/dispatch/new.go b/cmd/dispatch/new.go new file mode 100644 index 0000000..cd5217c --- /dev/null +++ b/cmd/dispatch/new.go @@ -0,0 +1,165 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/platform" +) + +// Function variables allow test substitution of external calls, matching the +// pattern used elsewhere in this package (see cli.go and open.go). +var ( + newLoadConfigFn = config.Load + newLaunchFn = defaultNewLaunch +) + +// runNew starts a brand-new Copilot session in a directory using the same +// launch path the TUI uses. args is the full argument slice with +// args[0] == "new". +func runNew(w io.Writer, args []string) error { + if w == nil { + w = io.Discard + } + + dir, modeFlag, err := parseNewArgs(args) + if err != nil { + return err + } + + cfg, err := newLoadConfigFn() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + mode := resolveOpenMode(modeFlag, cfg) + + resolvedDir, err := resolveNewDir(dir) + if err != nil { + return err + } + + return newLaunchFn(w, cfg, resolvedDir, mode) +} + +// parseNewArgs extracts the optional directory and launch mode from the "new" +// subcommand arguments. args[0] is expected to be "new". A missing directory +// means the current working directory. +func parseNewArgs(args []string) (dir, mode string, err error) { + rest := args + if len(rest) > 0 { + rest = rest[1:] // drop the "new" token + } + + var positionals []string + for i := 0; i < len(rest); i++ { + arg := rest[i] + switch { + case arg == "--mode" || arg == "-m": + if i+1 >= len(rest) { + return "", "", errors.New("--mode requires a value: inplace, tab, window, or pane") + } + mode = rest[i+1] + i++ + case strings.HasPrefix(arg, "--mode="): + mode = strings.TrimPrefix(arg, "--mode=") + case strings.HasPrefix(arg, "-"): + return "", "", fmt.Errorf("unknown flag: %s", arg) + default: + positionals = append(positionals, arg) + } + } + + switch len(positionals) { + case 0: + dir = "" // current directory + case 1: + dir = positionals[0] + default: + return "", "", fmt.Errorf("new accepts a single directory, got %d arguments", len(positionals)) + } + + if mode != "" { + if _, mErr := normalizeLaunchMode(mode); mErr != nil { + return "", "", mErr + } + } + return dir, mode, nil +} + +// resolveNewDir validates the target directory and returns an absolute path. +// An empty dir resolves to the current working directory. +func resolveNewDir(dir string) (string, error) { + if strings.TrimSpace(dir) == "" { + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("resolving current directory: %w", err) + } + return wd, nil + } + + info, err := os.Stat(dir) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("directory %q does not exist", dir) + } + return "", fmt.Errorf("checking directory %q: %w", dir, err) + } + if !info.IsDir() { + return "", fmt.Errorf("%q is not a directory", dir) + } + + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolving path %q: %w", dir, err) + } + return abs, nil +} + +// defaultNewLaunch starts a new session using the resolved launch mode. It +// mirrors defaultOpenLaunch but passes an empty session ID, which the platform +// resume builders treat as a new session (no --resume flag). +func defaultNewLaunch(w io.Writer, cfg *config.Config, dir string, mode string) error { + if mode == config.LaunchModeInPlace { + rc := platform.ResumeConfig{ + YoloMode: cfg.YoloMode, + Agent: cfg.Agent, + Model: cfg.Model, + CustomCommand: cfg.CustomCommand, + Cwd: dir, + } + cmd, err := platform.NewResumeCmd("", rc) + if err != nil { + return err + } + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() + } + + shell := resolveOpenShell(cfg) + if shell.Path == "" { + return errors.New("no shell detected on this system") + } + rc := platform.ResumeConfig{ + YoloMode: cfg.YoloMode, + Agent: cfg.Agent, + Model: cfg.Model, + Terminal: cfg.DefaultTerminal, + CustomCommand: cfg.CustomCommand, + Cwd: dir, + LaunchStyle: launchStyleForOpenMode(mode), + PaneDirection: cfg.EffectivePaneDirection(), + } + if err := platform.LaunchSession(shell, "", rc); err != nil { + return err + } + fmt.Fprintf(w, "Started a new session in %s\n", dir) + return nil +} diff --git a/cmd/dispatch/new_test.go b/cmd/dispatch/new_test.go new file mode 100644 index 0000000..95629c1 --- /dev/null +++ b/cmd/dispatch/new_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/jongio/dispatch/internal/config" + "github.com/jongio/dispatch/internal/update" +) + +func TestParseNewArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantDir string + wantMode string + wantErr bool + }{ + {name: "no args", args: []string{"new"}, wantDir: ""}, + {name: "dir only", args: []string{"new", "/tmp/proj"}, wantDir: "/tmp/proj"}, + {name: "mode space", args: []string{"new", "/tmp/proj", "--mode", "window"}, wantDir: "/tmp/proj", wantMode: "window"}, + {name: "mode equals", args: []string{"new", "/tmp/proj", "--mode=pane"}, wantDir: "/tmp/proj", wantMode: "pane"}, + {name: "short mode no dir", args: []string{"new", "-m", "tab"}, wantDir: "", wantMode: "tab"}, + {name: "mode before dir", args: []string{"new", "--mode", "inplace", "/x"}, wantDir: "/x", wantMode: "inplace"}, + {name: "two dirs", args: []string{"new", "a", "b"}, wantErr: true}, + {name: "unknown flag", args: []string{"new", "--nope"}, wantErr: true}, + {name: "mode without value", args: []string{"new", "--mode"}, wantErr: true}, + {name: "invalid mode", args: []string{"new", "--mode", "sideways"}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dir, mode, err := parseNewArgs(tc.args) + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got dir=%q mode=%q", dir, mode) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dir != tc.wantDir { + t.Errorf("dir = %q, want %q", dir, tc.wantDir) + } + if mode != tc.wantMode { + t.Errorf("mode = %q, want %q", mode, tc.wantMode) + } + }) + } +} + +func TestResolveNewDir(t *testing.T) { + // Existing directory resolves to an absolute path. + tmp := t.TempDir() + got, err := resolveNewDir(tmp) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !filepath.IsAbs(got) { + t.Errorf("resolveNewDir(%q) = %q, want absolute path", tmp, got) + } + + // Missing directory is a clear error. + if _, err := resolveNewDir(filepath.Join(tmp, "does-not-exist")); err == nil { + t.Error("expected error for missing directory") + } + + // A file is not a directory. + file := filepath.Join(tmp, "afile.txt") + if wErr := os.WriteFile(file, []byte("x"), 0o600); wErr != nil { + t.Fatalf("setup: %v", wErr) + } + if _, err := resolveNewDir(file); err == nil { + t.Error("expected error when target is a file, not a directory") + } +} + +// newCapture records what defaultNewLaunch would have received. +type newCapture struct { + launched bool + dir string + mode string +} + +func withNewStubs(t *testing.T, cfg *config.Config) *newCapture { + t.Helper() + capture := &newCapture{} + origCfg, origLaunch := newLoadConfigFn, newLaunchFn + newLoadConfigFn = func() (*config.Config, error) { return cfg, nil } + newLaunchFn = func(_ io.Writer, _ *config.Config, dir string, mode string) error { + capture.launched = true + capture.dir = dir + capture.mode = mode + return nil + } + t.Cleanup(func() { + newLoadConfigFn, newLaunchFn = origCfg, origLaunch + }) + return capture +} + +func TestRunNew_HappyPath(t *testing.T) { + cfg := config.Default() + cfg.LaunchMode = config.LaunchModeTab + capture := withNewStubs(t, cfg) + + tmp := t.TempDir() + if err := runNew(io.Discard, []string{"new", tmp, "--mode", "window"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !capture.launched { + t.Fatal("expected launch to be invoked") + } + if capture.mode != config.LaunchModeWindow { + t.Errorf("launched mode = %q, want %q", capture.mode, config.LaunchModeWindow) + } + if !filepath.IsAbs(capture.dir) { + t.Errorf("launched dir = %q, want absolute path", capture.dir) + } +} + +func TestRunNew_DefaultsToCurrentDir(t *testing.T) { + cfg := config.Default() + capture := withNewStubs(t, cfg) + + if err := runNew(io.Discard, []string{"new"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !capture.launched || capture.dir == "" { + t.Errorf("expected launch in current directory, got %+v", capture) + } + if capture.mode != cfg.EffectiveLaunchMode() { + t.Errorf("mode = %q, want configured default %q", capture.mode, cfg.EffectiveLaunchMode()) + } +} + +func TestRunNew_MissingDir(t *testing.T) { + withNewStubs(t, config.Default()) + tmp := t.TempDir() + if err := runNew(io.Discard, []string{"new", filepath.Join(tmp, "nope")}); err == nil { + t.Fatal("expected error for missing directory") + } +} + +func TestRunNew_BadArgs(t *testing.T) { + withNewStubs(t, config.Default()) + if err := runNew(io.Discard, []string{"new", "a", "b"}); err == nil { + t.Fatal("expected error for too many directories") + } +} + +func TestHandleArgs_New(t *testing.T) { + cfg := config.Default() + withNewStubs(t, cfg) + ch := make(chan *update.UpdateInfo, 1) + ch <- nil + + tmp := t.TempDir() + done, _, _, err := handleArgs([]string{"new", tmp}, io.Discard, ch) + if !done { + t.Error("expected done=true for new") + } + if err != nil { + t.Errorf("unexpected error: %v", err) + } +}