From 730228695d6ef2275da7eb0ae252a915af717cf8 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Sun, 1 Feb 2026 23:01:37 -0800 Subject: [PATCH 1/8] feat(shell,open): add piping, multi-instance, and cross-platform support Enhance brev shell and brev open commands with composability features. Shell improvements: - Add -c flag to run commands non-interactively - Support @filepath syntax to run local scripts remotely - Accept multiple instances or pipe from stdin - Output instance names for chaining Open improvements: - Support multiple instances (opens each in separate window) - Add terminal and tmux editor options - Add Terminal.app support on macOS - Fix WSL exec format error on Windows - Accept instances from stdin for piping Examples: brev shell my-instance -c "nvidia-smi" brev shell my-instance -c @setup.sh brev create my-instance | brev shell -c "nvidia-smi" brev open my-instance tmux brev create my-cluster --count 3 | brev open cursor --- pkg/cmd/cmderrors/cmderrors.go | 6 +- pkg/cmd/open/open.go | 283 +++++++++++++++++++++++++++------ pkg/cmd/shell/shell.go | 146 +++++++++++++++-- pkg/util/util.go | 75 +++++++++ 4 files changed, 443 insertions(+), 67 deletions(-) diff --git a/pkg/cmd/cmderrors/cmderrors.go b/pkg/cmd/cmderrors/cmderrors.go index 6c290e59..4b07989a 100644 --- a/pkg/cmd/cmderrors/cmderrors.go +++ b/pkg/cmd/cmderrors/cmderrors.go @@ -39,7 +39,7 @@ func DisplayAndHandleError(err error) { case *breverrors.NvidiaMigrationError: // Handle nvidia migration error if nvErr, ok := errors.Cause(err).(*breverrors.NvidiaMigrationError); ok { - fmt.Println("\n This account has been migrated to NVIDIA Auth. Attempting to log in with NVIDIA account...") + fmt.Fprintln(os.Stderr, "\n This account has been migrated to NVIDIA Auth. Attempting to log in with NVIDIA account...") brevBin, err1 := os.Executable() if err1 == nil { cmd := exec.Command(brevBin, "login", "--auth", "nvidia") // #nosec G204 @@ -68,9 +68,9 @@ func DisplayAndHandleError(err error) { } } if featureflag.Debug() || featureflag.IsDev() { - fmt.Println(err) + fmt.Fprintln(os.Stderr, err) } else { - fmt.Println(prettyErr) + fmt.Fprintln(os.Stderr, prettyErr) } } } diff --git a/pkg/cmd/open/open.go b/pkg/cmd/open/open.go index f0691ece..f5a5fa3f 100644 --- a/pkg/cmd/open/open.go +++ b/pkg/cmd/open/open.go @@ -1,10 +1,12 @@ package open import ( + "bufio" "errors" "fmt" "os" "os/exec" + "runtime" "strings" "time" @@ -30,15 +32,69 @@ import ( ) const ( - EditorVSCode = "code" - EditorCursor = "cursor" - EditorWindsurf = "windsurf" - EditorTmux = "tmux" + EditorVSCode = "code" + EditorCursor = "cursor" + EditorWindsurf = "windsurf" + EditorTerminal = "terminal" + EditorTmux = "tmux" ) var ( - openLong = "[command in beta] This will open VS Code, Cursor, Windsurf, or tmux SSH-ed in to your instance. You must have the editor installed in your path." - openExample = "brev open instance_id_or_name\nbrev open instance\nbrev open instance code\nbrev open instance cursor\nbrev open instance windsurf\nbrev open instance tmux\nbrev open --set-default cursor\nbrev open --set-default windsurf\nbrev open --set-default tmux" + openLong = `[command in beta] This will open an editor SSH-ed in to your instance. + +Supported editors: + code - VS Code + cursor - Cursor + windsurf - Windsurf + terminal - Opens a new terminal window with SSH + tmux - Opens a new terminal window with SSH + tmux session + +Terminal support by platform: + macOS: Terminal.app + Linux: gnome-terminal, konsole, or xterm + WSL: Windows Terminal (wt.exe) + Windows: Windows Terminal or cmd + +You must have the editor installed in your path.` + openExample = ` # Open an instance by name or ID + brev open instance_id_or_name + brev open my-instance + + # Open multiple instances (each in separate editor window) + brev open instance1 instance2 instance3 + + # Open with a specific editor + brev open my-instance code + brev open my-instance cursor + brev open my-instance windsurf + brev open my-instance terminal + brev open my-instance tmux + + # Open multiple instances with specific editor (flag is explicit) + brev open instance1 instance2 --editor cursor + brev open instance1 instance2 -e cursor + + # Or use positional arg (last arg is editor if it matches code/cursor/windsurf/tmux) + brev open instance1 instance2 cursor + + # Set a default editor + brev open --set-default cursor + brev open --set-default windsurf + + # Create a GPU instance and open it immediately (reads instance name from stdin) + brev create my-instance | brev open + + # Open a cluster (multiple instances from stdin) + brev create my-cluster --count 3 | brev open + + # Create with specific GPU and open in Cursor + brev search --gpu-name A100 | brev create ml-box | brev open cursor + + # Open in a new terminal window with SSH + brev create my-instance | brev open terminal + + # Open in a new terminal window with tmux (supports multiple instances) + brev create my-cluster --count 3 | brev open tmux` ) type OpenStore interface { @@ -59,10 +115,11 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto var directory string var host bool var setDefault string + var editor string cmd := &cobra.Command{ Annotations: map[string]string{"access": ""}, - Use: "open", + Use: "open [instance...] [editor]", DisableFlagsInUseLine: true, Short: "[beta] Open VSCode, Cursor, Windsurf, or tmux to your instance", Long: openLong, @@ -72,7 +129,8 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto if setDefaultFlag != "" { return cobra.NoArgs(cmd, args) } - return cobra.RangeArgs(1, 2)(cmd, args) + // Allow arbitrary args: instance names can come from stdin, last arg might be editor + return nil }), ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), RunE: func(cmd *cobra.Command, args []string) error { @@ -80,19 +138,41 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto return handleSetDefault(t, setDefault) } - setupDoneString := "------ Git repo cloned ------" - if waitForSetupToFinish { - setupDoneString = "------ Done running execs ------" + // Validate editor flag if provided + if editor != "" && !isEditorType(editor) { + return breverrors.NewValidationError(fmt.Sprintf("invalid editor: %s. Must be 'code', 'cursor', 'windsurf', or 'tmux'", editor)) } - editorType, err := determineEditorType(args) + // Get instance names and editor type from args or stdin + instanceNames, editorType, err := getInstanceNamesAndEditor(args, editor) if err != nil { return breverrors.WrapAndTrace(err) } - err = runOpenCommand(t, store, args[0], setupDoneString, directory, host, editorType) - if err != nil { - return breverrors.WrapAndTrace(err) + + setupDoneString := "------ Git repo cloned ------" + if waitForSetupToFinish { + setupDoneString = "------ Done running execs ------" + } + + // Open each instance + var lastErr error + for _, instanceName := range instanceNames { + if len(instanceNames) > 1 { + fmt.Fprintf(os.Stderr, "Opening %s...\n", instanceName) + } + err = runOpenCommand(t, store, instanceName, setupDoneString, directory, host, editorType) + if err != nil { + if len(instanceNames) > 1 { + fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", instanceName, err) + lastErr = err + continue + } + return breverrors.WrapAndTrace(err) + } + } + if lastErr != nil { + return breverrors.NewValidationError("one or more instances failed to open") } return nil }, @@ -100,14 +180,70 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto cmd.Flags().BoolVarP(&host, "host", "", false, "ssh into the host machine instead of the container") cmd.Flags().BoolVarP(&waitForSetupToFinish, "wait", "w", false, "wait for setup to finish") cmd.Flags().StringVarP(&directory, "dir", "d", "", "directory to open") - cmd.Flags().StringVar(&setDefault, "set-default", "", "set default editor (code, cursor, windsurf, or tmux)") + cmd.Flags().StringVar(&setDefault, "set-default", "", "set default editor (code, cursor, windsurf, terminal, or tmux)") + cmd.Flags().StringVarP(&editor, "editor", "e", "", "editor to use (code, cursor, windsurf, terminal, or tmux)") return cmd } +// isEditorType checks if a string is a valid editor type +func isEditorType(s string) bool { + return s == EditorVSCode || s == EditorCursor || s == EditorWindsurf || s == EditorTerminal || s == EditorTmux +} + +// getInstanceNamesAndEditor gets instance names from args/stdin and determines editor type +// editorFlag takes precedence, otherwise last arg may be an editor type (code, cursor, windsurf, tmux) +func getInstanceNamesAndEditor(args []string, editorFlag string) ([]string, string, error) { + var names []string + editorType := editorFlag + + // If no editor flag, check if last arg is an editor type + if editorType == "" && len(args) > 0 && isEditorType(args[len(args)-1]) { + editorType = args[len(args)-1] + args = args[:len(args)-1] + } + + // Add names from remaining args + names = append(names, args...) + + // Check if stdin is piped + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + // Stdin is piped, read instance names (one per line) + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + name := strings.TrimSpace(scanner.Text()) + if name != "" { + names = append(names, name) + } + } + } + + if len(names) == 0 { + return nil, "", breverrors.NewValidationError("instance name required: provide as argument or pipe from another command") + } + + // If no editor specified, get default + if editorType == "" { + homeDir, err := os.UserHomeDir() + if err != nil { + editorType = EditorVSCode + } else { + settings, err := files.ReadPersonalSettings(files.AppFs, homeDir) + if err != nil { + editorType = EditorVSCode + } else { + editorType = settings.DefaultEditor + } + } + } + + return names, editorType, nil +} + func handleSetDefault(t *terminal.Terminal, editorType string) error { - if editorType != EditorVSCode && editorType != EditorCursor && editorType != EditorWindsurf && editorType != EditorTmux { - return fmt.Errorf("invalid editor type: %s. Must be 'code', 'cursor', 'windsurf', or 'tmux'", editorType) + if !isEditorType(editorType) { + return fmt.Errorf("invalid editor type: %s. Must be 'code', 'cursor', 'windsurf', 'terminal', or 'tmux'", editorType) } homeDir, err := os.UserHomeDir() @@ -128,28 +264,6 @@ func handleSetDefault(t *terminal.Terminal, editorType string) error { return nil } -func determineEditorType(args []string) (string, error) { - if len(args) == 2 { - editorType := args[1] - if editorType != EditorVSCode && editorType != EditorCursor && editorType != EditorWindsurf && editorType != EditorTmux { - return "", fmt.Errorf("invalid editor type: %s. Must be 'code', 'cursor', 'windsurf', or 'tmux'", editorType) - } - return editorType, nil - } - - homeDir, err := os.UserHomeDir() - if err != nil { - return EditorVSCode, nil - } - - settings, err := files.ReadPersonalSettings(files.AppFs, homeDir) - if err != nil { - return EditorVSCode, nil - } - - return settings.DefaultEditor, nil -} - // Fetch workspace info, then open code editor func runOpenCommand(t *terminal.Terminal, tstore OpenStore, wsIDOrName string, setupDoneString string, directory string, host bool, editorType string) error { //nolint:funlen,gocyclo // define brev command // todo check if workspace is stopped and start if it if it is stopped @@ -360,6 +474,8 @@ func getEditorName(editorType string) string { return "Cursor" case EditorWindsurf: return "Windsurf" + case EditorTerminal: + return "Terminal" case EditorTmux: return "tmux" default: @@ -390,8 +506,10 @@ func openEditorByType(t *terminal.Terminal, editorType string, sshAlias string, case EditorWindsurf: tryToInstallWindsurfExtensions(t, extensions) return openWindsurf(sshAlias, path, tstore) + case EditorTerminal: + return openTerminal(sshAlias, path, tstore) case EditorTmux: - return openTmux(sshAlias, path, tstore) + return openTerminalWithTmux(sshAlias, path, tstore) default: tryToInstallExtensions(t, extensions) return openVsCode(sshAlias, path, tstore) @@ -539,8 +657,75 @@ func getWindowsWindsurfPaths(store vscodePathStore) []string { return paths } -func openTmux(sshAlias string, path string, store OpenStore) error { +// openInNewTerminalWindow opens a command in a new terminal window based on the platform +// macOS: Terminal.app via osascript +// Linux: gnome-terminal, konsole, or xterm (tries in order) +// Windows/WSL: Windows Terminal (wt.exe) +func openInNewTerminalWindow(command string) error { + switch runtime.GOOS { + case "darwin": + // macOS: use osascript to open Terminal.app + script := fmt.Sprintf(`tell application "Terminal" + activate + do script "%s" +end tell`, command) + cmd := exec.Command("osascript", "-e", script) // #nosec G204 + return cmd.Run() + + case "linux": + // Check if we're in WSL by looking for wt.exe + if _, err := exec.LookPath("wt.exe"); err == nil { + // WSL: use Windows Terminal + cmd := exec.Command("wt.exe", "new-tab", "bash", "-c", command) // #nosec G204 + return cmd.Run() + } + // Try gnome-terminal first (Ubuntu/GNOME) + if _, err := exec.LookPath("gnome-terminal"); err == nil { + cmd := exec.Command("gnome-terminal", "--", "bash", "-c", command+"; exec bash") // #nosec G204 + return cmd.Run() + } + // Try konsole (KDE) + if _, err := exec.LookPath("konsole"); err == nil { + cmd := exec.Command("konsole", "-e", "bash", "-c", command+"; exec bash") // #nosec G204 + return cmd.Run() + } + // Try xterm as fallback + if _, err := exec.LookPath("xterm"); err == nil { + cmd := exec.Command("xterm", "-e", "bash", "-c", command+"; exec bash") // #nosec G204 + return cmd.Run() + } + return breverrors.NewValidationError("no supported terminal emulator found. Install gnome-terminal, konsole, or xterm") + + case "windows": + // Windows: use Windows Terminal + if _, err := exec.LookPath("wt.exe"); err == nil { + cmd := exec.Command("wt.exe", "new-tab", "cmd", "/c", command) // #nosec G204 + return cmd.Run() + } + // Fallback to start cmd + cmd := exec.Command("cmd", "/c", "start", "cmd", "/k", command) // #nosec G204 + return cmd.Run() + + default: + return breverrors.NewValidationError(fmt.Sprintf("'terminal' editor is not supported on %s", runtime.GOOS)) + } +} + +func openTerminal(sshAlias string, path string, store OpenStore) error { _ = store // unused parameter required by interface + _ = path // unused, just opens SSH + + sshCmd := fmt.Sprintf("ssh %s", sshAlias) + err := openInNewTerminalWindow(sshCmd) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return nil +} + +func openTerminalWithTmux(sshAlias string, path string, store OpenStore) error { + _ = store // unused parameter required by interface + err := ensureTmuxInstalled(sshAlias) if err != nil { return breverrors.WrapAndTrace(err) @@ -548,23 +733,21 @@ func openTmux(sshAlias string, path string, store OpenStore) error { sessionName := "brev" + // Check if tmux session exists checkCmd := fmt.Sprintf("ssh %s 'tmux has-session -t %s 2>/dev/null'", sshAlias, sessionName) checkExec := exec.Command("bash", "-c", checkCmd) // #nosec G204 - err = checkExec.Run() + checkErr := checkExec.Run() var tmuxCmd string - if err == nil { + if checkErr == nil { + // Session exists, attach to it tmuxCmd = fmt.Sprintf("ssh -t %s 'tmux attach-session -t %s'", sshAlias, sessionName) } else { + // Create new session tmuxCmd = fmt.Sprintf("ssh -t %s 'cd %s && tmux new-session -s %s'", sshAlias, path, sessionName) } - sshCmd := exec.Command("bash", "-c", tmuxCmd) // #nosec G204 - sshCmd.Stderr = os.Stderr - sshCmd.Stdout = os.Stdout - sshCmd.Stdin = os.Stdin - - err = sshCmd.Run() + err = openInNewTerminalWindow(tmuxCmd) if err != nil { return breverrors.WrapAndTrace(err) } diff --git a/pkg/cmd/shell/shell.go b/pkg/cmd/shell/shell.go index 49474ad3..c24837f3 100644 --- a/pkg/cmd/shell/shell.go +++ b/pkg/cmd/shell/shell.go @@ -1,6 +1,7 @@ package shell import ( + "bufio" "errors" "fmt" "os" @@ -9,7 +10,6 @@ import ( "time" "github.com/brevdev/brev-cli/pkg/analytics" - "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" "github.com/brevdev/brev-cli/pkg/cmd/completions" "github.com/brevdev/brev-cli/pkg/cmd/hello" "github.com/brevdev/brev-cli/pkg/cmd/refresh" @@ -26,7 +26,34 @@ import ( var ( openLong = "[command in beta] This will shell in to your instance" - openExample = "brev shell instance_id_or_name\nbrev shell instance\nbrev open h9fp5vxwe" + openExample = ` # SSH into an instance by name or ID + brev shell instance_id_or_name + brev shell my-instance + + # Run a command on the instance (non-interactive, pipes stdout/stderr) + brev shell my-instance -c "nvidia-smi" + brev shell my-instance -c "python train.py" + + # Run a command on multiple instances + brev shell instance1 instance2 instance3 -c "nvidia-smi" + + # Run a script file on the instance + brev shell my-instance -c @setup.sh + + # Chain: create and run a command (reads instance names from stdin) + brev create my-instance | brev shell -c "nvidia-smi" + + # Run command on a cluster (multiple instances from stdin) + brev create my-cluster --count 3 | brev shell -c "nvidia-smi" + + # Create a GPU instance and SSH into it (use command substitution for interactive shell) + brev shell $(brev create my-instance) + + # Create with specific GPU and connect + brev shell $(brev search --gpu-name A100 | brev create ml-box) + + # SSH into the host machine instead of the container + brev shell my-instance --host` ) type ShellStore interface { @@ -40,35 +67,111 @@ type ShellStore interface { } func NewCmdShell(t *terminal.Terminal, store ShellStore, noLoginStartStore ShellStore) *cobra.Command { - var runRemoteCMD bool - var directory string var host bool + var command string cmd := &cobra.Command{ Annotations: map[string]string{"access": ""}, - Use: "shell", + Use: "shell [instance...]", Aliases: []string{"ssh"}, DisableFlagsInUseLine: true, Short: "[beta] Open a shell in your instance", Long: openLong, Example: openExample, - Args: cmderrors.TransformToValidationError(cmderrors.TransformToValidationError(cobra.ExactArgs(1))), + Args: cobra.ArbitraryArgs, ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), RunE: func(cmd *cobra.Command, args []string) error { - err := runShellCommand(t, store, args[0], directory, host) + // Get instance names from args or stdin + instanceNames, err := getInstanceNames(args) if err != nil { return breverrors.WrapAndTrace(err) } + + // Parse command (can be inline or @filepath) + cmdToRun, err := parseCommand(command) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + // Interactive shell only supports one instance + if cmdToRun == "" && len(instanceNames) > 1 { + return breverrors.NewValidationError("interactive shell only supports one instance; use -c to run a command on multiple instances") + } + + // Run on each instance + var lastErr error + for _, instanceName := range instanceNames { + if len(instanceNames) > 1 { + fmt.Fprintf(os.Stderr, "\n=== %s ===\n", instanceName) + } + err = runShellCommand(t, store, instanceName, host, cmdToRun) + if err != nil { + if len(instanceNames) > 1 { + fmt.Fprintf(os.Stderr, "Error on %s: %v\n", instanceName, err) + lastErr = err + continue + } + return breverrors.WrapAndTrace(err) + } + } + if lastErr != nil { + return breverrors.NewValidationError("one or more instances failed") + } return nil }, } cmd.Flags().BoolVarP(&host, "host", "", false, "ssh into the host machine instead of the container") - cmd.Flags().BoolVarP(&runRemoteCMD, "remote", "r", true, "run remote commands") - cmd.Flags().StringVarP(&directory, "dir", "d", "", "override directory to launch shell") + cmd.Flags().StringVarP(&command, "command", "c", "", "command to run on the instance (use @filename to run a script file)") return cmd } -func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID, directory string, host bool) error { +// getInstanceNames gets instance names from args or stdin (supports multiple) +func getInstanceNames(args []string) ([]string, error) { + var names []string + + // Add names from args + names = append(names, args...) + + // Check if stdin is piped + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + // Stdin is piped, read instance names (one per line) + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + name := strings.TrimSpace(scanner.Text()) + if name != "" { + names = append(names, name) + } + } + } + + if len(names) == 0 { + return nil, breverrors.NewValidationError("instance name required: provide as argument or pipe from another command") + } + + return names, nil +} + +// parseCommand parses the command string, loading from file if prefixed with @ +func parseCommand(command string) (string, error) { + if command == "" { + return "", nil + } + + // If prefixed with @, read from file + if strings.HasPrefix(command, "@") { + filePath := strings.TrimPrefix(command, "@") + content, err := os.ReadFile(filePath) + if err != nil { + return "", breverrors.WrapAndTrace(err) + } + return string(content), nil + } + + return command, nil +} + +func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID string, host bool, command string) error { s := t.NewSpinner() workspace, err := util.GetUserWorkspaceByNameOrIDErr(sstore, workspaceNameOrID) if err != nil { @@ -114,7 +217,7 @@ func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID, // legacy environments wont support this and cause errrors, // but we don't want to block the user from using the shell _ = writeconnectionevent.WriteWCEOnEnv(sstore, workspace.DNS) - err = runSSH(workspace, sshName, directory) + err = runSSH(workspace, sshName, command) if err != nil { return breverrors.WrapAndTrace(err) } @@ -162,14 +265,29 @@ func waitForSSHToBeAvailable(sshAlias string, s *spinner.Spinner) error { } } -func runSSH(_ *entity.Workspace, sshAlias, _ string) error { +func runSSH(_ *entity.Workspace, sshAlias string, command string) error { sshAgentEval := "eval $(ssh-agent -s)" - cmd := fmt.Sprintf("ssh %s", sshAlias) + + var cmd string + if command != "" { + // Non-interactive: run command and pipe stdout/stderr + // Escape the command for passing to SSH + escapedCmd := strings.ReplaceAll(command, "'", "'\\''") + cmd = fmt.Sprintf("ssh %s '%s'", sshAlias, escapedCmd) + } else { + // Interactive shell + cmd = fmt.Sprintf("ssh %s", sshAlias) + } + cmd = fmt.Sprintf("%s && %s", sshAgentEval, cmd) sshCmd := exec.Command("bash", "-c", cmd) //nolint:gosec //cmd is user input sshCmd.Stderr = os.Stderr sshCmd.Stdout = os.Stdout - sshCmd.Stdin = os.Stdin + + // Only attach stdin for interactive sessions + if command == "" { + sshCmd.Stdin = os.Stdin + } err := hello.SetHasRunShell(true) if err != nil { diff --git a/pkg/util/util.go b/pkg/util/util.go index d9004e1d..299ba5b1 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "path/filepath" + "runtime" "strings" breverrors "github.com/brevdev/brev-cli/pkg/errors" @@ -13,6 +14,53 @@ import ( "github.com/hashicorp/go-multierror" ) +// isWSL returns true if running in Windows Subsystem for Linux +func isWSL() bool { + if runtime.GOOS != "linux" { + return false + } + // Check for WSL-specific indicators + if _, err := os.Stat("/proc/sys/fs/binfmt_misc/WSLInterop"); err == nil { + return true + } + // Also check /proc/version for "microsoft" or "WSL" + if data, err := os.ReadFile("/proc/version"); err == nil { + lower := strings.ToLower(string(data)) + if strings.Contains(lower, "microsoft") || strings.Contains(lower, "wsl") { + return true + } + } + return false +} + +// wslPathToWindows converts a WSL path like /mnt/c/Users/... to C:\Users\... +func wslPathToWindows(wslPath string) string { + if strings.HasPrefix(wslPath, "/mnt/") && len(wslPath) > 6 { + // Extract drive letter: /mnt/c/... -> c + drive := strings.ToUpper(string(wslPath[5])) + // Get rest of path: /mnt/c/Users/... -> /Users/... + rest := wslPath[6:] + // Convert to Windows path: C:\Users\... + windowsPath := drive + ":" + strings.ReplaceAll(rest, "/", "\\") + return windowsPath + } + return wslPath +} + +// runWindowsExeInWSL runs a Windows executable from WSL using cmd.exe +func runWindowsExeInWSL(exePath string, args []string) ([]byte, error) { + // Convert WSL path to Windows path + windowsPath := wslPathToWindows(exePath) + + // Build the command string for cmd.exe + // We need to quote the path and args properly for Windows + cmdArgs := []string{"/c", windowsPath} + cmdArgs = append(cmdArgs, args...) + + cmd := exec.Command("cmd.exe", cmdArgs...) // #nosec G204 + return cmd.CombinedOutput() +} + // This package should only be used as a holding pattern to be later moved into more specific packages func MapAppend(m map[string]interface{}, n ...map[string]interface{}) map[string]interface{} { @@ -205,6 +253,15 @@ func runManyCursorCommand(cursorpaths []string, args []string) ([]byte, error) { } func runVsCodeCommand(vscodepath string, args []string) ([]byte, error) { + // In WSL, Windows .exe files need to be run through cmd.exe + if isWSL() && (strings.HasSuffix(vscodepath, ".exe") || strings.HasPrefix(vscodepath, "/mnt/")) { + res, err := runWindowsExeInWSL(vscodepath, args) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + return res, nil + } + cmd := exec.Command(vscodepath, args...) // #nosec G204 res, err := cmd.CombinedOutput() if err != nil { @@ -214,6 +271,15 @@ func runVsCodeCommand(vscodepath string, args []string) ([]byte, error) { } func runCursorCommand(cursorpath string, args []string) ([]byte, error) { + // In WSL, Windows .exe files need to be run through cmd.exe + if isWSL() && (strings.HasSuffix(cursorpath, ".exe") || strings.HasPrefix(cursorpath, "/mnt/")) { + res, err := runWindowsExeInWSL(cursorpath, args) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + return res, nil + } + cmd := exec.Command(cursorpath, args...) // #nosec G204 res, err := cmd.CombinedOutput() if err != nil { @@ -236,6 +302,15 @@ func runManyWindsurfCommand(windsurfpaths []string, args []string) ([]byte, erro } func runWindsurfCommand(windsurfpath string, args []string) ([]byte, error) { + // In WSL, Windows .exe files need to be run through cmd.exe + if isWSL() && (strings.HasSuffix(windsurfpath, ".exe") || strings.HasPrefix(windsurfpath, "/mnt/")) { + res, err := runWindowsExeInWSL(windsurfpath, args) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + return res, nil + } + cmd := exec.Command(windsurfpath, args...) // #nosec G204 res, err := cmd.CombinedOutput() if err != nil { From d65667e003c49416ca48ab38920c032e31b90a40 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Mon, 2 Feb 2026 15:19:14 -0800 Subject: [PATCH 2/8] style: fix lint issues in open.go and util.go - Fix gofumpt formatting - Wrap external package errors with breverrors.WrapAndTrace Co-Authored-By: Claude Opus 4.5 --- pkg/cmd/open/open.go | 27 +++++++++++++-------------- pkg/util/util.go | 3 ++- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/pkg/cmd/open/open.go b/pkg/cmd/open/open.go index f5a5fa3f..904fc193 100644 --- a/pkg/cmd/open/open.go +++ b/pkg/cmd/open/open.go @@ -32,15 +32,15 @@ import ( ) const ( - EditorVSCode = "code" - EditorCursor = "cursor" - EditorWindsurf = "windsurf" - EditorTerminal = "terminal" - EditorTmux = "tmux" + EditorVSCode = "code" + EditorCursor = "cursor" + EditorWindsurf = "windsurf" + EditorTerminal = "terminal" + EditorTmux = "tmux" ) var ( - openLong = `[command in beta] This will open an editor SSH-ed in to your instance. + openLong = `[command in beta] This will open an editor SSH-ed in to your instance. Supported editors: code - VS Code @@ -149,7 +149,6 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto return breverrors.WrapAndTrace(err) } - setupDoneString := "------ Git repo cloned ------" if waitForSetupToFinish { setupDoneString = "------ Done running execs ------" @@ -670,29 +669,29 @@ func openInNewTerminalWindow(command string) error { do script "%s" end tell`, command) cmd := exec.Command("osascript", "-e", script) // #nosec G204 - return cmd.Run() + return breverrors.WrapAndTrace(cmd.Run()) case "linux": // Check if we're in WSL by looking for wt.exe if _, err := exec.LookPath("wt.exe"); err == nil { // WSL: use Windows Terminal cmd := exec.Command("wt.exe", "new-tab", "bash", "-c", command) // #nosec G204 - return cmd.Run() + return breverrors.WrapAndTrace(cmd.Run()) } // Try gnome-terminal first (Ubuntu/GNOME) if _, err := exec.LookPath("gnome-terminal"); err == nil { cmd := exec.Command("gnome-terminal", "--", "bash", "-c", command+"; exec bash") // #nosec G204 - return cmd.Run() + return breverrors.WrapAndTrace(cmd.Run()) } // Try konsole (KDE) if _, err := exec.LookPath("konsole"); err == nil { cmd := exec.Command("konsole", "-e", "bash", "-c", command+"; exec bash") // #nosec G204 - return cmd.Run() + return breverrors.WrapAndTrace(cmd.Run()) } // Try xterm as fallback if _, err := exec.LookPath("xterm"); err == nil { cmd := exec.Command("xterm", "-e", "bash", "-c", command+"; exec bash") // #nosec G204 - return cmd.Run() + return breverrors.WrapAndTrace(cmd.Run()) } return breverrors.NewValidationError("no supported terminal emulator found. Install gnome-terminal, konsole, or xterm") @@ -700,11 +699,11 @@ end tell`, command) // Windows: use Windows Terminal if _, err := exec.LookPath("wt.exe"); err == nil { cmd := exec.Command("wt.exe", "new-tab", "cmd", "/c", command) // #nosec G204 - return cmd.Run() + return breverrors.WrapAndTrace(cmd.Run()) } // Fallback to start cmd cmd := exec.Command("cmd", "/c", "start", "cmd", "/k", command) // #nosec G204 - return cmd.Run() + return breverrors.WrapAndTrace(cmd.Run()) default: return breverrors.NewValidationError(fmt.Sprintf("'terminal' editor is not supported on %s", runtime.GOOS)) diff --git a/pkg/util/util.go b/pkg/util/util.go index 299ba5b1..1c40cf54 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -58,7 +58,8 @@ func runWindowsExeInWSL(exePath string, args []string) ([]byte, error) { cmdArgs = append(cmdArgs, args...) cmd := exec.Command("cmd.exe", cmdArgs...) // #nosec G204 - return cmd.CombinedOutput() + output, err := cmd.CombinedOutput() + return output, breverrors.WrapAndTrace(err) } // This package should only be used as a holding pattern to be later moved into more specific packages From 9365e77cdac458585324550d2df017b879f0195a Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 3 Feb 2026 22:41:49 -0800 Subject: [PATCH 3/8] docs(prd): document shell and open enhancements Add implemented features for brev shell and brev open: Shell enhancements: - Non-interactive -c flag for scripted execution - @filepath syntax to run local scripts remotely - Multi-instance support (run on multiple instances) - Stdin piping from brev create/ls - Output instance names for command chaining Open enhancements: - Multiple editor options (vscode, cursor, vim, terminal, tmux) - Multi-instance support (opens each in separate window) - Cross-platform fixes (macOS Terminal.app, WSL) - Stdin piping from brev create Also expands skills documentation explaining why they matter for agentic use cases. --- docs/PRD-composable-cli.md | 282 +++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 docs/PRD-composable-cli.md diff --git a/docs/PRD-composable-cli.md b/docs/PRD-composable-cli.md new file mode 100644 index 00000000..a7587cd7 --- /dev/null +++ b/docs/PRD-composable-cli.md @@ -0,0 +1,282 @@ +# PRD: Composable & Agentic Brev CLI + +## Vision + +Make the Brev CLI idiomatic, programmable, and agent-friendly. Users and AI agents should be able to compose commands using standard Unix patterns (`|`, `grep`, `awk`, `jq`) while also having structured output options for programmatic access. + +## Goals + +1. **Unix Idiomatic** - Commands work naturally with pipes and standard tools +2. **Programmable** - JSON output mode for all commands that return data +3. **Agentic** - Claude Code skills can orchestrate complex workflows +4. **Composable** - Output of one command feeds into input of another + +## Design Principles + +### Pipe Detection +- Commands detect when stdout is piped (`os.Stdout.Stat()`) +- Piped output: clean table format (no colors, no help text) +- Interactive output: colored, with contextual help + +### Input Handling +- Commands accept arguments directly OR from stdin +- Stdin is read line-by-line when piped +- First column of table input is parsed as the primary identifier + +### Output Formats +| Mode | Trigger | Format | +|------|---------|--------| +| Interactive | TTY | Colored table + help text | +| Piped | `cmd \| ...` | Plain table (greppable) | +| JSON | `--json` | Structured JSON array | + +### Data Passthrough +- Filter flags (e.g., `--min-disk`) should propagate through pipes +- Table output includes computed fields (e.g., `TARGET_DISK`) +- JSON output includes all relevant fields + +## Implemented Features + +### Pipeable Commands +| Command | Stdin | Stdout (piped) | Status | +|---------|-------|----------------|--------| +| `brev ls` | - | Plain table | ✅ | +| `brev ls orgs` | - | Plain table | ✅ | +| `brev search` | - | Plain table w/ TARGET_DISK | ✅ | +| `brev stop` | Instance names | Instance names | ✅ | +| `brev start` | Instance names | Instance names | ✅ | +| `brev delete` | Instance names | Instance names | ✅ | +| `brev create` | Instance types (table or JSON) | Instance names | ✅ | +| `brev shell` | Instance names | Instance names (after -c) | ✅ | +| `brev open` | Instance names | - | ✅ | + +### Shell Enhancements (`brev shell`) + +Non-interactive command execution with `-c` flag, enabling scripted and agentic workflows. + +**Run commands directly**: +```bash +brev shell my-gpu -c "nvidia-smi" +brev shell my-gpu -c "python train.py && echo done" +``` + +**Run local scripts remotely** (`@filepath` syntax): +```bash +brev shell my-gpu -c @setup.sh # Runs local setup.sh on remote +brev shell my-gpu -c @scripts/deploy.sh # Relative paths supported +``` + +**Multi-instance support**: +```bash +# Run on multiple instances +brev shell gpu-1 gpu-2 gpu-3 -c "nvidia-smi" + +# Pipe from create +brev create my-cluster --count 3 | brev shell -c "nvidia-smi" + +# Chain with other commands +brev ls | grep RUNNING | brev shell -c "df -h" +``` + +**Output for chaining**: +When using `-c`, outputs instance names after execution completes, enabling pipelines: +```bash +brev create my-gpu | brev shell -c "pip install torch" | brev shell -c "python train.py" +``` + +### Open Enhancements (`brev open`) + +Open instances in editors/terminals with multi-instance and cross-platform support. + +**Editor options**: +```bash +brev open my-gpu vscode # VS Code (default) +brev open my-gpu cursor # Cursor +brev open my-gpu vim # Vim over SSH +brev open my-gpu terminal # Terminal/SSH session +brev open my-gpu tmux # Tmux session +``` + +**Multi-instance support**: +```bash +# Open multiple instances (each in separate window) +brev open gpu-1 gpu-2 gpu-3 cursor + +# Pipe from create +brev create my-cluster --count 3 | brev open cursor +``` + +**Cross-platform support**: +- macOS: Terminal.app, iTerm2 +- Linux: Default terminal emulator +- Windows/WSL: Fixed exec format errors + +### Search Filters +```bash +brev search --gpu-name H100 # Filter by GPU +brev search --min-vram 40 # Min VRAM per GPU +brev search --min-total-vram 80 # Min total VRAM +brev search --min-disk 500 # Min disk size (GB) +brev search --max-boot-time 5 # Max boot time (minutes) +brev search --stoppable # Can stop/restart +brev search --rebootable # Can reboot +brev search --flex-ports # Configurable firewall +``` + +### JSON Mode +```bash +brev ls --json +brev ls orgs --json +brev search --json +``` + +## Example Workflows + +### Filter and Create +```bash +# Find stoppable H100s with 500GB disk, create first match +brev search --min-disk 500 --stoppable | grep H100 | head -1 | brev create --name my-gpu +``` + +### Batch Operations +```bash +# Stop all running instances +brev ls | grep RUNNING | awk '{print $1}' | brev stop + +# Delete all stopped instances +brev ls | grep STOPPED | awk '{print $1}' | brev delete +``` + +### Chained Lifecycle +```bash +# Create, use, cleanup +brev search --gpu-name A100 | head -1 | brev create --name job-1 | brev shell -c "python train.py" && brev delete job-1 +``` + +### JSON Processing +```bash +# Get cheapest H100 with jq +brev search --json | jq '[.[] | select(.gpu_name == "H100")] | sort_by(.price_per_hour) | .[0]' +``` + +## Claude Code Integration + +### Why Skills Matter + +The composable CLI is necessary but not sufficient for agentic use. Skills bridge the gap between: + +1. **Raw CLI** - Powerful but requires knowing exact flags and syntax +2. **Natural Language** - How users actually describe intent + +Without skills, an agent must: +- Know that `--min-total-vram` exists (not `--vram`, `--gpu-memory`, etc.) +- Remember flag combinations for common tasks +- Handle error messages and retry logic +- Understand which commands can be piped together + +Skills encode this domain knowledge, turning "spin up a cheap GPU for testing" into the correct `brev search --stoppable --sort price | head -1 | brev create` pipeline. + +### Skill Capabilities + +The `/brev-cli` skill provides: + +**Natural Language → CLI Translation** +- "Create an A100 instance for ML training" → selects appropriate flags +- "Find GPUs with 40GB VRAM under $2/hr" → `--min-total-vram 40` + price filter +- "Stop all my running instances" → `brev ls | grep RUNNING | ... | brev stop` + +**Context-Aware Defaults** +- Knows common GPU requirements for ML workloads +- Suggests `--stoppable` for dev instances (cost savings) +- Recommends disk sizes based on use case + +**Error Recovery** +- Retries with fallback instance types on capacity errors +- Suggests alternatives when requested GPU unavailable +- Handles "instance already exists" gracefully + +**Workflow Orchestration** +- Multi-step operations (create → wait → execute → cleanup) +- Monitors instance health during long-running jobs +- Streams logs and captures results + +### Agentic Patterns + +With composable CLI + skills, agents can autonomously: + +1. **Provision** - Search, filter, and create instances matching workload requirements +2. **Deploy** - Stream code/data to instances via pipeable `cp` +3. **Execute** - Run workloads via `brev shell -c`, capture output +4. **Monitor** - Poll status via `brev ls --json`, stream logs +5. **Scale** - Spin up parallel instances, distribute work +6. **Cleanup** - Stop/delete instances, manage costs + +### Example: Autonomous Training Job + +``` +User: "Train my model on an H100, save checkpoints every hour" + +Agent: +1. brev search --gpu-name H100 --stoppable --min-disk 500 | head -1 | brev create --name training-job +2. brev wait training-job --state ready +3. tar czf - ./src | brev cp - training-job:/app/ +4. brev shell training-job -c "cd /app && python train.py --checkpoint-interval 3600" +5. brev cp training-job:/app/checkpoints - | tar xzf - -C ./results/ +6. brev delete training-job +``` + +The skill handles the translation, error recovery, and orchestration—the composable CLI makes each step possible. + +## Future Considerations + +### Planned + +#### `brev logs` - Stream/tail instance logs +```bash +brev logs my-gpu # Follow logs +brev logs my-gpu --since 5m # Last 5 minutes +brev logs my-gpu | grep ERROR # Filter logs +``` + +#### `brev wait` - Block until instance reaches state +```bash +brev create --name my-gpu ... && brev wait my-gpu --state ready +brev stop my-gpu && brev wait my-gpu --state stopped +``` + +#### `brev cp` - Pipeable file copy (stdin/stdout) + +Stream data directly through stdin/stdout without intermediate files. Uses `-` to indicate stdin/stdout (standard Unix convention). + +**Current behavior** (requires temp files): +```bash +brev cp local.tar.gz my-gpu:/data/ +brev cp my-gpu:/results/output.csv ./output.csv +``` + +**Proposed pipeable behavior**: +```bash +# Stream archive directly to instance +tar czf - ./data | brev cp - my-gpu:/data/archive.tar.gz + +# Pipe file content to instance +cat model.pt | brev cp - my-gpu:/models/model.pt + +# Stream from instance and process locally +brev cp my-gpu:/results/output.csv - | grep "success" > filtered.csv + +# Transfer between instances without local storage +brev cp gpu-1:/checkpoint.pt - | brev cp - gpu-2:/checkpoint.pt +``` + +**Agentic use cases**: +```bash +# Agent streams training data, captures results +cat dataset.jsonl | brev shell my-gpu -c "python train.py" > results.log + +# Agent deploys code without temp files +tar czf - ./src | brev cp - my-gpu:/app/src.tar.gz + +# Agent extracts specific results +brev cp my-gpu:/logs/metrics.json - | jq '.accuracy' +``` From 0fbfd1c775e6e40a33ec3b5a3e1ef2d3cfdb8621 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 3 Feb 2026 22:44:56 -0800 Subject: [PATCH 4/8] docs(prd): clarify shell vs shell -c in pipeable commands table - shell: interactive, no stdin/stdout - shell -c: accepts instance names from stdin, outputs command stdout/stderr --- docs/PRD-composable-cli.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/PRD-composable-cli.md b/docs/PRD-composable-cli.md index a7587cd7..5af685c6 100644 --- a/docs/PRD-composable-cli.md +++ b/docs/PRD-composable-cli.md @@ -47,7 +47,8 @@ Make the Brev CLI idiomatic, programmable, and agent-friendly. Users and AI agen | `brev start` | Instance names | Instance names | ✅ | | `brev delete` | Instance names | Instance names | ✅ | | `brev create` | Instance types (table or JSON) | Instance names | ✅ | -| `brev shell` | Instance names | Instance names (after -c) | ✅ | +| `brev shell` | - | - (interactive) | ✅ | +| `brev shell -c` | Instance names | Command stdout/stderr | ✅ | | `brev open` | Instance names | - | ✅ | ### Shell Enhancements (`brev shell`) From 12d4c6606f6c4b09cc3a6a34c7e48fb6a63e8344 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 3 Feb 2026 23:22:36 -0800 Subject: [PATCH 5/8] feat: add brev exec command, keep shell interactive only Create new 'brev exec' command for non-interactive command execution: - Run commands on one or more instances - Support @filepath syntax to run local scripts remotely - Accept instance names from stdin for piping - Output instance names for command chaining Simplify 'brev shell' to be interactive only: - Remove -c flag (use 'brev exec' instead) - Single instance argument required - Keep --host flag for host SSH Update PRD to document the new command separation. --- docs/PRD-composable-cli.md | 42 +++-- pkg/cmd/cmd.go | 2 + pkg/cmd/exec/exec.go | 338 +++++++++++++++++++++++++++++++++++++ pkg/cmd/shell/shell.go | 136 ++------------- 4 files changed, 380 insertions(+), 138 deletions(-) create mode 100644 pkg/cmd/exec/exec.go diff --git a/docs/PRD-composable-cli.md b/docs/PRD-composable-cli.md index 5af685c6..66ba1876 100644 --- a/docs/PRD-composable-cli.md +++ b/docs/PRD-composable-cli.md @@ -48,41 +48,51 @@ Make the Brev CLI idiomatic, programmable, and agent-friendly. Users and AI agen | `brev delete` | Instance names | Instance names | ✅ | | `brev create` | Instance types (table or JSON) | Instance names | ✅ | | `brev shell` | - | - (interactive) | ✅ | -| `brev shell -c` | Instance names | Command stdout/stderr | ✅ | +| `brev exec` | Instance names | Command stdout/stderr | ✅ | | `brev open` | Instance names | - | ✅ | -### Shell Enhancements (`brev shell`) +### Exec Command (`brev exec`) -Non-interactive command execution with `-c` flag, enabling scripted and agentic workflows. +Non-interactive command execution for scripted and agentic workflows. **Run commands directly**: ```bash -brev shell my-gpu -c "nvidia-smi" -brev shell my-gpu -c "python train.py && echo done" +brev exec my-gpu "nvidia-smi" +brev exec my-gpu "python train.py && echo done" ``` **Run local scripts remotely** (`@filepath` syntax): ```bash -brev shell my-gpu -c @setup.sh # Runs local setup.sh on remote -brev shell my-gpu -c @scripts/deploy.sh # Relative paths supported +brev exec my-gpu @setup.sh # Runs local setup.sh on remote +brev exec my-gpu @scripts/deploy.sh # Relative paths supported ``` **Multi-instance support**: ```bash # Run on multiple instances -brev shell gpu-1 gpu-2 gpu-3 -c "nvidia-smi" +brev exec gpu-1 gpu-2 gpu-3 "nvidia-smi" # Pipe from create -brev create my-cluster --count 3 | brev shell -c "nvidia-smi" +brev create my-cluster --count 3 | brev exec "nvidia-smi" # Chain with other commands -brev ls | grep RUNNING | brev shell -c "df -h" +brev ls | grep RUNNING | brev exec "df -h" ``` **Output for chaining**: -When using `-c`, outputs instance names after execution completes, enabling pipelines: +Outputs instance names after execution completes, enabling pipelines: ```bash -brev create my-gpu | brev shell -c "pip install torch" | brev shell -c "python train.py" +brev create my-gpu | brev exec "pip install torch" | brev exec "python train.py" +``` + +### Shell Command (`brev shell`) + +Interactive SSH session to an instance. Use `brev exec` for non-interactive commands. + +```bash +brev shell my-gpu # Interactive shell +brev shell $(brev create my-gpu) # Create and connect +brev shell my-gpu --host # SSH to host instead of container ``` ### Open Enhancements (`brev open`) @@ -151,7 +161,7 @@ brev ls | grep STOPPED | awk '{print $1}' | brev delete ### Chained Lifecycle ```bash # Create, use, cleanup -brev search --gpu-name A100 | head -1 | brev create --name job-1 | brev shell -c "python train.py" && brev delete job-1 +brev search --gpu-name A100 | head -1 | brev create --name job-1 | brev exec "python train.py" && brev delete job-1 ``` ### JSON Processing @@ -207,7 +217,7 @@ With composable CLI + skills, agents can autonomously: 1. **Provision** - Search, filter, and create instances matching workload requirements 2. **Deploy** - Stream code/data to instances via pipeable `cp` -3. **Execute** - Run workloads via `brev shell -c`, capture output +3. **Execute** - Run workloads via `brev exec`, capture output 4. **Monitor** - Poll status via `brev ls --json`, stream logs 5. **Scale** - Spin up parallel instances, distribute work 6. **Cleanup** - Stop/delete instances, manage costs @@ -221,7 +231,7 @@ Agent: 1. brev search --gpu-name H100 --stoppable --min-disk 500 | head -1 | brev create --name training-job 2. brev wait training-job --state ready 3. tar czf - ./src | brev cp - training-job:/app/ -4. brev shell training-job -c "cd /app && python train.py --checkpoint-interval 3600" +4. brev exec training-job "cd /app && python train.py --checkpoint-interval 3600" 5. brev cp training-job:/app/checkpoints - | tar xzf - -C ./results/ 6. brev delete training-job ``` @@ -273,7 +283,7 @@ brev cp gpu-1:/checkpoint.pt - | brev cp - gpu-2:/checkpoint.pt **Agentic use cases**: ```bash # Agent streams training data, captures results -cat dataset.jsonl | brev shell my-gpu -c "python train.py" > results.log +cat dataset.jsonl | brev exec my-gpu "python train.py" > results.log # Agent deploys code without temp files tar czf - ./src | brev cp - my-gpu:/app/src.tar.gz diff --git a/pkg/cmd/cmd.go b/pkg/cmd/cmd.go index 2980b0ce..27c7a4f8 100644 --- a/pkg/cmd/cmd.go +++ b/pkg/cmd/cmd.go @@ -13,6 +13,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/create" "github.com/brevdev/brev-cli/pkg/cmd/delete" "github.com/brevdev/brev-cli/pkg/cmd/envvars" + "github.com/brevdev/brev-cli/pkg/cmd/exec" "github.com/brevdev/brev-cli/pkg/cmd/fu" "github.com/brevdev/brev-cli/pkg/cmd/healthcheck" "github.com/brevdev/brev-cli/pkg/cmd/hello" @@ -273,6 +274,7 @@ func createCmdTree(cmd *cobra.Command, t *terminal.Terminal, loginCmdStore *stor cmd.AddCommand(configureenvvars.NewCmdConfigureEnvVars(t, loginCmdStore)) cmd.AddCommand(importideconfig.NewCmdImportIDEConfig(t, noLoginCmdStore)) cmd.AddCommand(shell.NewCmdShell(t, loginCmdStore, noLoginCmdStore)) + cmd.AddCommand(exec.NewCmdExec(t, loginCmdStore, noLoginCmdStore)) cmd.AddCommand(copy.NewCmdCopy(t, loginCmdStore, noLoginCmdStore)) cmd.AddCommand(open.NewCmdOpen(t, loginCmdStore, noLoginCmdStore)) cmd.AddCommand(ollama.NewCmdOllama(t, loginCmdStore)) diff --git a/pkg/cmd/exec/exec.go b/pkg/cmd/exec/exec.go new file mode 100644 index 00000000..baced4d1 --- /dev/null +++ b/pkg/cmd/exec/exec.go @@ -0,0 +1,338 @@ +package exec + +import ( + "bufio" + "errors" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/brevdev/brev-cli/pkg/analytics" + "github.com/brevdev/brev-cli/pkg/cmd/completions" + "github.com/brevdev/brev-cli/pkg/cmd/refresh" + "github.com/brevdev/brev-cli/pkg/cmd/util" + "github.com/brevdev/brev-cli/pkg/entity" + breverrors "github.com/brevdev/brev-cli/pkg/errors" + "github.com/brevdev/brev-cli/pkg/store" + "github.com/brevdev/brev-cli/pkg/terminal" + "github.com/brevdev/brev-cli/pkg/writeconnectionevent" + "github.com/briandowns/spinner" + "github.com/hashicorp/go-multierror" + + "github.com/spf13/cobra" +) + +var ( + execLong = "Execute a command on one or more instances non-interactively" + execExample = ` # Run a command on an instance + brev exec my-instance "nvidia-smi" + brev exec my-instance "python train.py" + + # Run a command on multiple instances + brev exec instance1 instance2 instance3 "nvidia-smi" + + # Run a script file on the instance (@ prefix reads local file) + brev exec my-instance @setup.sh + brev exec my-instance @scripts/deploy.sh + + # Chain: create and run a command (reads instance names from stdin) + brev create my-instance | brev exec "nvidia-smi" + + # Run command on a cluster + brev create my-cluster --count 3 | brev exec "nvidia-smi" + + # Pipeline: create, setup, then run + brev create my-gpu | brev exec "pip install torch" | brev exec "python train.py" + + # SSH into the host machine instead of the container + brev exec my-instance --host "nvidia-smi"` +) + +type ExecStore interface { + util.GetWorkspaceByNameOrIDErrStore + refresh.RefreshStore + GetOrganizations(options *store.GetOrganizationsOptions) ([]entity.Organization, error) + GetWorkspaces(organizationID string, options *store.GetWorkspacesOptions) ([]entity.Workspace, error) + StartWorkspace(workspaceID string) (*entity.Workspace, error) + GetWorkspace(workspaceID string) (*entity.Workspace, error) + GetCurrentUserKeys() (*entity.UserKeys, error) +} + +func NewCmdExec(t *terminal.Terminal, store ExecStore, noLoginStartStore ExecStore) *cobra.Command { + var host bool + cmd := &cobra.Command{ + Annotations: map[string]string{"access": ""}, + Use: "exec [instance...] ", + DisableFlagsInUseLine: true, + Short: "Execute a command on instance(s)", + Long: execLong, + Example: execExample, + Args: cobra.MinimumNArgs(1), + ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), + RunE: func(cmd *cobra.Command, args []string) error { + // Last argument is the command, rest are instance names + command := args[len(args)-1] + instanceArgs := args[:len(args)-1] + + // Get instance names from args or stdin + instanceNames, err := getInstanceNames(instanceArgs) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + // Parse command (can be inline or @filepath) + cmdToRun, err := parseCommand(command) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + if cmdToRun == "" { + return breverrors.NewValidationError("command is required") + } + + // Run on each instance + var errors error + for _, instanceName := range instanceNames { + if len(instanceNames) > 1 { + fmt.Fprintf(os.Stderr, "\n=== %s ===\n", instanceName) + } + err = runExecCommand(t, store, instanceName, host, cmdToRun) + if err != nil { + if len(instanceNames) > 1 { + fmt.Fprintf(os.Stderr, "Error on %s: %v\n", instanceName, err) + errors = multierror.Append(errors, err) + continue + } + return breverrors.WrapAndTrace(err) + } + // Output instance name for chaining (only if stdout is piped) + if isPiped() { + fmt.Println(instanceName) + } + } + if errors != nil { + return breverrors.WrapAndTrace(errors) + } + return nil + }, + } + cmd.Flags().BoolVarP(&host, "host", "", false, "ssh into the host machine instead of the container") + + return cmd +} + +// isPiped returns true if stdout is piped to another command +func isPiped() bool { + stat, _ := os.Stdout.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + +// getInstanceNames gets instance names from args or stdin (supports multiple) +func getInstanceNames(args []string) ([]string, error) { + var names []string + + // Add names from args + names = append(names, args...) + + // Check if stdin is piped + stat, _ := os.Stdin.Stat() + if (stat.Mode() & os.ModeCharDevice) == 0 { + // Stdin is piped, read instance names (one per line) + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + name := strings.TrimSpace(scanner.Text()) + if name != "" { + names = append(names, name) + } + } + if err := scanner.Err(); err != nil { + return nil, breverrors.WrapAndTrace(err) + } + } + + if len(names) == 0 { + return nil, breverrors.NewValidationError("instance name required: provide as argument or pipe from another command") + } + + return names, nil +} + +// parseCommand parses the command string, loading from file if prefixed with @ +func parseCommand(command string) (string, error) { + if command == "" { + return "", nil + } + + // If prefixed with @, read from file + if strings.HasPrefix(command, "@") { + filePath := strings.TrimPrefix(command, "@") + content, err := os.ReadFile(filePath) + if err != nil { + return "", breverrors.WrapAndTrace(err) + } + return string(content), nil + } + + return command, nil +} + +func runExecCommand(t *terminal.Terminal, sstore ExecStore, workspaceNameOrID string, host bool, command string) error { + s := t.NewSpinner() + workspace, err := util.GetUserWorkspaceByNameOrIDErr(sstore, workspaceNameOrID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + + if workspace.Status == "STOPPED" { // we start the env for the user + err = startWorkspaceIfStopped(t, s, sstore, workspaceNameOrID, workspace) + if err != nil { + return breverrors.WrapAndTrace(err) + } + } + err = pollUntil(s, workspace.ID, "RUNNING", sstore, " waiting for instance to be ready...") + if err != nil { + return breverrors.WrapAndTrace(err) + } + refreshRes := refresh.RunRefreshAsync(sstore) + + workspace, err = util.GetUserWorkspaceByNameOrIDErr(sstore, workspaceNameOrID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + if workspace.Status != "RUNNING" { + return breverrors.New("Instance is not running") + } + + localIdentifier := workspace.GetLocalIdentifier() + if host { + localIdentifier = workspace.GetHostIdentifier() + } + + sshName := string(localIdentifier) + + err = refreshRes.Await() + if err != nil { + return breverrors.WrapAndTrace(err) + } + err = waitForSSHToBeAvailable(sshName, s) + if err != nil { + return breverrors.WrapAndTrace(err) + } + // we don't care about the error here but should log with sentry + // legacy environments wont support this and cause errrors, + // but we don't want to block the user from using the shell + _ = writeconnectionevent.WriteWCEOnEnv(sstore, workspace.DNS) + err = runSSH(sshName, command) + if err != nil { + return breverrors.WrapAndTrace(err) + } + // Call analytics for exec + userID := "" + user, err := sstore.GetCurrentUser() + if err != nil { + userID = workspace.CreatedByUserID + } else { + userID = user.ID + } + data := analytics.EventData{ + EventName: "Brev Exec", + UserID: userID, + Properties: map[string]string{ + "instanceId": workspace.ID, + }, + } + _ = analytics.TrackEvent(data) + + return nil +} + +func waitForSSHToBeAvailable(sshAlias string, s *spinner.Spinner) error { + counter := 0 + s.Suffix = " waiting for SSH connection to be available" + s.Start() + for { + cmd := exec.Command("ssh", "-o", "ConnectTimeout=10", sshAlias, "echo", " ") + out, err := cmd.CombinedOutput() + if err == nil { + s.Stop() + return nil + } + + outputStr := string(out) + stdErr := strings.Split(outputStr, "\n")[1] + + if counter == 40 || !store.SatisfactorySSHErrMessage(stdErr) { + return breverrors.WrapAndTrace(errors.New("\n" + stdErr)) + } + + counter++ + time.Sleep(1 * time.Second) + } +} + +func runSSH(sshAlias string, command string) error { + sshAgentEval := "eval $(ssh-agent -s)" + + // Non-interactive: run command and pipe stdout/stderr + // Escape the command for passing to SSH + escapedCmd := strings.ReplaceAll(command, "'", "'\\''") + cmd := fmt.Sprintf("ssh %s '%s'", sshAlias, escapedCmd) + + cmd = fmt.Sprintf("%s && %s", sshAgentEval, cmd) + sshCmd := exec.Command("bash", "-c", cmd) //nolint:gosec //cmd is user input + sshCmd.Stderr = os.Stderr + sshCmd.Stdout = os.Stdout + // Don't attach stdin - exec is non-interactive + + err := sshCmd.Run() + if err != nil { + return breverrors.WrapAndTrace(err) + } + return nil +} + +func startWorkspaceIfStopped(t *terminal.Terminal, s *spinner.Spinner, tstore ExecStore, wsIDOrName string, workspace *entity.Workspace) error { + activeOrg, err := tstore.GetActiveOrganizationOrDefault() + if err != nil { + return breverrors.WrapAndTrace(err) + } + workspaces, err := tstore.GetWorkspaceByNameOrID(activeOrg.ID, wsIDOrName) + if err != nil { + return breverrors.WrapAndTrace(err) + } + startedWorkspace, err := tstore.StartWorkspace(workspaces[0].ID) + if err != nil { + return breverrors.WrapAndTrace(err) + } + t.Vprintf("%s", t.Yellow("Instance %s is starting. \n\n", startedWorkspace.Name)) + err = pollUntil(s, workspace.ID, entity.Running, tstore, " hang tight") + if err != nil { + return breverrors.WrapAndTrace(err) + } + workspace, err = util.GetUserWorkspaceByNameOrIDErr(tstore, wsIDOrName) + if err != nil { + return breverrors.WrapAndTrace(err) + } + return nil +} + +func pollUntil(s *spinner.Spinner, wsid string, state string, execStore ExecStore, waitMsg string) error { + isReady := false + s.Suffix = waitMsg + s.Start() + for !isReady { + time.Sleep(5 * time.Second) + ws, err := execStore.GetWorkspace(wsid) + if err != nil { + return breverrors.WrapAndTrace(err) + } + s.Suffix = waitMsg + if ws.Status == state { + isReady = true + } + } + s.Stop() + return nil +} diff --git a/pkg/cmd/shell/shell.go b/pkg/cmd/shell/shell.go index c24837f3..f39601d5 100644 --- a/pkg/cmd/shell/shell.go +++ b/pkg/cmd/shell/shell.go @@ -1,7 +1,6 @@ package shell import ( - "bufio" "errors" "fmt" "os" @@ -30,30 +29,17 @@ var ( brev shell instance_id_or_name brev shell my-instance - # Run a command on the instance (non-interactive, pipes stdout/stderr) - brev shell my-instance -c "nvidia-smi" - brev shell my-instance -c "python train.py" - - # Run a command on multiple instances - brev shell instance1 instance2 instance3 -c "nvidia-smi" - - # Run a script file on the instance - brev shell my-instance -c @setup.sh - - # Chain: create and run a command (reads instance names from stdin) - brev create my-instance | brev shell -c "nvidia-smi" - - # Run command on a cluster (multiple instances from stdin) - brev create my-cluster --count 3 | brev shell -c "nvidia-smi" - - # Create a GPU instance and SSH into it (use command substitution for interactive shell) + # Create a GPU instance and SSH into it brev shell $(brev create my-instance) # Create with specific GPU and connect brev shell $(brev search --gpu-name A100 | brev create ml-box) # SSH into the host machine instead of the container - brev shell my-instance --host` + brev shell my-instance --host + + # For non-interactive command execution, use 'brev exec': + brev exec my-instance "nvidia-smi"` ) type ShellStore interface { @@ -68,110 +54,31 @@ type ShellStore interface { func NewCmdShell(t *terminal.Terminal, store ShellStore, noLoginStartStore ShellStore) *cobra.Command { var host bool - var command string cmd := &cobra.Command{ Annotations: map[string]string{"access": ""}, - Use: "shell [instance...]", + Use: "shell ", Aliases: []string{"ssh"}, DisableFlagsInUseLine: true, Short: "[beta] Open a shell in your instance", Long: openLong, Example: openExample, - Args: cobra.ArbitraryArgs, + Args: cobra.ExactArgs(1), ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), RunE: func(cmd *cobra.Command, args []string) error { - // Get instance names from args or stdin - instanceNames, err := getInstanceNames(args) - if err != nil { - return breverrors.WrapAndTrace(err) - } - - // Parse command (can be inline or @filepath) - cmdToRun, err := parseCommand(command) + instanceName := args[0] + err := runShellCommand(t, store, instanceName, host) if err != nil { return breverrors.WrapAndTrace(err) } - - // Interactive shell only supports one instance - if cmdToRun == "" && len(instanceNames) > 1 { - return breverrors.NewValidationError("interactive shell only supports one instance; use -c to run a command on multiple instances") - } - - // Run on each instance - var lastErr error - for _, instanceName := range instanceNames { - if len(instanceNames) > 1 { - fmt.Fprintf(os.Stderr, "\n=== %s ===\n", instanceName) - } - err = runShellCommand(t, store, instanceName, host, cmdToRun) - if err != nil { - if len(instanceNames) > 1 { - fmt.Fprintf(os.Stderr, "Error on %s: %v\n", instanceName, err) - lastErr = err - continue - } - return breverrors.WrapAndTrace(err) - } - } - if lastErr != nil { - return breverrors.NewValidationError("one or more instances failed") - } return nil }, } cmd.Flags().BoolVarP(&host, "host", "", false, "ssh into the host machine instead of the container") - cmd.Flags().StringVarP(&command, "command", "c", "", "command to run on the instance (use @filename to run a script file)") return cmd } -// getInstanceNames gets instance names from args or stdin (supports multiple) -func getInstanceNames(args []string) ([]string, error) { - var names []string - - // Add names from args - names = append(names, args...) - - // Check if stdin is piped - stat, _ := os.Stdin.Stat() - if (stat.Mode() & os.ModeCharDevice) == 0 { - // Stdin is piped, read instance names (one per line) - scanner := bufio.NewScanner(os.Stdin) - for scanner.Scan() { - name := strings.TrimSpace(scanner.Text()) - if name != "" { - names = append(names, name) - } - } - } - - if len(names) == 0 { - return nil, breverrors.NewValidationError("instance name required: provide as argument or pipe from another command") - } - - return names, nil -} - -// parseCommand parses the command string, loading from file if prefixed with @ -func parseCommand(command string) (string, error) { - if command == "" { - return "", nil - } - - // If prefixed with @, read from file - if strings.HasPrefix(command, "@") { - filePath := strings.TrimPrefix(command, "@") - content, err := os.ReadFile(filePath) - if err != nil { - return "", breverrors.WrapAndTrace(err) - } - return string(content), nil - } - - return command, nil -} - -func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID string, host bool, command string) error { +func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID string, host bool) error { s := t.NewSpinner() workspace, err := util.GetUserWorkspaceByNameOrIDErr(sstore, workspaceNameOrID) if err != nil { @@ -217,7 +124,7 @@ func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID // legacy environments wont support this and cause errrors, // but we don't want to block the user from using the shell _ = writeconnectionevent.WriteWCEOnEnv(sstore, workspace.DNS) - err = runSSH(workspace, sshName, command) + err = runSSH(sshName) if err != nil { return breverrors.WrapAndTrace(err) } @@ -265,29 +172,14 @@ func waitForSSHToBeAvailable(sshAlias string, s *spinner.Spinner) error { } } -func runSSH(_ *entity.Workspace, sshAlias string, command string) error { +func runSSH(sshAlias string) error { sshAgentEval := "eval $(ssh-agent -s)" + cmd := fmt.Sprintf("%s && ssh %s", sshAgentEval, sshAlias) - var cmd string - if command != "" { - // Non-interactive: run command and pipe stdout/stderr - // Escape the command for passing to SSH - escapedCmd := strings.ReplaceAll(command, "'", "'\\''") - cmd = fmt.Sprintf("ssh %s '%s'", sshAlias, escapedCmd) - } else { - // Interactive shell - cmd = fmt.Sprintf("ssh %s", sshAlias) - } - - cmd = fmt.Sprintf("%s && %s", sshAgentEval, cmd) sshCmd := exec.Command("bash", "-c", cmd) //nolint:gosec //cmd is user input sshCmd.Stderr = os.Stderr sshCmd.Stdout = os.Stdout - - // Only attach stdin for interactive sessions - if command == "" { - sshCmd.Stdin = os.Stdin - } + sshCmd.Stdin = os.Stdin err := hello.SetHasRunShell(true) if err != nil { From 43bd7be039775f1e5c38e34fa7d715203f4ffd4b Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 3 Feb 2026 23:23:04 -0800 Subject: [PATCH 6/8] refactor: improve open.go error handling and consolidate editor commands open.go: - Add 'terminal' to valid editors in error message - Use multierror for better multi-instance error aggregation - Add scanner error check for stdin reading util.go: - Extract common runEditorCommand helper for WSL compatibility - Simplify runVsCodeCommand, runCursorCommand, runWindsurfCommand --- pkg/cmd/open/open.go | 18 +++++++-------- pkg/util/util.go | 54 ++++++++++++-------------------------------- 2 files changed, 24 insertions(+), 48 deletions(-) diff --git a/pkg/cmd/open/open.go b/pkg/cmd/open/open.go index 904fc193..d24ee3f3 100644 --- a/pkg/cmd/open/open.go +++ b/pkg/cmd/open/open.go @@ -140,7 +140,7 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto // Validate editor flag if provided if editor != "" && !isEditorType(editor) { - return breverrors.NewValidationError(fmt.Sprintf("invalid editor: %s. Must be 'code', 'cursor', 'windsurf', or 'tmux'", editor)) + return breverrors.NewValidationError(fmt.Sprintf("invalid editor: %s. Must be 'code', 'cursor', 'windsurf', 'terminal', or 'tmux'", editor)) } // Get instance names and editor type from args or stdin @@ -155,7 +155,7 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto } // Open each instance - var lastErr error + var errors error for _, instanceName := range instanceNames { if len(instanceNames) > 1 { fmt.Fprintf(os.Stderr, "Opening %s...\n", instanceName) @@ -164,14 +164,14 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto if err != nil { if len(instanceNames) > 1 { fmt.Fprintf(os.Stderr, "Error opening %s: %v\n", instanceName, err) - lastErr = err + errors = multierror.Append(errors, err) continue } return breverrors.WrapAndTrace(err) } } - if lastErr != nil { - return breverrors.NewValidationError("one or more instances failed to open") + if errors != nil { + return breverrors.WrapAndTrace(errors) } return nil }, @@ -216,6 +216,9 @@ func getInstanceNamesAndEditor(args []string, editorFlag string) ([]string, stri names = append(names, name) } } + if err := scanner.Err(); err != nil { + return nil, "", breverrors.WrapAndTrace(err) + } } if len(names) == 0 { @@ -710,10 +713,7 @@ end tell`, command) } } -func openTerminal(sshAlias string, path string, store OpenStore) error { - _ = store // unused parameter required by interface - _ = path // unused, just opens SSH - +func openTerminal(sshAlias string, _ string, _ OpenStore) error { sshCmd := fmt.Sprintf("ssh %s", sshAlias) err := openInNewTerminalWindow(sshCmd) if err != nil { diff --git a/pkg/util/util.go b/pkg/util/util.go index 1c40cf54..1bb97057 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -59,7 +59,10 @@ func runWindowsExeInWSL(exePath string, args []string) ([]byte, error) { cmd := exec.Command("cmd.exe", cmdArgs...) // #nosec G204 output, err := cmd.CombinedOutput() - return output, breverrors.WrapAndTrace(err) + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } + return output, nil } // This package should only be used as a holding pattern to be later moved into more specific packages @@ -253,17 +256,14 @@ func runManyCursorCommand(cursorpaths []string, args []string) ([]byte, error) { return nil, breverrors.WrapAndTrace(errs.ErrorOrNil()) } -func runVsCodeCommand(vscodepath string, args []string) ([]byte, error) { +// runEditorCommand runs an editor executable with the given args, handling WSL compatibility +func runEditorCommand(path string, args []string) ([]byte, error) { // In WSL, Windows .exe files need to be run through cmd.exe - if isWSL() && (strings.HasSuffix(vscodepath, ".exe") || strings.HasPrefix(vscodepath, "/mnt/")) { - res, err := runWindowsExeInWSL(vscodepath, args) - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - return res, nil + if isWSL() && (strings.HasSuffix(path, ".exe") || strings.HasPrefix(path, "/mnt/")) { + return runWindowsExeInWSL(path, args) } - cmd := exec.Command(vscodepath, args...) // #nosec G204 + cmd := exec.Command(path, args...) // #nosec G204 res, err := cmd.CombinedOutput() if err != nil { return nil, breverrors.WrapAndTrace(err) @@ -271,22 +271,12 @@ func runVsCodeCommand(vscodepath string, args []string) ([]byte, error) { return res, nil } -func runCursorCommand(cursorpath string, args []string) ([]byte, error) { - // In WSL, Windows .exe files need to be run through cmd.exe - if isWSL() && (strings.HasSuffix(cursorpath, ".exe") || strings.HasPrefix(cursorpath, "/mnt/")) { - res, err := runWindowsExeInWSL(cursorpath, args) - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - return res, nil - } +func runVsCodeCommand(vscodepath string, args []string) ([]byte, error) { + return runEditorCommand(vscodepath, args) +} - cmd := exec.Command(cursorpath, args...) // #nosec G204 - res, err := cmd.CombinedOutput() - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - return res, nil +func runCursorCommand(cursorpath string, args []string) ([]byte, error) { + return runEditorCommand(cursorpath, args) } func runManyWindsurfCommand(windsurfpaths []string, args []string) ([]byte, error) { @@ -303,21 +293,7 @@ func runManyWindsurfCommand(windsurfpaths []string, args []string) ([]byte, erro } func runWindsurfCommand(windsurfpath string, args []string) ([]byte, error) { - // In WSL, Windows .exe files need to be run through cmd.exe - if isWSL() && (strings.HasSuffix(windsurfpath, ".exe") || strings.HasPrefix(windsurfpath, "/mnt/")) { - res, err := runWindowsExeInWSL(windsurfpath, args) - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - return res, nil - } - - cmd := exec.Command(windsurfpath, args...) // #nosec G204 - res, err := cmd.CombinedOutput() - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - return res, nil + return runEditorCommand(windsurfpath, args) } var commonVSCodePaths = []string{ From 109e513a332d42b6136521bad8dfc55bd64cbbdf Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 3 Feb 2026 23:42:11 -0800 Subject: [PATCH 7/8] feat(open): output instance names to stdout when piped Enable chaining with brev open by outputting instance names: brev create my-gpu | brev open cursor | brev exec 'pip install torch' Only outputs when stdout is piped, keeping interactive use clean. --- docs/PRD-composable-cli.md | 9 ++++++++- pkg/cmd/open/open.go | 10 ++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/PRD-composable-cli.md b/docs/PRD-composable-cli.md index 66ba1876..a1ce41db 100644 --- a/docs/PRD-composable-cli.md +++ b/docs/PRD-composable-cli.md @@ -49,7 +49,7 @@ Make the Brev CLI idiomatic, programmable, and agent-friendly. Users and AI agen | `brev create` | Instance types (table or JSON) | Instance names | ✅ | | `brev shell` | - | - (interactive) | ✅ | | `brev exec` | Instance names | Command stdout/stderr | ✅ | -| `brev open` | Instance names | - | ✅ | +| `brev open` | Instance names | Instance names | ✅ | ### Exec Command (`brev exec`) @@ -117,6 +117,13 @@ brev open gpu-1 gpu-2 gpu-3 cursor brev create my-cluster --count 3 | brev open cursor ``` +**Output for chaining**: +Outputs instance names when piped, enabling pipelines: +```bash +# Create, open in editor, then run setup +brev create my-gpu | brev open cursor | brev exec "pip install -r requirements.txt" +``` + **Cross-platform support**: - macOS: Terminal.app, iTerm2 - Linux: Default terminal emulator diff --git a/pkg/cmd/open/open.go b/pkg/cmd/open/open.go index d24ee3f3..f248995f 100644 --- a/pkg/cmd/open/open.go +++ b/pkg/cmd/open/open.go @@ -169,6 +169,10 @@ func NewCmdOpen(t *terminal.Terminal, store OpenStore, noLoginStartStore OpenSto } return breverrors.WrapAndTrace(err) } + // Output instance name for chaining (only if stdout is piped) + if isPiped() { + fmt.Println(instanceName) + } } if errors != nil { return breverrors.WrapAndTrace(errors) @@ -190,6 +194,12 @@ func isEditorType(s string) bool { return s == EditorVSCode || s == EditorCursor || s == EditorWindsurf || s == EditorTerminal || s == EditorTmux } +// isPiped returns true if stdout is piped to another command +func isPiped() bool { + stat, _ := os.Stdout.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + // getInstanceNamesAndEditor gets instance names from args/stdin and determines editor type // editorFlag takes precedence, otherwise last arg may be an editor type (code, cursor, windsurf, tmux) func getInstanceNamesAndEditor(args []string, editorFlag string) ([]string, string, error) { From 4b6003e8776f6a3e03a5b1543893e9bdd927e5cb Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Wed, 4 Feb 2026 00:02:07 -0800 Subject: [PATCH 8/8] docs(prd): add 'Why Now' section on coding agents and CLIs Coding agents prefer CLIs: text-native, self-documenting, composable, and already learned from training data. Positions Brev CLI as the default for autonomous GPU workflows. --- docs/PRD-composable-cli.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/PRD-composable-cli.md b/docs/PRD-composable-cli.md index a1ce41db..15dc2055 100644 --- a/docs/PRD-composable-cli.md +++ b/docs/PRD-composable-cli.md @@ -4,6 +4,17 @@ Make the Brev CLI idiomatic, programmable, and agent-friendly. Users and AI agents should be able to compose commands using standard Unix patterns (`|`, `grep`, `awk`, `jq`) while also having structured output options for programmatic access. +## Why Now + +Coding agents (Claude Code, Cursor, Cline, Aider, OpenCode, Clawdbot) are becoming the primary interface between developers and their tools. These agents prefer CLIs over APIs: + +- **Text-native** - LLMs think in text; pipes and grep are natural +- **Self-documenting** - `--help` and tab completion beat reading API docs +- **Composable** - Chain steps: `brev search | brev create | brev exec "setup.sh"` +- **Learned from training** - Agents already know Unix conventions from GitHub/Stack Overflow + +Most GPU clouds have dashboards and APIs, but weak CLIs. A composable Brev CLI becomes the default for autonomous GPU workflows. + ## Goals 1. **Unix Idiomatic** - Commands work naturally with pipes and standard tools