Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/PRD-composable-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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"
Expand Down
109 changes: 69 additions & 40 deletions pkg/cmd/gpucreate/gpucreate.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import (
"net/http"
"net/url"
"os"
"os/signal"
"slices"
"sort"
"strconv"
"strings"
"sync"
"syscall"
"time"
"unicode"

Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -267,7 +270,7 @@ func NewCmdGPUCreate(t *terminal.Terminal, gpuCreateStore GPUCreateStore) *cobra
},
}

registerCreateFlags(cmd, &name, &instanceTypes, &count, &parallel, &detached, &timeout, &startupScript, &dryRun, &mode, &jupyter, &containerImage, &composeFile, &launchable, &launchableParams, &filters)
registerCreateFlags(cmd, &name, &instanceTypes, &readStdin, &count, &parallel, &detached, &timeout, &startupScript, &dryRun, &mode, &jupyter, &containerImage, &composeFile, &launchable, &launchableParams, &filters)

return cmd
}
Expand All @@ -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 <name> --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")
Expand Down Expand Up @@ -750,53 +754,78 @@ 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)...)
}

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
Comment thread
callen-bot marked this conversation as resolved.
}

// 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
Expand Down
37 changes: 36 additions & 1 deletion pkg/cmd/gpucreate/gpucreate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -758,6 +758,41 @@ 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)

// --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) {
opts := GPUCreateOptions{
Name: "my-instance",
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/gpusearch/gpusearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --stdin
func displayGPUTablePlain(instances []GPUInstanceInfo) {
ta := table.NewWriter()
ta.SetOutputMirror(os.Stdout)
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/open/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/shell/shell.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion pkg/cmd/util/piping.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Loading