From 28e2f7f0c003310f61676c6ae4518fbf6071872f Mon Sep 17 00:00:00 2001 From: Guy Ben Aharon Date: Sun, 5 Apr 2026 15:10:41 +0300 Subject: [PATCH] fix: add contextual dashboard hint as first property in all JSON responses --- cmd/onecli/main.go | 21 +++++++ pkg/output/output.go | 99 ++++++++++++++++++++++++++--- pkg/output/output_test.go | 127 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 240 insertions(+), 7 deletions(-) diff --git a/cmd/onecli/main.go b/cmd/onecli/main.go index c1df065..4a50d36 100644 --- a/cmd/onecli/main.go +++ b/cmd/onecli/main.go @@ -4,6 +4,7 @@ import ( "context" "errors" "os" + "strings" "github.com/alecthomas/kong" "github.com/onecli/onecli-cli/internal/api" @@ -59,6 +60,7 @@ func main() { os.Exit(exitcode.Error) } + out.SetHint(hintForCommand(kCtx.Command(), config.APIHost())) err = kCtx.Run(out) if err != nil { handleError(out, err) @@ -103,3 +105,22 @@ func newClient() (*api.Client, error) { func newContext() context.Context { return context.Background() } + +// hintForCommand returns a contextual hint message based on the active command group. +func hintForCommand(cmd, host string) string { + group := strings.SplitN(cmd, " ", 2)[0] + switch group { + case "secrets": + return "Manage your secrets \u2192 " + host + case "agents": + return "Manage your agents \u2192 " + host + case "rules": + return "Manage your policy rules \u2192 " + host + case "auth": + return "Manage authentication \u2192 " + host + case "config": + return "Manage configuration \u2192 " + host + default: + return "" + } +} diff --git a/pkg/output/output.go b/pkg/output/output.go index 6dfc2db..1b74a30 100644 --- a/pkg/output/output.go +++ b/pkg/output/output.go @@ -12,8 +12,9 @@ import ( // Writer handles all structured output for the CLI. // All stdout/stderr writing must go through this. Never use fmt.Print or os.Stdout directly. type Writer struct { - out io.Writer - err io.Writer + out io.Writer + err io.Writer + hint string } // New creates a Writer that writes to stdout and stderr. @@ -32,6 +33,12 @@ func NewWithWriters(out, err io.Writer) *Writer { } } +// SetHint sets a contextual hint message that will be injected as the first +// property in every JSON response written to stdout. +func (w *Writer) SetHint(msg string) { + w.hint = msg +} + // Write marshals v as indented JSON and writes it to stdout. // HTML escaping is disabled because this is a CLI tool, not a web page. func (w *Writer) Write(v any) error { @@ -39,11 +46,7 @@ func (w *Writer) Write(v any) error { if err != nil { return fmt.Errorf("marshaling output: %w", err) } - _, writeErr := w.out.Write(data) - if writeErr != nil { - return fmt.Errorf("writing output: %w", writeErr) - } - return nil + return w.writeToOut(data) } // WriteFiltered marshals v as JSON, then filters to only include the specified @@ -65,6 +68,15 @@ func (w *Writer) WriteFiltered(v any, fields string) error { return fmt.Errorf("filtering fields: %w", err) } + // Re-indent: filterObject may return compact JSON. + var parsed any + if json.Unmarshal(filtered, &parsed) == nil { + if indented, mErr := marshalIndent(parsed); mErr == nil { + filtered = indented + } + } + + // Write directly without hint — agent explicitly requested specific fields. _, writeErr := w.out.Write(filtered) if writeErr != nil { return fmt.Errorf("writing output: %w", writeErr) @@ -273,6 +285,79 @@ func (w *Writer) writeError(resp ErrorResponse) error { return nil } +// writeToOut injects the hint (if set) and writes the final bytes to stdout. +func (w *Writer) writeToOut(data []byte) error { + data = w.injectHint(data) + _, err := w.out.Write(data) + if err != nil { + return fmt.Errorf("writing output: %w", err) + } + return nil +} + +// injectHint prepends a "hint" property to JSON objects or wraps arrays +// in a {"hint": ..., "data": [...]} envelope. +func (w *Writer) injectHint(data []byte) []byte { + if w.hint == "" { + return data + } + trimmed := bytes.TrimSpace(data) + if len(trimmed) < 2 { + return data + } + + hintVal, err := json.Marshal(w.hint) + if err != nil { + return data + } + + switch trimmed[0] { + case '{': + return injectHintObject(data, hintVal) + case '[': + return injectHintArray(data, w.hint) + default: + return data + } +} + +// injectHintObject splices "hint" as the first key in a JSON object. +func injectHintObject(data []byte, hintVal []byte) []byte { + idx := bytes.IndexByte(data, '{') + rest := data[idx+1:] + restTrimmed := bytes.TrimSpace(rest) + + var buf bytes.Buffer + buf.Write(data[:idx+1]) + buf.WriteString("\n \"hint\": ") + buf.Write(hintVal) + + if len(restTrimmed) == 0 || restTrimmed[0] == '}' { + buf.WriteString("\n}\n") + } else { + buf.WriteByte(',') + buf.Write(rest) + } + + return buf.Bytes() +} + +// injectHintArray wraps a JSON array in {"hint": ..., "data": [...]}. +func injectHintArray(data []byte, hint string) []byte { + wrapper := struct { + Hint string `json:"hint"` + Data json.RawMessage `json:"data"` + }{ + Hint: hint, + Data: json.RawMessage(bytes.TrimSpace(data)), + } + result, err := marshalIndent(wrapper) + if err != nil { + return data + } + return result +} + // marshalIndent encodes v as indented JSON with HTML escaping disabled. // Go's json.Marshal escapes &, <, > as unicode sequences (\u0026 etc.) // which breaks URLs in CLI output. Agents and humans both need raw characters. diff --git a/pkg/output/output_test.go b/pkg/output/output_test.go index a0d644c..1eb9cfd 100644 --- a/pkg/output/output_test.go +++ b/pkg/output/output_test.go @@ -230,6 +230,133 @@ func TestErrorWithAction(t *testing.T) { } } +func TestWriteWithHint(t *testing.T) { + var out bytes.Buffer + w := NewWithWriters(&out, &bytes.Buffer{}) + w.SetHint("Manage your agents \u2192 https://app.onecli.sh") + + if err := w.Write(map[string]string{"id": "abc", "status": "ok"}); err != nil { + t.Fatal(err) + } + + raw := out.String() + + // hint must be the first key + hintIdx := strings.Index(raw, `"hint"`) + idIdx := strings.Index(raw, `"id"`) + if hintIdx < 0 || idIdx < 0 || hintIdx >= idIdx { + t.Errorf("hint must appear before other keys:\n%s", raw) + } + + var got map[string]string + if err := json.Unmarshal([]byte(raw), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, raw) + } + if got["hint"] != "Manage your agents \u2192 https://app.onecli.sh" { + t.Errorf("hint = %q", got["hint"]) + } + if got["id"] != "abc" { + t.Errorf("id = %q", got["id"]) + } +} + +func TestWriteWithHintArray(t *testing.T) { + var out bytes.Buffer + w := NewWithWriters(&out, &bytes.Buffer{}) + w.SetHint("Manage your secrets \u2192 https://app.onecli.sh") + + data := []map[string]string{{"id": "a"}, {"id": "b"}} + if err := w.Write(data); err != nil { + t.Fatal(err) + } + + var got struct { + Hint string `json:"hint"` + Data []map[string]string `json:"data"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out.String()) + } + if got.Hint != "Manage your secrets \u2192 https://app.onecli.sh" { + t.Errorf("hint = %q", got.Hint) + } + if len(got.Data) != 2 { + t.Errorf("expected 2 items, got %d", len(got.Data)) + } +} + +func TestWriteNoHintWhenEmpty(t *testing.T) { + var out bytes.Buffer + w := NewWithWriters(&out, &bytes.Buffer{}) + + if err := w.Write(map[string]string{"id": "abc"}); err != nil { + t.Fatal(err) + } + + if strings.Contains(out.String(), "hint") { + t.Errorf("should not contain hint when not set:\n%s", out.String()) + } +} + +func TestWriteQuietNoHint(t *testing.T) { + var out bytes.Buffer + w := NewWithWriters(&out, &bytes.Buffer{}) + w.SetHint("Manage your agents \u2192 https://app.onecli.sh") + + if err := w.WriteQuiet(map[string]string{"id": "abc"}, "id"); err != nil { + t.Fatal(err) + } + + got := strings.TrimSpace(out.String()) + if got != "abc" { + t.Errorf("got %q, want %q", got, "abc") + } +} + +func TestWriteFilteredWithHintExcluded(t *testing.T) { + var out bytes.Buffer + w := NewWithWriters(&out, &bytes.Buffer{}) + w.SetHint("Manage your secrets \u2192 https://app.onecli.sh") + + data := map[string]string{"id": "abc", "name": "test", "extra": "drop"} + if err := w.WriteFiltered(data, "id,name"); err != nil { + t.Fatal(err) + } + + var got map[string]string + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out.String()) + } + if _, ok := got["hint"]; ok { + t.Error("hint should not be present when --fields is specified") + } + if got["id"] != "abc" { + t.Errorf("id = %q", got["id"]) + } + if _, ok := got["extra"]; ok { + t.Error("extra should have been filtered out") + } +} + +func TestWriteFilteredEmptyFieldsWithHint(t *testing.T) { + var out bytes.Buffer + w := NewWithWriters(&out, &bytes.Buffer{}) + w.SetHint("Manage your agents \u2192 https://app.onecli.sh") + + data := map[string]string{"id": "abc"} + if err := w.WriteFiltered(data, ""); err != nil { + t.Fatal(err) + } + + var got map[string]string + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out.String()) + } + if got["hint"] != "Manage your agents \u2192 https://app.onecli.sh" { + t.Errorf("hint = %q", got["hint"]) + } +} + func TestErrorGoesToStderr(t *testing.T) { var stdout, stderr bytes.Buffer w := NewWithWriters(&stdout, &stderr)