Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions cmd/onecli/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"os"
"strings"

"github.com/alecthomas/kong"
"github.com/onecli/onecli-cli/internal/api"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ""
}
}
99 changes: 92 additions & 7 deletions pkg/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -32,18 +33,20 @@ 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 {
data, err := marshalIndent(v)
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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
127 changes: 127 additions & 0 deletions pkg/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading