Skip to content

Commit cb64385

Browse files
authored
fix: add contextual dashboard hint as first property in all JSON responses (#8)
1 parent 5ccc062 commit cb64385

File tree

3 files changed

+240
-7
lines changed

3 files changed

+240
-7
lines changed

cmd/onecli/main.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"os"
7+
"strings"
78

89
"github.com/alecthomas/kong"
910
"github.com/onecli/onecli-cli/internal/api"
@@ -59,6 +60,7 @@ func main() {
5960
os.Exit(exitcode.Error)
6061
}
6162

63+
out.SetHint(hintForCommand(kCtx.Command(), config.APIHost()))
6264
err = kCtx.Run(out)
6365
if err != nil {
6466
handleError(out, err)
@@ -103,3 +105,22 @@ func newClient() (*api.Client, error) {
103105
func newContext() context.Context {
104106
return context.Background()
105107
}
108+
109+
// hintForCommand returns a contextual hint message based on the active command group.
110+
func hintForCommand(cmd, host string) string {
111+
group := strings.SplitN(cmd, " ", 2)[0]
112+
switch group {
113+
case "secrets":
114+
return "Manage your secrets \u2192 " + host
115+
case "agents":
116+
return "Manage your agents \u2192 " + host
117+
case "rules":
118+
return "Manage your policy rules \u2192 " + host
119+
case "auth":
120+
return "Manage authentication \u2192 " + host
121+
case "config":
122+
return "Manage configuration \u2192 " + host
123+
default:
124+
return ""
125+
}
126+
}

pkg/output/output.go

Lines changed: 92 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import (
1212
// Writer handles all structured output for the CLI.
1313
// All stdout/stderr writing must go through this. Never use fmt.Print or os.Stdout directly.
1414
type Writer struct {
15-
out io.Writer
16-
err io.Writer
15+
out io.Writer
16+
err io.Writer
17+
hint string
1718
}
1819

1920
// New creates a Writer that writes to stdout and stderr.
@@ -32,18 +33,20 @@ func NewWithWriters(out, err io.Writer) *Writer {
3233
}
3334
}
3435

36+
// SetHint sets a contextual hint message that will be injected as the first
37+
// property in every JSON response written to stdout.
38+
func (w *Writer) SetHint(msg string) {
39+
w.hint = msg
40+
}
41+
3542
// Write marshals v as indented JSON and writes it to stdout.
3643
// HTML escaping is disabled because this is a CLI tool, not a web page.
3744
func (w *Writer) Write(v any) error {
3845
data, err := marshalIndent(v)
3946
if err != nil {
4047
return fmt.Errorf("marshaling output: %w", err)
4148
}
42-
_, writeErr := w.out.Write(data)
43-
if writeErr != nil {
44-
return fmt.Errorf("writing output: %w", writeErr)
45-
}
46-
return nil
49+
return w.writeToOut(data)
4750
}
4851

4952
// 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 {
6568
return fmt.Errorf("filtering fields: %w", err)
6669
}
6770

71+
// Re-indent: filterObject may return compact JSON.
72+
var parsed any
73+
if json.Unmarshal(filtered, &parsed) == nil {
74+
if indented, mErr := marshalIndent(parsed); mErr == nil {
75+
filtered = indented
76+
}
77+
}
78+
79+
// Write directly without hint — agent explicitly requested specific fields.
6880
_, writeErr := w.out.Write(filtered)
6981
if writeErr != nil {
7082
return fmt.Errorf("writing output: %w", writeErr)
@@ -273,6 +285,79 @@ func (w *Writer) writeError(resp ErrorResponse) error {
273285
return nil
274286
}
275287

288+
// writeToOut injects the hint (if set) and writes the final bytes to stdout.
289+
func (w *Writer) writeToOut(data []byte) error {
290+
data = w.injectHint(data)
291+
_, err := w.out.Write(data)
292+
if err != nil {
293+
return fmt.Errorf("writing output: %w", err)
294+
}
295+
return nil
296+
}
297+
298+
// injectHint prepends a "hint" property to JSON objects or wraps arrays
299+
// in a {"hint": ..., "data": [...]} envelope.
300+
func (w *Writer) injectHint(data []byte) []byte {
301+
if w.hint == "" {
302+
return data
303+
}
304+
trimmed := bytes.TrimSpace(data)
305+
if len(trimmed) < 2 {
306+
return data
307+
}
308+
309+
hintVal, err := json.Marshal(w.hint)
310+
if err != nil {
311+
return data
312+
}
313+
314+
switch trimmed[0] {
315+
case '{':
316+
return injectHintObject(data, hintVal)
317+
case '[':
318+
return injectHintArray(data, w.hint)
319+
default:
320+
return data
321+
}
322+
}
323+
324+
// injectHintObject splices "hint" as the first key in a JSON object.
325+
func injectHintObject(data []byte, hintVal []byte) []byte {
326+
idx := bytes.IndexByte(data, '{')
327+
rest := data[idx+1:]
328+
restTrimmed := bytes.TrimSpace(rest)
329+
330+
var buf bytes.Buffer
331+
buf.Write(data[:idx+1])
332+
buf.WriteString("\n \"hint\": ")
333+
buf.Write(hintVal)
334+
335+
if len(restTrimmed) == 0 || restTrimmed[0] == '}' {
336+
buf.WriteString("\n}\n")
337+
} else {
338+
buf.WriteByte(',')
339+
buf.Write(rest)
340+
}
341+
342+
return buf.Bytes()
343+
}
344+
345+
// injectHintArray wraps a JSON array in {"hint": ..., "data": [...]}.
346+
func injectHintArray(data []byte, hint string) []byte {
347+
wrapper := struct {
348+
Hint string `json:"hint"`
349+
Data json.RawMessage `json:"data"`
350+
}{
351+
Hint: hint,
352+
Data: json.RawMessage(bytes.TrimSpace(data)),
353+
}
354+
result, err := marshalIndent(wrapper)
355+
if err != nil {
356+
return data
357+
}
358+
return result
359+
}
360+
276361
// marshalIndent encodes v as indented JSON with HTML escaping disabled.
277362
// Go's json.Marshal escapes &, <, > as unicode sequences (\u0026 etc.)
278363
// which breaks URLs in CLI output. Agents and humans both need raw characters.

pkg/output/output_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,133 @@ func TestErrorWithAction(t *testing.T) {
230230
}
231231
}
232232

233+
func TestWriteWithHint(t *testing.T) {
234+
var out bytes.Buffer
235+
w := NewWithWriters(&out, &bytes.Buffer{})
236+
w.SetHint("Manage your agents \u2192 https://app.onecli.sh")
237+
238+
if err := w.Write(map[string]string{"id": "abc", "status": "ok"}); err != nil {
239+
t.Fatal(err)
240+
}
241+
242+
raw := out.String()
243+
244+
// hint must be the first key
245+
hintIdx := strings.Index(raw, `"hint"`)
246+
idIdx := strings.Index(raw, `"id"`)
247+
if hintIdx < 0 || idIdx < 0 || hintIdx >= idIdx {
248+
t.Errorf("hint must appear before other keys:\n%s", raw)
249+
}
250+
251+
var got map[string]string
252+
if err := json.Unmarshal([]byte(raw), &got); err != nil {
253+
t.Fatalf("invalid JSON: %v\n%s", err, raw)
254+
}
255+
if got["hint"] != "Manage your agents \u2192 https://app.onecli.sh" {
256+
t.Errorf("hint = %q", got["hint"])
257+
}
258+
if got["id"] != "abc" {
259+
t.Errorf("id = %q", got["id"])
260+
}
261+
}
262+
263+
func TestWriteWithHintArray(t *testing.T) {
264+
var out bytes.Buffer
265+
w := NewWithWriters(&out, &bytes.Buffer{})
266+
w.SetHint("Manage your secrets \u2192 https://app.onecli.sh")
267+
268+
data := []map[string]string{{"id": "a"}, {"id": "b"}}
269+
if err := w.Write(data); err != nil {
270+
t.Fatal(err)
271+
}
272+
273+
var got struct {
274+
Hint string `json:"hint"`
275+
Data []map[string]string `json:"data"`
276+
}
277+
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
278+
t.Fatalf("invalid JSON: %v\n%s", err, out.String())
279+
}
280+
if got.Hint != "Manage your secrets \u2192 https://app.onecli.sh" {
281+
t.Errorf("hint = %q", got.Hint)
282+
}
283+
if len(got.Data) != 2 {
284+
t.Errorf("expected 2 items, got %d", len(got.Data))
285+
}
286+
}
287+
288+
func TestWriteNoHintWhenEmpty(t *testing.T) {
289+
var out bytes.Buffer
290+
w := NewWithWriters(&out, &bytes.Buffer{})
291+
292+
if err := w.Write(map[string]string{"id": "abc"}); err != nil {
293+
t.Fatal(err)
294+
}
295+
296+
if strings.Contains(out.String(), "hint") {
297+
t.Errorf("should not contain hint when not set:\n%s", out.String())
298+
}
299+
}
300+
301+
func TestWriteQuietNoHint(t *testing.T) {
302+
var out bytes.Buffer
303+
w := NewWithWriters(&out, &bytes.Buffer{})
304+
w.SetHint("Manage your agents \u2192 https://app.onecli.sh")
305+
306+
if err := w.WriteQuiet(map[string]string{"id": "abc"}, "id"); err != nil {
307+
t.Fatal(err)
308+
}
309+
310+
got := strings.TrimSpace(out.String())
311+
if got != "abc" {
312+
t.Errorf("got %q, want %q", got, "abc")
313+
}
314+
}
315+
316+
func TestWriteFilteredWithHintExcluded(t *testing.T) {
317+
var out bytes.Buffer
318+
w := NewWithWriters(&out, &bytes.Buffer{})
319+
w.SetHint("Manage your secrets \u2192 https://app.onecli.sh")
320+
321+
data := map[string]string{"id": "abc", "name": "test", "extra": "drop"}
322+
if err := w.WriteFiltered(data, "id,name"); err != nil {
323+
t.Fatal(err)
324+
}
325+
326+
var got map[string]string
327+
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
328+
t.Fatalf("invalid JSON: %v\n%s", err, out.String())
329+
}
330+
if _, ok := got["hint"]; ok {
331+
t.Error("hint should not be present when --fields is specified")
332+
}
333+
if got["id"] != "abc" {
334+
t.Errorf("id = %q", got["id"])
335+
}
336+
if _, ok := got["extra"]; ok {
337+
t.Error("extra should have been filtered out")
338+
}
339+
}
340+
341+
func TestWriteFilteredEmptyFieldsWithHint(t *testing.T) {
342+
var out bytes.Buffer
343+
w := NewWithWriters(&out, &bytes.Buffer{})
344+
w.SetHint("Manage your agents \u2192 https://app.onecli.sh")
345+
346+
data := map[string]string{"id": "abc"}
347+
if err := w.WriteFiltered(data, ""); err != nil {
348+
t.Fatal(err)
349+
}
350+
351+
var got map[string]string
352+
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
353+
t.Fatalf("invalid JSON: %v\n%s", err, out.String())
354+
}
355+
if got["hint"] != "Manage your agents \u2192 https://app.onecli.sh" {
356+
t.Errorf("hint = %q", got["hint"])
357+
}
358+
}
359+
233360
func TestErrorGoesToStderr(t *testing.T) {
234361
var stdout, stderr bytes.Buffer
235362
w := NewWithWriters(&stdout, &stderr)

0 commit comments

Comments
 (0)