From fe4495614a358bc5f3df3a5011d7579cefeeb027 Mon Sep 17 00:00:00 2001 From: Ria Chauhan Date: Tue, 15 Sep 2026 07:00:49 +0000 Subject: [PATCH 1/2] fix: require --stdin to read instance types from stdin --- docs/PRD-composable-cli.md | 8 +-- pkg/cmd/gpucreate/gpucreate.go | 105 +++++++++++++++++----------- pkg/cmd/gpucreate/gpucreate_test.go | 33 ++++++++- pkg/cmd/gpusearch/gpusearch.go | 2 +- pkg/cmd/open/open.go | 2 +- pkg/cmd/shell/shell.go | 2 +- pkg/cmd/util/piping.go | 5 +- 7 files changed, 108 insertions(+), 49 deletions(-) diff --git a/docs/PRD-composable-cli.md b/docs/PRD-composable-cli.md index e14ba7adf..9309e799c 100644 --- a/docs/PRD-composable-cli.md +++ b/docs/PRD-composable-cli.md @@ -72,7 +72,7 @@ brev search --json ### 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 +brev search --min-disk 500 --stoppable | grep H100 | head -1 | brev create --name my-gpu --stdin ``` ### Batch Operations @@ -87,7 +87,7 @@ brev ls | grep STOPPED | awk '{print $1}' | brev delete ### Chained Lifecycle ```bash # Create, use, cleanup -brev search --gpu-name A100 | head -1 | brev create --name job-1 | brev shell -c "python train.py" && brev delete job-1 +brev search --gpu-name A100 | head -1 | brev create --name job-1 --stdin | brev shell -c "python train.py" && brev delete job-1 ``` ### JSON Processing @@ -111,7 +111,7 @@ Without skills, an agent must: - 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. +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 --stdin` pipeline. ### Skill Capabilities @@ -154,7 +154,7 @@ With composable CLI + skills, agents can autonomously: 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 +1. brev search --gpu-name H100 --stoppable --min-disk 500 | head -1 | brev create --name training-job --stdin 2. brev wait training-job --state ready 3. tar czf - ./src | brev cp - training-job:/app/ 4. brev shell training-job -c "cd /app && python train.py --checkpoint-interval 3600" diff --git a/pkg/cmd/gpucreate/gpucreate.go b/pkg/cmd/gpucreate/gpucreate.go index c41798245..bd3aef1c5 100644 --- a/pkg/cmd/gpucreate/gpucreate.go +++ b/pkg/cmd/gpucreate/gpucreate.go @@ -10,11 +10,13 @@ import ( "net/http" "net/url" "os" + "os/signal" "slices" "sort" "strconv" "strings" "sync" + "syscall" "time" "unicode" @@ -37,11 +39,11 @@ var ( This command attempts to create GPU instances, trying different instance types until the desired number of instances are successfully created. Instance types -can be specified directly, piped from 'brev search', or auto-selected using defaults. +can be specified directly, piped from 'brev search' (with --stdin), or auto-selected using defaults. Search Filters: You can use the same filter flags as 'brev search' to control which GPU types -are considered. If no instance types are specified (no --type flag and no piped input), +are considered. If no instance types are specified (no --type flag and no --stdin input), the command automatically searches for GPUs matching either your filters or defaults: - Minimum 20GB total VRAM (--min-total-vram) - Minimum 500GB disk (--min-disk) @@ -50,7 +52,7 @@ the command automatically searches for GPUs matching either your filters or defa Results are sorted by price (cheapest first) unless --sort is specified. Retry and Fallback Logic: -When multiple instance types are provided (via --type or piped input), the command +When multiple instance types are provided (via --type or piped --stdin input), the command tries to create ALL instances using the first type before falling back to the next: 1. Try first type for all instances (using --parallel workers if specified) @@ -86,8 +88,8 @@ You can attach a startup script that runs when the instance boots using the # Try multiple types in order (fallback chain) brev create my-instance --type g5.xlarge,g5.2xlarge,g4dn.xlarge - # Pipe from search for automatic fallback - brev search --gpu-name A100 | brev create my-instance + # Pipe from search for automatic fallback (requires --stdin) + brev search --gpu-name A100 | brev create my-instance --stdin # Create multiple instances in parallel brev create my-cluster --count 3 --type g5.xlarge --parallel 3 @@ -158,6 +160,7 @@ func (f *searchFilterFlags) hasUserFilters() bool { func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra.Command { //nolint:gocognit,gocyclo,funlen // easier to read as one function var name string var instanceTypes string + var readStdin bool var count int var parallel int var detached bool @@ -226,7 +229,7 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra return err } - types, err := parseInstanceTypes(instanceTypes) + types, err := parseInstanceTypes(instanceTypes, readStdin, os.Stdin) if err != nil { return breverrors.WrapAndTrace(err) } @@ -267,7 +270,7 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra }, } - registerCreateFlags(cmd, &name, &instanceTypes, &count, ¶llel, &detached, &timeout, &startupScript, &dryRun, &mode, &jupyter, &containerImage, &composeFile, &launchable, &launchableParams, &filters) + registerCreateFlags(cmd, &name, &instanceTypes, &readStdin, &count, ¶llel, &detached, &timeout, &startupScript, &dryRun, &mode, &jupyter, &containerImage, &composeFile, &launchable, &launchableParams, &filters) return cmd } @@ -284,9 +287,10 @@ func validateArgs(name string, count int) error { } // registerCreateFlags registers all flags for the create command -func registerCreateFlags(cmd *cobra.Command, name, instanceTypes *string, count, parallel *int, detached *bool, timeout *int, startupScript *string, dryRun *bool, mode *string, jupyter *bool, containerImage, composeFile, launchable *string, launchableParams *[]string, filters *searchFilterFlags) { +func registerCreateFlags(cmd *cobra.Command, name, instanceTypes *string, readStdin *bool, count, parallel *int, detached *bool, timeout *int, startupScript *string, dryRun *bool, mode *string, jupyter *bool, containerImage, composeFile, launchable *string, launchableParams *[]string, filters *searchFilterFlags) { cmd.Flags().StringVarP(name, "name", "n", "", "Base name for the instances (or pass as first argument)") cmd.Flags().StringVarP(instanceTypes, "type", "t", "", "Comma-separated list of instance types to try") + cmd.Flags().BoolVar(readStdin, "stdin", false, "Read instance types from stdin (opt-in; e.g. brev search | brev create --stdin)") cmd.Flags().IntVarP(count, "count", "c", 1, "Number of instances to create") cmd.Flags().IntVarP(parallel, "parallel", "p", 1, "Number of parallel creation attempts") cmd.Flags().BoolVarP(detached, "detached", "d", false, "Don't wait for instances to be ready") @@ -750,53 +754,74 @@ func orDefault(val, def float64) float64 { return def } -// parseInstanceTypes parses instance types from flag value or stdin -// Returns InstanceSpec with type and optional disk size (from JSON input) -func parseInstanceTypes(flagValue string) ([]InstanceSpec, error) { - var specs []InstanceSpec - - // First check if there's a flag value +// parseInstanceTypes returns instance types from --type, or from stdin only when readStdin is set (opt-in so an open pipe can't block the command). +func parseInstanceTypes(flagValue string, readStdin bool, stdin io.Reader) ([]InstanceSpec, error) { if flagValue != "" { - parts := strings.Split(flagValue, ",") - for _, p := range parts { - p = strings.TrimSpace(p) - if p != "" { + if readStdin { + fmt.Fprintln(os.Stderr, "ignoring piped stdin because --type was set; using the instance types from --type") + } + var specs []InstanceSpec + for _, p := range strings.Split(flagValue, ",") { + if p = strings.TrimSpace(p); p != "" { specs = append(specs, InstanceSpec{Type: p}) } } + return specs, nil } - // Check if there's piped input from stdin - stat, _ := os.Stdin.Stat() - if (stat.Mode() & os.ModeCharDevice) == 0 { - // Data is being piped to stdin - read all input first - input, err := io.ReadAll(os.Stdin) - if err != nil { - return nil, breverrors.WrapAndTrace(err) + if !readStdin { + if util.IsStdinPiped() { + fmt.Fprintln(os.Stderr, "ignoring piped input and falling back to the default GPU search; pass --stdin to use the piped instance types") } + return nil, nil + } - inputStr := strings.TrimSpace(string(input)) - if inputStr == "" { - return specs, nil - } + fmt.Fprintln(os.Stderr, "Waiting for instance types on stdin (Ctrl-D / close the pipe to finish)...") + stop := handleStdinWaitSignals() + input, err := io.ReadAll(stdin) + stop() + if err != nil { + return nil, breverrors.WrapAndTrace(err) + } - // Check if input is JSON (starts with '[') - if strings.HasPrefix(inputStr, "[") { - jsonSpecs, err := parseJSONInput(inputStr) - if err != nil { - return nil, breverrors.WrapAndTrace(err) - } - specs = append(specs, jsonSpecs...) - } else { - // Parse as table format - tableSpecs := parseTableInput(inputStr) - specs = append(specs, tableSpecs...) + inputStr := strings.TrimSpace(string(input)) + if inputStr == "" { + return nil, breverrors.NewValidationError("--stdin was set but no instance types were received on stdin; pass --type or pipe types from 'brev search'") + } + + var specs []InstanceSpec + if strings.HasPrefix(inputStr, "[") { + jsonSpecs, err := parseJSONInput(inputStr) + if err != nil { + return nil, breverrors.WrapAndTrace(err) } + specs = append(specs, jsonSpecs...) + } else { + specs = append(specs, parseTableInput(inputStr)...) } return specs, nil } +// handleStdinWaitSignals exits cleanly if interrupted while waiting on stdin. +func handleStdinWaitSignals() (stop func()) { + sigCh := make(chan os.Signal, 1) + done := make(chan struct{}) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + select { + case <-sigCh: + fmt.Fprintln(os.Stderr, "\ncanceled while waiting for instance types on stdin") + os.Exit(130) + case <-done: + } + }() + return func() { + signal.Stop(sigCh) + close(done) + } +} + // parseJSONInput parses JSON array input from gpu-search --json func parseJSONInput(input string) ([]InstanceSpec, error) { var instances []gpusearch.GPUInstanceInfo diff --git a/pkg/cmd/gpucreate/gpucreate_test.go b/pkg/cmd/gpucreate/gpucreate_test.go index a812a8ec4..1024a9a56 100644 --- a/pkg/cmd/gpucreate/gpucreate_test.go +++ b/pkg/cmd/gpucreate/gpucreate_test.go @@ -740,7 +740,7 @@ func TestParseInstanceTypesFromFlag(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := parseInstanceTypes(tt.input) + result, err := parseInstanceTypes(tt.input, false, strings.NewReader("")) assert.NoError(t, err) // Handle nil vs empty slice @@ -758,6 +758,37 @@ func TestParseInstanceTypesFromFlag(t *testing.T) { } } +func TestParseInstanceTypesStdin(t *testing.T) { + // --type is authoritative: stdin (even with data) is ignored and never read. + specs, err := parseInstanceTypes("g5.xlarge", true, strings.NewReader("should-be-ignored\n")) + assert.NoError(t, err) + assert.Equal(t, []InstanceSpec{{Type: "g5.xlarge"}}, specs) + + // No --type and no --stdin: stdin is not read; caller falls back to the default GPU search. + specs, err = parseInstanceTypes("", false, strings.NewReader("g5.xlarge\n")) + assert.NoError(t, err) + assert.Empty(t, specs) + + // --stdin with piped table data: types are parsed from stdin. + specs, err = parseInstanceTypes("", true, strings.NewReader("g5.xlarge\ng4dn.xlarge\n")) + assert.NoError(t, err) + var got []string + for _, s := range specs { + got = append(got, s.Type) + } + assert.Equal(t, []string{"g5.xlarge", "g4dn.xlarge"}, got) + + // --stdin with piped JSON data: types are parsed from stdin. + specs, err = parseInstanceTypes("", true, strings.NewReader(`[{"type":"p4d.24xlarge"}]`)) + assert.NoError(t, err) + require.Len(t, specs, 1) + assert.Equal(t, "p4d.24xlarge", specs[0].Type) + + // --stdin set but nothing arrives on stdin: fail loudly. + _, err = parseInstanceTypes("", true, strings.NewReader(" \n")) + assert.Error(t, err) +} + func TestGPUCreateOptions(t *testing.T) { opts := GPUCreateOptions{ Name: "my-instance", diff --git a/pkg/cmd/gpusearch/gpusearch.go b/pkg/cmd/gpusearch/gpusearch.go index a00aa3ce4..12c7670ce 100644 --- a/pkg/cmd/gpusearch/gpusearch.go +++ b/pkg/cmd/gpusearch/gpusearch.go @@ -1146,7 +1146,7 @@ func displayGPUTable(t *terminal.Terminal, instances []GPUInstanceInfo) { // displayGPUTablePlain renders the GPU instances as a plain table without colors for piping // Includes TARGET_DISK column for passing disk size to brev create -// Enables: brev search --min-disk 500 | grep H100 | brev create +// Enables: brev search --min-disk 500 | grep H100 | brev create --stdin func displayGPUTablePlain(instances []GPUInstanceInfo) { ta := table.NewWriter() ta.SetOutputMirror(os.Stdout) diff --git a/pkg/cmd/open/open.go b/pkg/cmd/open/open.go index f29a402fc..faa42316c 100644 --- a/pkg/cmd/open/open.go +++ b/pkg/cmd/open/open.go @@ -89,7 +89,7 @@ You must have the editor installed in your path.` 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 + brev search --gpu-name A100 | brev create ml-box --stdin | brev open cursor # Open in a new terminal window with SSH brev create my-instance | brev open terminal diff --git a/pkg/cmd/shell/shell.go b/pkg/cmd/shell/shell.go index a76ee18e3..9a3c67cc0 100644 --- a/pkg/cmd/shell/shell.go +++ b/pkg/cmd/shell/shell.go @@ -34,7 +34,7 @@ var ( brev shell $(brev create my-instance) # Create with specific GPU and connect - brev shell $(brev search --gpu-name A100 | brev create ml-box) + brev shell $(brev search --gpu-name A100 | brev create ml-box --stdin) # SSH into the host machine instead of the container brev shell my-instance --host diff --git a/pkg/cmd/util/piping.go b/pkg/cmd/util/piping.go index 68e45f6b4..9f6afc760 100644 --- a/pkg/cmd/util/piping.go +++ b/pkg/cmd/util/piping.go @@ -18,7 +18,10 @@ func IsStdoutPiped() bool { // IsStdinPiped returns true if stdin is being piped from another command func IsStdinPiped() bool { - stat, _ := os.Stdin.Stat() + stat, err := os.Stdin.Stat() + if err != nil { + return false + } return (stat.Mode() & os.ModeCharDevice) == 0 } From 8ee590e360b7558a7e3245595e1ef25cd888c883 Mon Sep 17 00:00:00 2001 From: Ria Chauhan Date: Wed, 16 Sep 2026 04:38:40 +0000 Subject: [PATCH 2/2] fix: reject empty --stdin input, doc example --- pkg/cmd/gpucreate/gpucreate.go | 4 ++++ pkg/cmd/gpucreate/gpucreate_test.go | 4 ++++ pkg/cmd/gpusearch/gpusearch.go | 2 +- 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/cmd/gpucreate/gpucreate.go b/pkg/cmd/gpucreate/gpucreate.go index bd3aef1c5..cb6b8f123 100644 --- a/pkg/cmd/gpucreate/gpucreate.go +++ b/pkg/cmd/gpucreate/gpucreate.go @@ -800,6 +800,10 @@ func parseInstanceTypes(flagValue string, readStdin bool, stdin io.Reader) ([]In specs = append(specs, parseTableInput(inputStr)...) } + if len(specs) == 0 { + return nil, breverrors.NewValidationError("--stdin input contained no valid instance types; pass --type or pipe types from 'brev search'") + } + return specs, nil } diff --git a/pkg/cmd/gpucreate/gpucreate_test.go b/pkg/cmd/gpucreate/gpucreate_test.go index 1024a9a56..e259dc06e 100644 --- a/pkg/cmd/gpucreate/gpucreate_test.go +++ b/pkg/cmd/gpucreate/gpucreate_test.go @@ -787,6 +787,10 @@ func TestParseInstanceTypesStdin(t *testing.T) { // --stdin set but nothing arrives on stdin: fail loudly. _, err = parseInstanceTypes("", true, strings.NewReader(" \n")) assert.Error(t, err) + + // --stdin input that parses to zero instance types (e.g. JSON []): fail loudly. + _, err = parseInstanceTypes("", true, strings.NewReader("[]")) + assert.Error(t, err) } func TestGPUCreateOptions(t *testing.T) { diff --git a/pkg/cmd/gpusearch/gpusearch.go b/pkg/cmd/gpusearch/gpusearch.go index 12c7670ce..a08116148 100644 --- a/pkg/cmd/gpusearch/gpusearch.go +++ b/pkg/cmd/gpusearch/gpusearch.go @@ -1146,7 +1146,7 @@ func displayGPUTable(t *terminal.Terminal, instances []GPUInstanceInfo) { // displayGPUTablePlain renders the GPU instances as a plain table without colors for piping // Includes TARGET_DISK column for passing disk size to brev create -// Enables: brev search --min-disk 500 | grep H100 | brev create --stdin +// Enables: brev search --min-disk 500 | grep H100 | brev create --stdin func displayGPUTablePlain(instances []GPUInstanceInfo) { ta := table.NewWriter() ta.SetOutputMirror(os.Stdout)