Skip to content
Draft
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
4 changes: 3 additions & 1 deletion internal/cli/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (
"strings"

"github.com/spf13/cobra"

"github.com/revenuecat/cli/internal/output"
)

func newAPICmd() *cobra.Command {
Expand Down Expand Up @@ -63,7 +65,7 @@ Exit code reflects the HTTP status: non-2xx responses exit non-zero.`,
return err
}
if len(data) > 0 {
if _, werr := cmd.OutOrStdout().Write(data); werr != nil {
if _, werr := cmd.OutOrStdout().Write(output.EscapeC1JSON(data)); werr != nil {
return werr
}
// Ensure trailing newline for shell friendliness.
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/customers.go
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ func pickProjectInteractive(ctx context.Context, rt *Runtime) (string, error) {
const noDefault = "__no_default__"
projectOpts := make([]huh.Option[string], len(page.Items))
for i, p := range page.Items {
projectOpts[i] = huh.NewOption(fmt.Sprintf("%s (%s)", p.Name, p.ID), p.ID)
projectOpts[i] = huh.NewOption(output.SanitizeLine(fmt.Sprintf("%s (%s)", p.Name, p.ID)), p.ID)
}
allOpts := append([]huh.Option[string]{
huh.NewOption("Ask me every time (don't save a default)", noDefault),
Expand Down Expand Up @@ -672,7 +672,7 @@ pass --json for machine-readable output or --no-input to disable the browser.`,
return err
}
if page.NextPage != "" && !rt.Globals.JSON {
rt.Out.Info(fmt.Sprintf("more results — pass --cursor %s for the next page", lastID(page.Items)))
rt.Out.Info("more results — pass --cursor " + output.SanitizeLine(lastID(page.Items)) + " for the next page")
}
return nil
},
Expand Down
3 changes: 2 additions & 1 deletion internal/cli/pagination.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"github.com/spf13/cobra"

"github.com/revenuecat/cli/internal/api"
"github.com/revenuecat/cli/internal/output"
)

// addListPaginationFlags binds --limit / --cursor.
Expand All @@ -18,6 +19,6 @@ func hintMoreResults[T any](rt *Runtime, page *api.Page[T]) {
return
}
if cursor := page.NextCursor(); cursor != "" {
rt.Out.Info("more results — pass --cursor " + cursor + " for the next page")
rt.Out.Info("more results — pass --cursor " + output.SanitizeLine(cursor) + " for the next page")
}
}
7 changes: 4 additions & 3 deletions internal/cli/paywalls_ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (

"github.com/revenuecat/cli/internal/api"
"github.com/revenuecat/cli/internal/config"
"github.com/revenuecat/cli/internal/output"
"github.com/revenuecat/cli/internal/paywallai"
"github.com/revenuecat/cli/internal/tui"
)
Expand Down Expand Up @@ -819,11 +820,11 @@ func reportPaywallAIActivity(rt *Runtime, activity []paywallai.ToolActivity, alr
for _, item := range activity[min(alreadyReported, len(activity)):] {
switch item.Type {
case "assistant_message":
rt.Out.Info("Paywalls AI: " + item.Content)
rt.Out.Info("Paywalls AI: " + output.Sanitize(item.Content))
default:
text := item.Display.Text
text := output.SanitizeLine(item.Display.Text)
if text == "" {
text = item.ToolName
text = output.SanitizeLine(item.ToolName)
}
if item.Status == "error" {
rt.Out.Warn("⚙ " + text + " (errored — the Paywalls AI Editor retries these itself)")
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/charmbracelet/huh"

"github.com/revenuecat/cli/internal/output"
"github.com/revenuecat/cli/internal/tui"
)

Expand Down Expand Up @@ -39,7 +40,7 @@ func requireID(rt *Runtime, arg, noun string, fetch func() ([]PickerItem, error)
return "", fmt.Errorf("no %ss found — pass an ID explicitly", noun)
}
if len(items) == 1 {
rt.Out.Info(fmt.Sprintf("Only one %s available: %s", noun, items[0].Label))
rt.Out.Info(fmt.Sprintf("Only one %s available: %s", noun, output.SanitizeLine(items[0].Label)))
return items[0].ID, nil
}
return selectID(rt, noun, items, "")
Expand All @@ -51,7 +52,7 @@ func requireID(rt *Runtime, arg, noun string, fetch func() ([]PickerItem, error)
func selectID(rt *Runtime, noun string, items []PickerItem, defaultID string) (string, error) {
opts := make([]huh.Option[string], len(items))
for i, item := range items {
opts[i] = huh.NewOption(item.Label, item.ID)
opts[i] = huh.NewOption(output.SanitizeLine(item.Label), item.ID)
}
chosen := defaultID
sel := huh.NewSelect[string]().
Expand Down
18 changes: 9 additions & 9 deletions internal/cli/rico.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ func pickRicoConversation(ctx context.Context, rt *Runtime, client *rico.Client)
}
options := make([]huh.Option[string], len(items))
for i, item := range items {
options[i] = huh.NewOption(item.Label, item.ID)
options[i] = huh.NewOption(output.SanitizeLine(item.Label), item.ID)
}
var chosen string
selectField := huh.NewSelect[string]().
Expand Down Expand Up @@ -372,7 +372,7 @@ func (s *ricoSession) repl(ctx context.Context) error {
return nil
}
if err := s.turn(ctx, message); err != nil {
s.rt.Out.Error(err.Error())
s.rt.Out.Error(output.Sanitize(err.Error()))
}
}
}
Expand Down Expand Up @@ -473,9 +473,9 @@ func (s *ricoSession) streamRun(ctx context.Context, input rico.RunAgentInput, r
func (s *ricoSession) resolveInterrupts(interrupts []rico.Interrupt, result *ricoTurnResult, sink ricoSink) ([]rico.ResumeEntry, error) {
entries := make([]rico.ResumeEntry, 0, len(interrupts))
for _, interrupt := range interrupts {
label := interrupt.Message
label := output.SanitizeLine(interrupt.Message)
if label == "" {
label = interrupt.Reason
label = output.SanitizeLine(interrupt.Reason)
}
approved, err := sink.Approve(interrupt, label)
if err != nil {
Expand Down Expand Up @@ -507,7 +507,7 @@ func (s *ricoPlainSink) Delta(text string) {
if s.silent {
return
}
fmt.Print(text)
fmt.Print(output.Sanitize(text))
s.midLine = true
}

Expand All @@ -523,7 +523,7 @@ func (s *ricoPlainSink) Tool(name string) {
return
}
s.endLine()
s.session.rt.Out.Info("⚙ " + name)
s.session.rt.Out.Info("⚙ " + output.SanitizeLine(name))
}

func (s *ricoPlainSink) Approve(interrupt rico.Interrupt, label string) (bool, error) {
Expand Down Expand Up @@ -606,15 +606,15 @@ scope to a single Project.`,
for _, message := range snapshot.Messages {
text := message.Text()
for _, call := range message.ToolCalls {
rt.Out.Info("⚙ " + call.Function.Name)
rt.Out.Info("⚙ " + output.SanitizeLine(call.Function.Name))
}
if text == "" {
continue
}
fmt.Printf("%s: %s\n", message.Role, text)
fmt.Printf("%s: %s\n", message.Role, output.Sanitize(text))
}
for _, interrupt := range snapshot.PendingInterrupts {
rt.Out.Warn("Pending approval: " + interrupt.Reason)
rt.Out.Warn("Pending approval: " + output.SanitizeLine(interrupt.Reason))
}
return nil
},
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func Run(version string) int {
if jsonMode {
writeJSONError(os.Stderr, err)
} else {
fmt.Fprintln(os.Stderr, output.StyleError.Render("✗")+" "+err.Error())
fmt.Fprintln(os.Stderr, output.StyleError.Render("✗")+" "+output.Sanitize(err.Error()))
if hint := hintFor(err); hint != "" {
fmt.Fprintln(os.Stderr, output.StyleDim.Render("Hint: "+hint))
}
Expand Down
44 changes: 44 additions & 0 deletions internal/output/card.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ func (r *Renderer) RenderCard(c Card) error {
if r.json {
return r.Render(c.Raw)
}
c = sanitizeCard(c)

titleStyle := lipgloss.NewStyle().Bold(true)
subtitleStyle := StyleDim
Expand Down Expand Up @@ -101,6 +102,49 @@ func (r *Renderer) RenderCard(c Card) error {
return nil
}

// sanitizeCard runs every displayed card value through SanitizeLine. Raw is
// left alone — it's the --json payload and never printed here.
func sanitizeCard(c Card) Card {
c.Title = SanitizeLine(c.Title)
c.Subtitle = SanitizeLine(c.Subtitle)
sections := make([]CardSection, len(c.Sections))
for i, s := range c.Sections {
s.Heading = SanitizeLine(s.Heading)
if len(s.Chips) > 0 {
chips := make([]Chip, len(s.Chips))
for j, ch := range s.Chips {
ch.Label = SanitizeLine(ch.Label)
chips[j] = ch
}
s.Chips = chips
}
if s.Table != nil {
t := *s.Table
rows := make([][]string, len(t.Rows))
for ri, row := range t.Rows {
rows[ri] = make([]string, len(row))
for ci, cell := range row {
rows[ri][ci] = SanitizeLine(cell)
}
}
t.Rows = rows
s.Table = &t
}
if len(s.Lines) > 0 {
lines := make([]CardLine, len(s.Lines))
for j, l := range s.Lines {
l.Key = SanitizeLine(l.Key)
l.Value = SanitizeLine(l.Value)
lines[j] = l
}
s.Lines = lines
}
sections[i] = s
}
c.Sections = sections
return c
}

func (r *Renderer) writeChips(chips []Chip) {
var parts []string
for _, c := range chips {
Expand Down
73 changes: 60 additions & 13 deletions internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
package output

import (
"bytes"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -149,9 +150,7 @@ func (r *Renderer) Render(v any) error {
if r.format != "" {
return r.renderJSONFiltered(env)
}
enc := json.NewEncoder(r.stdout)
enc.SetIndent("", " ")
return enc.Encode(env)
return encodeJSON(r.stdout, env)
}
if r.format != "" {
// --format without --json: warn on stderr, fall through to pretty.
Expand All @@ -166,9 +165,45 @@ func (r *Renderer) RenderJSON(v any) error {
if r.json {
return r.Render(v)
}
enc := json.NewEncoder(r.stdout)
return encodeJSON(r.stdout, v)
}

// encodeJSON writes v as indented JSON with C1 codepoints \u-escaped:
// encoding/json escapes C0 controls but emits U+0080–U+009F as raw UTF-8
// bytes, and JSON output frequently lands on a terminal.
func encodeJSON(w io.Writer, v any) error {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetIndent("", " ")
return enc.Encode(v)
if err := enc.Encode(v); err != nil {
return err
}
_, err := w.Write(escapeC1(buf.Bytes()))
return err
}

// EscapeC1JSON exposes escapeC1 for callers that stream raw JSON bodies to
// stdout (rc api): JSON guarantees C0 is escaped on the wire, but C1 arrives
// as raw UTF-8 bytes.
func EscapeC1JSON(b []byte) []byte { return escapeC1(b) }

// escapeC1 rewrites UTF-8-encoded C1 codepoints (0xC2 0x80–0x9F; in valid
// UTF-8, 0xC2 only ever appears as that lead byte) as JSON \u escapes.
func escapeC1(b []byte) []byte {
if bytes.IndexByte(b, 0xC2) < 0 {
return b
}
var out bytes.Buffer
out.Grow(len(b) + 16)
for i := 0; i < len(b); i++ {
if b[i] == 0xC2 && i+1 < len(b) && b[i+1] >= 0x80 && b[i+1] <= 0x9F {
fmt.Fprintf(&out, `\u%04x`, b[i+1])
i++
continue
}
out.WriteByte(b[i])
}
return out.Bytes()
}

// renderHuman is the human-mode fallback for structured results: aligned
Expand All @@ -182,7 +217,7 @@ func (r *Renderer) renderHuman(v any) error {
var m map[string]json.RawMessage
if err := json.Unmarshal(raw, &m); err != nil {
// Not an object (array/scalar): print compactly.
fmt.Fprintln(r.stdout, humanValue(raw))
fmt.Fprintln(r.stdout, Sanitize(humanValue(raw)))
return nil
}
keys := humanKeyOrder(m)
Expand All @@ -193,7 +228,7 @@ func (r *Renderer) renderHuman(v any) error {
}
}
for _, k := range keys {
fmt.Fprintf(r.stdout, "%s %s\n", r.style(r.dim, padRight(k, width)), humanFieldValue(k, m[k]))
fmt.Fprintf(r.stdout, "%s %s\n", r.style(r.dim, padRight(SanitizeLine(k), width)), SanitizeLine(humanFieldValue(k, m[k])))
}
return nil
}
Expand Down Expand Up @@ -290,7 +325,9 @@ func (r *Renderer) renderJSONFiltered(env any) error {
}
switch t := v.(type) {
case string:
fmt.Fprintln(r.stdout, t)
// Unmarshal decoded the API's \u escapes back into real control
// bytes; keep --format output to visible text like every other path.
fmt.Fprintln(r.stdout, Sanitize(t))
case nil:
// jq emits nil for `.missing`; skip rather than print "null".
default:
Expand Down Expand Up @@ -322,11 +359,18 @@ func (r *Renderer) RenderTable(t Table) error {
fmt.Fprintln(r.stderr, r.style(r.info, "• ")+"no results")
return nil
}
rows := make([][]string, len(t.Rows))
for ri, row := range t.Rows {
rows[ri] = make([]string, len(row))
for i, cell := range row {
rows[ri][i] = SanitizeLine(cell)
}
}
widths := make([]int, len(t.Columns))
for i, c := range t.Columns {
widths[i] = len(c)
}
for _, row := range t.Rows {
for _, row := range rows {
for i, cell := range row {
if i >= len(widths) {
continue
Expand All @@ -344,7 +388,7 @@ func (r *Renderer) RenderTable(t Table) error {
fmt.Fprint(r.stdout, r.style(headerStyle, padRight(c, widths[i])))
}
fmt.Fprintln(r.stdout)
for _, row := range t.Rows {
for _, row := range rows {
for i, cell := range row {
if i > 0 {
fmt.Fprint(r.stdout, " ")
Expand Down Expand Up @@ -392,13 +436,16 @@ func (r *Renderer) Info(msg string) {
// Supporting terminals make it clickable; others render the label text. This is
// the one place the OSC 8 escape lives.
func Hyperlink(styledLabel, url string) string {
return "\x1b]8;;" + url + "\x1b\\" + styledLabel + "\x1b]8;;\x1b\\"
// A control character in url would terminate the OSC 8 sequence early and
// leave the rest to the terminal.
return "\x1b]8;;" + Sanitize(url) + "\x1b\\" + styledLabel + "\x1b]8;;\x1b\\"
}

// LinkText renders a clickable hyperlink (OSC 8) with a custom label instead of
// the raw URL, so long auth URLs don't dominate the output. With color off it
// falls back to "label (url)" so the URL stays copyable.
func (r *Renderer) LinkText(label, url string) string {
label, url = SanitizeLine(label), Sanitize(url)
if r.noColor {
return label + " (" + url + ")"
}
Expand Down Expand Up @@ -490,7 +537,7 @@ func (r *Renderer) Answer(key, value string) {
if r.json || r.quiet {
return
}
fmt.Fprintf(r.stderr, "%s %s %s\n", r.style(r.success, "✓"), r.style(r.dim, padRight(key, 26)), value)
fmt.Fprintf(r.stderr, "%s %s %s\n", r.style(r.success, "✓"), r.style(r.dim, padRight(key, 26)), SanitizeLine(value))
}

// Plan renders the guided-command plan: a titled, numbered list of the
Expand Down Expand Up @@ -547,5 +594,5 @@ func (r *Renderer) Error(msg string) {
if r.json {
return
}
fmt.Fprintln(r.stderr, r.style(r.errSty, "✗ ")+msg)
fmt.Fprintln(r.stderr, r.style(r.errSty, "✗ ")+Sanitize(msg))
}
Loading
Loading