From cbd42b6b6220b911fa32ef605712dd0d86bae80d Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Sun, 1 Feb 2026 23:02:00 -0800 Subject: [PATCH 1/7] feat(cli): add pipe support to stop, start, delete, and ls commands Add stdin piping and improved output for CLI composability. Stop/Start/Delete: - Accept instance names from stdin (one per line) - Accept multiple instance names as arguments - Output instance names when piped for chaining - Add --all flag to stop command Ls improvements: - Add --json flag for JSON output - Output plain table when piped (for grep/awk) - Add JSON and pipe support to ls orgs Examples: brev ls | awk '/RUNNING/ {print $1}' | brev stop brev ls | grep "test-" | awk '{print $1}' | brev delete brev ls --json | jq '.[] | select(.status == "RUNNING")' brev stop --all --- pkg/cmd/delete/delete.go | 69 +++++++++++-- pkg/cmd/ls/ls.go | 213 +++++++++++++++++++++++++++++++++++---- pkg/cmd/start/start.go | 83 ++++++++++++++- pkg/cmd/stop/stop.go | 90 ++++++++++++++--- 4 files changed, 405 insertions(+), 50 deletions(-) diff --git a/pkg/cmd/delete/delete.go b/pkg/cmd/delete/delete.go index 2465ca0a..18ce01e5 100644 --- a/pkg/cmd/delete/delete.go +++ b/pkg/cmd/delete/delete.go @@ -1,8 +1,10 @@ package delete import ( + "bufio" _ "embed" "fmt" + "os" "strings" "github.com/brevdev/brev-cli/pkg/cmd/completions" @@ -18,7 +20,7 @@ import ( var ( //go:embed doc.md deleteLong string - deleteExample = "brev delete " + deleteExample = "brev delete ...\necho instance-name | brev delete" ) type DeleteStore interface { @@ -37,11 +39,25 @@ func NewCmdDelete(t *terminal.Terminal, loginDeleteStore DeleteStore, noLoginDel Example: deleteExample, ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginDeleteStore, t), RunE: func(cmd *cobra.Command, args []string) error { + piped := isStdoutPiped() + names, err := getInstanceNames(args) + if err != nil { + return err + } var allError error - for _, workspace := range args { - err := deleteWorkspace(workspace, t, loginDeleteStore) + var deletedNames []string + for _, workspace := range names { + err := deleteWorkspace(workspace, t, loginDeleteStore, piped) if err != nil { allError = multierror.Append(allError, err) + } else { + deletedNames = append(deletedNames, workspace) + } + } + // Output names for piping to next command + if piped { + for _, name := range deletedNames { + fmt.Println(name) } } if allError != nil { @@ -54,10 +70,10 @@ func NewCmdDelete(t *terminal.Terminal, loginDeleteStore DeleteStore, noLoginDel return cmd } -func deleteWorkspace(workspaceName string, t *terminal.Terminal, deleteStore DeleteStore) error { +func deleteWorkspace(workspaceName string, t *terminal.Terminal, deleteStore DeleteStore, piped bool) error { workspace, err := util.GetUserWorkspaceByNameOrIDErr(deleteStore, workspaceName) if err != nil { - err1 := handleAdminUser(err, deleteStore) + err1 := handleAdminUser(err, deleteStore, piped) if err1 != nil { return breverrors.WrapAndTrace(err1) } @@ -75,12 +91,14 @@ func deleteWorkspace(workspaceName string, t *terminal.Terminal, deleteStore Del return breverrors.WrapAndTrace(err) } - t.Vprintf("Deleting instance %s. This can take a few minutes. Run 'brev ls' to check status\n", deletedWorkspace.Name) + if !piped { + t.Vprintf("Deleting instance %s. This can take a few minutes. Run 'brev ls' to check status\n", deletedWorkspace.Name) + } return nil } -func handleAdminUser(err error, deleteStore DeleteStore) error { +func handleAdminUser(err error, deleteStore DeleteStore, piped bool) error { if strings.Contains(err.Error(), "not found") { user, err1 := deleteStore.GetCurrentUser() if err1 != nil { @@ -89,7 +107,9 @@ func handleAdminUser(err error, deleteStore DeleteStore) error { if user.GlobalUserType != "Admin" { return breverrors.WrapAndTrace(err) } - fmt.Println("attempting to delete an instance you don't own as admin") + if !piped { + fmt.Println("attempting to delete an instance you don't own as admin") + } return nil } @@ -99,3 +119,36 @@ func handleAdminUser(err error, deleteStore DeleteStore) error { return nil } + +// isStdoutPiped returns true if stdout is being piped to another command +func isStdoutPiped() bool { + stat, _ := os.Stdout.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + +// getInstanceNames gets instance names from args or stdin (supports piping) +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 +} diff --git a/pkg/cmd/ls/ls.go b/pkg/cmd/ls/ls.go index 67ff24df..7aa0d525 100644 --- a/pkg/cmd/ls/ls.go +++ b/pkg/cmd/ls/ls.go @@ -2,6 +2,7 @@ package ls import ( + "encoding/json" "fmt" "os" @@ -37,17 +38,23 @@ type LsStore interface { func NewCmdLs(t *terminal.Terminal, loginLsStore LsStore, noLoginLsStore LsStore) *cobra.Command { var showAll bool var org string + var jsonOutput bool cmd := &cobra.Command{ Annotations: map[string]string{"workspace": ""}, Use: "ls", Aliases: []string{"list"}, Short: "List instances within active org", - Long: "List instances within your active org. List all instances if no active org is set.", + Long: `List instances within your active org. List all instances if no active org is set. + +When stdout is piped, outputs instance names only (one per line) for easy chaining +with other commands like stop, start, or delete.`, Example: ` brev ls + brev ls --json + brev ls | grep running | brev stop brev ls orgs - brev ls --org + brev ls orgs --json `, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { if hello.ShouldWeRunOnboardingLSStep(noLoginLsStore) && hello.ShouldWeRunOnboarding(noLoginLsStore) { @@ -94,23 +101,28 @@ func NewCmdLs(t *terminal.Terminal, loginLsStore LsStore, noLoginLsStore LsStore Args: cmderrors.TransformToValidationError(cobra.MinimumNArgs(0)), ValidArgs: []string{"orgs", "workspaces"}, RunE: func(cmd *cobra.Command, args []string) error { - err := RunLs(t, loginLsStore, args, org, showAll) + // Auto-switch to names-only output when piped (for chaining with stop/start/delete) + piped := isStdoutPiped() + + err := RunLs(t, loginLsStore, args, org, showAll, jsonOutput, piped) if err != nil { return breverrors.WrapAndTrace(err) } - // Call analytics for ls - userID := "" - user, err := loginLsStore.GetCurrentUser() - if err != nil { - userID = "" - } else { - userID = user.ID - } - data := analytics.EventData{ - EventName: "Brev ls", - UserID: userID, + // Call analytics for ls (skip when piped to avoid polluting output) + if !piped && !jsonOutput { + userID := "" + user, err := loginLsStore.GetCurrentUser() + if err != nil { + userID = "" + } else { + userID = user.ID + } + data := analytics.EventData{ + EventName: "Brev ls", + UserID: userID, + } + _ = analytics.TrackEvent(data) } - _ = analytics.TrackEvent(data) return nil }, } @@ -123,10 +135,17 @@ func NewCmdLs(t *terminal.Terminal, loginLsStore LsStore, noLoginLsStore LsStore } cmd.Flags().BoolVar(&showAll, "all", false, "show all workspaces in org") + cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON") return cmd } +// isStdoutPiped returns true if stdout is being piped to another command +func isStdoutPiped() bool { + stat, _ := os.Stdout.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + func getOrgForRunLs(lsStore LsStore, orgflag string) (*entity.Organization, error) { var org *entity.Organization if orgflag != "" { @@ -156,8 +175,8 @@ func getOrgForRunLs(lsStore LsStore, orgflag string) (*entity.Organization, erro return org, nil } -func RunLs(t *terminal.Terminal, lsStore LsStore, args []string, orgflag string, showAll bool) error { - ls := NewLs(lsStore, t) +func RunLs(t *terminal.Terminal, lsStore LsStore, args []string, orgflag string, showAll bool, jsonOutput bool, piped bool) error { + ls := NewLs(lsStore, t, jsonOutput, piped) user, err := lsStore.GetCurrentUser() if err != nil { return breverrors.WrapAndTrace(err) @@ -219,23 +238,41 @@ func handleLsArg(ls *Ls, arg string, user *entity.User, org *entity.Organization } type Ls struct { - lsStore LsStore - terminal *terminal.Terminal + lsStore LsStore + terminal *terminal.Terminal + jsonOutput bool + piped bool } -func NewLs(lsStore LsStore, terminal *terminal.Terminal) *Ls { +func NewLs(lsStore LsStore, terminal *terminal.Terminal, jsonOutput bool, piped bool) *Ls { return &Ls{ - lsStore: lsStore, - terminal: terminal, + lsStore: lsStore, + terminal: terminal, + jsonOutput: jsonOutput, + piped: piped, } } +// OrgInfo represents organization data for JSON output +type OrgInfo struct { + Name string `json:"name"` + ID string `json:"id"` + IsActive bool `json:"is_active"` +} + func (ls Ls) RunOrgs() error { orgs, err := ls.lsStore.GetOrganizations(nil) if err != nil { return breverrors.WrapAndTrace(err) } if len(orgs) == 0 { + if ls.jsonOutput { + fmt.Println("[]") + return nil + } + if ls.piped { + return nil + } ls.terminal.Vprint(ls.terminal.Yellow(fmt.Sprintf("You don't have any orgs. Create one! %s", config.GlobalConfig.GetConsoleURL()))) return nil } @@ -244,6 +281,19 @@ func (ls Ls) RunOrgs() error { if err != nil { return breverrors.WrapAndTrace(err) } + + // Handle JSON output + if ls.jsonOutput { + return ls.outputOrgsJSON(orgs, defaultOrg) + } + + // Handle piped output - clean table without colors + if ls.piped { + displayOrgTablePlain(orgs, defaultOrg) + return nil + } + + // Standard table output ls.terminal.Vprint(ls.terminal.Yellow("Your organizations:")) displayOrgTable(ls.terminal, orgs, defaultOrg) if len(orgs) > 1 { @@ -257,6 +307,23 @@ func (ls Ls) RunOrgs() error { return nil } +func (ls Ls) outputOrgsJSON(orgs []entity.Organization, defaultOrg *entity.Organization) error { + var infos []OrgInfo + for _, o := range orgs { + infos = append(infos, OrgInfo{ + Name: o.Name, + ID: o.ID, + IsActive: defaultOrg != nil && o.ID == defaultOrg.ID, + }) + } + output, err := json.MarshalIndent(infos, "", " ") + if err != nil { + return breverrors.WrapAndTrace(err) + } + fmt.Println(string(output)) + return nil +} + func getOtherOrg(orgs []entity.Organization, org entity.Organization) *entity.Organization { for _, o := range orgs { if org.ID != o.ID { @@ -350,6 +417,27 @@ func (ls Ls) RunWorkspaces(org *entity.Organization, user *entity.User, showAll return breverrors.WrapAndTrace(err) } + // Determine which workspaces to show + var workspacesToShow []entity.Workspace + if showAll { + workspacesToShow = allWorkspaces + } else { + workspacesToShow = store.FilterForUserWorkspaces(allWorkspaces, user.ID) + } + + // Handle JSON output + if ls.jsonOutput { + return ls.outputWorkspacesJSON(workspacesToShow) + } + + // Handle piped output - clean table without colors or extra text + // Enables: brev ls | grep RUNNING | awk '{print $1}' | brev stop + if ls.piped { + displayWorkspacesTablePlain(workspacesToShow) + return nil + } + + // Standard table output with colors and help text orgs, err := ls.lsStore.GetOrganizations(nil) if err != nil { return breverrors.WrapAndTrace(err) @@ -362,6 +450,52 @@ func (ls Ls) RunWorkspaces(org *entity.Organization, user *entity.User, showAll return nil } +// WorkspaceInfo represents workspace data for JSON output +type WorkspaceInfo struct { + Name string `json:"name"` + ID string `json:"id"` + Status string `json:"status"` + BuildStatus string `json:"build_status"` + ShellStatus string `json:"shell_status"` + HealthStatus string `json:"health_status"` + InstanceType string `json:"instance_type"` + InstanceKind string `json:"instance_kind"` +} + +// getInstanceTypeAndKind returns the instance type and kind (gpu/cpu) +func getInstanceTypeAndKind(w entity.Workspace) (string, string) { + if w.InstanceType != "" { + return w.InstanceType, "gpu" + } + if w.WorkspaceClassID != "" { + return w.WorkspaceClassID, "cpu" + } + return "", "" +} + +func (ls Ls) outputWorkspacesJSON(workspaces []entity.Workspace) error { + var infos []WorkspaceInfo + for _, w := range workspaces { + instanceType, instanceKind := getInstanceTypeAndKind(w) + infos = append(infos, WorkspaceInfo{ + Name: w.Name, + ID: w.ID, + Status: getWorkspaceDisplayStatus(w), + BuildStatus: string(w.VerbBuildStatus), + ShellStatus: getShellDisplayStatus(w), + HealthStatus: w.HealthStatus, + InstanceType: instanceType, + InstanceKind: instanceKind, + }) + } + output, err := json.MarshalIndent(infos, "", " ") + if err != nil { + return breverrors.WrapAndTrace(err) + } + fmt.Println(string(output)) + return nil +} + func (ls Ls) RunHosts(org *entity.Organization) error { user, err := ls.lsStore.GetCurrentUser() if err != nil { @@ -420,6 +554,23 @@ func displayWorkspacesTable(t *terminal.Terminal, workspaces []entity.Workspace) ta.Render() } +// displayWorkspacesTablePlain outputs a clean table without colors for piping +// Enables: brev ls | grep RUNNING | awk '{print $1}' | brev stop +func displayWorkspacesTablePlain(workspaces []entity.Workspace) { + ta := table.NewWriter() + ta.SetOutputMirror(os.Stdout) + ta.Style().Options = getBrevTableOptions() + header := table.Row{"NAME", "STATUS", "BUILD", "SHELL", "ID", "MACHINE"} + ta.AppendHeader(header) + for _, w := range workspaces { + status := getWorkspaceDisplayStatus(w) + instanceString := utilities.GetInstanceString(w) + workspaceRow := []table.Row{{w.Name, status, string(w.VerbBuildStatus), getShellDisplayStatus(w), w.ID, instanceString}} + ta.AppendRows(workspaceRow) + } + ta.Render() +} + func getShellDisplayStatus(w entity.Workspace) string { status := entity.NotReady if w.Status == entity.Running && w.VerbBuildStatus == entity.Completed { @@ -452,6 +603,24 @@ func displayOrgTable(t *terminal.Terminal, orgs []entity.Organization, currentOr ta.Render() } +// displayOrgTablePlain outputs a clean table without colors for piping +// Enables: brev ls orgs | grep myorg | awk '{print $1}' +func displayOrgTablePlain(orgs []entity.Organization, currentOrg *entity.Organization) { + ta := table.NewWriter() + ta.SetOutputMirror(os.Stdout) + ta.Style().Options = getBrevTableOptions() + header := table.Row{"NAME", "ID"} + ta.AppendHeader(header) + for _, o := range orgs { + activeMarker := "" + if currentOrg != nil && o.ID == currentOrg.ID { + activeMarker = "* " + } + ta.AppendRows([]table.Row{{activeMarker + o.Name, o.ID}}) + } + ta.Render() +} + func displayProjectsTable(projects []virtualproject.VirtualProject) { ta := table.NewWriter() ta.SetOutputMirror(os.Stdout) diff --git a/pkg/cmd/start/start.go b/pkg/cmd/start/start.go index 9f082e31..547d537b 100644 --- a/pkg/cmd/start/start.go +++ b/pkg/cmd/start/start.go @@ -2,8 +2,10 @@ package start import ( + "bufio" "fmt" "net/url" + "os" "path/filepath" "strings" "time" @@ -29,6 +31,7 @@ var ( brev start brev start brev start --org myFancyOrg + echo instance-name | brev start ` ) @@ -65,10 +68,8 @@ func NewCmdStart(t *terminal.Terminal, startStore StartStore, noLoginStartStore Example: startExample, ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), RunE: func(cmd *cobra.Command, args []string) error { - repoOrPathOrNameOrID := "" - if len(args) > 0 { - repoOrPathOrNameOrID = args[0] - } + piped := isStdoutPiped() + names, stdinPiped := getInstanceNamesFromStdin(args) if gpu != "" { isValid := instancetypes.ValidateInstanceType(gpu) @@ -78,6 +79,45 @@ func NewCmdStart(t *terminal.Terminal, startStore StartStore, noLoginStartStore } } + // If stdin is piped, handle multiple instances (only start existing stopped instances) + if stdinPiped && len(names) > 0 { + var startedNames []string + for _, instanceName := range names { + err := runStartWorkspace(t, StartOptions{ + RepoOrPathOrNameOrID: instanceName, + Name: "", + OrgName: org, + SetupScript: setupScript, + SetupRepo: setupRepo, + SetupPath: setupPath, + WorkspaceClass: cpu, + Detached: true, // Always detached when piping multiple + InstanceType: gpu, + Piped: piped, + }, startStore) + if err != nil { + if !piped { + t.Vprintf("Error starting %s: %s\n", instanceName, err.Error()) + } + } else { + startedNames = append(startedNames, instanceName) + } + } + // Output names for piping to next command + if piped { + for _, n := range startedNames { + fmt.Println(n) + } + } + return nil + } + + // Single instance mode (original behavior) + repoOrPathOrNameOrID := "" + if len(names) > 0 { + repoOrPathOrNameOrID = names[0] + } + err := runStartWorkspace(t, StartOptions{ RepoOrPathOrNameOrID: repoOrPathOrNameOrID, Name: name, @@ -88,6 +128,7 @@ func NewCmdStart(t *terminal.Terminal, startStore StartStore, noLoginStartStore WorkspaceClass: cpu, Detached: detached, InstanceType: gpu, + Piped: piped, }, startStore) if err != nil { if strings.Contains(err.Error(), "duplicate instance with name") { @@ -97,6 +138,10 @@ func NewCmdStart(t *terminal.Terminal, startStore StartStore, noLoginStartStore } return breverrors.WrapAndTrace(err) } + // Output name for piping to next command + if piped && repoOrPathOrNameOrID != "" { + fmt.Println(repoOrPathOrNameOrID) + } return nil }, } @@ -129,6 +174,7 @@ type StartOptions struct { WorkspaceClass string Detached bool InstanceType string + Piped bool // true when stdout is piped to another command } func runStartWorkspace(t *terminal.Terminal, options StartOptions, startStore StartStore) error { @@ -689,3 +735,32 @@ func pollUntil(t *terminal.Terminal, wsid string, state string, startStore Start } return nil } + +// isStdoutPiped returns true if stdout is being piped to another command +func isStdoutPiped() bool { + stat, _ := os.Stdout.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + +// getInstanceNamesFromStdin returns instance names from args and stdin if piped +// Returns the names and whether stdin was piped +func getInstanceNamesFromStdin(args []string) ([]string, bool) { + var names []string + names = append(names, args...) + + // Check if stdin is piped + stat, _ := os.Stdin.Stat() + stdinPiped := (stat.Mode() & os.ModeCharDevice) == 0 + + if stdinPiped { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + name := strings.TrimSpace(scanner.Text()) + if name != "" { + names = append(names, name) + } + } + } + + return names, stdinPiped +} diff --git a/pkg/cmd/stop/stop.go b/pkg/cmd/stop/stop.go index 8f76eb7a..135dcdb8 100644 --- a/pkg/cmd/stop/stop.go +++ b/pkg/cmd/stop/stop.go @@ -2,7 +2,9 @@ package stop import ( + "bufio" "fmt" + "os" "strings" "github.com/brevdev/brev-cli/pkg/cmd/completions" @@ -17,7 +19,7 @@ import ( var ( stopLong = "Stop a Brev machine that's in a running state" - stopExample = "brev stop ... \nbrev stop --all" + stopExample = "brev stop ...\nbrev stop --all\necho instance-name | brev stop" ) type StopStore interface { @@ -43,17 +45,28 @@ func NewCmdStop(t *terminal.Terminal, loginStopStore StopStore, noLoginStopStore // Args: cmderrors.TransformToValidationError(cobra.ExactArgs()), ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStopStore, t), RunE: func(cmd *cobra.Command, args []string) error { + piped := isStdoutPiped() if all { - return stopAllWorkspaces(t, loginStopStore) + return stopAllWorkspaces(t, loginStopStore, piped) } else { - if len(args) == 0 { - return breverrors.NewValidationError("please provide an instance to stop") + names, err := getInstanceNames(args) + if err != nil { + return err } var allErr error - for _, arg := range args { - err := stopWorkspace(arg, t, loginStopStore) + var stoppedNames []string + for _, name := range names { + err := stopWorkspace(name, t, loginStopStore, piped) if err != nil { allErr = multierror.Append(allErr, err) + } else { + stoppedNames = append(stoppedNames, name) + } + } + // Output names for piping to next command + if piped { + for _, name := range stoppedNames { + fmt.Println(name) } } if allErr != nil { @@ -68,7 +81,7 @@ func NewCmdStop(t *terminal.Terminal, loginStopStore StopStore, noLoginStopStore return cmd } -func stopAllWorkspaces(t *terminal.Terminal, stopStore StopStore) error { +func stopAllWorkspaces(t *terminal.Terminal, stopStore StopStore, piped bool) error { user, err := stopStore.GetCurrentUser() if err != nil { return breverrors.WrapAndTrace(err) @@ -81,21 +94,33 @@ func stopAllWorkspaces(t *terminal.Terminal, stopStore StopStore) error { if err != nil { return breverrors.WrapAndTrace(err) } - t.Vprintf("Turning off all of your instances") + if !piped { + t.Vprintf("Turning off all of your instances") + } + var stoppedNames []string for _, v := range workspaces { if v.Status == entity.Running { _, err = stopStore.StopWorkspace(v.ID) if err != nil { return breverrors.WrapAndTrace(err) } else { - t.Vprintf("%s", t.Green("\n%s stopped ✓", v.Name)) + stoppedNames = append(stoppedNames, v.Name) + if !piped { + t.Vprintf("%s", t.Green("\n%s stopped ✓", v.Name)) + } } } } + // Output names for piping to next command + if piped { + for _, name := range stoppedNames { + fmt.Println(name) + } + } return nil } -func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopStore) error { +func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopStore, piped bool) error { user, err := stopStore.GetCurrentUser() if err != nil { return breverrors.WrapAndTrace(err) @@ -106,7 +131,9 @@ func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopSto if workspaceName == "self" { wsID, err2 := stopStore.GetCurrentWorkspaceID() if err2 != nil { - t.Vprintf("\n Error: %s", t.Red(err2.Error())) + if !piped { + t.Vprintf("\n Error: %s", t.Red(err2.Error())) + } return breverrors.WrapAndTrace(err2) } workspaceID = wsID @@ -117,7 +144,9 @@ func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopSto return breverrors.WrapAndTrace(err3) } else { if user.GlobalUserType == entity.Admin { - fmt.Println("admin trying to stop any instance") + if !piped { + fmt.Println("admin trying to stop any instance") + } workspace, err = util.GetAnyWorkspaceByIDOrNameInActiveOrgErr(stopStore, workspaceName) if err != nil { return breverrors.WrapAndTrace(err) @@ -133,7 +162,7 @@ func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopSto _, err = stopStore.StopWorkspace(workspaceID) if err != nil { return breverrors.WrapAndTrace(err) - } else { + } else if !piped { if workspaceName == "self" { t.Vprintf("%s", t.Green("Stopping this instance\n")+ "Note: this can take a few seconds. Run 'brev ls' to check status\n") @@ -146,6 +175,35 @@ func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopSto return nil } -// get current workspace -// stopWorkspace("") -// stop the workspace +// isStdoutPiped returns true if stdout is being piped to another command +func isStdoutPiped() bool { + stat, _ := os.Stdout.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + +// getInstanceNames gets instance names from args or stdin (supports piping) +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 +} From 3ddae0a1d81bd08964d033ca1b86bd950eb1f173 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Mon, 2 Feb 2026 15:22:00 -0800 Subject: [PATCH 2/7] refactor: extract helper functions to reduce cognitive complexity - Extract trackLsAnalytics() in ls.go - Extract runBatchStart() and runSingleStart() in start.go Co-Authored-By: Claude Opus 4.5 --- pkg/cmd/ls/ls.go | 27 +++++---- pkg/cmd/start/start.go | 128 ++++++++++++++++++++++------------------- 2 files changed, 84 insertions(+), 71 deletions(-) diff --git a/pkg/cmd/ls/ls.go b/pkg/cmd/ls/ls.go index 7aa0d525..748a21cb 100644 --- a/pkg/cmd/ls/ls.go +++ b/pkg/cmd/ls/ls.go @@ -110,18 +110,7 @@ with other commands like stop, start, or delete.`, } // Call analytics for ls (skip when piped to avoid polluting output) if !piped && !jsonOutput { - userID := "" - user, err := loginLsStore.GetCurrentUser() - if err != nil { - userID = "" - } else { - userID = user.ID - } - data := analytics.EventData{ - EventName: "Brev ls", - UserID: userID, - } - _ = analytics.TrackEvent(data) + trackLsAnalytics(loginLsStore) } return nil }, @@ -146,6 +135,20 @@ func isStdoutPiped() bool { return (stat.Mode() & os.ModeCharDevice) == 0 } +// trackLsAnalytics sends analytics event for ls command +func trackLsAnalytics(store LsStore) { + userID := "" + user, err := store.GetCurrentUser() + if err == nil { + userID = user.ID + } + data := analytics.EventData{ + EventName: "Brev ls", + UserID: userID, + } + _ = analytics.TrackEvent(data) +} + func getOrgForRunLs(lsStore LsStore, orgflag string) (*entity.Organization, error) { var org *entity.Organization if orgflag != "" { diff --git a/pkg/cmd/start/start.go b/pkg/cmd/start/start.go index 547d537b..7c7fe0dc 100644 --- a/pkg/cmd/start/start.go +++ b/pkg/cmd/start/start.go @@ -81,68 +81,11 @@ func NewCmdStart(t *terminal.Terminal, startStore StartStore, noLoginStartStore // If stdin is piped, handle multiple instances (only start existing stopped instances) if stdinPiped && len(names) > 0 { - var startedNames []string - for _, instanceName := range names { - err := runStartWorkspace(t, StartOptions{ - RepoOrPathOrNameOrID: instanceName, - Name: "", - OrgName: org, - SetupScript: setupScript, - SetupRepo: setupRepo, - SetupPath: setupPath, - WorkspaceClass: cpu, - Detached: true, // Always detached when piping multiple - InstanceType: gpu, - Piped: piped, - }, startStore) - if err != nil { - if !piped { - t.Vprintf("Error starting %s: %s\n", instanceName, err.Error()) - } - } else { - startedNames = append(startedNames, instanceName) - } - } - // Output names for piping to next command - if piped { - for _, n := range startedNames { - fmt.Println(n) - } - } - return nil + return runBatchStart(t, names, org, setupScript, setupRepo, setupPath, cpu, gpu, piped, startStore) } // Single instance mode (original behavior) - repoOrPathOrNameOrID := "" - if len(names) > 0 { - repoOrPathOrNameOrID = names[0] - } - - err := runStartWorkspace(t, StartOptions{ - RepoOrPathOrNameOrID: repoOrPathOrNameOrID, - Name: name, - OrgName: org, - SetupScript: setupScript, - SetupRepo: setupRepo, - SetupPath: setupPath, - WorkspaceClass: cpu, - Detached: detached, - InstanceType: gpu, - Piped: piped, - }, startStore) - if err != nil { - if strings.Contains(err.Error(), "duplicate instance with name") { - t.Vprint(t.Yellow("try running:")) - t.Vprint(t.Yellow("\tbrev start --name [different name] [repo] # or")) - t.Vprint(t.Yellow("\tbrev delete [name]")) - } - return breverrors.WrapAndTrace(err) - } - // Output name for piping to next command - if piped && repoOrPathOrNameOrID != "" { - fmt.Println(repoOrPathOrNameOrID) - } - return nil + return runSingleStart(t, names, name, org, setupScript, setupRepo, setupPath, cpu, gpu, detached, piped, startStore) }, } cmd.Flags().BoolVarP(&detached, "detached", "d", false, "run the command in the background instead of blocking the shell") @@ -764,3 +707,70 @@ func getInstanceNamesFromStdin(args []string) ([]string, bool) { return names, stdinPiped } + +// runBatchStart handles starting multiple instances when stdin is piped +func runBatchStart(t *terminal.Terminal, names []string, org, setupScript, setupRepo, setupPath, cpu, gpu string, piped bool, startStore StartStore) error { + var startedNames []string + for _, instanceName := range names { + err := runStartWorkspace(t, StartOptions{ + RepoOrPathOrNameOrID: instanceName, + Name: "", + OrgName: org, + SetupScript: setupScript, + SetupRepo: setupRepo, + SetupPath: setupPath, + WorkspaceClass: cpu, + Detached: true, // Always detached when piping multiple + InstanceType: gpu, + Piped: piped, + }, startStore) + if err != nil { + if !piped { + t.Vprintf("Error starting %s: %s\n", instanceName, err.Error()) + } + } else { + startedNames = append(startedNames, instanceName) + } + } + // Output names for piping to next command + if piped { + for _, n := range startedNames { + fmt.Println(n) + } + } + return nil +} + +// runSingleStart handles starting a single instance (original behavior) +func runSingleStart(t *terminal.Terminal, names []string, name, org, setupScript, setupRepo, setupPath, cpu, gpu string, detached, piped bool, startStore StartStore) error { + repoOrPathOrNameOrID := "" + if len(names) > 0 { + repoOrPathOrNameOrID = names[0] + } + + err := runStartWorkspace(t, StartOptions{ + RepoOrPathOrNameOrID: repoOrPathOrNameOrID, + Name: name, + OrgName: org, + SetupScript: setupScript, + SetupRepo: setupRepo, + SetupPath: setupPath, + WorkspaceClass: cpu, + Detached: detached, + InstanceType: gpu, + Piped: piped, + }, startStore) + if err != nil { + if strings.Contains(err.Error(), "duplicate instance with name") { + t.Vprint(t.Yellow("try running:")) + t.Vprint(t.Yellow("\tbrev start --name [different name] [repo] # or")) + t.Vprint(t.Yellow("\tbrev delete [name]")) + } + return breverrors.WrapAndTrace(err) + } + // Output name for piping to next command + if piped && repoOrPathOrNameOrID != "" { + fmt.Println(repoOrPathOrNameOrID) + } + return nil +} From 03b8f8c419b1e861bffcd9ae9dc3d97780c5bff2 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Mon, 2 Feb 2026 19:41:20 -0800 Subject: [PATCH 3/7] refactor: extract piping utilities to shared pkg/cmd/util/piping.go - Consolidate duplicate isStdoutPiped() from delete, ls, start, stop - Consolidate duplicate getInstanceNames() from delete, stop - Consolidate duplicate getInstanceNamesFromStdin() from start - Create IsStdoutPiped(), IsStdinPiped(), GetInstanceNames(), GetInstanceNamesWithPipeInfo() - Remove ~108 lines of duplicated code --- pkg/cmd/delete/delete.go | 41 +++--------------------------- pkg/cmd/ls/ls.go | 14 +++-------- pkg/cmd/start/start.go | 54 ++++++++-------------------------------- pkg/cmd/stop/stop.go | 41 +++--------------------------- pkg/cmd/util/piping.go | 53 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 74 insertions(+), 129 deletions(-) create mode 100644 pkg/cmd/util/piping.go diff --git a/pkg/cmd/delete/delete.go b/pkg/cmd/delete/delete.go index 18ce01e5..198d7c32 100644 --- a/pkg/cmd/delete/delete.go +++ b/pkg/cmd/delete/delete.go @@ -1,10 +1,8 @@ package delete import ( - "bufio" _ "embed" "fmt" - "os" "strings" "github.com/brevdev/brev-cli/pkg/cmd/completions" @@ -39,10 +37,10 @@ func NewCmdDelete(t *terminal.Terminal, loginDeleteStore DeleteStore, noLoginDel Example: deleteExample, ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginDeleteStore, t), RunE: func(cmd *cobra.Command, args []string) error { - piped := isStdoutPiped() - names, err := getInstanceNames(args) + piped := util.IsStdoutPiped() + names, err := util.GetInstanceNames(args) if err != nil { - return err + return breverrors.WrapAndTrace(err) } var allError error var deletedNames []string @@ -119,36 +117,3 @@ func handleAdminUser(err error, deleteStore DeleteStore, piped bool) error { return nil } - -// isStdoutPiped returns true if stdout is being piped to another command -func isStdoutPiped() bool { - stat, _ := os.Stdout.Stat() - return (stat.Mode() & os.ModeCharDevice) == 0 -} - -// getInstanceNames gets instance names from args or stdin (supports piping) -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 -} diff --git a/pkg/cmd/ls/ls.go b/pkg/cmd/ls/ls.go index 748a21cb..01280159 100644 --- a/pkg/cmd/ls/ls.go +++ b/pkg/cmd/ls/ls.go @@ -10,7 +10,7 @@ import ( "github.com/brevdev/brev-cli/pkg/cmd/cmderrors" "github.com/brevdev/brev-cli/pkg/cmd/completions" "github.com/brevdev/brev-cli/pkg/cmd/hello" - utilities "github.com/brevdev/brev-cli/pkg/cmd/util" + cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" "github.com/brevdev/brev-cli/pkg/cmdcontext" "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" @@ -102,7 +102,7 @@ with other commands like stop, start, or delete.`, ValidArgs: []string{"orgs", "workspaces"}, RunE: func(cmd *cobra.Command, args []string) error { // Auto-switch to names-only output when piped (for chaining with stop/start/delete) - piped := isStdoutPiped() + piped := cmdutil.IsStdoutPiped() err := RunLs(t, loginLsStore, args, org, showAll, jsonOutput, piped) if err != nil { @@ -129,12 +129,6 @@ with other commands like stop, start, or delete.`, return cmd } -// isStdoutPiped returns true if stdout is being piped to another command -func isStdoutPiped() bool { - stat, _ := os.Stdout.Stat() - return (stat.Mode() & os.ModeCharDevice) == 0 -} - // trackLsAnalytics sends analytics event for ls command func trackLsAnalytics(store LsStore) { userID := "" @@ -550,7 +544,7 @@ func displayWorkspacesTable(t *terminal.Terminal, workspaces []entity.Workspace) ta.AppendHeader(header) for _, w := range workspaces { status := getWorkspaceDisplayStatus(w) - instanceString := utilities.GetInstanceString(w) + instanceString := cmdutil.GetInstanceString(w) workspaceRow := []table.Row{{w.Name, getStatusColoredText(t, status), getStatusColoredText(t, string(w.VerbBuildStatus)), getStatusColoredText(t, getShellDisplayStatus(w)), w.ID, instanceString}} ta.AppendRows(workspaceRow) } @@ -567,7 +561,7 @@ func displayWorkspacesTablePlain(workspaces []entity.Workspace) { ta.AppendHeader(header) for _, w := range workspaces { status := getWorkspaceDisplayStatus(w) - instanceString := utilities.GetInstanceString(w) + instanceString := cmdutil.GetInstanceString(w) workspaceRow := []table.Row{{w.Name, status, string(w.VerbBuildStatus), getShellDisplayStatus(w), w.ID, instanceString}} ta.AppendRows(workspaceRow) } diff --git a/pkg/cmd/start/start.go b/pkg/cmd/start/start.go index 7c7fe0dc..ecb53d66 100644 --- a/pkg/cmd/start/start.go +++ b/pkg/cmd/start/start.go @@ -2,27 +2,24 @@ package start import ( - "bufio" "fmt" "net/url" - "os" "path/filepath" "strings" "time" "github.com/brevdev/brev-cli/pkg/cmd/completions" - "github.com/brevdev/brev-cli/pkg/cmd/util" + cmdutil "github.com/brevdev/brev-cli/pkg/cmd/util" "github.com/brevdev/brev-cli/pkg/config" "github.com/brevdev/brev-cli/pkg/entity" + breverrors "github.com/brevdev/brev-cli/pkg/errors" "github.com/brevdev/brev-cli/pkg/featureflag" "github.com/brevdev/brev-cli/pkg/instancetypes" "github.com/brevdev/brev-cli/pkg/mergeshells" "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" - allutil "github.com/brevdev/brev-cli/pkg/util" + "github.com/brevdev/brev-cli/pkg/util" "github.com/spf13/cobra" - - breverrors "github.com/brevdev/brev-cli/pkg/errors" ) var ( @@ -36,7 +33,7 @@ var ( ) type StartStore interface { - util.GetWorkspaceByNameOrIDErrStore + cmdutil.GetWorkspaceByNameOrIDErrStore GetWorkspaces(organizationID string, options *store.GetWorkspacesOptions) ([]entity.Workspace, error) GetActiveOrganizationOrDefault() (*entity.Organization, error) GetCurrentUser() (*entity.User, error) @@ -68,8 +65,8 @@ func NewCmdStart(t *terminal.Terminal, startStore StartStore, noLoginStartStore Example: startExample, ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStartStore, t), RunE: func(cmd *cobra.Command, args []string) error { - piped := isStdoutPiped() - names, stdinPiped := getInstanceNamesFromStdin(args) + piped := cmdutil.IsStdoutPiped() + names, stdinPiped := cmdutil.GetInstanceNamesWithPipeInfo(args) if gpu != "" { isValid := instancetypes.ValidateInstanceType(gpu) @@ -162,7 +159,7 @@ func runStartWorkspace(t *terminal.Terminal, options StartOptions, startStore St } func maybeStartWithLocalPath(options StartOptions, user *entity.User, t *terminal.Terminal, startStore StartStore) (bool, error) { - if allutil.DoesPathExist(options.RepoOrPathOrNameOrID) { + if util.DoesPathExist(options.RepoOrPathOrNameOrID) { err := startWorkspaceFromPath(user, t, options, startStore) if err != nil { return false, breverrors.WrapAndTrace(err) @@ -194,7 +191,7 @@ func maybeStartStoppedOrJoin(t *terminal.Terminal, user *entity.User, options St return false, breverrors.NewValidationError(fmt.Sprintf("workspace with id/name %s is a failed workspace", options.RepoOrPathOrNameOrID)) } } - if allutil.DoesPathExist(options.RepoOrPathOrNameOrID) { + if util.DoesPathExist(options.RepoOrPathOrNameOrID) { t.Print(t.Yellow(fmt.Sprintf("Warning: local path found and instance name/id found %s. Using instance name/id. If you meant to specify a local path change directory and try again.", options.RepoOrPathOrNameOrID))) } errr := startStopppedWorkspace(&userWorkspaces[0], startStore, t, options) @@ -215,7 +212,7 @@ func maybeStartStoppedOrJoin(t *terminal.Terminal, user *entity.User, options St } func maybeStartFromGitURL(t *terminal.Terminal, user *entity.User, options StartOptions, startStore StartStore) (bool, error) { - if allutil.IsGitURL(options.RepoOrPathOrNameOrID) { // todo this is function is not complete, some cloneable urls are not identified + if util.IsGitURL(options.RepoOrPathOrNameOrID) { // todo this is function is not complete, some cloneable urls are not identified err := createNewWorkspaceFromGit(user, t, options.SetupScript, options, startStore) if err != nil { return true, breverrors.WrapAndTrace(err) @@ -237,7 +234,7 @@ func maybeStartEmpty(t *terminal.Terminal, user *entity.User, options StartOptio } func startWorkspaceFromPath(user *entity.User, t *terminal.Terminal, options StartOptions, startStore StartStore) error { - pathExists := allutil.DoesPathExist(options.RepoOrPathOrNameOrID) + pathExists := util.DoesPathExist(options.RepoOrPathOrNameOrID) if !pathExists { return fmt.Errorf("Path: %s does not exist", options.RepoOrPathOrNameOrID) } @@ -267,7 +264,7 @@ func startWorkspaceFromPath(user *entity.User, t *terminal.Terminal, options Sta if options.RepoOrPathOrNameOrID == "." { localSetupPath = filepath.Join(".brev", "setup.sh") } - if !allutil.DoesPathExist(localSetupPath) { + if !util.DoesPathExist(localSetupPath) { fmt.Println(strings.Join([]string{"Generating setup script at", localSetupPath}, "\n")) mergeshells.ImportPath(t, options.RepoOrPathOrNameOrID, startStore) fmt.Println("setup script generated.") @@ -679,35 +676,6 @@ func pollUntil(t *terminal.Terminal, wsid string, state string, startStore Start return nil } -// isStdoutPiped returns true if stdout is being piped to another command -func isStdoutPiped() bool { - stat, _ := os.Stdout.Stat() - return (stat.Mode() & os.ModeCharDevice) == 0 -} - -// getInstanceNamesFromStdin returns instance names from args and stdin if piped -// Returns the names and whether stdin was piped -func getInstanceNamesFromStdin(args []string) ([]string, bool) { - var names []string - names = append(names, args...) - - // Check if stdin is piped - stat, _ := os.Stdin.Stat() - stdinPiped := (stat.Mode() & os.ModeCharDevice) == 0 - - if stdinPiped { - scanner := bufio.NewScanner(os.Stdin) - for scanner.Scan() { - name := strings.TrimSpace(scanner.Text()) - if name != "" { - names = append(names, name) - } - } - } - - return names, stdinPiped -} - // runBatchStart handles starting multiple instances when stdin is piped func runBatchStart(t *terminal.Terminal, names []string, org, setupScript, setupRepo, setupPath, cpu, gpu string, piped bool, startStore StartStore) error { var startedNames []string diff --git a/pkg/cmd/stop/stop.go b/pkg/cmd/stop/stop.go index 135dcdb8..f7572cc4 100644 --- a/pkg/cmd/stop/stop.go +++ b/pkg/cmd/stop/stop.go @@ -2,9 +2,7 @@ package stop import ( - "bufio" "fmt" - "os" "strings" "github.com/brevdev/brev-cli/pkg/cmd/completions" @@ -45,13 +43,13 @@ func NewCmdStop(t *terminal.Terminal, loginStopStore StopStore, noLoginStopStore // Args: cmderrors.TransformToValidationError(cobra.ExactArgs()), ValidArgsFunction: completions.GetAllWorkspaceNameCompletionHandler(noLoginStopStore, t), RunE: func(cmd *cobra.Command, args []string) error { - piped := isStdoutPiped() + piped := util.IsStdoutPiped() if all { return stopAllWorkspaces(t, loginStopStore, piped) } else { - names, err := getInstanceNames(args) + names, err := util.GetInstanceNames(args) if err != nil { - return err + return breverrors.WrapAndTrace(err) } var allErr error var stoppedNames []string @@ -174,36 +172,3 @@ func stopWorkspace(workspaceName string, t *terminal.Terminal, stopStore StopSto return nil } - -// isStdoutPiped returns true if stdout is being piped to another command -func isStdoutPiped() bool { - stat, _ := os.Stdout.Stat() - return (stat.Mode() & os.ModeCharDevice) == 0 -} - -// getInstanceNames gets instance names from args or stdin (supports piping) -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 -} diff --git a/pkg/cmd/util/piping.go b/pkg/cmd/util/piping.go new file mode 100644 index 00000000..fbabb7d0 --- /dev/null +++ b/pkg/cmd/util/piping.go @@ -0,0 +1,53 @@ +package util + +import ( + "bufio" + "os" + "strings" + + breverrors "github.com/brevdev/brev-cli/pkg/errors" +) + +// IsStdoutPiped returns true if stdout is being piped to another command +// Enables command chaining like: brev ls | grep RUNNING | brev stop +func IsStdoutPiped() bool { + stat, _ := os.Stdout.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + +// IsStdinPiped returns true if stdin is being piped from another command +func IsStdinPiped() bool { + stat, _ := os.Stdin.Stat() + return (stat.Mode() & os.ModeCharDevice) == 0 +} + +// GetInstanceNames gets instance names from args or stdin (supports piping) +// Returns error if no names are provided +func GetInstanceNames(args []string) ([]string, error) { + names, _ := GetInstanceNamesWithPipeInfo(args) + if len(names) == 0 { + return nil, breverrors.NewValidationError("instance name required: provide as argument or pipe from another command") + } + return names, nil +} + +// GetInstanceNamesWithPipeInfo gets instance names from args or stdin and +// returns whether stdin was piped. Useful when you need to know if input came +// from a pipe vs args. +func GetInstanceNamesWithPipeInfo(args []string) ([]string, bool) { + var names []string + names = append(names, args...) + + stdinPiped := IsStdinPiped() + if stdinPiped { + scanner := bufio.NewScanner(os.Stdin) + for scanner.Scan() { + name := strings.TrimSpace(scanner.Text()) + if name != "" { + names = append(names, name) + } + } + } + + return names, stdinPiped +} From fba04110d150626a5280ebe9dce41a85e162b68c Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Thu, 12 Feb 2026 12:02:08 -0800 Subject: [PATCH 4/7] fix: address PR review comments for piping-composability - Return errors from runBatchStart instead of silently swallowing them - Remove unused Piped field from StartOptions - Add scanner.Err() check after stdin scan loop in piping.go --- pkg/cmd/start/start.go | 14 ++++++++------ pkg/cmd/util/piping.go | 5 +++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/pkg/cmd/start/start.go b/pkg/cmd/start/start.go index ecb53d66..56a7cf6f 100644 --- a/pkg/cmd/start/start.go +++ b/pkg/cmd/start/start.go @@ -4,6 +4,7 @@ package start import ( "fmt" "net/url" + "os" "path/filepath" "strings" "time" @@ -19,6 +20,7 @@ import ( "github.com/brevdev/brev-cli/pkg/store" "github.com/brevdev/brev-cli/pkg/terminal" "github.com/brevdev/brev-cli/pkg/util" + "github.com/hashicorp/go-multierror" "github.com/spf13/cobra" ) @@ -114,7 +116,6 @@ type StartOptions struct { WorkspaceClass string Detached bool InstanceType string - Piped bool // true when stdout is piped to another command } func runStartWorkspace(t *terminal.Terminal, options StartOptions, startStore StartStore) error { @@ -679,6 +680,7 @@ func pollUntil(t *terminal.Terminal, wsid string, state string, startStore Start // runBatchStart handles starting multiple instances when stdin is piped func runBatchStart(t *terminal.Terminal, names []string, org, setupScript, setupRepo, setupPath, cpu, gpu string, piped bool, startStore StartStore) error { var startedNames []string + var errs error for _, instanceName := range names { err := runStartWorkspace(t, StartOptions{ RepoOrPathOrNameOrID: instanceName, @@ -690,12 +692,10 @@ func runBatchStart(t *terminal.Terminal, names []string, org, setupScript, setup WorkspaceClass: cpu, Detached: true, // Always detached when piping multiple InstanceType: gpu, - Piped: piped, }, startStore) if err != nil { - if !piped { - t.Vprintf("Error starting %s: %s\n", instanceName, err.Error()) - } + fmt.Fprintf(os.Stderr, "Error starting %s: %s\n", instanceName, err.Error()) + errs = multierror.Append(errs, err) } else { startedNames = append(startedNames, instanceName) } @@ -706,6 +706,9 @@ func runBatchStart(t *terminal.Terminal, names []string, org, setupScript, setup fmt.Println(n) } } + if errs != nil { + return breverrors.WrapAndTrace(errs) + } return nil } @@ -726,7 +729,6 @@ func runSingleStart(t *terminal.Terminal, names []string, name, org, setupScript WorkspaceClass: cpu, Detached: detached, InstanceType: gpu, - Piped: piped, }, startStore) if err != nil { if strings.Contains(err.Error(), "duplicate instance with name") { diff --git a/pkg/cmd/util/piping.go b/pkg/cmd/util/piping.go index fbabb7d0..68e45f6b 100644 --- a/pkg/cmd/util/piping.go +++ b/pkg/cmd/util/piping.go @@ -2,6 +2,7 @@ package util import ( "bufio" + "fmt" "os" "strings" @@ -47,6 +48,10 @@ func GetInstanceNamesWithPipeInfo(args []string) ([]string, bool) { names = append(names, name) } } + if err := scanner.Err(); err != nil { + // Log but don't fail - partial input is still useful + fmt.Fprintf(os.Stderr, "warning: error reading stdin: %v\n", err) + } } return names, stdinPiped From 6d2c612a6212f6acb9c578dbdd76efe21211bb04 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 17 Feb 2026 16:56:31 -0800 Subject: [PATCH 5/7] feat: add exit code support for partial batch failures - Add ExitCodeError type to distinguish failure modes - Batch start returns exit code 2 for partial failure, 1 for all failed - main.go propagates custom exit codes from ExitCodeError --- main.go | 8 +++++++- pkg/cmd/start/start.go | 11 ++++++++--- pkg/errors/errors.go | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index c7b9bc5c..c12b5be3 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + stderrors "errors" "os" "github.com/brevdev/brev-cli/pkg/cmd" @@ -16,6 +17,11 @@ func main() { if err := command.Execute(); err != nil { cmderrors.DisplayAndHandleError(err) done() - os.Exit(1) //nolint:gocritic // manually call done + exitCode := 1 + var exitErr errors.ExitCodeError + if stderrors.As(err, &exitErr) { + exitCode = exitErr.ExitCode + } + os.Exit(exitCode) //nolint:gocritic // manually call done } } diff --git a/pkg/cmd/start/start.go b/pkg/cmd/start/start.go index 56a7cf6f..4de9186d 100644 --- a/pkg/cmd/start/start.go +++ b/pkg/cmd/start/start.go @@ -677,7 +677,8 @@ func pollUntil(t *terminal.Terminal, wsid string, state string, startStore Start return nil } -// runBatchStart handles starting multiple instances when stdin is piped +// runBatchStart handles starting multiple instances when stdin is piped. +// Exit codes: 0 = all succeeded, 1 = all failed, 2 = partial failure. func runBatchStart(t *terminal.Terminal, names []string, org, setupScript, setupRepo, setupPath, cpu, gpu string, piped bool, startStore StartStore) error { var startedNames []string var errs error @@ -700,14 +701,18 @@ func runBatchStart(t *terminal.Terminal, names []string, org, setupScript, setup startedNames = append(startedNames, instanceName) } } - // Output names for piping to next command + // Always output successful names for piping to next command if piped { for _, n := range startedNames { fmt.Println(n) } } if errs != nil { - return breverrors.WrapAndTrace(errs) + exitCode := 1 // all failed + if len(startedNames) > 0 { + exitCode = 2 // partial failure + } + return breverrors.NewExitCodeError(errs, exitCode) } return nil } diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 9f2c2080..53e56261 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -123,6 +123,27 @@ func (v ValidationError) Error() string { return v.Message } +// ExitCodeError wraps an error with a specific process exit code. +// Use this to distinguish between failure modes (e.g., partial vs full failure). +type ExitCodeError struct { + Err error + ExitCode int +} + +func NewExitCodeError(err error, exitCode int) ExitCodeError { + return ExitCodeError{Err: err, ExitCode: exitCode} +} + +var _ error = ExitCodeError{} + +func (e ExitCodeError) Error() string { + return e.Err.Error() +} + +func (e ExitCodeError) Unwrap() error { + return e.Err +} + type DeclineToLoginError struct{} func (d *DeclineToLoginError) Error() string { return "declined to login" } From 4336440159dcda32a4ba13f3c8241a69bb9acc2e Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 17 Feb 2026 16:57:22 -0800 Subject: [PATCH 6/7] feat: add exit code support for partial batch failures in stop --- pkg/cmd/stop/stop.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/stop/stop.go b/pkg/cmd/stop/stop.go index f7572cc4..d02ba7ce 100644 --- a/pkg/cmd/stop/stop.go +++ b/pkg/cmd/stop/stop.go @@ -68,7 +68,11 @@ func NewCmdStop(t *terminal.Terminal, loginStopStore StopStore, noLoginStopStore } } if allErr != nil { - return breverrors.WrapAndTrace(allErr) + exitCode := 1 // all failed + if len(stoppedNames) > 0 { + exitCode = 2 // partial failure + } + return breverrors.NewExitCodeError(allErr, exitCode) } } return nil From cb76342ca1ceb81385cf993b8a86701a1f72eb66 Mon Sep 17 00:00:00 2001 From: Alec Fong Date: Tue, 17 Feb 2026 17:00:04 -0800 Subject: [PATCH 7/7] fix: only pipe output to stdout on full success Address drewmalin's review: don't send names to stdout when there are partial failures, since the next piped command would act on them even without pipefail. Also send admin delete message to stderr instead of stdout, and remove unused ExitCodeError type. --- main.go | 8 +------- pkg/cmd/delete/delete.go | 11 ++++++----- pkg/cmd/start/start.go | 12 ++++-------- pkg/cmd/stop/stop.go | 12 ++++-------- pkg/errors/errors.go | 21 --------------------- 5 files changed, 15 insertions(+), 49 deletions(-) diff --git a/main.go b/main.go index c12b5be3..c7b9bc5c 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,6 @@ package main import ( - stderrors "errors" "os" "github.com/brevdev/brev-cli/pkg/cmd" @@ -17,11 +16,6 @@ func main() { if err := command.Execute(); err != nil { cmderrors.DisplayAndHandleError(err) done() - exitCode := 1 - var exitErr errors.ExitCodeError - if stderrors.As(err, &exitErr) { - exitCode = exitErr.ExitCode - } - os.Exit(exitCode) //nolint:gocritic // manually call done + os.Exit(1) //nolint:gocritic // manually call done } } diff --git a/pkg/cmd/delete/delete.go b/pkg/cmd/delete/delete.go index 198d7c32..570320de 100644 --- a/pkg/cmd/delete/delete.go +++ b/pkg/cmd/delete/delete.go @@ -3,6 +3,7 @@ package delete import ( _ "embed" "fmt" + "os" "strings" "github.com/brevdev/brev-cli/pkg/cmd/completions" @@ -52,15 +53,15 @@ func NewCmdDelete(t *terminal.Terminal, loginDeleteStore DeleteStore, noLoginDel deletedNames = append(deletedNames, workspace) } } - // Output names for piping to next command + if allError != nil { + return breverrors.WrapAndTrace(allError) + } + // Only output names for piping if all succeeded if piped { for _, name := range deletedNames { fmt.Println(name) } } - if allError != nil { - return breverrors.WrapAndTrace(allError) - } return nil }, } @@ -106,7 +107,7 @@ func handleAdminUser(err error, deleteStore DeleteStore, piped bool) error { return breverrors.WrapAndTrace(err) } if !piped { - fmt.Println("attempting to delete an instance you don't own as admin") + fmt.Fprintln(os.Stderr, "attempting to delete an instance you don't own as admin") } return nil } diff --git a/pkg/cmd/start/start.go b/pkg/cmd/start/start.go index 4de9186d..708f2bff 100644 --- a/pkg/cmd/start/start.go +++ b/pkg/cmd/start/start.go @@ -701,19 +701,15 @@ func runBatchStart(t *terminal.Terminal, names []string, org, setupScript, setup startedNames = append(startedNames, instanceName) } } - // Always output successful names for piping to next command + if errs != nil { + return breverrors.WrapAndTrace(errs) + } + // Only output names for piping if all succeeded if piped { for _, n := range startedNames { fmt.Println(n) } } - if errs != nil { - exitCode := 1 // all failed - if len(startedNames) > 0 { - exitCode = 2 // partial failure - } - return breverrors.NewExitCodeError(errs, exitCode) - } return nil } diff --git a/pkg/cmd/stop/stop.go b/pkg/cmd/stop/stop.go index d02ba7ce..bdf855f2 100644 --- a/pkg/cmd/stop/stop.go +++ b/pkg/cmd/stop/stop.go @@ -61,19 +61,15 @@ func NewCmdStop(t *terminal.Terminal, loginStopStore StopStore, noLoginStopStore stoppedNames = append(stoppedNames, name) } } - // Output names for piping to next command + if allErr != nil { + return breverrors.WrapAndTrace(allErr) + } + // Only output names for piping if all succeeded if piped { for _, name := range stoppedNames { fmt.Println(name) } } - if allErr != nil { - exitCode := 1 // all failed - if len(stoppedNames) > 0 { - exitCode = 2 // partial failure - } - return breverrors.NewExitCodeError(allErr, exitCode) - } } return nil }, diff --git a/pkg/errors/errors.go b/pkg/errors/errors.go index 53e56261..9f2c2080 100644 --- a/pkg/errors/errors.go +++ b/pkg/errors/errors.go @@ -123,27 +123,6 @@ func (v ValidationError) Error() string { return v.Message } -// ExitCodeError wraps an error with a specific process exit code. -// Use this to distinguish between failure modes (e.g., partial vs full failure). -type ExitCodeError struct { - Err error - ExitCode int -} - -func NewExitCodeError(err error, exitCode int) ExitCodeError { - return ExitCodeError{Err: err, ExitCode: exitCode} -} - -var _ error = ExitCodeError{} - -func (e ExitCodeError) Error() string { - return e.Err.Error() -} - -func (e ExitCodeError) Unwrap() error { - return e.Err -} - type DeclineToLoginError struct{} func (d *DeclineToLoginError) Error() string { return "declined to login" }