diff --git a/cmd/agents_get.go b/cmd/agents_get.go index 33c5c4f..4bb07c4 100644 --- a/cmd/agents_get.go +++ b/cmd/agents_get.go @@ -113,6 +113,21 @@ func printAgentDetail(out io.Writer, a *apiclient.AgentDetail, hasRoute *bool) { rows.add("UID", strDeref(a.Metadata.UID, "N/A")) rows.flush(out) + if len(a.Contexts) > 0 { + section(out, "Contexts") + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, " NAME\tTYPE\tMOUNT PATH\tACCESS\tCLAIM") + for _, c := range a.Contexts { + access := "Read-write" + if c.ReadOnly { + access = "Read-only" + } + fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", + c.Name, orDefault(c.Type, "-"), c.MountPath, access, orDefault(c.ClaimName, "-")) + } + _ = w.Flush() + } + // Endpoint (Service info, when present). // // The external route is reported here, beside the Service, because both diff --git a/cmd/agents_get_test.go b/cmd/agents_get_test.go index cc492e3..6cf91cd 100644 --- a/cmd/agents_get_test.go +++ b/cmd/agents_get_test.go @@ -34,6 +34,10 @@ const agentDetailBody = `{ }, "workloadType": "deployment", "readyStatus": "Ready", + "contexts": [{ + "name": "research", "type": "workspace", "mountPath": "/workspace", + "readOnly": false, "claimName": "context-research" + }], "service": { "name": "orders", "type": "ClusterIP", @@ -100,6 +104,7 @@ func TestAgentsGetText(t *testing.T) { "Replicas:", "2/2 ready (2 available)", "Created:", "2026-01-02T03:04:05Z", "UID:", "abc-123", + "Contexts", "research", "workspace", "/workspace", "Read-write", "context-research", "Endpoint", "Service:", "orders (ClusterIP)", "Cluster IP:", "10.0.0.5", @@ -137,6 +142,10 @@ func TestAgentsGetJSON(t *testing.T) { Name string `json:"name"` } `json:"metadata"` ReadyStatus string `json:"readyStatus"` + Contexts []struct { + Name string `json:"name"` + MountPath string `json:"mountPath"` + } `json:"contexts"` } if err := json.Unmarshal([]byte(out), &decoded); err != nil { t.Fatalf("--json output is not valid JSON: %v\n%s", err, out) @@ -144,6 +153,9 @@ func TestAgentsGetJSON(t *testing.T) { if decoded.Metadata.Name != "orders" || decoded.ReadyStatus != "Ready" { t.Errorf("unexpected decoded JSON: %+v", decoded) } + if len(decoded.Contexts) != 1 || decoded.Contexts[0].Name != "research" || decoded.Contexts[0].MountPath != "/workspace" { + t.Errorf("contexts missing from JSON: %+v", decoded.Contexts) + } } func TestAgentsGetNamespaceOverride(t *testing.T) { diff --git a/cmd/contexts.go b/cmd/contexts.go index a6d0a94..ff2a13d 100644 --- a/cmd/contexts.go +++ b/cmd/contexts.go @@ -145,7 +145,7 @@ func newContextsListCmd() *cobra.Command { if err != nil { return err } - cmd.Println(string(encoded)) + fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) return nil } if len(result.Items) == 0 { @@ -195,12 +195,30 @@ func printContextResource(cmd *cobra.Command, value *apiclient.ContextResource, if err != nil { return err } - cmd.Println(string(encoded)) + fmt.Fprintln(cmd.OutOrStdout(), string(encoded)) return nil } - cmd.Printf("%s/%s: %s %s, %s %s, claim %s\n", value.Namespace, value.Name, - value.Status, value.Type, value.Storage.Size, value.Storage.AccessMode, value.Attachment.ClaimName) - return nil + storageClass := value.Storage.StorageClass + if storageClass == "" { + storageClass = "" + } + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + fmt.Fprintln(writer, "Context Information") + fmt.Fprintf(writer, " Name:\t%s\n", value.Name) + fmt.Fprintf(writer, " Namespace:\t%s\n", value.Namespace) + fmt.Fprintf(writer, " Type:\t%s\n", value.Type) + fmt.Fprintf(writer, " Status:\t%s\n", value.Status) + fmt.Fprintln(writer) + fmt.Fprintln(writer, "Storage") + fmt.Fprintf(writer, " Backend:\t%s\n", value.Storage.Backend) + fmt.Fprintf(writer, " Size:\t%s\n", value.Storage.Size) + fmt.Fprintf(writer, " Access Mode:\t%s\n", value.Storage.AccessMode) + fmt.Fprintf(writer, " Storage Class:\t%s\n", storageClass) + fmt.Fprintln(writer) + fmt.Fprintln(writer, "Attachment") + fmt.Fprintf(writer, " Kind:\t%s\n", value.Attachment.Kind) + fmt.Fprintf(writer, " Claim:\t%s\n", value.Attachment.ClaimName) + return writer.Flush() } func init() { diff --git a/cmd/contexts_test.go b/cmd/contexts_test.go index 9863819..03a9a0d 100644 --- a/cmd/contexts_test.go +++ b/cmd/contexts_test.go @@ -87,6 +87,79 @@ func TestContextsList(t *testing.T) { } } +func TestContextGetShowsLabeledDetails(t *testing.T) { + isolateHome(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/namespaces": + _, _ = w.Write([]byte(`{"namespaces":["team1"]}`)) + case "/api/v1/contexts/team1/research": + _, _ = w.Write([]byte(`{"name":"research","namespace":"team1","type":"workspace","status":"ready","storage":{"backend":"pvc","size":"10Gi","accessMode":"ReadWriteMany","storageClass":"ibm-scale-csi"},"attachment":{"kind":"pvc","claimName":"context-research"}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + setupImportContext(t, srv, "team1") + + out, err := execute(t, "context", "get", "research") + if err != nil { + t.Fatal(err) + } + for _, expected := range []string{ + "Context Information", + "Name: research", + "Namespace: team1", + "Type: workspace", + "Status: ready", + "Storage", + "Backend: pvc", + "Size: 10Gi", + "Access Mode: ReadWriteMany", + "Storage Class: ibm-scale-csi", + "Attachment", + "Kind: pvc", + "Claim: context-research", + } { + if !strings.Contains(out, expected) { + t.Errorf("get output missing %q:\n%s", expected, out) + } + } +} + +func TestContextGetJSONWritesOnlyToStdout(t *testing.T) { + isolateHome(t) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/namespaces": + _, _ = w.Write([]byte(`{"namespaces":["team1"]}`)) + case "/api/v1/contexts/team1/research": + _, _ = w.Write([]byte(`{"name":"research","namespace":"team1","type":"workspace","status":"ready","storage":{"backend":"pvc","size":"1Gi","accessMode":"ReadWriteOnce"},"attachment":{"kind":"pvc","claimName":"context-research"}}`)) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer srv.Close() + setupImportContext(t, srv, "team1") + + stdout, stderr, err := executeSplit(t, "context", "get", "research", "--json") + if err != nil { + t.Fatal(err) + } + if stderr != "" { + t.Fatalf("stderr = %q, want empty", stderr) + } + var result map[string]any + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("stdout is not valid JSON: %v\n%s", err, stdout) + } + if result["name"] != "research" { + t.Fatalf("name = %v, want research", result["name"]) + } +} + func TestContextsListExplainsUnsupportedServer(t *testing.T) { isolateHome(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/agentapi/agentapi.go b/internal/agentapi/agentapi.go index a887f69..7e313ba 100644 --- a/internal/agentapi/agentapi.go +++ b/internal/agentapi/agentapi.go @@ -40,11 +40,12 @@ package agentapi // backend fills them from a Kubernetes custom resource, which has no fixed shape // here. Callers read them opportunistically rather than relying on any key. type AgentDetail struct { - Metadata AgentMetadata `json:"metadata"` - Spec map[string]any `json:"spec"` - Status map[string]any `json:"status"` - WorkloadType string `json:"workloadType"` - ReadyStatus string `json:"readyStatus"` + Metadata AgentMetadata `json:"metadata"` + Spec map[string]any `json:"spec"` + Status map[string]any `json:"status"` + WorkloadType string `json:"workloadType"` + ReadyStatus string `json:"readyStatus"` + Contexts []ContextAttachment `json:"contexts,omitempty"` // Service is absent for anything not fronted by a Kubernetes Service — a // local `authbridge exec` instance has no ClusterIP, and naming one nothing @@ -52,6 +53,15 @@ type AgentDetail struct { Service *ServiceInfo `json:"service"` } +// ContextAttachment describes a named Context Service resource mounted in an agent. +type ContextAttachment struct { + Name string `json:"name"` + Type string `json:"type,omitempty"` + MountPath string `json:"mountPath"` + ReadOnly bool `json:"readOnly"` + ClaimName string `json:"claimName,omitempty"` +} + // ToolDetail mirrors the backend's GET /tools/{namespace}/{name} response, which // has the same shape as an agent's. An alias rather than a copy, so the two // cannot drift and one set of renderer helpers serves both.