diff --git a/docs/PRD-composable-cli.md b/docs/PRD-composable-cli.md new file mode 100644 index 00000000..15dc2055 --- /dev/null +++ b/docs/PRD-composable-cli.md @@ -0,0 +1,311 @@ +# 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. + +## 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 +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` | - | - (interactive) | ✅ | +| `brev exec` | Instance names | Command stdout/stderr | ✅ | +| `brev open` | Instance names | Instance names | ✅ | + +### Exec Command (`brev exec`) + +Non-interactive command execution for scripted and agentic workflows. + +**Run commands directly**: +```bash +brev exec my-gpu "nvidia-smi" +brev exec my-gpu "python train.py && echo done" +``` + +**Run local scripts remotely** (`@filepath` syntax): +```bash +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 exec gpu-1 gpu-2 gpu-3 "nvidia-smi" + +# Pipe from create +brev create my-cluster --count 3 | brev exec "nvidia-smi" + +# Chain with other commands +brev ls | grep RUNNING | brev exec "df -h" +``` + +**Output for chaining**: +Outputs instance names after execution completes, enabling pipelines: +```bash +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`) + +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 +``` + +**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 +- 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 exec "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 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 + +### 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 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 +``` + +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 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 + +# Agent extracts specific results +brev cp my-gpu:/logs/metrics.json - | jq '.accuracy' +``` 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/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/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/open/open.go b/pkg/cmd/open/open.go index f0691ece..f248995f 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" @@ -33,12 +35,66 @@ const ( 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,44 @@ 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', 'terminal', 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 errors 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) + 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 }, @@ -100,14 +183,79 @@ 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 +} + +// 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) { + 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 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") + } + + // 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 +276,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 +486,8 @@ func getEditorName(editorType string) string { return "Cursor" case EditorWindsurf: return "Windsurf" + case EditorTerminal: + return "Terminal" case EditorTmux: return "tmux" default: @@ -390,8 +518,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 +669,72 @@ 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 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 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 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 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 breverrors.WrapAndTrace(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 breverrors.WrapAndTrace(cmd.Run()) + } + // Fallback to start cmd + cmd := exec.Command("cmd", "/c", "start", "cmd", "/k", command) // #nosec G204 + return breverrors.WrapAndTrace(cmd.Run()) + + default: + return breverrors.NewValidationError(fmt.Sprintf("'terminal' editor is not supported on %s", runtime.GOOS)) + } +} + +func openTerminal(sshAlias string, _ string, _ OpenStore) error { + 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 +742,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..f39601d5 100644 --- a/pkg/cmd/shell/shell.go +++ b/pkg/cmd/shell/shell.go @@ -9,7 +9,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 +25,21 @@ 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 + + # 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 + + # For non-interactive command execution, use 'brev exec': + brev exec my-instance "nvidia-smi"` ) type ShellStore interface { @@ -40,21 +53,20 @@ type ShellStore interface { } func NewCmdShell(t *terminal.Terminal, store ShellStore, noLoginStartStore ShellStore) *cobra.Command { - var runRemoteCMD bool - var directory string var host bool cmd := &cobra.Command{ Annotations: map[string]string{"access": ""}, - Use: "shell", + Use: "shell ", 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.ExactArgs(1), ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), RunE: func(cmd *cobra.Command, args []string) error { - err := runShellCommand(t, store, args[0], directory, host) + instanceName := args[0] + err := runShellCommand(t, store, instanceName, host) if err != nil { return breverrors.WrapAndTrace(err) } @@ -62,13 +74,11 @@ func NewCmdShell(t *terminal.Terminal, store ShellStore, noLoginStartStore Shell }, } 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") return cmd } -func runShellCommand(t *terminal.Terminal, sstore ShellStore, workspaceNameOrID, directory string, host bool) 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 { @@ -114,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, directory) + err = runSSH(sshName) if err != nil { return breverrors.WrapAndTrace(err) } @@ -162,10 +172,10 @@ func waitForSSHToBeAvailable(sshAlias string, s *spinner.Spinner) error { } } -func runSSH(_ *entity.Workspace, sshAlias, _ string) error { +func runSSH(sshAlias string) error { sshAgentEval := "eval $(ssh-agent -s)" - cmd := fmt.Sprintf("ssh %s", sshAlias) - cmd = fmt.Sprintf("%s && %s", sshAgentEval, cmd) + cmd := fmt.Sprintf("%s && ssh %s", sshAgentEval, sshAlias) + sshCmd := exec.Command("bash", "-c", cmd) //nolint:gosec //cmd is user input sshCmd.Stderr = os.Stderr sshCmd.Stdout = os.Stdout diff --git a/pkg/util/util.go b/pkg/util/util.go index d9004e1d..1bb97057 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,57 @@ 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 + output, err := cmd.CombinedOutput() + 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 func MapAppend(m map[string]interface{}, n ...map[string]interface{}) map[string]interface{} { @@ -204,8 +256,14 @@ func runManyCursorCommand(cursorpaths []string, args []string) ([]byte, error) { return nil, breverrors.WrapAndTrace(errs.ErrorOrNil()) } -func runVsCodeCommand(vscodepath string, args []string) ([]byte, error) { - cmd := exec.Command(vscodepath, args...) // #nosec G204 +// 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(path, ".exe") || strings.HasPrefix(path, "/mnt/")) { + return runWindowsExeInWSL(path, args) + } + + cmd := exec.Command(path, args...) // #nosec G204 res, err := cmd.CombinedOutput() if err != nil { return nil, breverrors.WrapAndTrace(err) @@ -213,13 +271,12 @@ func runVsCodeCommand(vscodepath string, args []string) ([]byte, error) { return res, nil } +func runVsCodeCommand(vscodepath string, args []string) ([]byte, error) { + return runEditorCommand(vscodepath, args) +} + func runCursorCommand(cursorpath string, args []string) ([]byte, error) { - cmd := exec.Command(cursorpath, args...) // #nosec G204 - res, err := cmd.CombinedOutput() - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - return res, nil + return runEditorCommand(cursorpath, args) } func runManyWindsurfCommand(windsurfpaths []string, args []string) ([]byte, error) { @@ -236,12 +293,7 @@ func runManyWindsurfCommand(windsurfpaths []string, args []string) ([]byte, erro } func runWindsurfCommand(windsurfpath string, args []string) ([]byte, error) { - 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{