From 89299e36df5ed8922e2687bcff95f722d477af58 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 18:21:21 -0700 Subject: [PATCH 1/3] X-Smart-Branch-Parent: main From f4aab0217719bbb63d4807cd1bed1e548c497a86 Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 18:23:32 -0700 Subject: [PATCH 2/3] feat: add get and describe commands (Phase 2 of kubectl-style CLI) - harness get agents/providers/gateways with -o table|json|yaml - harness describe for detailed sandbox status - Shared OutputFormat type and table/json/yaml formatters - status command updated to "use 'harness get agents' instead" --- cmd/describe.go | 62 ++++++++++++++++ cmd/get.go | 187 ++++++++++++++++++++++++++++++++++++++++++++++++ cmd/output.go | 76 ++++++++++++++++++++ main.go | 6 +- 4 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 cmd/describe.go create mode 100644 cmd/get.go create mode 100644 cmd/output.go diff --git a/cmd/describe.go b/cmd/describe.go new file mode 100644 index 0000000..587ded6 --- /dev/null +++ b/cmd/describe.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "fmt" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/robbycochran/harness-openshell/internal/status" + "github.com/spf13/cobra" +) + +func NewDescribeCmd(harnessDir, cli string) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe [NAME]", + Short: "Show detailed status for a sandbox", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + gw := gateway.New(cli) + + sandboxes, err := gw.SandboxStatus() + if err != nil { + return fmt.Errorf("listing sandboxes: %w", err) + } + + var found *gateway.SandboxInfo + for i := range sandboxes { + if sandboxes[i].Name == name { + found = &sandboxes[i] + break + } + } + if found == nil { + return fmt.Errorf("sandbox %q not found", name) + } + + status.Header(found.Name) + status.Infof("Phase: %s", found.Phase) + + gateways, err := gw.GatewayList() + if err == nil { + for _, g := range gateways { + if g.Active { + status.Infof("Gateway: %s (%s)", g.Name, g.Endpoint) + break + } + } + } + + providers, err := gw.ProviderList() + if err == nil && len(providers) > 0 { + status.Infof("Providers: %d registered", len(providers)) + for _, p := range providers { + fmt.Printf(" - %s\n", p) + } + } + + return nil + }, + } + + return cmd +} diff --git a/cmd/get.go b/cmd/get.go new file mode 100644 index 0000000..8d8e76b --- /dev/null +++ b/cmd/get.go @@ -0,0 +1,187 @@ +package cmd + +import ( + "fmt" + + "github.com/robbycochran/harness-openshell/internal/gateway" + "github.com/spf13/cobra" +) + +func NewGetCmd(harnessDir, cli string) *cobra.Command { + cmd := &cobra.Command{ + Use: "get", + Short: "Display resources", + Long: "List sandboxes, providers, or gateways. Use -o json or -o yaml for machine-readable output.", + } + + cmd.AddCommand( + newGetAgentsCmd(cli), + newGetProvidersCmd(cli), + newGetGatewaysCmd(cli), + ) + + return cmd +} + +func newGetAgentsCmd(cli string) *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "agents", + Aliases: []string{"sandboxes", "sandbox"}, + Short: "List running sandboxes", + RunE: func(cmd *cobra.Command, args []string) error { + format, err := parseOutputFormat(output) + if err != nil { + return err + } + + gw := gateway.New(cli) + sandboxes, err := gw.SandboxStatus() + if err != nil { + return fmt.Errorf("listing sandboxes: %w", err) + } + + if len(sandboxes) == 0 { + if format == formatTable { + fmt.Println("No sandboxes running.") + } else { + return printStructured(format, []any{}) + } + return nil + } + + if format != formatTable { + type sandboxOut struct { + Name string `json:"name" yaml:"name"` + Phase string `json:"phase" yaml:"phase"` + } + out := make([]sandboxOut, len(sandboxes)) + for i, s := range sandboxes { + out[i] = sandboxOut{Name: s.Name, Phase: s.Phase} + } + return printStructured(format, out) + } + + rows := make([][]string, len(sandboxes)) + for i, s := range sandboxes { + rows[i] = []string{s.Name, s.Phase} + } + printTable([]string{"Name", "Phase"}, rows) + return nil + }, + } + + cmd.Flags().StringVarP(&output, "output", "o", "", "Output format: table, json, or yaml") + return cmd +} + +func newGetProvidersCmd(cli string) *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "providers", + Aliases: []string{"provider"}, + Short: "List registered providers", + RunE: func(cmd *cobra.Command, args []string) error { + format, err := parseOutputFormat(output) + if err != nil { + return err + } + + gw := gateway.New(cli) + providers, err := gw.ProviderList() + if err != nil { + return fmt.Errorf("listing providers: %w", err) + } + + if len(providers) == 0 { + if format == formatTable { + fmt.Println("No providers registered.") + } else { + return printStructured(format, []any{}) + } + return nil + } + + if format != formatTable { + type providerOut struct { + Name string `json:"name" yaml:"name"` + } + out := make([]providerOut, len(providers)) + for i, p := range providers { + out[i] = providerOut{Name: p} + } + return printStructured(format, out) + } + + rows := make([][]string, len(providers)) + for i, p := range providers { + rows[i] = []string{p} + } + printTable([]string{"Name"}, rows) + return nil + }, + } + + cmd.Flags().StringVarP(&output, "output", "o", "", "Output format: table, json, or yaml") + return cmd +} + +func newGetGatewaysCmd(cli string) *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "gateways", + Aliases: []string{"gateway", "gw"}, + Short: "List gateways", + RunE: func(cmd *cobra.Command, args []string) error { + format, err := parseOutputFormat(output) + if err != nil { + return err + } + + gw := gateway.New(cli) + gateways, err := gw.GatewayList() + if err != nil { + return fmt.Errorf("listing gateways: %w", err) + } + + if len(gateways) == 0 { + if format == formatTable { + fmt.Println("No gateways registered.") + } else { + return printStructured(format, []any{}) + } + return nil + } + + if format != formatTable { + type gwOut struct { + Name string `json:"name" yaml:"name"` + Endpoint string `json:"endpoint" yaml:"endpoint"` + Active bool `json:"active" yaml:"active"` + } + out := make([]gwOut, len(gateways)) + for i, g := range gateways { + out[i] = gwOut{Name: g.Name, Endpoint: g.Endpoint, Active: g.Active} + } + return printStructured(format, out) + } + + rows := make([][]string, len(gateways)) + for i, g := range gateways { + active := "" + if g.Active { + active = "*" + } + rows[i] = []string{active + g.Name, g.Endpoint} + } + printTable([]string{"Name", "Endpoint"}, rows) + return nil + }, + } + + cmd.Flags().StringVarP(&output, "output", "o", "", "Output format: table, json, or yaml") + return cmd +} diff --git a/cmd/output.go b/cmd/output.go new file mode 100644 index 0000000..3a2609d --- /dev/null +++ b/cmd/output.go @@ -0,0 +1,76 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "gopkg.in/yaml.v3" +) + +type outputFormat string + +const ( + formatTable outputFormat = "table" + formatJSON outputFormat = "json" + formatYAML outputFormat = "yaml" +) + +func parseOutputFormat(s string) (outputFormat, error) { + switch strings.ToLower(s) { + case "", "table": + return formatTable, nil + case "json": + return formatJSON, nil + case "yaml": + return formatYAML, nil + default: + return "", fmt.Errorf("unsupported output format %q (use table, json, or yaml)", s) + } +} + +func printTable(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 { + fmt.Printf("%-*s ", widths[i], strings.ToUpper(h)) + } + fmt.Println() + + for _, row := range rows { + for i, cell := range row { + if i < len(widths) { + fmt.Printf("%-*s ", widths[i], cell) + } + } + fmt.Println() + } +} + +func printStructured(format outputFormat, data any) error { + switch format { + case formatJSON: + out, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + fmt.Println(string(out)) + case formatYAML: + out, err := yaml.Marshal(data) + if err != nil { + return err + } + fmt.Print(string(out)) + } + return nil +} diff --git a/main.go b/main.go index 4c02cf7..9b1bf9c 100644 --- a/main.go +++ b/main.go @@ -62,18 +62,20 @@ func main() { root.AddCommand( cmd.NewApplyCmd(harnessDir, cli), + cmd.NewGetCmd(harnessDir, cli), + cmd.NewDescribeCmd(harnessDir, cli), cmd.NewDeployCmd(harnessDir, cli), cmd.NewStopCmd(harnessDir, cli), cmd.NewStartCmd(harnessDir, cli), ) - // Deprecated aliases (kept until get/delete commands ship) + // Deprecated aliases teardownCmd := cmd.NewTeardownCmd(harnessDir, cli) teardownCmd.Hidden = true teardownCmd.Deprecated = "will be replaced by 'harness delete' in a future release" statusCmd := cmd.NewStatusCmd(harnessDir, cli) statusCmd.Hidden = true - statusCmd.Deprecated = "will be replaced by 'harness get agents' in a future release" + statusCmd.Deprecated = "use 'harness get agents' instead" root.AddCommand(teardownCmd, statusCmd) if err := root.Execute(); err != nil { From 2e6e1376abd1e4f39887044665fa57cb669ba85c Mon Sep 17 00:00:00 2001 From: Robby Cochran Date: Tue, 16 Jun 2026 18:29:08 -0700 Subject: [PATCH 3/3] docs: add get/describe to README and SPEC, add docs check to validate skill - README: document get agents/providers/gateways with -o flag, describe command - SPEC: add get command section with resource table, note these wrap openshell - Validate skill: add Step 9 docs consistency check (verifies all primary commands appear in README and SPEC, flags stale command references) - Removed deprecated status command reference from README --- .agents/skills/validate/SKILL.md | 33 ++++++++++++++++++++++++++++++-- README.md | 17 ++++++++++++---- SPEC.md | 18 ++++++++++++++++- 3 files changed, 61 insertions(+), 7 deletions(-) diff --git a/.agents/skills/validate/SKILL.md b/.agents/skills/validate/SKILL.md index 472f458..1a12f04 100644 --- a/.agents/skills/validate/SKILL.md +++ b/.agents/skills/validate/SKILL.md @@ -5,7 +5,7 @@ description: Run the full test matrix for harness-openshell. Use when asked to " # Validate -Run the full test matrix. Skip steps that require unavailable infrastructure. +Run the full test and documentation matrix. Skip steps that require unavailable infrastructure. ## Steps @@ -77,13 +77,41 @@ Check if CI is green for the current branch: gh run list --branch $(git branch --show-current) --limit 3 ``` +### 9. Docs consistency + +Check that README.md and SPEC.md accurately reference the commands +registered in main.go. Every primary command in main.go should appear +in both README.md and SPEC.md. No stale command references should exist. + +```bash +# Commands registered in main.go (primary, not deprecated) +grep 'cmd.New.*Cmd' main.go | grep -v Hidden | grep -v Deprecated + +# Check README references all primary commands +for cmd in apply get describe deploy stop start; do + grep -q "harness $cmd" README.md && echo "README: $cmd OK" || echo "README: $cmd MISSING" +done + +# Check SPEC references all primary commands +for cmd in apply get describe deploy stop start; do + grep -q "harness $cmd" SPEC.md && echo "SPEC: $cmd OK" || echo "SPEC: $cmd MISSING" +done + +# Check for stale references to removed commands +for cmd in "harness up" "harness create" "harness render"; do + grep -c "$cmd" README.md SPEC.md 2>/dev/null +done +``` + +Report any docs/code mismatches. + ## Output Report a summary table: ``` Validation Results -────────────────── +------------------ Build: PASS Unit tests: PASS (6 packages) Vet: PASS @@ -92,4 +120,5 @@ Validation Results OCP: PASS (10/10) Kind: SKIP (kind not installed) CI: GREEN (3/3 workflows) + Docs: PASS (all commands documented, no stale refs) ``` diff --git a/README.md b/README.md index 4b55dd8..6f63308 100644 --- a/README.md +++ b/README.md @@ -157,16 +157,25 @@ harness apply [-f FILE] [--agent NAME] [--gateway NAME] [--attach] [--dry-run] [ harness deploy [local|ocp|kind] Deploy or verify the gateway for a target. +harness get agents [-o table|json|yaml] + List running sandboxes. Wraps openshell sandbox list with + consistent structured output. Aliases: sandboxes, sandbox. + +harness get providers [-o table|json|yaml] + List registered providers. Credentials never included in output. + +harness get gateways [-o table|json|yaml] + List gateways. Aliases: gateway, gw. + +harness describe + Detailed status for a specific sandbox (phase, gateway, providers). + harness stop [NAME] / harness start [NAME] Stop or start a sandbox without deleting it. harness teardown [--sandboxes] [--providers] [--k8s] Tear down resources. At least one flag required. (Deprecated: will be replaced by 'harness delete') - -harness status - Show sandbox status. - (Deprecated: will be replaced by 'harness get agents') ``` For sandbox connect/logs, use openshell directly: diff --git a/SPEC.md b/SPEC.md index 1ca9f3b..9078b47 100644 --- a/SPEC.md +++ b/SPEC.md @@ -98,6 +98,22 @@ Default is non-interactive (headless). Use `--attach` for TTY mode. `--provider-refresh` deletes and recreates all providers. +### `harness get [-o table|json|yaml]` + +List resources. Wraps `openshell` list commands with consistent structured output across resource types. `-o table` is the default. Credential values are never included in structured output. + +| Subcommand | Aliases | What it lists | +|------------|---------|--------------| +| `get agents` | `sandboxes`, `sandbox` | Running sandboxes (name, phase) | +| `get providers` | `provider` | Registered providers (name only, no credentials) | +| `get gateways` | `gateway`, `gw` | Gateways (name, endpoint, active) | + +These are convenience wrappers. For full details, use `openshell sandbox list`, `openshell provider list`, etc. directly. + +### `harness describe ` + +Show detailed status for a specific sandbox: phase, active gateway, and registered providers. + ### `harness deploy [local|ocp|kind]` Deploy or verify the gateway for a target. Reads `profiles/gateways/.yaml`. @@ -113,7 +129,7 @@ These commands still work but will be removed in a future release: | Old command | Replacement | Notes | |-------------|-------------|-------| | `harness teardown` | `harness delete` (planned) | Flags: `--sandboxes`, `--providers`, `--k8s` | -| `harness status` | `harness get agents` (planned) | | +| `harness status` | `harness get agents` | | ## Config Files