Skip to content

Commit 6c7108f

Browse files
committed
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.
1 parent e81bc74 commit 6c7108f

14 files changed

Lines changed: 645 additions & 7 deletions

File tree

‎cmd/helpers_test.go‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ func (m *mockGW) ProviderProfileImport(string) error
5454
func (m *mockGW) ProviderProfileDelete(string) error { return nil }
5555
func (m *mockGW) SettingsSet(string, string) error { return nil }
5656
func (m *mockGW) SandboxList() ([]string, error) { return nil, nil }
57+
func (m *mockGW) SandboxStatus() ([]gateway.SandboxInfo, error) { return nil, nil }
5758
func (m *mockGW) SandboxConnect(string) error { return nil }
59+
func (m *mockGW) SandboxLogs(string, bool) error { return nil }
60+
func (m *mockGW) SandboxStop(string) error { return nil }
61+
func (m *mockGW) SandboxStart(string) error { return nil }
5862
func (m *mockGW) GatewayAdd(string, string, bool, bool) error { return nil }
5963
func (m *mockGW) GatewayRemove(name string) error {
6064
if m.onGatewayRemove != nil {

‎cmd/logs.go‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package cmd
2+
3+
import (
4+
"github.com/robbycochran/harness-openshell/internal/gateway"
5+
"github.com/spf13/cobra"
6+
)
7+
8+
func NewLogsCmd(harnessDir, cli string) *cobra.Command {
9+
var follow bool
10+
11+
cmd := &cobra.Command{
12+
Use: "logs [SANDBOX_NAME]",
13+
Short: "Stream sandbox logs",
14+
Args: cobra.MaximumNArgs(1),
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
gw := gateway.New(cli)
17+
name := ""
18+
if len(args) > 0 {
19+
name = args[0]
20+
}
21+
return gw.SandboxLogs(name, follow)
22+
},
23+
}
24+
25+
cmd.Flags().BoolVarP(&follow, "follow", "f", false, "Follow log output")
26+
return cmd
27+
}

‎cmd/start.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/robbycochran/harness-openshell/internal/gateway"
7+
"github.com/robbycochran/harness-openshell/internal/status"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func NewStartCmd(harnessDir, cli string) *cobra.Command {
12+
return &cobra.Command{
13+
Use: "start [SANDBOX_NAME]",
14+
Short: "Start a stopped sandbox",
15+
Args: cobra.MaximumNArgs(1),
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
gw := gateway.New(cli)
18+
name, err := resolveSandboxName(gw, args)
19+
if err != nil {
20+
return err
21+
}
22+
if err := gw.SandboxStart(name); err != nil {
23+
return fmt.Errorf("starting %s: %w", name, err)
24+
}
25+
status.OKf("Started %s", name)
26+
return nil
27+
},
28+
}
29+
}

‎cmd/start_test.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
)
7+
8+
func TestStart_Success(t *testing.T) {
9+
gw := &stopStartMockGW{sandboxNames: []string{"agent"}}
10+
name, _ := resolveSandboxName(gw, nil)
11+
if err := gw.SandboxStart(name); err != nil {
12+
t.Fatal(err)
13+
}
14+
if len(gw.startedNames) != 1 || gw.startedNames[0] != "agent" {
15+
t.Errorf("started = %v", gw.startedNames)
16+
}
17+
}
18+
19+
func TestStart_Error(t *testing.T) {
20+
gw := &stopStartMockGW{startErr: fmt.Errorf("sandbox not found")}
21+
if err := gw.SandboxStart("missing"); err == nil {
22+
t.Fatal("expected error")
23+
}
24+
}

‎cmd/status_cmd.go‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/robbycochran/harness-openshell/internal/gateway"
7+
"github.com/robbycochran/harness-openshell/internal/status"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func NewStatusCmd(harnessDir, cli string) *cobra.Command {
12+
return &cobra.Command{
13+
Use: "status",
14+
Short: "Show sandbox and gateway status",
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
gw := gateway.New(cli)
17+
return runStatus(gw)
18+
},
19+
}
20+
}
21+
22+
func runStatus(gw gateway.Gateway) error {
23+
status.Header("Gateway")
24+
active := gw.ActiveGateway()
25+
if active != "" {
26+
status.OKf("Active: %s", active)
27+
ver := gw.CLIVersion()
28+
if ver != "" {
29+
status.Infof("CLI: %s", ver)
30+
}
31+
} else {
32+
status.Info("No active gateway")
33+
}
34+
35+
fmt.Println()
36+
status.Header("Sandboxes")
37+
infos, err := gw.SandboxStatus()
38+
if err != nil {
39+
if active == "" {
40+
status.Info("No active gateway, cannot list sandboxes")
41+
return nil
42+
}
43+
return fmt.Errorf("listing sandboxes: %w", err)
44+
}
45+
if len(infos) == 0 {
46+
status.Info("None running")
47+
return nil
48+
}
49+
50+
headers := []string{"NAME", "PHASE"}
51+
var rows [][]string
52+
for _, info := range infos {
53+
rows = append(rows, []string{info.Name, info.Phase})
54+
}
55+
status.Table(headers, rows)
56+
return nil
57+
}

‎cmd/status_cmd_test.go‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"github.com/robbycochran/harness-openshell/internal/gateway"
7+
)
8+
9+
type statusMockGW struct {
10+
mockGW
11+
statusResult []gateway.SandboxInfo
12+
statusErr error
13+
activeGW string
14+
cliVer string
15+
}
16+
17+
func (m *statusMockGW) SandboxStatus() ([]gateway.SandboxInfo, error) {
18+
return m.statusResult, m.statusErr
19+
}
20+
func (m *statusMockGW) ActiveGateway() string { return m.activeGW }
21+
func (m *statusMockGW) CLIVersion() string { return m.cliVer }
22+
23+
func TestRunStatus_DisplaysSandboxes(t *testing.T) {
24+
gw := &statusMockGW{
25+
activeGW: "local",
26+
cliVer: "openshell v0.0.58",
27+
statusResult: []gateway.SandboxInfo{
28+
{Name: "agent", Phase: "Ready"},
29+
{Name: "test", Phase: "Stopped"},
30+
},
31+
}
32+
if err := runStatus(gw); err != nil {
33+
t.Fatalf("runStatus: %v", err)
34+
}
35+
}
36+
37+
func TestRunStatus_NoSandboxes(t *testing.T) {
38+
gw := &statusMockGW{
39+
activeGW: "local",
40+
cliVer: "openshell v0.0.58",
41+
}
42+
if err := runStatus(gw); err != nil {
43+
t.Fatalf("runStatus: %v", err)
44+
}
45+
}
46+
47+
func TestRunStatus_NoGateway(t *testing.T) {
48+
gw := &statusMockGW{}
49+
if err := runStatus(gw); err != nil {
50+
t.Fatalf("runStatus: %v", err)
51+
}
52+
}

‎cmd/stop.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/robbycochran/harness-openshell/internal/gateway"
7+
"github.com/robbycochran/harness-openshell/internal/status"
8+
"github.com/spf13/cobra"
9+
)
10+
11+
func NewStopCmd(harnessDir, cli string) *cobra.Command {
12+
return &cobra.Command{
13+
Use: "stop [SANDBOX_NAME]",
14+
Short: "Stop a running sandbox",
15+
Args: cobra.MaximumNArgs(1),
16+
RunE: func(cmd *cobra.Command, args []string) error {
17+
gw := gateway.New(cli)
18+
name, err := resolveSandboxName(gw, args)
19+
if err != nil {
20+
return err
21+
}
22+
if err := gw.SandboxStop(name); err != nil {
23+
return fmt.Errorf("stopping %s: %w", name, err)
24+
}
25+
status.OKf("Stopped %s", name)
26+
return nil
27+
},
28+
}
29+
}
30+
31+
func resolveSandboxName(gw gateway.Gateway, args []string) (string, error) {
32+
if len(args) > 0 {
33+
return args[0], nil
34+
}
35+
names, err := gw.SandboxList()
36+
if err != nil {
37+
return "", fmt.Errorf("listing sandboxes: %w", err)
38+
}
39+
if len(names) == 0 {
40+
return "", fmt.Errorf("no sandboxes running")
41+
}
42+
if len(names) > 1 {
43+
return "", fmt.Errorf("multiple sandboxes running, specify one: %v", names)
44+
}
45+
return names[0], nil
46+
}

‎cmd/stop_test.go‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
)
7+
8+
type stopStartMockGW struct {
9+
mockGW
10+
sandboxNames []string
11+
stoppedNames []string
12+
startedNames []string
13+
stopErr error
14+
startErr error
15+
}
16+
17+
func (m *stopStartMockGW) SandboxList() ([]string, error) { return m.sandboxNames, nil }
18+
func (m *stopStartMockGW) SandboxStop(name string) error {
19+
m.stoppedNames = append(m.stoppedNames, name)
20+
return m.stopErr
21+
}
22+
func (m *stopStartMockGW) SandboxStart(name string) error {
23+
m.startedNames = append(m.startedNames, name)
24+
return m.startErr
25+
}
26+
27+
func TestResolveSandboxName_Explicit(t *testing.T) {
28+
gw := &stopStartMockGW{}
29+
name, err := resolveSandboxName(gw, []string{"my-agent"})
30+
if err != nil {
31+
t.Fatal(err)
32+
}
33+
if name != "my-agent" {
34+
t.Errorf("got %q, want my-agent", name)
35+
}
36+
}
37+
38+
func TestResolveSandboxName_AutoSingle(t *testing.T) {
39+
gw := &stopStartMockGW{sandboxNames: []string{"agent"}}
40+
name, err := resolveSandboxName(gw, nil)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
if name != "agent" {
45+
t.Errorf("got %q, want agent", name)
46+
}
47+
}
48+
49+
func TestResolveSandboxName_AmbiguousError(t *testing.T) {
50+
gw := &stopStartMockGW{sandboxNames: []string{"a", "b"}}
51+
_, err := resolveSandboxName(gw, nil)
52+
if err == nil {
53+
t.Fatal("expected error for multiple sandboxes")
54+
}
55+
}
56+
57+
func TestResolveSandboxName_NoneError(t *testing.T) {
58+
gw := &stopStartMockGW{}
59+
_, err := resolveSandboxName(gw, nil)
60+
if err == nil {
61+
t.Fatal("expected error for no sandboxes")
62+
}
63+
}
64+
65+
func TestStop_Success(t *testing.T) {
66+
gw := &stopStartMockGW{sandboxNames: []string{"agent"}}
67+
name, _ := resolveSandboxName(gw, nil)
68+
if err := gw.SandboxStop(name); err != nil {
69+
t.Fatal(err)
70+
}
71+
if len(gw.stoppedNames) != 1 || gw.stoppedNames[0] != "agent" {
72+
t.Errorf("stopped = %v", gw.stoppedNames)
73+
}
74+
}
75+
76+
func TestStop_Error(t *testing.T) {
77+
gw := &stopStartMockGW{stopErr: fmt.Errorf("sandbox not found")}
78+
if err := gw.SandboxStop("missing"); err == nil {
79+
t.Fatal("expected error")
80+
}
81+
}

‎internal/gateway/cli.go‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,46 @@ func (c *CLI) SandboxCreate(opts SandboxCreateOpts) error {
226226
return c.passthrough(args...)
227227
}
228228

229+
func (c *CLI) SandboxStatus() ([]SandboxInfo, error) {
230+
out, err := c.output("sandbox", "list")
231+
if err != nil {
232+
return nil, err
233+
}
234+
var infos []SandboxInfo
235+
for i, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
236+
if i == 0 || strings.TrimSpace(line) == "" {
237+
continue
238+
}
239+
cleaned := ansiRE.ReplaceAllString(line, "")
240+
fields := strings.Fields(cleaned)
241+
if len(fields) >= 2 {
242+
infos = append(infos, SandboxInfo{Name: fields[0], Phase: fields[1]})
243+
} else if len(fields) == 1 {
244+
infos = append(infos, SandboxInfo{Name: fields[0]})
245+
}
246+
}
247+
return infos, nil
248+
}
249+
250+
func (c *CLI) SandboxLogs(name string, follow bool) error {
251+
args := []string{"sandbox", "logs"}
252+
if name != "" {
253+
args = append(args, name)
254+
}
255+
if follow {
256+
args = append(args, "--follow")
257+
}
258+
return c.passthrough(args...)
259+
}
260+
261+
func (c *CLI) SandboxStop(name string) error {
262+
return c.silent("sandbox", "stop", name)
263+
}
264+
265+
func (c *CLI) SandboxStart(name string) error {
266+
return c.silent("sandbox", "start", name)
267+
}
268+
229269
func (c *CLI) SandboxDelete(name string) error {
230270
return c.silent("sandbox", "delete", name)
231271
}

0 commit comments

Comments
 (0)