From 6c7108f9366de5453bfe46645e803fea4a184601 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 18:05:22 -0700 Subject: [PATCH 1/3] feat: lifecycle commands, output formatting, and show-commands mode Add harness status/logs/stop/start commands wrapping openshell sandbox lifecycle operations. Add Header() and Table() to status module for structured output. Add --show-commands flag that prints openshell CLI commands to stdout as a teaching aid, decoupled from execution so it survives the future gRPC migration. --- cmd/helpers_test.go | 4 ++ cmd/logs.go | 27 +++++++++ cmd/start.go | 29 +++++++++ cmd/start_test.go | 24 ++++++++ cmd/status_cmd.go | 57 ++++++++++++++++++ cmd/status_cmd_test.go | 52 ++++++++++++++++ cmd/stop.go | 46 ++++++++++++++ cmd/stop_test.go | 81 +++++++++++++++++++++++++ internal/gateway/cli.go | 40 ++++++++++++ internal/gateway/cli_test.go | 87 +++++++++++++++++++++++++++ internal/gateway/gateway.go | 9 +++ internal/status/status.go | 82 ++++++++++++++++++++++--- internal/status/status_test.go | 107 +++++++++++++++++++++++++++++++++ main.go | 7 +++ 14 files changed, 645 insertions(+), 7 deletions(-) create mode 100644 cmd/logs.go create mode 100644 cmd/start.go create mode 100644 cmd/start_test.go create mode 100644 cmd/status_cmd.go create mode 100644 cmd/status_cmd_test.go create mode 100644 cmd/stop.go create mode 100644 cmd/stop_test.go diff --git a/cmd/helpers_test.go b/cmd/helpers_test.go index ba9b855..09e387e 100644 --- a/cmd/helpers_test.go +++ b/cmd/helpers_test.go @@ -54,7 +54,11 @@ func (m *mockGW) ProviderProfileImport(string) error func (m *mockGW) ProviderProfileDelete(string) error { return nil } func (m *mockGW) SettingsSet(string, string) error { return nil } func (m *mockGW) SandboxList() ([]string, error) { return nil, nil } +func (m *mockGW) SandboxStatus() ([]gateway.SandboxInfo, error) { return nil, nil } func (m *mockGW) SandboxConnect(string) error { return nil } +func (m *mockGW) SandboxLogs(string, bool) error { return nil } +func (m *mockGW) SandboxStop(string) error { return nil } +func (m *mockGW) SandboxStart(string) error { return nil } func (m *mockGW) GatewayAdd(string, string, bool, bool) error { return nil } func (m *mockGW) GatewayRemove(name string) error { if m.onGatewayRemove != nil { diff --git a/cmd/logs.go b/cmd/logs.go new file mode 100644 index 0000000..cc8254e --- /dev/null +++ b/cmd/logs.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/spf13/cobra" +) + +func NewLogsCmd(harnessDir, cli string) *cobra.Command { + var follow bool + + cmd := &cobra.Command{ + Use: "logs [SANDBOX_NAME]", + Short: "Stream sandbox logs", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + gw := gateway.New(cli) + name := "" + if len(args) > 0 { + name = args[0] + } + return gw.SandboxLogs(name, follow) + }, + } + + cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output") + return cmd +} diff --git a/cmd/start.go b/cmd/start.go new file mode 100644 index 0000000..c9f00be --- /dev/null +++ b/cmd/start.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "fmt" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/status" + "github.com/spf13/cobra" +) + +func NewStartCmd(harnessDir, cli string) *cobra.Command { + return &cobra.Command{ + Use: "start [SANDBOX_NAME]", + Short: "Start a stopped sandbox", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + gw := gateway.New(cli) + name, err := resolveSandboxName(gw, args) + if err != nil { + return err + } + if err := gw.SandboxStart(name); err != nil { + return fmt.Errorf("starting %s: %w", name, err) + } + status.OKf("Started %s", name) + return nil + }, + } +} diff --git a/cmd/start_test.go b/cmd/start_test.go new file mode 100644 index 0000000..0092eb8 --- /dev/null +++ b/cmd/start_test.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + "testing" +) + +func TestStart_Success(t *testing.T) { + gw := &stopStartMockGW{sandboxNames: []string{"agent"}} + name, _ := resolveSandboxName(gw, nil) + if err := gw.SandboxStart(name); err != nil { + t.Fatal(err) + } + if len(gw.startedNames) != 1 || gw.startedNames[0] != "agent" { + t.Errorf("started = %v", gw.startedNames) + } +} + +func TestStart_Error(t *testing.T) { + gw := &stopStartMockGW{startErr: fmt.Errorf("sandbox not found")} + if err := gw.SandboxStart("missing"); err == nil { + t.Fatal("expected error") + } +} diff --git a/cmd/status_cmd.go b/cmd/status_cmd.go new file mode 100644 index 0000000..b8c3d27 --- /dev/null +++ b/cmd/status_cmd.go @@ -0,0 +1,57 @@ +package cmd + +import ( + "fmt" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/status" + "github.com/spf13/cobra" +) + +func NewStatusCmd(harnessDir, cli string) *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show sandbox and gateway status", + RunE: func(cmd *cobra.Command, args []string) error { + gw := gateway.New(cli) + return runStatus(gw) + }, + } +} + +func runStatus(gw gateway.Gateway) error { + status.Header("Gateway") + active := gw.ActiveGateway() + if active != "" { + status.OKf("Active: %s", active) + ver := gw.CLIVersion() + if ver != "" { + status.Infof("CLI: %s", ver) + } + } else { + status.Info("No active gateway") + } + + fmt.Println() + status.Header("Sandboxes") + infos, err := gw.SandboxStatus() + if err != nil { + if active == "" { + status.Info("No active gateway, cannot list sandboxes") + return nil + } + return fmt.Errorf("listing sandboxes: %w", err) + } + if len(infos) == 0 { + status.Info("None running") + return nil + } + + headers := []string{"NAME", "PHASE"} + var rows [][]string + for _, info := range infos { + rows = append(rows, []string{info.Name, info.Phase}) + } + status.Table(headers, rows) + return nil +} diff --git a/cmd/status_cmd_test.go b/cmd/status_cmd_test.go new file mode 100644 index 0000000..efc864f --- /dev/null +++ b/cmd/status_cmd_test.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "testing" + + "github.com/robbycochran/harness-openshell/internal/gateway" +) + +type statusMockGW struct { + mockGW + statusResult []gateway.SandboxInfo + statusErr error + activeGW string + cliVer string +} + +func (m *statusMockGW) SandboxStatus() ([]gateway.SandboxInfo, error) { + return m.statusResult, m.statusErr +} +func (m *statusMockGW) ActiveGateway() string { return m.activeGW } +func (m *statusMockGW) CLIVersion() string { return m.cliVer } + +func TestRunStatus_DisplaysSandboxes(t *testing.T) { + gw := &statusMockGW{ + activeGW: "local", + cliVer: "openshell v0.0.58", + statusResult: []gateway.SandboxInfo{ + {Name: "agent", Phase: "Ready"}, + {Name: "test", Phase: "Stopped"}, + }, + } + if err := runStatus(gw); err != nil { + t.Fatalf("runStatus: %v", err) + } +} + +func TestRunStatus_NoSandboxes(t *testing.T) { + gw := &statusMockGW{ + activeGW: "local", + cliVer: "openshell v0.0.58", + } + if err := runStatus(gw); err != nil { + t.Fatalf("runStatus: %v", err) + } +} + +func TestRunStatus_NoGateway(t *testing.T) { + gw := &statusMockGW{} + if err := runStatus(gw); err != nil { + t.Fatalf("runStatus: %v", err) + } +} diff --git a/cmd/stop.go b/cmd/stop.go new file mode 100644 index 0000000..eb5944d --- /dev/null +++ b/cmd/stop.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "fmt" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/status" + "github.com/spf13/cobra" +) + +func NewStopCmd(harnessDir, cli string) *cobra.Command { + return &cobra.Command{ + Use: "stop [SANDBOX_NAME]", + Short: "Stop a running sandbox", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + gw := gateway.New(cli) + name, err := resolveSandboxName(gw, args) + if err != nil { + return err + } + if err := gw.SandboxStop(name); err != nil { + return fmt.Errorf("stopping %s: %w", name, err) + } + status.OKf("Stopped %s", name) + return nil + }, + } +} + +func resolveSandboxName(gw gateway.Gateway, args []string) (string, error) { + if len(args) > 0 { + return args[0], nil + } + names, err := gw.SandboxList() + if err != nil { + return "", fmt.Errorf("listing sandboxes: %w", err) + } + if len(names) == 0 { + return "", fmt.Errorf("no sandboxes running") + } + if len(names) > 1 { + return "", fmt.Errorf("multiple sandboxes running, specify one: %v", names) + } + return names[0], nil +} diff --git a/cmd/stop_test.go b/cmd/stop_test.go new file mode 100644 index 0000000..eb69f81 --- /dev/null +++ b/cmd/stop_test.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "fmt" + "testing" +) + +type stopStartMockGW struct { + mockGW + sandboxNames []string + stoppedNames []string + startedNames []string + stopErr error + startErr error +} + +func (m *stopStartMockGW) SandboxList() ([]string, error) { return m.sandboxNames, nil } +func (m *stopStartMockGW) SandboxStop(name string) error { + m.stoppedNames = append(m.stoppedNames, name) + return m.stopErr +} +func (m *stopStartMockGW) SandboxStart(name string) error { + m.startedNames = append(m.startedNames, name) + return m.startErr +} + +func TestResolveSandboxName_Explicit(t *testing.T) { + gw := &stopStartMockGW{} + name, err := resolveSandboxName(gw, []string{"my-agent"}) + if err != nil { + t.Fatal(err) + } + if name != "my-agent" { + t.Errorf("got %q, want my-agent", name) + } +} + +func TestResolveSandboxName_AutoSingle(t *testing.T) { + gw := &stopStartMockGW{sandboxNames: []string{"agent"}} + name, err := resolveSandboxName(gw, nil) + if err != nil { + t.Fatal(err) + } + if name != "agent" { + t.Errorf("got %q, want agent", name) + } +} + +func TestResolveSandboxName_AmbiguousError(t *testing.T) { + gw := &stopStartMockGW{sandboxNames: []string{"a", "b"}} + _, err := resolveSandboxName(gw, nil) + if err == nil { + t.Fatal("expected error for multiple sandboxes") + } +} + +func TestResolveSandboxName_NoneError(t *testing.T) { + gw := &stopStartMockGW{} + _, err := resolveSandboxName(gw, nil) + if err == nil { + t.Fatal("expected error for no sandboxes") + } +} + +func TestStop_Success(t *testing.T) { + gw := &stopStartMockGW{sandboxNames: []string{"agent"}} + name, _ := resolveSandboxName(gw, nil) + if err := gw.SandboxStop(name); err != nil { + t.Fatal(err) + } + if len(gw.stoppedNames) != 1 || gw.stoppedNames[0] != "agent" { + t.Errorf("stopped = %v", gw.stoppedNames) + } +} + +func TestStop_Error(t *testing.T) { + gw := &stopStartMockGW{stopErr: fmt.Errorf("sandbox not found")} + if err := gw.SandboxStop("missing"); err == nil { + t.Fatal("expected error") + } +} diff --git a/internal/gateway/cli.go b/internal/gateway/cli.go index fe5a7cd..a58b175 100644 --- a/internal/gateway/cli.go +++ b/internal/gateway/cli.go @@ -226,6 +226,46 @@ func (c *CLI) SandboxCreate(opts SandboxCreateOpts) error { return c.passthrough(args...) } +func (c *CLI) SandboxStatus() ([]SandboxInfo, error) { + out, err := c.output("sandbox", "list") + if err != nil { + return nil, err + } + var infos []SandboxInfo + for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if i == 0 || strings.TrimSpace(line) == "" { + continue + } + cleaned := ansiRE.ReplaceAllString(line, "") + fields := strings.Fields(cleaned) + if len(fields) >= 2 { + infos = append(infos, SandboxInfo{Name: fields[0], Phase: fields[1]}) + } else if len(fields) == 1 { + infos = append(infos, SandboxInfo{Name: fields[0]}) + } + } + return infos, nil +} + +func (c *CLI) SandboxLogs(name string, follow bool) error { + args := []string{"sandbox", "logs"} + if name != "" { + args = append(args, name) + } + if follow { + args = append(args, "--follow") + } + return c.passthrough(args...) +} + +func (c *CLI) SandboxStop(name string) error { + return c.silent("sandbox", "stop", name) +} + +func (c *CLI) SandboxStart(name string) error { + return c.silent("sandbox", "start", name) +} + func (c *CLI) SandboxDelete(name string) error { return c.silent("sandbox", "delete", name) } diff --git a/internal/gateway/cli_test.go b/internal/gateway/cli_test.go index dd68a4a..59ba091 100644 --- a/internal/gateway/cli_test.go +++ b/internal/gateway/cli_test.go @@ -228,6 +228,93 @@ printf "\033[32mtest-agent\033[0m\tReady\n" } } +func TestSandboxStatus_ParsesTable(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tPHASE\n" +printf "agent\tReady\n" +printf "test-agent\tStopped\n" +`) + gw := New(bin) + infos, err := gw.SandboxStatus() + if err != nil { + t.Fatalf("SandboxStatus: %v", err) + } + if len(infos) != 2 { + t.Fatalf("got %d sandboxes, want 2: %v", len(infos), infos) + } + if infos[0].Name != "agent" || infos[0].Phase != "Ready" { + t.Errorf("infos[0] = %+v", infos[0]) + } + if infos[1].Name != "test-agent" || infos[1].Phase != "Stopped" { + t.Errorf("infos[1] = %+v", infos[1]) + } +} + +func TestSandboxStatus_Empty(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +printf "NAME\tPHASE\n" +`) + gw := New(bin) + infos, err := gw.SandboxStatus() + if err != nil { + t.Fatalf("SandboxStatus: %v", err) + } + if len(infos) != 0 { + t.Errorf("got %d, want 0", len(infos)) + } +} + +func TestSandboxStop_Silent(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 0 +`) + gw := New(bin) + if err := gw.SandboxStop("test"); err != nil { + t.Errorf("SandboxStop = %v", err) + } +} + +func TestSandboxStart_Silent(t *testing.T) { + bin := writeStub(t, `#!/bin/bash +exit 0 +`) + gw := New(bin) + if err := gw.SandboxStart("test"); err != nil { + t.Errorf("SandboxStart = %v", err) + } +} + +func TestSandboxLogs_PassesFollow(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args") + bin := writeStub(t, `#!/bin/bash +echo "$@" > `+argsFile+` +`) + gw := New(bin) + gw.SandboxLogs("my-agent", true) + data, _ := os.ReadFile(argsFile) + args := strings.TrimSpace(string(data)) + if !strings.Contains(args, "sandbox logs my-agent --follow") { + t.Errorf("expected sandbox logs with --follow, got: %s", args) + } +} + +func TestSandboxLogs_NoFollow(t *testing.T) { + argsFile := filepath.Join(t.TempDir(), "args") + bin := writeStub(t, `#!/bin/bash +echo "$@" > `+argsFile+` +`) + gw := New(bin) + gw.SandboxLogs("my-agent", false) + data, _ := os.ReadFile(argsFile) + args := strings.TrimSpace(string(data)) + if strings.Contains(args, "--follow") { + t.Errorf("should not have --follow: %s", args) + } + if !strings.Contains(args, "sandbox logs my-agent") { + t.Errorf("missing sandbox logs: %s", args) + } +} + func TestSandboxList_Empty(t *testing.T) { bin := writeStub(t, `#!/bin/bash printf "NAME\tPHASE\n" diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 989c7b7..d02a2cc 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -14,9 +14,13 @@ type Gateway interface { // Sandboxes SandboxList() ([]string, error) + SandboxStatus() ([]SandboxInfo, error) SandboxCreate(opts SandboxCreateOpts) error SandboxDelete(name string) error SandboxConnect(name string) error + SandboxLogs(name string, follow bool) error + SandboxStop(name string) error + SandboxStart(name string) error // Inference InferenceGet() error @@ -54,6 +58,11 @@ type GatewayInfo struct { Active bool } +type SandboxInfo struct { + Name string + Phase string +} + type SandboxCreateOpts struct { Name string From string diff --git a/internal/status/status.go b/internal/status/status.go index 02877f7..e1f1bff 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -7,31 +7,55 @@ import ( ) var Verbose bool +var ShowCommands bool func Cmd(name string, args ...string) { - if !Verbose { + if !Verbose && !ShowCommands { return } - fmt.Fprintf(os.Stderr, " $ %s", name) + line := formatCmdLine(name, args) + if ShowCommands { + fmt.Printf(" %s\n", line) + } + if Verbose { + fmt.Fprintf(os.Stderr, " %s\n", line) + } +} + +// ShowEquivalentCmd displays the equivalent openshell CLI command for +// an operation, regardless of how it was actually executed. Use this +// in gRPC Gateway implementations to show the CLI equivalent. +func ShowEquivalentCmd(name string, args ...string) { + if !ShowCommands { + return + } + fmt.Printf(" %s\n", formatCmdLine(name, args)) +} + +func formatCmdLine(name string, args []string) string { + var b strings.Builder + b.WriteString("$ ") + b.WriteString(name) redactNext := false for _, a := range args { + b.WriteByte(' ') if redactNext { - fmt.Fprintf(os.Stderr, " %s", redactValue(a)) + b.WriteString(redactValue(a)) redactNext = false continue } if a == "--credential" || a == "--material" || a == "--secret-material-key" { redactNext = true - fmt.Fprintf(os.Stderr, " %s", a) + b.WriteString(a) continue } if strings.HasPrefix(a, "--from-literal=") && isSensitiveLiteral(a) { - fmt.Fprintf(os.Stderr, " %s", redactFromLiteral(a)) + b.WriteString(redactFromLiteral(a)) continue } - fmt.Fprintf(os.Stderr, " %s", a) + b.WriteString(a) } - fmt.Fprintln(os.Stderr) + return b.String() } // redactValue replaces the value portion of KEY=VALUE with ***. @@ -87,3 +111,47 @@ func Done(msg string) { fmt.Println() fmt.Println(msg) } + +func Header(title string) { + fmt.Printf("\n%s\n", title) + fmt.Println(strings.Repeat("─", len(title))) +} + +func Table(headers []string, rows [][]string) { + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = len(h) + } + for _, row := range rows { + for i, cell := range row { + if i < len(widths) && len(cell) > widths[i] { + widths[i] = len(cell) + } + } + } + for i, h := range headers { + if i > 0 { + fmt.Print(" ") + } + fmt.Printf("%-*s", widths[i], h) + } + fmt.Println() + for i := range headers { + if i > 0 { + fmt.Print(" ") + } + fmt.Print(strings.Repeat("─", widths[i])) + } + fmt.Println() + for _, row := range rows { + for i, cell := range row { + if i > 0 { + fmt.Print(" ") + } + if i < len(widths) { + fmt.Printf("%-*s", widths[i], cell) + } + } + fmt.Println() + } +} diff --git a/internal/status/status_test.go b/internal/status/status_test.go index f0b3135..1a6f421 100644 --- a/internal/status/status_test.go +++ b/internal/status/status_test.go @@ -12,6 +12,7 @@ func captureCmd(name string, args ...string) string { r, w, _ := os.Pipe() os.Stderr = w Verbose = true + ShowCommands = false Cmd(name, args...) w.Close() os.Stderr = old @@ -92,6 +93,7 @@ func TestCmdNotVerbose(t *testing.T) { r, w, _ := os.Pipe() os.Stderr = w Verbose = false + ShowCommands = false Cmd("openshell", "--credential", "TOKEN=secret") w.Close() os.Stderr = old @@ -110,6 +112,111 @@ func TestCmdNormalArgs(t *testing.T) { } } +func TestCmdShowCommands_PrintsToStdout(t *testing.T) { + Verbose = false + ShowCommands = true + defer func() { ShowCommands = false }() + out := captureStdout(func() { + Cmd("openshell", "sandbox", "create", "--name", "test") + }) + if !strings.Contains(out, "$ openshell sandbox create --name test") { + t.Errorf("expected command on stdout, got: %q", out) + } +} + +func TestCmdShowCommands_RedactsCredentials(t *testing.T) { + Verbose = false + ShowCommands = true + defer func() { ShowCommands = false }() + out := captureStdout(func() { + Cmd("openshell", "provider", "create", "github", "--credential", "TOKEN=secret") + }) + if contains(out, "secret") { + t.Errorf("credential leaked in show-commands: %s", out) + } + if !contains(out, "TOKEN=***") { + t.Errorf("expected redacted credential: %s", out) + } +} + +func TestShowEquivalentCmd_OnlyWhenEnabled(t *testing.T) { + ShowCommands = false + out := captureStdout(func() { + ShowEquivalentCmd("openshell", "sandbox", "list") + }) + if out != "" { + t.Errorf("expected no output when ShowCommands=false, got: %q", out) + } +} + +func TestShowEquivalentCmd_Prints(t *testing.T) { + ShowCommands = true + defer func() { ShowCommands = false }() + out := captureStdout(func() { + ShowEquivalentCmd("openshell", "sandbox", "list") + }) + if !strings.Contains(out, "$ openshell sandbox list") { + t.Errorf("expected equivalent command, got: %q", out) + } +} + +func captureStdout(fn func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf bytes.Buffer + buf.ReadFrom(r) + return buf.String() +} + +func TestHeader(t *testing.T) { + out := captureStdout(func() { Header("Sandboxes") }) + if !strings.Contains(out, "Sandboxes") { + t.Errorf("missing title: %q", out) + } + if !strings.Contains(out, "─") { + t.Errorf("missing underline: %q", out) + } +} + +func TestTable_Alignment(t *testing.T) { + out := captureStdout(func() { + Table( + []string{"NAME", "PHASE"}, + [][]string{ + {"my-agent", "Ready"}, + {"test", "Stopped"}, + }, + ) + }) + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 4 { + t.Fatalf("expected 4 lines (header + separator + 2 rows), got %d: %q", len(lines), out) + } + if !strings.HasPrefix(lines[0], "NAME") { + t.Errorf("header missing NAME: %q", lines[0]) + } + if !strings.Contains(lines[1], "─") { + t.Errorf("separator missing: %q", lines[1]) + } + if !strings.Contains(lines[2], "my-agent") { + t.Errorf("row 1 missing: %q", lines[2]) + } +} + +func TestTable_Empty(t *testing.T) { + out := captureStdout(func() { + Table([]string{"NAME", "PHASE"}, nil) + }) + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines (header + separator), got %d: %q", len(lines), out) + } +} + func contains(s, substr string) bool { return strings.Contains(s, substr) } diff --git a/main.go b/main.go index 79a581a..9dc99d9 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ func main() { harnessDir := detectHarnessDir() var verbose bool + var showCommands bool root := &cobra.Command{ Use: "harness", @@ -25,10 +26,12 @@ func main() { SilenceUsage: true, PersistentPreRun: func(cmd *cobra.Command, args []string) { status.Verbose = verbose + status.ShowCommands = showCommands }, } root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Show kubectl/helm/openshell commands") + root.PersistentFlags().BoolVar(&showCommands, "show-commands", false, "Show openshell commands being executed") cli := os.Getenv("OPENSHELL_CLI") if cli == "" { @@ -47,6 +50,10 @@ func main() { cmd.NewPreflightCmd(harnessDir, cli), cmd.NewProvidersCmd(harnessDir, cli), cmd.NewLaunchCmd(harnessDir, cli), + cmd.NewStatusCmd(harnessDir, cli), + cmd.NewLogsCmd(harnessDir, cli), + cmd.NewStopCmd(harnessDir, cli), + cmd.NewStartCmd(harnessDir, cli), ) if err := root.Execute(); err != nil { From 386452d0aa9b337f70d6cebbac7beb789dcbbd8e Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 19:30:32 -0700 Subject: [PATCH 2/3] fix: clean up output formatting and fix standalone providers bug Quiet noisy kubectl/helm passthrough output in deploy (CRD install, SCC grants, rollout status now captured instead of streamed). Switch phase headers from === Section === to Header() with underline. Fix registerProviders printing "Done. Launch a sandbox with: harness up --local" when called from up --remote by adding a standalone parameter. Add top-level context lines (agent name, image) at the start of up flows. Remove inconsistent spacing and stray fmt.Println() calls. --- cmd/create.go | 14 ++--- cmd/deploy.go | 56 ++++++++----------- cmd/providers.go | 46 +++++++--------- cmd/providers_test.go | 14 ++--- cmd/up.go | 121 ++++++++++++++++++++---------------------- 5 files changed, 112 insertions(+), 139 deletions(-) diff --git a/cmd/create.go b/cmd/create.go index f1f8e3c..68461f4 100644 --- a/cmd/create.go +++ b/cmd/create.go @@ -42,7 +42,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { } isLocal := strings.Contains(activeGW.Endpoint, "127.0.0.1") - status.Section("Gateway") + status.Header("Gateway") status.OKf("%s (%s)", activeGW.Name, activeGW.Endpoint) agentCfg, err := agent.ParseFile(agentPath) if err != nil { @@ -60,12 +60,12 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { sandboxImage = envImage } - status.Section("Agent") - fmt.Printf(" Name: %s\n", name) - fmt.Printf(" Image: %s\n", sandboxImage) + status.Header("Agent") + status.Infof("Name: %s", name) + status.Infof("Image: %s", sandboxImage) // 3. Validate providers are registered - status.Section("Providers") + status.Header("Providers") providerNames := agentCfg.ProviderNames() registered, missing := profile.ValidateProviders(providerNames, gw) for _, n := range registered { @@ -84,7 +84,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { // 5. Run preflight checks (only for unregistered providers) if len(missing) > 0 && allProviders != nil { - status.Section("Preflight") + status.Header("Preflight") preflightOK := true for _, p := range allProviders { if !providerInList(p.Name, missing) { @@ -115,7 +115,7 @@ func NewCreateCmd(harnessDir, cli string) *cobra.Command { } // 6. Deploy the sandbox - status.Section("Creating sandbox") + status.Header("Creating sandbox") if needsRunner { status.Info("Custom providers detected — using in-cluster runner") gwCfg := loadGatewayConfigForActive(harnessDir, activeGW) diff --git a/cmd/deploy.go b/cmd/deploy.go index 79adaf8..d4b4bb7 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -80,15 +80,13 @@ func deployLocal(gw gateway.Gateway) error { return fmt.Errorf("openshell CLI not found. Install it first:\n curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh") } - status.Section("Container Runtime") + status.Header("Deploy") if _, err := exec.LookPath("podman"); err != nil { status.Fail("Podman not found") return fmt.Errorf("podman is required") } out, _ := exec.Command("podman", "--version").Output() status.OKf("Podman: %s", strings.TrimSpace(string(out))) - - status.Section("Gateway") gateways, err := gw.GatewayList() if err != nil { return fmt.Errorf("listing gateways: %w", err) @@ -104,9 +102,8 @@ func deployLocal(gw gateway.Gateway) error { if localGW == "" { status.Fail("No local gateway found") - fmt.Println() - fmt.Println(" Install OpenShell (auto-registers the gateway):") - fmt.Println(" curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh") + status.Detail("Install OpenShell (auto-registers the gateway):") + status.Sub("curl -LsSf https://raw.githubusercontent.com/NVIDIA/OpenShell/main/install.sh | sh") return fmt.Errorf("no local gateway") } @@ -118,15 +115,11 @@ func deployLocal(gw gateway.Gateway) error { status.OKf("%s (active, reachable)", localGW) } else { status.Failf("%s (not responding)", localGW) - fmt.Println() - fmt.Println(" Start the gateway:") - fmt.Println(" macOS: brew services start openshell") - fmt.Println(" Linux: systemctl --user start openshell") + status.Detail("Start the gateway:") + status.Sub("macOS: brew services start openshell") + status.Sub("Linux: systemctl --user start openshell") return fmt.Errorf("gateway not responding") } - - fmt.Println() - status.Done("Done.") return nil } @@ -150,14 +143,14 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa chartVersion = gwCfg.Chart.Version } - fmt.Printf("OpenShell chart: %s\n", chartVersion) + status.Header("Deploy") + status.Infof("Chart: %s", chartVersion) if kbcfg := os.Getenv("KUBECONFIG"); kbcfg != "" { - fmt.Printf("KUBECONFIG: %s\n", kbcfg) + status.Infof("KUBECONFIG: %s", kbcfg) } - fmt.Println() // Step 1: Namespace - status.Step(1, "Creating namespace") + status.Step(1, "Namespace") clusterRunner.RunKubectl(ctx, "create", "ns", namespace) if _, err := clusterRunner.RunKubectl(ctx, "label", "ns", namespace, "pod-security.kubernetes.io/enforce=privileged", @@ -167,33 +160,33 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa } // Step 2: Sandbox CRD - status.Step(2, "Installing Sandbox CRD") - if err := clusterRunner.RunKubectlPassthrough(ctx, "apply", "-f", gwCfg.Chart.CRD.URL); err != nil { + status.Step(2, "Sandbox CRD") + if _, err := clusterRunner.RunKubectl(ctx, "apply", "-f", gwCfg.Chart.CRD.URL); err != nil { return fmt.Errorf("installing sandbox CRD: %w", err) } + status.OK("Installed") // Step 3: Platform-specific setup if gwCfg.IsOCP() { - status.Step(3, "Granting OpenShift SCCs") + status.Step(3, "OpenShift SCCs") for _, sa := range gwCfg.OCP.SCCPrivileged { kc.RunOC(ctx, "adm", "policy", "add-scc-to-user", "privileged", "-z", sa, "-n", namespace) } for _, sa := range gwCfg.OCP.SCCAnyuid { kc.RunOC(ctx, "adm", "policy", "add-scc-to-user", "anyuid", "-z", sa, "-n", namespace) } - // The sandbox controller ClusterRole and ClusterRoleBinding are - // included in the upstream manifest.yaml applied in step 2. + status.OK("Granted") } // Addon manifests (RBAC, etc.) for _, manifestPath := range gwCfg.ManifestPaths() { - if err := kc.RunKubectlPassthrough(ctx, "apply", "-f", manifestPath); err != nil { + if _, err := kc.RunKubectl(ctx, "apply", "-f", manifestPath); err != nil { return fmt.Errorf("applying %s: %w", filepath.Base(manifestPath), err) } } // Step 4: Helm install - status.Step(4, "Deploying gateway via Helm") + status.Step(4, "Helm install") // routeHost is needed before Helm (for OCP PKI cert SAN). // gatewayURL is resolved after Helm for nodeport (service doesn't exist yet). @@ -229,14 +222,13 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa return fmt.Errorf("helm install failed: %w", err) } - status.Section("Waiting for gateway") - if err := kc.RunKubectlPassthrough(ctx, "rollout", "status", "statefulset/openshell", "--timeout=300s"); err != nil { + if _, err := kc.RunKubectl(ctx, "rollout", "status", "statefulset/openshell", "--timeout=300s"); err != nil { return fmt.Errorf("gateway rollout failed: %w", err) } + status.OK("Gateway ready") // Step 5: CLI gateway config - // Resolve the gateway URL now that the service exists. - status.Step(5, "Configuring CLI gateway") + status.Step(5, "CLI gateway") gatewayName := gwCfg.Gateway.Name var gatewayURL string @@ -305,23 +297,17 @@ func deployFromConfig(harnessDir string, gwCfg *gateway.GatewayConfig, gw gatewa status.OKf("%s registered", gatewayName) } - fmt.Print(" Waiting for gateway...") var gwReachable bool for range 30 { if gw.InferenceGet() == nil { gwReachable = true - status.OK("reachable") break } time.Sleep(2 * time.Second) - fmt.Print(".") } if !gwReachable { - fmt.Println() return fmt.Errorf("gateway not reachable after 60s (try: openshell inference get)") } - - fmt.Println() - status.Done("Done.") + status.OK("Reachable") return nil } diff --git a/cmd/providers.go b/cmd/providers.go index 7e37e13..fa5643f 100644 --- a/cmd/providers.go +++ b/cmd/providers.go @@ -22,7 +22,7 @@ func NewProvidersCmd(harnessDir, cli string) *cobra.Command { Short: "Register providers with the gateway", RunE: func(cmd *cobra.Command, args []string) error { gw := gateway.New(cli) - return registerProviders(harnessDir, gw, force, nil) + return registerProviders(harnessDir, gw, force, nil, true) }, } @@ -34,7 +34,7 @@ func NewProvidersCmd(harnessDir, cli string) *cobra.Command { // registerProviders registers providers with the gateway. If gwCfg is non-nil // and has a [providers] section, only providers in that list are registered. // Otherwise all providers are registered (backward-compatible behavior). -func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg *gateway.GatewayConfig) error { +func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg *gateway.GatewayConfig, standalone bool) error { model := envOr("OPENSHELL_MODEL", "claude-sonnet-4-6") // Build the set of enabled provider names from gateway config (if available) @@ -71,21 +71,16 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg status.Info("Deleted existing providers") } + status.Header("Providers") + // Enable providers v2 - status.Section("Enabling providers v2") if err := gw.SettingsSet("providers_v2_enabled", "true"); err != nil { return fmt.Errorf("enabling providers v2: %w", err) } // Import custom profiles - status.Section("Importing custom profiles") profilesDir := filepath.Join(harnessDir, "agents", "providers", "profiles") - if err := gw.ProviderProfileImport(profilesDir); err != nil { - status.Info("already imported") - } - - // Register providers - status.Section("Registering providers") + gw.ProviderProfileImport(profilesDir) if err := registerGitHub(gw, providerEnabled); err != nil { return err @@ -100,24 +95,21 @@ func registerProviders(harnessDir string, gw gateway.Gateway, force bool, gwCfg return err } - // Show results - status.Section("Providers") - names, err := gw.ProviderList() - if err != nil { - return fmt.Errorf("listing providers: %w", err) - } - for _, n := range names { - status.OK(n) - } - - status.Section("Inference") - m := gw.InferenceModel() - if m != "" { - status.OKf("Model: %s", m) + if standalone { + names, err := gw.ProviderList() + if err != nil { + return fmt.Errorf("listing providers: %w", err) + } + fmt.Println() + for _, n := range names { + status.OK(n) + } + m := gw.InferenceModel() + if m != "" { + status.OKf("Inference: %s", m) + } + status.Done("Done. Launch a sandbox with: harness up --local") } - - fmt.Println() - status.Done("Done. Launch a sandbox with: harness up --local") return nil } diff --git a/cmd/providers_test.go b/cmd/providers_test.go index 210cd28..ef1da2c 100644 --- a/cmd/providers_test.go +++ b/cmd/providers_test.go @@ -26,7 +26,7 @@ func TestRegisterProviders_GitHubWhenTokenSet(t *testing.T) { providers: map[string]bool{}, } - err := registerProviders(dir, gw, false, nil) + err := registerProviders(dir, gw, false, nil, true) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -41,7 +41,7 @@ func TestRegisterProviders_SkipsWhenTokenMissing(t *testing.T) { providers: map[string]bool{}, } - err := registerProviders(dir, gw, false, nil) + err := registerProviders(dir, gw, false, nil, true) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -56,7 +56,7 @@ func TestRegisterProviders_SkipsExistingProvider(t *testing.T) { providers: map[string]bool{"github": true}, } - err := registerProviders(dir, gw, false, nil) + err := registerProviders(dir, gw, false, nil, true) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -72,7 +72,7 @@ func TestRegisterProviders_ForceWithRunningSandboxes(t *testing.T) { sandboxes: []string{"test-sandbox"}, } - err := registerProviders(dir, gw, true, nil) + err := registerProviders(dir, gw, true, nil, true) if err == nil { t.Fatal("expected error with --force and running sandboxes") } @@ -90,7 +90,7 @@ func TestRegisterProviders_ForceDeletesAndRecreates(t *testing.T) { providers: map[string]bool{}, } - err := registerProviders(dir, gw, true, nil) + err := registerProviders(dir, gw, true, nil, true) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -108,7 +108,7 @@ func TestRegisterProviders_RespectsGatewayConfig(t *testing.T) { gwCfg := &gateway.GatewayConfig{} gwCfg.Providers.Enabled = []string{"github"} - err := registerProviders(dir, gw, false, gwCfg) + err := registerProviders(dir, gw, false, gwCfg, true) if err != nil { t.Fatalf("registerProviders: %v", err) } @@ -122,7 +122,7 @@ func TestRegisterProviders_ListError(t *testing.T) { providerErr: fmt.Errorf("gateway unreachable"), } - err := registerProviders(dir, gw, false, nil) + err := registerProviders(dir, gw, false, nil, true) if err == nil { t.Fatal("expected error when provider list fails") } diff --git a/cmd/up.go b/cmd/up.go index cd83cfe..70523dc 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -100,6 +100,27 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa kc := k8s.New("", namespace) clusterRunner := k8s.New("", "") + // Parse agent config early so we can show context + agentCfg, err := agent.ParseFile(agentPath) + if err != nil { + return err + } + name := agentCfg.Name + if sandboxName != "" { + name = sandboxName + } + + sandboxImage := agentCfg.Image + if sandboxImage == "" { + sandboxImage = defaultSandboxImage() + } else if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { + sandboxImage = envImage + } + + // Top-level context + status.Infof("Agent: %s (%s)", name, filepath.Base(agentPath)) + status.Infof("Image: %s", sandboxImage) + // 1. Ensure gateway and namespace gwReachable := gw.InferenceGet() == nil _, nsErr := kc.RunKubectl(ctx, "get", "namespace", namespace) @@ -108,42 +129,22 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa if gwCfg == nil { return fmt.Errorf("no active gateway and no gateway config — use: harness deploy ocp") } - status.Section("Deploying gateway") if err := deployFromConfig(harnessDir, gwCfg, gw, kc, clusterRunner); err != nil { return fmt.Errorf("deploy failed: %w", err) } } - // 2. Parse agent config - agentCfg, err := agent.ParseFile(agentPath) - if err != nil { - return err - } - name := agentCfg.Name - if sandboxName != "" { - name = sandboxName - } - - // 3. Ensure providers needed by the agent + // 2. Ensure providers needed by the agent providerNames := agentCfg.ProviderNames() if len(providerNames) > 0 { _, missing := profile.ValidateProviders(providerNames, gw) if len(missing) > 0 { - status.Section("Registering providers") - if err := registerProviders(harnessDir, gw, false, gwCfg); err != nil { + if err := registerProviders(harnessDir, gw, false, gwCfg, false); err != nil { return fmt.Errorf("provider registration failed: %w", err) } } } - // Resolve sandbox image: agent config > SANDBOX_IMAGE env > version default - sandboxImage := agentCfg.Image - if sandboxImage == "" { - sandboxImage = defaultSandboxImage() - } else if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { - sandboxImage = envImage - } - // 4. ConfigMap from agent.yaml out, err := kc.RunKubectl(ctx, "create", "configmap", "sandbox-"+name, "--from-file=agent.yaml="+agentPath, @@ -211,7 +212,7 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa } // 7. Wait for runner pod - fmt.Println() + status.Header("Sandbox") status.Info("Waiting for runner...") kc.RunKubectl(ctx, "wait", "--for=condition=ready", "pod", "-l", "job-name="+jobName, "--timeout=120s") @@ -243,9 +244,9 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa logCmd.Wait() } - fmt.Println() if jobStatus == "Complete" || jobStatus == "SuccessCriteriaMet" { - status.OKf("Sandbox ready. Connect with: harness connect %s", name) + fmt.Println() + status.OKf("Connect with: harness connect %s", name) return nil } if jobStatus == "" { @@ -257,9 +258,33 @@ func upRemote(harnessDir string, gwCfg *gateway.GatewayConfig, gw gateway.Gatewa func upLocal(opts upLocalOpts) error { gw := opts.gw - // 1. Ensure gateway + // 1. Parse agent config + agentCfg, err := agent.ParseFile(opts.agentPath) + if err != nil { + return err + } + sandboxName := agentCfg.Name + if opts.sandboxName != "" { + sandboxName = opts.sandboxName + } + noTTY := opts.noTTY || agentCfg.NoTTY() + + sandboxImage := agentCfg.Image + if sandboxImage == "" { + sandboxImage = defaultSandboxImage() + } else if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { + sandboxImage = envImage + } + + // Top-level context + status.Infof("Agent: %s (%s)", sandboxName, filepath.Base(opts.agentPath)) + status.Infof("Image: %s", sandboxImage) + if agentCfg.Task != "" { + status.Infof("Task: %s", agentCfg.Task) + } + + // 2. Ensure gateway if opts.ensureLocal { - fmt.Println("=== Ensuring local gateway ===") if err := deployLocal(gw); err != nil { return fmt.Errorf("deploy failed: %w", err) } @@ -269,12 +294,6 @@ func upLocal(opts upLocalOpts) error { } } - // 2. Parse agent config - agentCfg, err := agent.ParseFile(opts.agentPath) - if err != nil { - return err - } - // 3. Ensure providers needed by the agent are registered providerNames := agentCfg.ProviderNames() var registered []string @@ -282,18 +301,17 @@ func upLocal(opts upLocalOpts) error { var missing []string registered, missing = profile.ValidateProviders(providerNames, gw) if len(missing) > 0 { - status.Section("Registering providers") - if err := registerProviders(opts.harnessDir, gw, false, opts.gwCfg); err != nil { + if err := registerProviders(opts.harnessDir, gw, false, opts.gwCfg, false); err != nil { status.Warn(fmt.Sprintf("provider registration: %v", err)) } registered, missing = profile.ValidateProviders(providerNames, gw) } - status.Section("Providers") + status.Header("Providers") for _, name := range registered { - status.OKf("%s: attached", name) + status.OKf("%s", name) } for _, name := range missing { - status.Failf("%s: not registered (skipping)", name) + status.Failf("%s (not registered)", name) } } @@ -308,36 +326,13 @@ func upLocal(opts upLocalOpts) error { return fmt.Errorf("rendering payload: %w", err) } - // 5. Build sandbox config - sandboxName := agentCfg.Name - if opts.sandboxName != "" { - sandboxName = opts.sandboxName - } - noTTY := opts.noTTY || agentCfg.NoTTY() - - sandboxImage := agentCfg.Image - if sandboxImage == "" { - sandboxImage = defaultSandboxImage() - } else if envImage := os.Getenv("SANDBOX_IMAGE"); envImage != "" { - sandboxImage = envImage - } - cfg := &profile.Config{ Name: sandboxName, From: sandboxImage, } - fmt.Println() - fmt.Println("=== Sandbox ===") - fmt.Printf(" Agent: %s\n", opts.agentPath) - fmt.Printf(" Image: %s\n", cfg.From) - if agentCfg.Task != "" { - fmt.Printf(" Task: %s\n", agentCfg.Task) - } - - // 7. Create sandbox - fmt.Println() - fmt.Println("=== Creating sandbox ===") + // 5. Create sandbox + status.Header("Sandbox") envInit := ". /sandbox/.config/openshell/sandbox.env 2>/dev/null && " + "cat /sandbox/.config/openshell/sandbox.env >> /sandbox/.bashrc 2>/dev/null; " var sandboxCmd []string From df5fbbde3ea508e118b717466bbcfc09d5d40e67 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Mon, 8 Jun 2026 19:37:53 -0700 Subject: [PATCH 3/3] fix: force-remove dev image before manifest rebuild in dev-push --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 92f19b7..dcdc521 100644 --- a/Makefile +++ b/Makefile @@ -39,7 +39,7 @@ cli: ## Sandbox image (Claude Code + mcp-atlassian + gws, multi-arch) sandbox: sandbox/Dockerfile sandbox/startup.sh \ sandbox/policy.yaml sandbox/CLAUDE.md sandbox/settings.json - -$(CONTAINER_CLI) manifest rm $(SANDBOX_IMAGE) 2>/dev/null + @$(CONTAINER_CLI) manifest rm $(SANDBOX_IMAGE) 2>/dev/null || true $(CONTAINER_CLI) build --platform linux/amd64 --manifest $(SANDBOX_IMAGE) sandbox/ $(CONTAINER_CLI) build --platform linux/arm64 --manifest $(SANDBOX_IMAGE) sandbox/ @echo "Built: $(SANDBOX_IMAGE) (multi-arch)" @@ -119,8 +119,8 @@ dev-runner: cli-runner ## Build and push dev images (sandbox: multi-arch, runner: amd64) dev-push: cli-runner - -$(CONTAINER_CLI) rmi $(DEV_SANDBOX_IMAGE) 2>/dev/null - -$(CONTAINER_CLI) manifest rm $(DEV_SANDBOX_IMAGE) 2>/dev/null + @$(CONTAINER_CLI) rmi --force $(DEV_SANDBOX_IMAGE) 2>/dev/null || true + @$(CONTAINER_CLI) manifest rm $(DEV_SANDBOX_IMAGE) 2>/dev/null || true $(CONTAINER_CLI) build --platform linux/amd64 --manifest $(DEV_SANDBOX_IMAGE) sandbox/ $(CONTAINER_CLI) build --platform linux/arm64 --manifest $(DEV_SANDBOX_IMAGE) sandbox/ $(CONTAINER_CLI) manifest push $(DEV_SANDBOX_IMAGE)