Skip to content

Commit a1d4769

Browse files
Fix pc login for agentic workflows (#75)
## Problem `pc login` was unusable in agentic contexts: - The OAuth URL was printed as ANSI-styled prose on `stdout` (`Visit https://... to authorize the CLI.`), making it impossible for agents to reliably extract the URL from the output. - In non-interactive contexts, the command attempted to read from `stdin` unnecessarily. - There was no structured output mode — no `--json` flag, no machine-readable URL emission. - When already authenticated, the command returned nothing to `stdout`, giving agents no way to detect the existing session. - All human-readable output (prompts, status messages, hints) were going to `stdout`, mixing with any data output that would be relevant for agent / machine interaction. ## Solution ### Auto-detected JSON output When `stdout` is not a TTY (machine / agent capturing output), JSON mode is enabled automatically — no flag required. `--json` is also available as an explicit override for users who want structured output in a terminal. The format decision is resolved once at the top of `Run()` and passed down; `GetAndSetAccessToken` and other helpers use it directly without re-running TTY detection. ### Structured output at each stage Immediately before blocking on the OAuth callback: ``` { "status": "pending", "url": "https://login.pinecone.io/..." } ``` After successful authentication: ``` { "status": "authenticated", "email": "user@example.com", "org_id": "...", "project_id": "..." } ``` When already logged in: ``` { "status": "already_authenticated", "email": "user@example.com", "org_id": "..." } ``` ### TTY-gated interactivity The "Press [Enter]" prompt and `stdin`-reading goroutine are gated on `term.IsTerminal(int(os.Stdin.Fd()))`. Non-interactive runs skip `stdin` entirely and block cleanly on the OAuth callback. The prompt is preserved for TTY sessions regardless of JSON mode — a user passing --json in a terminal is still at a keyboard and benefits from browser-open. ### All human-readable output routed to stderr The prose URL line, Enter prompt, success messages, info messages, and hints all go to `stderr`. In JSON mode, `stdout` carries only the structured objects above. In prose mode, TTY users see everything in their terminal as before. ### Fixed HTML escaping in JSON output `text.IndentJSON` and `text.InlineJSON` now use `json.Encoder` with `SetEscapeHTML(false)`. The default Go JSON marshaler escapes & as `\u0026` — correct for embedding JSON in HTML, wrong for CLI output. This was causing OAuth URLs in the pending JSON object to be un-pasteable. Unit tests added to prevent regression. ### Removed dead code - `IO` struct and parameter removed from `Run()` — was accepted but never used; all writes go directly to `os.Stdout/os.Stderr/os.Stdin`. - Dead `--quiet/io.Discard` branches removed from both login command files. - `msg.Blank()` added to the msg package for consistent stderr blank-line spacing ## Type of Change - [X] Bug fix (non-breaking change which fixes an issue) - [X] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] This change requires a documentation update - [ ] Infrastructure change (CI configs, etc) - [ ] Non-code change (docs, etc) - [ ] None of the above: (explain here) ## Test Plan You can test locally by piping stdin / stdout, or passing the `--json` flag manually. ```bash # force redirecting stdout to trigger JSON output automatically ./dist/pc_darwin_all/pc login | cat { "status": "pending", "url": "https://login.pinecone.io/oauth/authorize?audience=https%3A%2F%2Fus-central1-production-console.cloudfunctions.net%2Fapi%2Fv1&client_id=A4ONXSaOGstwwir0zUztoI6zjyt9zsRH&code_challenge=9LpXGYNRqvQRgd4NWSCIyBGAiZClfzkM_SeFI3_280I&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A59049%2Fauth-callback&response_type=code&scope=openid+profile+email+offline_access&sourceTag=pinecone_cli&state=l_CIfL4pkyhj7N7DmCI1qd0OD72_nCyi1ikGvYvCGdw" } Press [Enter] to open the browser, or manually paste the URL above. { "status": "authenticated", "email": "austin.d@pinecone.io", "org_id": "-NF9kuQiPWth9QLPg5NS", "project_id": "504ef178-3aac-49ce-93cd-86dc262e6e43" } # login with explicit --json flagged, make sure the browser prompt works when it's displayed ./dist/pc_darwin_all/pc login --json { "status": "pending", "url": "https://login.pinecone.io/oauth/authorize?audience=https%3A%2F%2Fus-central1-production-console.cloudfunctions.net%2Fapi%2Fv1&client_id=A4ONXSaOGstwwir0zUztoI6zjyt9zsRH&code_challenge=YoI6PnfYkLeSKzoOzfSQIKxlHduoawCij2LWu2XFg7w&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A59049%2Fauth-callback&response_type=code&scope=openid+profile+email+offline_access&sourceTag=pinecone_cli&state=qEVw2RwQnHX32iQ5wtK7yzxalAZE8wDWtFDH8eTqN6Y" } Press [Enter] to open the browser, or manually paste the URL above. { "status": "authenticated", "email": "austin.d@pinecone.io", "org_id": "-NF9kuQiPWth9QLPg5NS", "project_id": "504ef178-3aac-49ce-93cd-86dc262e6e43" } # pipe stderr explicitly and trigger --json or stdout explicitly to check that stdout output is clean ./dist/pc_darwin_all/pc login --json 2>/dev/null ./dist/pc_darwin_all/pc login 2>/dev/null | cat ./dist/pc_darwin_all/pc login < /dev/null | cat { "status": "pending", "url": "https://login.pinecone.io/oauth/authorize?audience=https%3A%2F%2Fus-central1-production-console.cloudfunctions.net%2Fapi%2Fv1&client_id=A4ONXSaOGstwwir0zUztoI6zjyt9zsRH&code_challenge=BSh06S5jkmTH8lucj8CAVwy5AhGKITxjX7V4knal0Mw&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A59049%2Fauth-callback&response_type=code&scope=openid+profile+email+offline_access&sourceTag=pinecone_cli&state=7y-Jygv-RPLGY4X-kMPJHfd4PeMxphll3x8xA-0MRKY" } { "status": "authenticated", "email": "austin.d@pinecone.io", "org_id": "-NF9kuQiPWth9QLPg5NS", "project_id": "504ef178-3aac-49ce-93cd-86dc262e6e43" } ``` Make sure the traditional login experience works as expected: ```bash ./dist/pc_darwin_all/pc login Visit https://login.pinecone.io/oauth/authorize?audience=https%3A%2F%2Fus-central1-production-console.cloudfunctions.net%2Fapi%2Fv1&client_id=A4ONXSaOGstwwir0zUztoI6zjyt9zsRH&code_challenge=2seMya2Pxhr_ug_shJZkhMxVnVdVT0gD3H8zKACJzYk&code_challenge_method=S256&redirect_uri=http%3A%2F%2F127.0.0.1%3A59049%2Fauth-callback&response_type=code&scope=openid+profile+email+offline_access&sourceTag=pinecone_cli&state=Hm31H8dOMNjGtTdMr4n0Erfa42JqsZo7DEs5vZZ9gfY to authorize the CLI. Press [Enter] to open the browser, or manually paste the URL above. [SUCCESS] Logged in as austin.d@pinecone.io. Defaulted to organization ID: -NF9kuQiPWth9QLPg5NS [INFO] Target org set to pinecone-official. [INFO] Target project set cmek-integration-tests. Hint: Run pc target to change the target context. Hint: Now try pc index -h to learn about index operations. ``` <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes `pc login`/`pc auth login` output behavior and login helper signatures, which could affect existing scripts and interactive UX, though core OAuth exchange remains the same. > > **Overview** > Improves `pc login` and `pc auth login` for non-interactive/agentic use by adding `--json` and *auto-emitting JSON when stdout is not a TTY*, while keeping human-readable messaging on interactive terminals. > > The login flow now prints structured JSON status objects (e.g. `pending` with the OAuth URL, `authenticated` / `already_authenticated` with claims) to stdout, moves prompts and prose to stderr, and gates the "Press [Enter]" stdin reader on interactive stdin to avoid hanging in piped runs. > > Updates shared helpers (`login.Run`, `GetAndSetAccessToken`) to take `login.Options{Json: ...}`, adds `msg.Blank()` for consistent spacing, and adjusts JSON encoding (`text.InlineJSON`/`IndentJSON`) to disable HTML escaping (with new tests). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 64aea12. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 91c1584 commit a1d4769

7 files changed

Lines changed: 214 additions & 95 deletions

File tree

internal/pkg/cli/command/auth/login.go

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package auth
22

33
import (
44
_ "embed"
5-
"io"
65

76
"github.com/pinecone-io/cli/internal/pkg/utils/help"
87
"github.com/pinecone-io/cli/internal/pkg/utils/login"
@@ -28,6 +27,8 @@ var (
2827
)
2928

3029
func NewLoginCmd() *cobra.Command {
30+
var jsonOutput bool
31+
3132
cmd := &cobra.Command{
3233
Use: "login",
3334
Short: "Authenticate with Pinecone via user login in a web browser",
@@ -37,21 +38,11 @@ func NewLoginCmd() *cobra.Command {
3738
`),
3839
GroupID: help.GROUP_AUTH.ID,
3940
Run: func(cmd *cobra.Command, args []string) {
40-
out := cmd.OutOrStdout()
41-
if quiet, _ := cmd.Flags().GetBool("quiet"); quiet {
42-
out = io.Discard
43-
}
44-
45-
login.Run(cmd.Context(),
46-
login.IO{
47-
In: cmd.InOrStdin(),
48-
Out: out,
49-
Err: cmd.ErrOrStderr(),
50-
},
51-
login.Options{},
52-
)
41+
login.Run(cmd.Context(), login.Options{Json: jsonOutput})
5342
},
5443
}
5544

45+
cmd.Flags().BoolVar(&jsonOutput, "json", false, "emit JSON output")
46+
5647
return cmd
5748
}

internal/pkg/cli/command/login/login.go

Lines changed: 5 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ package login
22

33
import (
44
_ "embed"
5-
"io"
65

76
"github.com/pinecone-io/cli/internal/pkg/utils/help"
87
"github.com/pinecone-io/cli/internal/pkg/utils/login"
@@ -20,6 +19,8 @@ var (
2019
)
2120

2221
func NewLoginCmd() *cobra.Command {
22+
var jsonOutput bool
23+
2324
cmd := &cobra.Command{
2425
Use: "login",
2526
Short: "Authenticate with Pinecone via user login in a web browser",
@@ -29,21 +30,11 @@ func NewLoginCmd() *cobra.Command {
2930
`),
3031
GroupID: help.GROUP_AUTH.ID,
3132
Run: func(cmd *cobra.Command, args []string) {
32-
out := cmd.OutOrStdout()
33-
if quiet, _ := cmd.Flags().GetBool("quiet"); quiet {
34-
out = io.Discard
35-
}
36-
37-
login.Run(cmd.Context(),
38-
login.IO{
39-
In: cmd.InOrStdin(),
40-
Out: out,
41-
Err: cmd.ErrOrStderr(),
42-
},
43-
login.Options{},
44-
)
33+
login.Run(cmd.Context(), login.Options{Json: jsonOutput})
4534
},
4635
}
4736

37+
cmd.Flags().BoolVar(&jsonOutput, "json", false, "emit JSON output")
38+
4839
return cmd
4940
}

internal/pkg/cli/command/target/target.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ func NewTargetCmd() *cobra.Command {
158158
// If the org chosen differs from the current orgId in the token, we need to login again
159159
if currentTokenOrgId != "" && currentTokenOrgId != targetOrg.Id {
160160
oauth.Logout()
161-
err = login.GetAndSetAccessToken(ctx, &targetOrg.Id)
161+
err = login.GetAndSetAccessToken(ctx, &targetOrg.Id, login.Options{})
162162
if err != nil {
163163
msg.FailMsg("Failed to get access token: %s", err)
164164
exit.Error(err, "Error getting access token")
@@ -204,7 +204,7 @@ func NewTargetCmd() *cobra.Command {
204204
// If the org chosen differs from the current orgId in the token, we need to login again
205205
if currentTokenOrgId != org.Id {
206206
oauth.Logout()
207-
err = login.GetAndSetAccessToken(ctx, &org.Id)
207+
err = login.GetAndSetAccessToken(ctx, &org.Id, login.Options{})
208208
if err != nil {
209209
msg.FailMsg("Failed to get access token: %s", err)
210210
exit.Error(err, "Error getting access token")

internal/pkg/utils/login/login.go

Lines changed: 103 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import (
99
"errors"
1010
"fmt"
1111
"html/template"
12-
"io"
1312
"net/http"
1413
"os"
1514
"time"
1615

16+
"golang.org/x/term"
17+
1718
"github.com/pinecone-io/cli/internal/pkg/utils/browser"
1819
"github.com/pinecone-io/cli/internal/pkg/utils/configuration/secrets"
1920
"github.com/pinecone-io/cli/internal/pkg/utils/configuration/state"
@@ -23,6 +24,7 @@ import (
2324
"github.com/pinecone-io/cli/internal/pkg/utils/oauth"
2425
"github.com/pinecone-io/cli/internal/pkg/utils/sdk"
2526
"github.com/pinecone-io/cli/internal/pkg/utils/style"
27+
"github.com/pinecone-io/cli/internal/pkg/utils/text"
2628
"github.com/pinecone-io/go-pinecone/v5/pinecone"
2729
)
2830

@@ -35,15 +37,15 @@ var errorHTML string
3537
//go:embed assets/pinecone_logo.svg
3638
var logoSVG string
3739

38-
type IO struct {
39-
In io.Reader
40-
Out io.Writer
41-
Err io.Writer
40+
type Options struct {
41+
Json bool
4242
}
4343

44-
type Options struct{}
44+
func Run(ctx context.Context, opts Options) {
45+
// Resolve output format once at the top level: explicit --json flag or auto-detected non-TTY stdout.
46+
// Normalizing opts.Json here means GetAndSetAccessToken and other helpers use opts.Json directly.
47+
opts.Json = opts.Json || !term.IsTerminal(int(os.Stdout.Fd()))
4548

46-
func Run(ctx context.Context, io IO, opts Options) {
4749
// Check if the user is currently logged in
4850
token, err := oauth.Token(ctx)
4951

@@ -55,12 +57,27 @@ func Run(ctx context.Context, io IO, opts Options) {
5557
}
5658

5759
if !expired && token != nil && token.AccessToken != "" {
58-
msg.WarnMsg("You are already logged in. Please log out first using %s.", style.Code("pc auth logout"))
60+
if opts.Json {
61+
claims, err := oauth.ParseClaimsUnverified(token)
62+
if err == nil {
63+
fmt.Fprintln(os.Stdout, text.IndentJSON(struct {
64+
Status string `json:"status"`
65+
Email string `json:"email"`
66+
OrgId string `json:"org_id"`
67+
}{Status: "already_authenticated", Email: claims.Email, OrgId: claims.OrgId}))
68+
} else {
69+
fmt.Fprintln(os.Stdout, text.IndentJSON(struct {
70+
Status string `json:"status"`
71+
}{Status: "already_authenticated"}))
72+
}
73+
} else {
74+
msg.WarnMsg("You are already logged in. Please log out first using %s.", style.Code("pc auth logout"))
75+
}
5976
return
6077
}
6178

6279
// Initiate login flow
63-
err = GetAndSetAccessToken(ctx, nil)
80+
err = GetAndSetAccessToken(ctx, nil, opts)
6481
if err != nil {
6582
msg.FailMsg("Error acquiring access token while logging in: %s", err)
6683
exit.Error(err, "Error acquiring access token while logging in")
@@ -77,7 +94,10 @@ func Run(ctx context.Context, io IO, opts Options) {
7794
msg.FailMsg("An auth token was fetched but an error occurred while parsing the token's claims: %s", err)
7895
exit.Error(err, "Error parsing claims from access token")
7996
}
80-
msg.SuccessMsg("Logged in as " + style.Emphasis(claims.Email) + ". Defaulted to organization ID: " + style.Emphasis(claims.OrgId))
97+
if !opts.Json {
98+
msg.Blank()
99+
msg.SuccessMsg("Logged in as " + style.Emphasis(claims.Email) + ". Defaulted to organization ID: " + style.Emphasis(claims.OrgId))
100+
}
81101

82102
ac := sdk.NewPineconeAdminClient(ctx)
83103
if err != nil {
@@ -111,34 +131,50 @@ func Run(ctx context.Context, io IO, opts Options) {
111131
Name: targetOrg.Name,
112132
Id: targetOrg.Id,
113133
})
114-
fmt.Println()
115-
fmt.Printf(style.InfoMsg("Target org set to %s.\n"), style.Emphasis(targetOrg.Name))
116134

117-
if projects != nil {
118-
if len(projects) == 0 {
119-
fmt.Printf(style.InfoMsg("No projects found for organization %s.\n"), style.Emphasis(targetOrg.Name))
120-
fmt.Println(style.InfoMsg("Please create a project for this organization to work with project resources."))
121-
} else {
135+
if opts.Json {
136+
projectId := ""
137+
if len(projects) > 0 {
122138
targetProj := projects[0]
123139
state.TargetProj.Set(state.TargetProject{
124140
Name: targetProj.Name,
125141
Id: targetProj.Id,
126142
})
127-
128-
fmt.Printf(style.InfoMsg("Target project set %s.\n"), style.Emphasis(targetProj.Name))
143+
projectId = targetProj.Id
144+
}
145+
fmt.Fprintln(os.Stdout, text.IndentJSON(struct {
146+
Status string `json:"status"`
147+
Email string `json:"email"`
148+
OrgId string `json:"org_id"`
149+
ProjectId string `json:"project_id"`
150+
}{Status: "authenticated", Email: claims.Email, OrgId: targetOrg.Id, ProjectId: projectId}))
151+
} else {
152+
msg.InfoMsg("Target org set to %s.", style.Emphasis(targetOrg.Name))
153+
154+
if projects != nil {
155+
if len(projects) == 0 {
156+
msg.InfoMsg("No projects found for organization %s.", style.Emphasis(targetOrg.Name))
157+
msg.InfoMsg("Please create a project for this organization to work with project resources.")
158+
} else {
159+
targetProj := projects[0]
160+
state.TargetProj.Set(state.TargetProject{
161+
Name: targetProj.Name,
162+
Id: targetProj.Id,
163+
})
164+
165+
msg.InfoMsg("Target project set %s.", style.Emphasis(targetProj.Name))
166+
}
129167
}
130-
}
131-
132-
fmt.Println()
133-
fmt.Println(style.CodeHint("Run %s to change the target context.", style.Code("pc target")))
134168

135-
fmt.Println()
136-
fmt.Printf("Now try %s to learn about index operations.\n", style.Code("pc index -h"))
169+
msg.Blank()
170+
msg.HintMsg("Run %s to change the target context.", style.Code("pc target"))
171+
msg.HintMsg("Now try %s to learn about index operations.", style.Code("pc index -h"))
172+
}
137173
}
138174

139175
// Takes an optional orgId, and attempts to acquire an access token scoped to the orgId if provided.
140176
// If a token is successfully acquired it's set in the secrets store, and the user is considered logged in with state.AuthUserToken.
141-
func GetAndSetAccessToken(ctx context.Context, orgId *string) error {
177+
func GetAndSetAccessToken(ctx context.Context, orgId *string, opts Options) error {
142178
a := oauth.Auth{}
143179

144180
// CSRF state
@@ -170,40 +206,51 @@ func GetAndSetAccessToken(ctx context.Context, orgId *string) error {
170206
codeCh <- code
171207
}()
172208

173-
fmt.Printf("Visit %s to authorize the CLI.\n", style.Underline(authURL))
174-
fmt.Println()
175-
fmt.Printf("Press %s to open the browser, or manually paste the URL above.\n", style.Code("[Enter]"))
176-
177-
// spawn a goroutine to optionally wait for [Enter] as input
178-
go func(ctx context.Context) {
179-
// inner channel to signal that [Enter] was pressed
180-
inputCh := make(chan struct{}, 1)
209+
if opts.Json {
210+
fmt.Fprintln(os.Stdout, text.IndentJSON(struct {
211+
Status string `json:"status"`
212+
URL string `json:"url"`
213+
}{Status: "pending", URL: authURL}))
214+
} else {
215+
fmt.Fprintf(os.Stderr, "Visit %s to authorize the CLI.\n", style.Underline(authURL))
216+
}
181217

182-
// spawn inner goroutine to read stdin (blocking)
183-
go func() {
184-
_, err := bufio.NewReader(os.Stdin).ReadBytes('\n')
185-
if err != nil {
186-
log.Error().Err(err).Msg("stdin error: unable to open browser")
218+
// Prompt for [Enter] and spawn stdin reader whenever stdin is an interactive TTY,
219+
// regardless of output format. JSON mode only affects what goes to stdout — a user
220+
// running --json in a terminal is still at a keyboard and benefits from browser-open.
221+
// The prompt goes to stderr so it never corrupts the stdout JSON stream.
222+
if term.IsTerminal(int(os.Stdin.Fd())) {
223+
msg.Blank()
224+
fmt.Fprintf(os.Stderr, "Press %s to open the browser, or manually paste the URL above.\n", style.Code("[Enter]"))
225+
226+
go func(ctx context.Context) {
227+
// inner channel to signal that [Enter] was pressed
228+
inputCh := make(chan struct{}, 1)
229+
230+
// spawn inner goroutine to read stdin (blocking)
231+
go func() {
232+
_, err := bufio.NewReader(os.Stdin).ReadBytes('\n')
233+
if err != nil {
234+
log.Error().Err(err).Msg("stdin error: unable to open browser")
235+
return
236+
}
237+
close(inputCh)
238+
}()
239+
240+
// wait for [Enter], auth code, or timeout
241+
select {
242+
case <-ctx.Done():
187243
return
188-
}
189-
close(inputCh)
190-
}()
191-
192-
// wait for [Enter], auth code, or timeout
193-
select {
194-
case <-ctx.Done():
195-
return
196-
case <-inputCh:
197-
err = browser.OpenBrowser(authURL)
198-
if err != nil {
199-
log.Error().Err(err).Msg("error opening browser")
244+
case <-inputCh:
245+
if err := browser.OpenBrowser(authURL); err != nil {
246+
log.Error().Err(err).Msg("error opening browser")
247+
}
248+
case <-time.After(5 * time.Minute):
249+
// extra precaution to prevent hanging indefinitely on stdin
200250
return
201251
}
202-
case <-time.After(5 * time.Minute):
203-
// extra precaution to prevent hanging indefinitely on stdin
204-
return
205-
}
206-
}(serverCtx)
252+
}(serverCtx)
253+
}
207254

208255
// Wait for auth code and exchange for access token
209256
code := <-codeCh

internal/pkg/utils/msg/message.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,7 @@ func HintMsg(format string, a ...any) {
3131
formatted := fmt.Sprintf(format, a...)
3232
fmt.Fprintln(os.Stderr, style.Hint(formatted))
3333
}
34+
35+
func Blank() {
36+
fmt.Fprintln(os.Stderr, "")
37+
}

internal/pkg/utils/text/json.go

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
package text
22

33
import (
4+
"bytes"
45
"encoding/json"
6+
"strings"
57
)
68

7-
func InlineJSON(data any) string {
8-
jsonData, err := json.Marshal(data)
9-
if err != nil {
9+
// encode marshals data to JSON with HTML escaping disabled.
10+
// json.Marshal and json.MarshalIndent escape &, <, > as \uXXXX by default —
11+
// a safety measure for embedding JSON in HTML that is incorrect for CLI output.
12+
func encode(data any, indent bool) string {
13+
var buf bytes.Buffer
14+
enc := json.NewEncoder(&buf)
15+
enc.SetEscapeHTML(false)
16+
if indent {
17+
enc.SetIndent("", " ")
18+
}
19+
if err := enc.Encode(data); err != nil {
1020
return ""
1121
}
12-
return string(jsonData)
22+
// json.Encoder.Encode appends a trailing newline; trim it so callers
23+
// control their own newlines (consistent with the old MarshalIndent behavior).
24+
return strings.TrimRight(buf.String(), "\n")
25+
}
26+
27+
func InlineJSON(data any) string {
28+
return encode(data, false)
1329
}
1430

1531
func IndentJSON(data any) string {
16-
jsonData, err := json.MarshalIndent(data, "", " ")
17-
if err != nil {
18-
return ""
19-
}
20-
return string(jsonData)
32+
return encode(data, true)
2133
}

0 commit comments

Comments
 (0)