From acd05e53205cb2bc542f354539e9a85af2bd18e9 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Mon, 23 Mar 2026 14:27:55 -0400 Subject: [PATCH 1/6] update login command and underlying utility to allow --json as a flag, tty detection for the input handling on the login flow, supporting JSON output through stdout for agentic use. update target GetAndSetAccessToken to handle things --- internal/pkg/cli/command/auth/login.go | 14 +-- internal/pkg/cli/command/login/login.go | 14 +-- internal/pkg/cli/command/target/target.go | 4 +- internal/pkg/utils/login/login.go | 136 ++++++++++++++-------- 4 files changed, 102 insertions(+), 66 deletions(-) diff --git a/internal/pkg/cli/command/auth/login.go b/internal/pkg/cli/command/auth/login.go index 449a5f40..b2d924eb 100644 --- a/internal/pkg/cli/command/auth/login.go +++ b/internal/pkg/cli/command/auth/login.go @@ -2,7 +2,6 @@ package auth import ( _ "embed" - "io" "github.com/pinecone-io/cli/internal/pkg/utils/help" "github.com/pinecone-io/cli/internal/pkg/utils/login" @@ -28,6 +27,8 @@ var ( ) func NewLoginCmd() *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ Use: "login", Short: "Authenticate with Pinecone via user login in a web browser", @@ -37,21 +38,18 @@ func NewLoginCmd() *cobra.Command { `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { - out := cmd.OutOrStdout() - if quiet, _ := cmd.Flags().GetBool("quiet"); quiet { - out = io.Discard - } - login.Run(cmd.Context(), login.IO{ In: cmd.InOrStdin(), - Out: out, + Out: cmd.OutOrStdout(), Err: cmd.ErrOrStderr(), }, - login.Options{}, + login.Options{Json: jsonOutput}, ) }, } + cmd.Flags().BoolVar(&jsonOutput, "json", false, "emit JSON output") + return cmd } diff --git a/internal/pkg/cli/command/login/login.go b/internal/pkg/cli/command/login/login.go index e5648d56..9a27b7e6 100644 --- a/internal/pkg/cli/command/login/login.go +++ b/internal/pkg/cli/command/login/login.go @@ -2,7 +2,6 @@ package login import ( _ "embed" - "io" "github.com/pinecone-io/cli/internal/pkg/utils/help" "github.com/pinecone-io/cli/internal/pkg/utils/login" @@ -20,6 +19,8 @@ var ( ) func NewLoginCmd() *cobra.Command { + var jsonOutput bool + cmd := &cobra.Command{ Use: "login", Short: "Authenticate with Pinecone via user login in a web browser", @@ -29,21 +30,18 @@ func NewLoginCmd() *cobra.Command { `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { - out := cmd.OutOrStdout() - if quiet, _ := cmd.Flags().GetBool("quiet"); quiet { - out = io.Discard - } - login.Run(cmd.Context(), login.IO{ In: cmd.InOrStdin(), - Out: out, + Out: cmd.OutOrStdout(), Err: cmd.ErrOrStderr(), }, - login.Options{}, + login.Options{Json: jsonOutput}, ) }, } + cmd.Flags().BoolVar(&jsonOutput, "json", false, "emit JSON output") + return cmd } diff --git a/internal/pkg/cli/command/target/target.go b/internal/pkg/cli/command/target/target.go index 2a66ae01..219d96f5 100644 --- a/internal/pkg/cli/command/target/target.go +++ b/internal/pkg/cli/command/target/target.go @@ -158,7 +158,7 @@ func NewTargetCmd() *cobra.Command { // If the org chosen differs from the current orgId in the token, we need to login again if currentTokenOrgId != "" && currentTokenOrgId != targetOrg.Id { oauth.Logout() - err = login.GetAndSetAccessToken(ctx, &targetOrg.Id) + err = login.GetAndSetAccessToken(ctx, &targetOrg.Id, login.Options{}) if err != nil { msg.FailMsg("Failed to get access token: %s", err) exit.Error(err, "Error getting access token") @@ -204,7 +204,7 @@ func NewTargetCmd() *cobra.Command { // If the org chosen differs from the current orgId in the token, we need to login again if currentTokenOrgId != org.Id { oauth.Logout() - err = login.GetAndSetAccessToken(ctx, &org.Id) + err = login.GetAndSetAccessToken(ctx, &org.Id, login.Options{}) if err != nil { msg.FailMsg("Failed to get access token: %s", err) exit.Error(err, "Error getting access token") diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 73d91c6f..65126c27 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -14,6 +14,8 @@ import ( "os" "time" + "golang.org/x/term" + "github.com/pinecone-io/cli/internal/pkg/utils/browser" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/secrets" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" @@ -23,6 +25,7 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/oauth" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/pinecone-io/go-pinecone/v5/pinecone" ) @@ -41,9 +44,14 @@ type IO struct { Err io.Writer } -type Options struct{} +type Options struct { + Json bool +} func Run(ctx context.Context, io IO, opts Options) { + // Use JSON output if explicitly requested or if stdout is not a TTY (e.g. agent capturing output) + jsonMode := opts.Json || !term.IsTerminal(int(os.Stdout.Fd())) + // Check if the user is currently logged in token, err := oauth.Token(ctx) @@ -60,7 +68,7 @@ func Run(ctx context.Context, io IO, opts Options) { } // Initiate login flow - err = GetAndSetAccessToken(ctx, nil) + err = GetAndSetAccessToken(ctx, nil, opts) if err != nil { msg.FailMsg("Error acquiring access token while logging in: %s", err) exit.Error(err, "Error acquiring access token while logging in") @@ -77,7 +85,9 @@ func Run(ctx context.Context, io IO, opts Options) { msg.FailMsg("An auth token was fetched but an error occurred while parsing the token's claims: %s", err) exit.Error(err, "Error parsing claims from access token") } - msg.SuccessMsg("Logged in as " + style.Emphasis(claims.Email) + ". Defaulted to organization ID: " + style.Emphasis(claims.OrgId)) + if !jsonMode { + msg.SuccessMsg("Logged in as " + style.Emphasis(claims.Email) + ". Defaulted to organization ID: " + style.Emphasis(claims.OrgId)) + } ac := sdk.NewPineconeAdminClient(ctx) if err != nil { @@ -111,34 +121,55 @@ func Run(ctx context.Context, io IO, opts Options) { Name: targetOrg.Name, Id: targetOrg.Id, }) - fmt.Println() - fmt.Printf(style.InfoMsg("Target org set to %s.\n"), style.Emphasis(targetOrg.Name)) - if projects != nil { - if len(projects) == 0 { - fmt.Printf(style.InfoMsg("No projects found for organization %s.\n"), style.Emphasis(targetOrg.Name)) - fmt.Println(style.InfoMsg("Please create a project for this organization to work with project resources.")) - } else { + if jsonMode { + projectId := "" + if len(projects) > 0 { targetProj := projects[0] state.TargetProj.Set(state.TargetProject{ Name: targetProj.Name, Id: targetProj.Id, }) - - fmt.Printf(style.InfoMsg("Target project set %s.\n"), style.Emphasis(targetProj.Name)) + projectId = targetProj.Id + } + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + Email string `json:"email"` + OrgId string `json:"org_id"` + ProjectId string `json:"project_id"` + }{Status: "authenticated", Email: claims.Email, OrgId: targetOrg.Id, ProjectId: projectId})) + } else { + fmt.Println() + fmt.Printf(style.InfoMsg("Target org set to %s.\n"), style.Emphasis(targetOrg.Name)) + + if projects != nil { + if len(projects) == 0 { + fmt.Printf(style.InfoMsg("No projects found for organization %s.\n"), style.Emphasis(targetOrg.Name)) + fmt.Println(style.InfoMsg("Please create a project for this organization to work with project resources.")) + } else { + targetProj := projects[0] + state.TargetProj.Set(state.TargetProject{ + Name: targetProj.Name, + Id: targetProj.Id, + }) + + fmt.Printf(style.InfoMsg("Target project set %s.\n"), style.Emphasis(targetProj.Name)) + } } - } - fmt.Println() - fmt.Println(style.CodeHint("Run %s to change the target context.", style.Code("pc target"))) + fmt.Println() + fmt.Println(style.CodeHint("Run %s to change the target context.", style.Code("pc target"))) - fmt.Println() - fmt.Printf("Now try %s to learn about index operations.\n", style.Code("pc index -h")) + fmt.Println() + fmt.Printf("Now try %s to learn about index operations.\n", style.Code("pc index -h")) + } } // Takes an optional orgId, and attempts to acquire an access token scoped to the orgId if provided. // If a token is successfully acquired it's set in the secrets store, and the user is considered logged in with state.AuthUserToken. -func GetAndSetAccessToken(ctx context.Context, orgId *string) error { +func GetAndSetAccessToken(ctx context.Context, orgId *string, opts Options) error { + jsonMode := opts.Json || !term.IsTerminal(int(os.Stdout.Fd())) + a := oauth.Auth{} // CSRF state @@ -170,40 +201,49 @@ func GetAndSetAccessToken(ctx context.Context, orgId *string) error { codeCh <- code }() - fmt.Printf("Visit %s to authorize the CLI.\n", style.Underline(authURL)) - fmt.Println() - fmt.Printf("Press %s to open the browser, or manually paste the URL above.\n", style.Code("[Enter]")) - - // spawn a goroutine to optionally wait for [Enter] as input - go func(ctx context.Context) { - // inner channel to signal that [Enter] was pressed - inputCh := make(chan struct{}, 1) + if jsonMode { + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + URL string `json:"url"` + Port int `json:"port"` + }{Status: "pending", URL: authURL, Port: 59049})) + } else { + fmt.Printf("Visit %s to authorize the CLI.\n", style.Underline(authURL)) + fmt.Println() + } - // spawn inner goroutine to read stdin (blocking) - go func() { - _, err := bufio.NewReader(os.Stdin).ReadBytes('\n') - if err != nil { - log.Error().Err(err).Msg("stdin error: unable to open browser") + // Only prompt for [Enter] and spawn stdin reader in interactive TTY sessions + if term.IsTerminal(int(os.Stdin.Fd())) { + fmt.Printf("Press %s to open the browser, or manually paste the URL above.\n", style.Code("[Enter]")) + + go func(ctx context.Context) { + // inner channel to signal that [Enter] was pressed + inputCh := make(chan struct{}, 1) + + // spawn inner goroutine to read stdin (blocking) + go func() { + _, err := bufio.NewReader(os.Stdin).ReadBytes('\n') + if err != nil { + log.Error().Err(err).Msg("stdin error: unable to open browser") + return + } + close(inputCh) + }() + + // wait for [Enter], auth code, or timeout + select { + case <-ctx.Done(): return - } - close(inputCh) - }() - - // wait for [Enter], auth code, or timeout - select { - case <-ctx.Done(): - return - case <-inputCh: - err = browser.OpenBrowser(authURL) - if err != nil { - log.Error().Err(err).Msg("error opening browser") + case <-inputCh: + if err := browser.OpenBrowser(authURL); err != nil { + log.Error().Err(err).Msg("error opening browser") + } + case <-time.After(5 * time.Minute): + // extra precaution to prevent hanging indefinitely on stdin return } - case <-time.After(5 * time.Minute): - // extra precaution to prevent hanging indefinitely on stdin - return - } - }(serverCtx) + }(serverCtx) + } // Wait for auth code and exchange for access token code := <-codeCh From f2f1dd31d6ade4fa348de177abcb52bee297a155 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Tue, 24 Mar 2026 12:51:24 -0400 Subject: [PATCH 2/6] clean up login output formatting: make sure all human readable prose-style output is going to stderr. add text.encode helper to allows marshalling to JSON without HTML escaping. drive json logic from the top down checking the provided flag along with TTY presence. add unit tests for the new text functionality --- .claude/settings.local.json | 18 +++++++ internal/pkg/utils/login/login.go | 45 ++++++++--------- internal/pkg/utils/msg/message.go | 4 ++ internal/pkg/utils/text/json.go | 30 +++++++---- internal/pkg/utils/text/json_test.go | 74 ++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 33 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 internal/pkg/utils/text/json_test.go diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..25ccbd65 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,18 @@ +{ + "permissions": { + "allow": [ + "mcp__claude_ai_Linear__save_issue", + "mcp__claude_ai_Linear__get_issue", + "Bash(go test:*)", + "Bash(go build:*)", + "Bash(gh pr:*)", + "Bash(gh api:*)", + "Bash(goimports:*)", + "Bash(grep -r pcio . --include=*.go)", + "Bash(xargs sed:*)", + "mcp__claude_ai_Linear__list_teams", + "mcp__claude_ai_Linear__list_issue_statuses", + "Bash(grep:*)" + ] + } +} diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 65126c27..10e9a985 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -49,8 +49,9 @@ type Options struct { } func Run(ctx context.Context, io IO, opts Options) { - // Use JSON output if explicitly requested or if stdout is not a TTY (e.g. agent capturing output) - jsonMode := opts.Json || !term.IsTerminal(int(os.Stdout.Fd())) + // Resolve output format once at the top level: explicit --json flag or auto-detected non-TTY stdout. + // Normalizing opts.Json here means GetAndSetAccessToken and other helpers use opts.Json directly. + opts.Json = opts.Json || !term.IsTerminal(int(os.Stdout.Fd())) // Check if the user is currently logged in token, err := oauth.Token(ctx) @@ -85,7 +86,8 @@ func Run(ctx context.Context, io IO, opts Options) { msg.FailMsg("An auth token was fetched but an error occurred while parsing the token's claims: %s", err) exit.Error(err, "Error parsing claims from access token") } - if !jsonMode { + if !opts.Json { + msg.Blank() msg.SuccessMsg("Logged in as " + style.Emphasis(claims.Email) + ". Defaulted to organization ID: " + style.Emphasis(claims.OrgId)) } @@ -122,7 +124,7 @@ func Run(ctx context.Context, io IO, opts Options) { Id: targetOrg.Id, }) - if jsonMode { + if opts.Json { projectId := "" if len(projects) > 0 { targetProj := projects[0] @@ -139,13 +141,12 @@ func Run(ctx context.Context, io IO, opts Options) { ProjectId string `json:"project_id"` }{Status: "authenticated", Email: claims.Email, OrgId: targetOrg.Id, ProjectId: projectId})) } else { - fmt.Println() - fmt.Printf(style.InfoMsg("Target org set to %s.\n"), style.Emphasis(targetOrg.Name)) + msg.InfoMsg("Target org set to %s.", style.Emphasis(targetOrg.Name)) if projects != nil { if len(projects) == 0 { - fmt.Printf(style.InfoMsg("No projects found for organization %s.\n"), style.Emphasis(targetOrg.Name)) - fmt.Println(style.InfoMsg("Please create a project for this organization to work with project resources.")) + msg.InfoMsg("No projects found for organization %s.", style.Emphasis(targetOrg.Name)) + msg.InfoMsg("Please create a project for this organization to work with project resources.") } else { targetProj := projects[0] state.TargetProj.Set(state.TargetProject{ @@ -153,23 +154,19 @@ func Run(ctx context.Context, io IO, opts Options) { Id: targetProj.Id, }) - fmt.Printf(style.InfoMsg("Target project set %s.\n"), style.Emphasis(targetProj.Name)) + msg.InfoMsg("Target project set %s.", style.Emphasis(targetProj.Name)) } } - fmt.Println() - fmt.Println(style.CodeHint("Run %s to change the target context.", style.Code("pc target"))) - - fmt.Println() - fmt.Printf("Now try %s to learn about index operations.\n", style.Code("pc index -h")) + msg.Blank() + msg.HintMsg("Run %s to change the target context.", style.Code("pc target")) + msg.HintMsg("Now try %s to learn about index operations.", style.Code("pc index -h")) } } // Takes an optional orgId, and attempts to acquire an access token scoped to the orgId if provided. // If a token is successfully acquired it's set in the secrets store, and the user is considered logged in with state.AuthUserToken. func GetAndSetAccessToken(ctx context.Context, orgId *string, opts Options) error { - jsonMode := opts.Json || !term.IsTerminal(int(os.Stdout.Fd())) - a := oauth.Auth{} // CSRF state @@ -201,20 +198,20 @@ func GetAndSetAccessToken(ctx context.Context, orgId *string, opts Options) erro codeCh <- code }() - if jsonMode { + if opts.Json { fmt.Fprintln(os.Stdout, text.IndentJSON(struct { Status string `json:"status"` URL string `json:"url"` - Port int `json:"port"` - }{Status: "pending", URL: authURL, Port: 59049})) + }{Status: "pending", URL: authURL})) } else { - fmt.Printf("Visit %s to authorize the CLI.\n", style.Underline(authURL)) - fmt.Println() + fmt.Fprintf(os.Stderr, "Visit %s to authorize the CLI.\n", style.Underline(authURL)) } - // Only prompt for [Enter] and spawn stdin reader in interactive TTY sessions - if term.IsTerminal(int(os.Stdin.Fd())) { - fmt.Printf("Press %s to open the browser, or manually paste the URL above.\n", style.Code("[Enter]")) + // Only prompt for [Enter] and spawn stdin reader in prose mode with an interactive TTY. + // In JSON mode the URL is in the pending object; browser-open is a prose-mode convenience only. + if !opts.Json && term.IsTerminal(int(os.Stdin.Fd())) { + msg.Blank() + fmt.Fprintf(os.Stderr, "Press %s to open the browser, or manually paste the URL above.\n", style.Code("[Enter]")) go func(ctx context.Context) { // inner channel to signal that [Enter] was pressed diff --git a/internal/pkg/utils/msg/message.go b/internal/pkg/utils/msg/message.go index 290846cc..2311830d 100644 --- a/internal/pkg/utils/msg/message.go +++ b/internal/pkg/utils/msg/message.go @@ -31,3 +31,7 @@ func HintMsg(format string, a ...any) { formatted := fmt.Sprintf(format, a...) fmt.Fprintln(os.Stderr, style.Hint(formatted)) } + +func Blank() { + fmt.Fprintln(os.Stderr, "") +} diff --git a/internal/pkg/utils/text/json.go b/internal/pkg/utils/text/json.go index ed17b3f8..a2a95a09 100644 --- a/internal/pkg/utils/text/json.go +++ b/internal/pkg/utils/text/json.go @@ -1,21 +1,33 @@ package text import ( + "bytes" "encoding/json" + "strings" ) -func InlineJSON(data any) string { - jsonData, err := json.Marshal(data) - if err != nil { +// encode marshals data to JSON with HTML escaping disabled. +// json.Marshal and json.MarshalIndent escape &, <, > as \uXXXX by default — +// a safety measure for embedding JSON in HTML that is incorrect for CLI output. +func encode(data any, indent bool) string { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if indent { + enc.SetIndent("", " ") + } + if err := enc.Encode(data); err != nil { return "" } - return string(jsonData) + // json.Encoder.Encode appends a trailing newline; trim it so callers + // control their own newlines (consistent with the old MarshalIndent behavior). + return strings.TrimRight(buf.String(), "\n") +} + +func InlineJSON(data any) string { + return encode(data, false) } func IndentJSON(data any) string { - jsonData, err := json.MarshalIndent(data, "", " ") - if err != nil { - return "" - } - return string(jsonData) + return encode(data, true) } diff --git a/internal/pkg/utils/text/json_test.go b/internal/pkg/utils/text/json_test.go new file mode 100644 index 00000000..dca02348 --- /dev/null +++ b/internal/pkg/utils/text/json_test.go @@ -0,0 +1,74 @@ +package text + +import ( + "strings" + "testing" +) + +func TestIndentJSON_DoesNotEscapeHTMLChars(t *testing.T) { + input := struct { + URL string `json:"url"` + }{URL: "https://example.com/auth?foo=1&bar=2"} + + result := IndentJSON(input) + + if strings.Contains(result, `\u0026`) { + t.Errorf("IndentJSON escaped & as \\u0026; got: %s", result) + } + if !strings.Contains(result, "&") { + t.Errorf("IndentJSON did not preserve literal &; got: %s", result) + } +} + +func TestInlineJSON_DoesNotEscapeHTMLChars(t *testing.T) { + input := struct { + URL string `json:"url"` + }{URL: "https://example.com/auth?foo=1&bar=2"} + + result := InlineJSON(input) + + if strings.Contains(result, `\u0026`) { + t.Errorf("InlineJSON escaped & as \\u0026; got: %s", result) + } + if !strings.Contains(result, "&") { + t.Errorf("InlineJSON did not preserve literal &; got: %s", result) + } +} + +func TestIndentJSON_IsIndented(t *testing.T) { + input := struct { + Key string `json:"key"` + }{Key: "value"} + + result := IndentJSON(input) + + if !strings.Contains(result, "\n ") { + t.Errorf("IndentJSON output is not indented; got: %s", result) + } +} + +func TestInlineJSON_IsCompact(t *testing.T) { + input := struct { + Key string `json:"key"` + }{Key: "value"} + + result := InlineJSON(input) + + if strings.Contains(result, "\n") { + t.Errorf("InlineJSON output contains newlines; got: %s", result) + } +} + +func TestIndentJSON_NoTrailingNewline(t *testing.T) { + result := IndentJSON(struct{ K string }{K: "v"}) + if strings.HasSuffix(result, "\n") { + t.Errorf("IndentJSON has trailing newline") + } +} + +func TestInlineJSON_NoTrailingNewline(t *testing.T) { + result := InlineJSON(struct{ K string }{K: "v"}) + if strings.HasSuffix(result, "\n") { + t.Errorf("InlineJSON has trailing newline") + } +} From 65441514beadff5705536f38652b91b26d1c2904 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Tue, 24 Mar 2026 12:52:19 -0400 Subject: [PATCH 3/6] remove local claude settings from source --- .claude/settings.local.json | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 25ccbd65..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "permissions": { - "allow": [ - "mcp__claude_ai_Linear__save_issue", - "mcp__claude_ai_Linear__get_issue", - "Bash(go test:*)", - "Bash(go build:*)", - "Bash(gh pr:*)", - "Bash(gh api:*)", - "Bash(goimports:*)", - "Bash(grep -r pcio . --include=*.go)", - "Bash(xargs sed:*)", - "mcp__claude_ai_Linear__list_teams", - "mcp__claude_ai_Linear__list_issue_statuses", - "Bash(grep:*)" - ] - } -} From 402ecb7c17c7039c4250db2b51516985a0dca07e Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Tue, 24 Mar 2026 13:12:19 -0400 Subject: [PATCH 4/6] show the browser opening prompt even when --json has been passed if there's a TTY detected --- internal/pkg/utils/login/login.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 10e9a985..ef9547f7 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -207,9 +207,11 @@ func GetAndSetAccessToken(ctx context.Context, orgId *string, opts Options) erro fmt.Fprintf(os.Stderr, "Visit %s to authorize the CLI.\n", style.Underline(authURL)) } - // Only prompt for [Enter] and spawn stdin reader in prose mode with an interactive TTY. - // In JSON mode the URL is in the pending object; browser-open is a prose-mode convenience only. - if !opts.Json && term.IsTerminal(int(os.Stdin.Fd())) { + // Prompt for [Enter] and spawn stdin reader whenever stdin is an interactive TTY, + // regardless of output format. JSON mode only affects what goes to stdout — a user + // running --json in a terminal is still at a keyboard and benefits from browser-open. + // The prompt goes to stderr so it never corrupts the stdout JSON stream. + if term.IsTerminal(int(os.Stdin.Fd())) { msg.Blank() fmt.Fprintf(os.Stderr, "Press %s to open the browser, or manually paste the URL above.\n", style.Code("[Enter]")) From 37156e2bab4cdef22c041bce8580033b7eaaa9a7 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Tue, 24 Mar 2026 13:22:38 -0400 Subject: [PATCH 5/6] remove explicit IO struct being passed through the login command and utility for now --- internal/pkg/cli/command/auth/login.go | 9 +-------- internal/pkg/cli/command/login/login.go | 9 +-------- internal/pkg/utils/login/login.go | 9 +-------- 3 files changed, 3 insertions(+), 24 deletions(-) diff --git a/internal/pkg/cli/command/auth/login.go b/internal/pkg/cli/command/auth/login.go index b2d924eb..c305821a 100644 --- a/internal/pkg/cli/command/auth/login.go +++ b/internal/pkg/cli/command/auth/login.go @@ -38,14 +38,7 @@ func NewLoginCmd() *cobra.Command { `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { - login.Run(cmd.Context(), - login.IO{ - In: cmd.InOrStdin(), - Out: cmd.OutOrStdout(), - Err: cmd.ErrOrStderr(), - }, - login.Options{Json: jsonOutput}, - ) + login.Run(cmd.Context(), login.Options{Json: jsonOutput}) }, } diff --git a/internal/pkg/cli/command/login/login.go b/internal/pkg/cli/command/login/login.go index 9a27b7e6..1b7bbb84 100644 --- a/internal/pkg/cli/command/login/login.go +++ b/internal/pkg/cli/command/login/login.go @@ -30,14 +30,7 @@ func NewLoginCmd() *cobra.Command { `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { - login.Run(cmd.Context(), - login.IO{ - In: cmd.InOrStdin(), - Out: cmd.OutOrStdout(), - Err: cmd.ErrOrStderr(), - }, - login.Options{Json: jsonOutput}, - ) + login.Run(cmd.Context(), login.Options{Json: jsonOutput}) }, } diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index ef9547f7..5dec49fa 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -9,7 +9,6 @@ import ( "errors" "fmt" "html/template" - "io" "net/http" "os" "time" @@ -38,17 +37,11 @@ var errorHTML string //go:embed assets/pinecone_logo.svg var logoSVG string -type IO struct { - In io.Reader - Out io.Writer - Err io.Writer -} - type Options struct { Json bool } -func Run(ctx context.Context, io IO, opts Options) { +func Run(ctx context.Context, opts Options) { // Resolve output format once at the top level: explicit --json flag or auto-detected non-TTY stdout. // Normalizing opts.Json here means GetAndSetAccessToken and other helpers use opts.Json directly. opts.Json = opts.Json || !term.IsTerminal(int(os.Stdout.Fd())) From 64aea1231b1e18eda34080d772dc259cd795b880 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Tue, 24 Mar 2026 13:25:35 -0400 Subject: [PATCH 6/6] add JSON output for the login case where the user is already authenticated - we should output structured JSON to stdout --- internal/pkg/utils/login/login.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 5dec49fa..99c08284 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -57,7 +57,22 @@ func Run(ctx context.Context, opts Options) { } if !expired && token != nil && token.AccessToken != "" { - msg.WarnMsg("You are already logged in. Please log out first using %s.", style.Code("pc auth logout")) + if opts.Json { + claims, err := oauth.ParseClaimsUnverified(token) + if err == nil { + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + Email string `json:"email"` + OrgId string `json:"org_id"` + }{Status: "already_authenticated", Email: claims.Email, OrgId: claims.OrgId})) + } else { + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + }{Status: "already_authenticated"})) + } + } else { + msg.WarnMsg("You are already logged in. Please log out first using %s.", style.Code("pc auth logout")) + } return }