From e449f0f79778c1bb07adc228c374b51fa254aa0e Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Thu, 2 Apr 2026 17:16:38 -0400 Subject: [PATCH 1/7] make sure target cmd detects non-TTY and sets JSON mode automatically, same as login, when pc target needs to authenticate the URL is output to stdout properly, add shorthand --json flag for needed commands --- internal/pkg/cli/command/auth/login.go | 2 +- internal/pkg/cli/command/login/login.go | 2 +- internal/pkg/cli/command/root/root.go | 42 ++++++++ internal/pkg/cli/command/target/target.go | 13 +++ internal/pkg/utils/login/login.go | 120 ++++++++++++++++++++-- 5 files changed, 167 insertions(+), 12 deletions(-) diff --git a/internal/pkg/cli/command/auth/login.go b/internal/pkg/cli/command/auth/login.go index c305821..6d0d4ed 100644 --- a/internal/pkg/cli/command/auth/login.go +++ b/internal/pkg/cli/command/auth/login.go @@ -42,7 +42,7 @@ func NewLoginCmd() *cobra.Command { }, } - cmd.Flags().BoolVar(&jsonOutput, "json", false, "emit JSON output") + cmd.Flags().BoolVarP(&jsonOutput, "json", "j", 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 1b7bbb8..fc815f8 100644 --- a/internal/pkg/cli/command/login/login.go +++ b/internal/pkg/cli/command/login/login.go @@ -34,7 +34,7 @@ func NewLoginCmd() *cobra.Command { }, } - cmd.Flags().BoolVar(&jsonOutput, "json", false, "emit JSON output") + cmd.Flags().BoolVarP(&jsonOutput, "json", "j", false, "emit JSON output") return cmd } diff --git a/internal/pkg/cli/command/root/root.go b/internal/pkg/cli/command/root/root.go index dc2c848..957d54a 100644 --- a/internal/pkg/cli/command/root/root.go +++ b/internal/pkg/cli/command/root/root.go @@ -7,6 +7,8 @@ import ( "syscall" "time" + "golang.org/x/term" + "github.com/pinecone-io/cli/internal/pkg/cli/command/apiKey" "github.com/pinecone-io/cli/internal/pkg/cli/command/auth" "github.com/pinecone-io/cli/internal/pkg/cli/command/backup" @@ -20,7 +22,10 @@ import ( "github.com/pinecone-io/cli/internal/pkg/cli/command/target" "github.com/pinecone-io/cli/internal/pkg/cli/command/version" "github.com/pinecone-io/cli/internal/pkg/cli/command/whoami" + "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" + loginutil "github.com/pinecone-io/cli/internal/pkg/utils/login" + "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/spf13/cobra" ) @@ -30,6 +35,26 @@ var ( cancelRootFunc context.CancelFunc ) +// skipAuthCommands lists the full command paths that do not require authentication. +// These are commands that either establish credentials or work on local state only. +// When adding new commands that don't need auth, add their CommandPath() here. +var skipAuthCommands = map[string]struct{}{ + "pc login": {}, + "pc logout": {}, + "pc auth login": {}, + "pc auth logout": {}, + "pc auth configure": {}, + "pc auth clear": {}, + "pc auth status": {}, + "pc auth _daemon": {}, + "pc version": {}, + "pc config": {}, + "pc config get-api-key": {}, + "pc config set-api-key": {}, + "pc config set-color": {}, + "pc config set-environment": {}, +} + type GlobalOptions struct { timeout time.Duration } @@ -89,6 +114,23 @@ func init() { cancelRootFunc = cancel cmd.SetContext(ctx) } + + // Skip auth check for commands that establish or manage credentials. + if _, skip := skipAuthCommands[cmd.CommandPath()]; skip { + return + } + + // JSON mode: non-TTY stdout OR the command's own --json/-j flag was set. + isJSON := !term.IsTerminal(int(os.Stdout.Fd())) + if !isJSON { + if f := cmd.Flags().Lookup("json"); f != nil && f.Value.String() == "true" { + isJSON = true + } + } + if err := loginutil.EnsureAuthenticated(cmd.Context()); err != nil { + msg.FailJSON(isJSON, "%s", err) + exit.Error(err, "authentication required") + } }, PersistentPostRun: func(cmd *cobra.Command, args []string) { // Cancel the root context when the command completes diff --git a/internal/pkg/cli/command/target/target.go b/internal/pkg/cli/command/target/target.go index e6948d9..a48b49d 100644 --- a/internal/pkg/cli/command/target/target.go +++ b/internal/pkg/cli/command/target/target.go @@ -6,6 +6,7 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/secrets" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" @@ -72,6 +73,8 @@ func NewTargetCmd() *cobra.Command { GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { ctx := cmd.Context() + // Auto-detect non-TTY (agentic) environments just as the login flow does. + options.json = options.json || !term.IsTerminal(int(os.Stdout.Fd())) log.Debug(). Str("org", options.org). Str("project", options.project). @@ -146,6 +149,16 @@ func NewTargetCmd() *cobra.Command { options.project == "" && options.projectID == "" { + if options.json { + // In non-TTY/JSON mode there's no interactive selector — just + // show the current target context so agents can read it. + targetContext := state.GetTargetContext() + defaultAPIKey := secrets.DefaultAPIKey.Get() + targetContext.DefaultAPIKey = presenters.MaskHeadTail(defaultAPIKey, 4, 4) + fmt.Fprintln(os.Stdout, text.IndentJSON(targetContext)) + return + } + // Ask the user to choose a target org targetOrg := postLoginInteractiveTargetOrg(orgs, options.json) if targetOrg == nil { diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 2d4d09a..ce0b93b 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -263,8 +263,14 @@ func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, ses if wait { // Caller needs the token on return — block until the daemon completes. - // Print the auth URL to stderr so the user/agent can navigate to it while - // we poll. Stderr keeps stdout clean for the caller's own JSON output. + // Emit the auth URL to stdout as JSON so agents watching stdout can extract + // it. Also write to stderr for human users. The caller is responsible for + // emitting its own JSON result once this function returns. + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { + Status string `json:"status"` + URL string `json:"url"` + SessionId string `json:"session_id"` + }{Status: "authenticating", URL: authURL, SessionId: sessionId})) fmt.Fprintf(os.Stderr, "Visit the following URL to authenticate:\n\n %s\n\n", authURL) return pollForResult(sessionId, newSess.CreatedAt, true) } @@ -480,9 +486,10 @@ func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string) error { return nil } -// RunPostAuthSetup fetches the user's org/project context, sets target defaults, -// and emits the final {"status":"authenticated",...} JSON. -func RunPostAuthSetup(ctx context.Context) error { +// applyAuthContext fetches the user's org/project and writes them to state. +// It is the side-effect half of the post-auth setup, separated so that callers +// that don't want to emit JSON (e.g. EnsureAuthenticated) can still set context. +func applyAuthContext(ctx context.Context) error { token, err := oauth.Token(ctx) if err != nil { return fmt.Errorf("error retrieving oauth token: %w", err) @@ -519,18 +526,36 @@ func RunPostAuthSetup(ctx context.Context) error { Id: targetOrg.Id, }) - projectId := "" - projectName := "" if len(projects) > 0 { targetProj := projects[0] state.TargetProj.Set(state.TargetProject{ Name: targetProj.Name, Id: targetProj.Id, }) - projectId = targetProj.Id - projectName = targetProj.Name } + return nil +} + +// RunPostAuthSetup fetches the user's org/project context, sets target defaults, +// and emits the final {"status":"authenticated",...} JSON. +func RunPostAuthSetup(ctx context.Context) error { + if err := applyAuthContext(ctx); err != nil { + return err + } + + token, err := oauth.Token(ctx) + if err != nil { + return fmt.Errorf("error retrieving oauth token: %w", err) + } + claims, err := oauth.ParseClaimsUnverified(token) + if err != nil { + return fmt.Errorf("error parsing token claims: %w", err) + } + + targetOrg := state.TargetOrg.Get() + targetProj := state.TargetProj.Get() + fmt.Fprintln(os.Stdout, text.IndentJSON(struct { Status string `json:"status"` Email string `json:"email"` @@ -538,7 +563,7 @@ func RunPostAuthSetup(ctx context.Context) error { OrgName string `json:"org_name"` ProjectId string `json:"project_id"` ProjectName string `json:"project_name"` - }{Status: "authenticated", Email: claims.Email, OrgId: targetOrg.Id, OrgName: targetOrg.Name, ProjectId: projectId, ProjectName: projectName})) + }{Status: "authenticated", Email: claims.Email, OrgId: targetOrg.Id, OrgName: targetOrg.Name, ProjectId: targetProj.Id, ProjectName: targetProj.Name})) return nil } @@ -630,6 +655,81 @@ func renderHTML(w http.ResponseWriter, htmlTemplate string, data map[string]temp return nil } +// EnsureAuthenticated verifies that valid credentials are available, transparently +// completing a finished pending session if one exists. +// +// - Valid token or non-OAuth credentials present → returns nil immediately. +// - Pending session whose daemon has already completed → reloads credentials from +// disk and returns nil. This is the "lazy completion" path: after a successful +// browser login the next command just works without a second `pc login` call. +// - Pending session still in progress → returns an error containing the auth URL. +// - No credentials and no session → returns a "not authenticated" error. +func EnsureAuthenticated(ctx context.Context) error { + // Service-account and API key credentials don't use OAuth tokens. + if secrets.ClientId.Get() != "" && secrets.ClientSecret.Get() != "" { + return nil + } + if secrets.DefaultAPIKey.Get() != "" { + return nil + } + + // Check for a valid OAuth token. + token, err := oauth.Token(ctx) + var te *oauth.TokenError + if err != nil { + if !errors.As(err, &te) || te.Kind != oauth.TokenErrSessionExpired { + // Real error (network failure, etc.) — propagate it. + return err + } + // Session expired — fall through to check for a pending session. + } else if token != nil && token.AccessToken != "" { + // Valid token. If no target org is set yet (e.g. first command after a fresh + // login before a second `pc login` call was made), initialize the context now + // so the calling command doesn't fail with "need to target a project". + if state.TargetOrg.Get().Id == "" { + if err := applyAuthContext(ctx); err != nil { + log.Debug().Err(err).Msg("EnsureAuthenticated: applyAuthContext failed") + } + } + return nil + } + + // No valid token — look for a pending session whose daemon may have finished. + sess, result, sessErr := findResumableSession() + if sessErr != nil { + return fmt.Errorf("error checking auth session: %w", sessErr) + } + + if sess == nil { + if err != nil { + // Token was expired and there's no pending session to fall back to. + return err + } + return fmt.Errorf("not authenticated. Run %s to log in.", style.Code("pc login")) + } + + if result == nil { + // Daemon still running — auth not yet complete. + return fmt.Errorf("authentication in progress. Visit the following URL to complete login, then retry:\n\n %s\n\nOr run %s to check status.", sess.AuthURL, style.Code("pc login -j")) + } + + // Daemon finished. + defer CleanupSession(sess.SessionId) + if result.Status == "error" { + return fmt.Errorf("authentication failed: %s. Run %s to try again.", result.Error, style.Code("pc login")) + } + + // Reload credentials written by the daemon process into this process's cache, + // then set the target org/project context so the calling command can proceed + // without a separate `pc login` or `pc target` call. + _ = secrets.SecretsViper.ReadInConfig() + if err := applyAuthContext(ctx); err != nil { + // Non-fatal: credentials are valid, context setup is best-effort. + log.Debug().Err(err).Msg("EnsureAuthenticated: applyAuthContext failed after lazy credential reload") + } + return nil +} + func randomCSRFState() string { b := make([]byte, 32) _, _ = rand.Read(b) From 1bda73bfb8399106a3a913b414bd9eddfc58345a Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Fri, 3 Apr 2026 01:33:37 -0400 Subject: [PATCH 2/7] prevent RunPostAuthSetup from returning stale project data, target --show/--clear are now not blocked by auth state --- internal/pkg/cli/command/root/root.go | 1 + internal/pkg/cli/command/target/target.go | 7 +++++++ internal/pkg/utils/login/login.go | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/internal/pkg/cli/command/root/root.go b/internal/pkg/cli/command/root/root.go index 957d54a..e79fb56 100644 --- a/internal/pkg/cli/command/root/root.go +++ b/internal/pkg/cli/command/root/root.go @@ -47,6 +47,7 @@ var skipAuthCommands = map[string]struct{}{ "pc auth clear": {}, "pc auth status": {}, "pc auth _daemon": {}, + "pc target": {}, // handles its own auth after --show/--clear early returns "pc version": {}, "pc config": {}, "pc config get-api-key": {}, diff --git a/internal/pkg/cli/command/target/target.go b/internal/pkg/cli/command/target/target.go index a48b49d..eb4180b 100644 --- a/internal/pkg/cli/command/target/target.go +++ b/internal/pkg/cli/command/target/target.go @@ -114,6 +114,13 @@ func NewTargetCmd() *cobra.Command { return } + // --show and --clear are local-state operations that return above. + // Everything below requires valid credentials, so check now. + if err := login.EnsureAuthenticated(ctx); err != nil { + msg.FailJSON(options.json, "%s", err) + exit.Error(err, "authentication required") + } + // Get the current access token and parse the orgID from the claims token, err := oauth.Token(cmd.Context()) if err != nil { diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index ce0b93b..a958bd4 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -526,12 +526,16 @@ func applyAuthContext(ctx context.Context) error { Id: targetOrg.Id, }) + // Always write TargetProj so stale data from a previous session is never + // returned when the current org has no projects. if len(projects) > 0 { targetProj := projects[0] state.TargetProj.Set(state.TargetProject{ Name: targetProj.Name, Id: targetProj.Id, }) + } else { + state.TargetProj.Set(state.TargetProject{}) } return nil From 01680c43848ab1b9917a9c17191028c7fa32151c Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Fri, 3 Apr 2026 03:42:41 -0400 Subject: [PATCH 3/7] get rid of redundant token fetch in RunPostAuthSetup --- internal/pkg/utils/login/login.go | 38 +++++++++++++------------------ 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index a958bd4..d89156f 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -487,27 +487,29 @@ func getAndSetAccessTokenInteractive(ctx context.Context, orgId *string) error { } // applyAuthContext fetches the user's org/project and writes them to state. -// It is the side-effect half of the post-auth setup, separated so that callers -// that don't want to emit JSON (e.g. EnsureAuthenticated) can still set context. -func applyAuthContext(ctx context.Context) error { +// It returns the authenticated user's email so callers can use it without a +// second token fetch. It is the side-effect half of the post-auth setup, +// separated so that callers that don't want to emit JSON (e.g. +// EnsureAuthenticated) can still set context. +func applyAuthContext(ctx context.Context) (email string, err error) { token, err := oauth.Token(ctx) if err != nil { - return fmt.Errorf("error retrieving oauth token: %w", err) + return "", fmt.Errorf("error retrieving oauth token: %w", err) } claims, err := oauth.ParseClaimsUnverified(token) if err != nil { - return fmt.Errorf("error parsing token claims: %w", err) + return "", fmt.Errorf("error parsing token claims: %w", err) } ac := sdk.NewPineconeAdminClient(ctx) orgs, err := ac.Organization.List(ctx) if err != nil { - return fmt.Errorf("error fetching organizations: %w", err) + return "", fmt.Errorf("error fetching organizations: %w", err) } projects, err := ac.Project.List(ctx) if err != nil { - return fmt.Errorf("error fetching projects: %w", err) + return "", fmt.Errorf("error fetching projects: %w", err) } var targetOrg *pinecone.Organization @@ -518,7 +520,7 @@ func applyAuthContext(ctx context.Context) error { } } if targetOrg == nil { - return fmt.Errorf("target organization %s not found", claims.OrgId) + return "", fmt.Errorf("target organization %s not found", claims.OrgId) } state.TargetOrg.Set(state.TargetOrganization{ @@ -538,23 +540,15 @@ func applyAuthContext(ctx context.Context) error { state.TargetProj.Set(state.TargetProject{}) } - return nil + return claims.Email, nil } // RunPostAuthSetup fetches the user's org/project context, sets target defaults, // and emits the final {"status":"authenticated",...} JSON. func RunPostAuthSetup(ctx context.Context) error { - if err := applyAuthContext(ctx); err != nil { - return err - } - - token, err := oauth.Token(ctx) + email, err := applyAuthContext(ctx) if err != nil { - return fmt.Errorf("error retrieving oauth token: %w", err) - } - claims, err := oauth.ParseClaimsUnverified(token) - if err != nil { - return fmt.Errorf("error parsing token claims: %w", err) + return err } targetOrg := state.TargetOrg.Get() @@ -567,7 +561,7 @@ func RunPostAuthSetup(ctx context.Context) error { OrgName string `json:"org_name"` ProjectId string `json:"project_id"` ProjectName string `json:"project_name"` - }{Status: "authenticated", Email: claims.Email, OrgId: targetOrg.Id, OrgName: targetOrg.Name, ProjectId: targetProj.Id, ProjectName: targetProj.Name})) + }{Status: "authenticated", Email: email, OrgId: targetOrg.Id, OrgName: targetOrg.Name, ProjectId: targetProj.Id, ProjectName: targetProj.Name})) return nil } @@ -691,7 +685,7 @@ func EnsureAuthenticated(ctx context.Context) error { // login before a second `pc login` call was made), initialize the context now // so the calling command doesn't fail with "need to target a project". if state.TargetOrg.Get().Id == "" { - if err := applyAuthContext(ctx); err != nil { + if _, err := applyAuthContext(ctx); err != nil { log.Debug().Err(err).Msg("EnsureAuthenticated: applyAuthContext failed") } } @@ -727,7 +721,7 @@ func EnsureAuthenticated(ctx context.Context) error { // then set the target org/project context so the calling command can proceed // without a separate `pc login` or `pc target` call. _ = secrets.SecretsViper.ReadInConfig() - if err := applyAuthContext(ctx); err != nil { + if _, err := applyAuthContext(ctx); err != nil { // Non-fatal: credentials are valid, context setup is best-effort. log.Debug().Err(err).Msg("EnsureAuthenticated: applyAuthContext failed after lazy credential reload") } From 1a6eb4201d6c87a2e3ccb4f497695a7b9ac5e883 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Fri, 3 Apr 2026 04:29:26 -0400 Subject: [PATCH 4/7] get rid of extra URL JSON object --- internal/pkg/utils/login/login.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index d89156f..4237428 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -263,14 +263,8 @@ func getAndSetAccessTokenJSON(ctx context.Context, orgId *string, wait bool, ses if wait { // Caller needs the token on return — block until the daemon completes. - // Emit the auth URL to stdout as JSON so agents watching stdout can extract - // it. Also write to stderr for human users. The caller is responsible for - // emitting its own JSON result once this function returns. - fmt.Fprintln(os.Stdout, text.IndentJSON(struct { - Status string `json:"status"` - URL string `json:"url"` - SessionId string `json:"session_id"` - }{Status: "authenticating", URL: authURL, SessionId: sessionId})) + // Print the auth URL to stderr only: stdout must stay clean so the caller + // can emit a single JSON document once this function returns. fmt.Fprintf(os.Stderr, "Visit the following URL to authenticate:\n\n %s\n\n", authURL) return pollForResult(sessionId, newSess.CreatedAt, true) } From caa84a71e303c356d20d14726d170ca4ef5d9952 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Fri, 3 Apr 2026 15:13:19 -0400 Subject: [PATCH 5/7] fix unnecessary API calls on early JSON return in target command, make sure auth local-keys is not blocked by auth logic, remove duplicated output --- internal/pkg/cli/command/root/root.go | 2 ++ internal/pkg/cli/command/target/target.go | 43 +++++++++++------------ 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/internal/pkg/cli/command/root/root.go b/internal/pkg/cli/command/root/root.go index e79fb56..e057ac2 100644 --- a/internal/pkg/cli/command/root/root.go +++ b/internal/pkg/cli/command/root/root.go @@ -47,6 +47,8 @@ var skipAuthCommands = map[string]struct{}{ "pc auth clear": {}, "pc auth status": {}, "pc auth _daemon": {}, + "pc auth local-keys": {}, // parent command (shows help) + "pc auth local-keys list": {}, // reads local state only, no API calls "pc target": {}, // handles its own auth after --show/--clear early returns "pc version": {}, "pc config": {}, diff --git a/internal/pkg/cli/command/target/target.go b/internal/pkg/cli/command/target/target.go index eb4180b..51dac6a 100644 --- a/internal/pkg/cli/command/target/target.go +++ b/internal/pkg/cli/command/target/target.go @@ -100,11 +100,7 @@ func NewTargetCmd() *cobra.Command { if options.show { if options.json { log.Info().Msg("Outputting target context as JSON") - targetContext := state.GetTargetContext() - defaultAPIKey := secrets.DefaultAPIKey.Get() - targetContext.DefaultAPIKey = presenters.MaskHeadTail(defaultAPIKey, 4, 4) - json := text.IndentJSON(targetContext) - fmt.Fprintln(os.Stdout, json) + printTargetContextJSON() return } log.Info(). @@ -114,8 +110,19 @@ func NewTargetCmd() *cobra.Command { return } - // --show and --clear are local-state operations that return above. - // Everything below requires valid credentials, so check now. + // In JSON mode with no targeting flags, show current context — same as + // --show --json. This is a local-state read with no API calls needed. + if options.json && + options.org == "" && + options.orgID == "" && + options.project == "" && + options.projectID == "" { + printTargetContextJSON() + return + } + + // --show, --clear, and the no-flags JSON path are local-state operations + // that return above. Everything below requires valid credentials. if err := login.EnsureAuthenticated(ctx); err != nil { msg.FailJSON(options.json, "%s", err) exit.Error(err, "authentication required") @@ -156,16 +163,6 @@ func NewTargetCmd() *cobra.Command { options.project == "" && options.projectID == "" { - if options.json { - // In non-TTY/JSON mode there's no interactive selector — just - // show the current target context so agents can read it. - targetContext := state.GetTargetContext() - defaultAPIKey := secrets.DefaultAPIKey.Get() - targetContext.DefaultAPIKey = presenters.MaskHeadTail(defaultAPIKey, 4, 4) - fmt.Fprintln(os.Stdout, text.IndentJSON(targetContext)) - return - } - // Ask the user to choose a target org targetOrg := postLoginInteractiveTargetOrg(orgs, options.json) if targetOrg == nil { @@ -276,11 +273,7 @@ func NewTargetCmd() *cobra.Command { // Output JSON if the option was passed if options.json { - targetContext := state.GetTargetContext() - defaultAPIKey := secrets.DefaultAPIKey.Get() - targetContext.DefaultAPIKey = presenters.MaskHeadTail(defaultAPIKey, 4, 4) - json := text.IndentJSON(targetContext) - fmt.Fprintln(os.Stdout, json) + printTargetContextJSON() return } @@ -302,6 +295,12 @@ func NewTargetCmd() *cobra.Command { return cmd } +func printTargetContextJSON() { + targetContext := state.GetTargetContext() + targetContext.DefaultAPIKey = presenters.MaskHeadTail(secrets.DefaultAPIKey.Get(), 4, 4) + fmt.Fprintln(os.Stdout, text.IndentJSON(targetContext)) +} + func validateTargetOptions(options targetCmdOptions) error { // Check organization targeting if options.org != "" && options.orgID != "" { From 9f7bb12f266fab4887ad419042cfb59b785f3307 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Fri, 3 Apr 2026 15:33:04 -0400 Subject: [PATCH 6/7] add returns after exit in auth check --- internal/pkg/cli/command/root/root.go | 1 + internal/pkg/cli/command/target/target.go | 1 + 2 files changed, 2 insertions(+) diff --git a/internal/pkg/cli/command/root/root.go b/internal/pkg/cli/command/root/root.go index e057ac2..674eb19 100644 --- a/internal/pkg/cli/command/root/root.go +++ b/internal/pkg/cli/command/root/root.go @@ -133,6 +133,7 @@ func init() { if err := loginutil.EnsureAuthenticated(cmd.Context()); err != nil { msg.FailJSON(isJSON, "%s", err) exit.Error(err, "authentication required") + return } }, PersistentPostRun: func(cmd *cobra.Command, args []string) { diff --git a/internal/pkg/cli/command/target/target.go b/internal/pkg/cli/command/target/target.go index 51dac6a..38624a1 100644 --- a/internal/pkg/cli/command/target/target.go +++ b/internal/pkg/cli/command/target/target.go @@ -126,6 +126,7 @@ func NewTargetCmd() *cobra.Command { if err := login.EnsureAuthenticated(ctx); err != nil { msg.FailJSON(options.json, "%s", err) exit.Error(err, "authentication required") + return } // Get the current access token and parse the orgID from the claims From ddb7ecd2c76455f773e572758469ba4db5c76224 Mon Sep 17 00:00:00 2001 From: Austin DeNoble Date: Fri, 3 Apr 2026 21:41:10 -0400 Subject: [PATCH 7/7] update command documentation for login and target operations to clarify usage --- internal/pkg/cli/command/auth/login.go | 38 ++++++++++++++++++++--- internal/pkg/cli/command/login/login.go | 34 ++++++++++++++++++-- internal/pkg/cli/command/target/target.go | 28 +++++++++++++++-- 3 files changed, 90 insertions(+), 10 deletions(-) diff --git a/internal/pkg/cli/command/auth/login.go b/internal/pkg/cli/command/auth/login.go index 6d0d4ed..1b264f3 100644 --- a/internal/pkg/cli/command/auth/login.go +++ b/internal/pkg/cli/command/auth/login.go @@ -17,12 +17,33 @@ var ( organizations, projects, and other account-level resources directly from the command line), as well as control and data plane operations. - Running this command opens a browser to the Pinecone login page. - After you successfully authenticate, the CLI is automatically configured with a + INTERACTIVE MODE (default) + + Opens a browser to the Pinecone login page and waits for you to complete + authentication. Once complete, the CLI is automatically configured with a default target organization and project. - - You can view your current target with 'pc target -s' or change it at any - time with 'pc target -o "ORGANIZATION_NAME" -p "PROECT_NAME"'. + + You can view your current target with 'pc target --show' or change it at + any time with 'pc target --org "ORG_NAME" --project "PROJECT_NAME"'. + + AGENTIC / NON-INTERACTIVE MODE (--json / -j, or non-TTY stdout) + + Uses a daemon-backed two-call flow designed for AI agents and scripts: + + First call — starts a background listener and returns immediately: + {"status":"pending","url":"","session_id":""} + + Open the URL in a browser to complete authentication. The background + listener captures the OAuth callback automatically. + + Second call (or any other command) — completes the flow: + {"status":"authenticated","email":"...","org_id":"...","org_name":"...","project_id":"...","project_name":"..."} + + If the process is interrupted between calls, the background listener keeps + running. The next invocation detects the pending session and resumes + automatically. After authentication is complete, the first subsequent + command also sets the target context automatically, so a separate + 'pc target' call is not required. `) ) @@ -34,7 +55,14 @@ func NewLoginCmd() *cobra.Command { Short: "Authenticate with Pinecone via user login in a web browser", Long: loginHelp, Example: help.Examples(` + # Interactive login (opens a browser) pc auth login + + # Agentic login — first call returns a pending URL + pc auth login --json + + # Agentic login — second call (or any command) completes the flow + pc auth login --json `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { diff --git a/internal/pkg/cli/command/login/login.go b/internal/pkg/cli/command/login/login.go index fc815f8..40a6802 100644 --- a/internal/pkg/cli/command/login/login.go +++ b/internal/pkg/cli/command/login/login.go @@ -12,9 +12,30 @@ var ( loginHelp = help.Long(` Authenticate with Pinecone via user login in a web browser. - After logging in, the CLI automatically sets a target organization and project. However, you can - set a new target organization or project using 'pc target' before accessing control - and data plane resources. + INTERACTIVE MODE (default) + + Opens a browser to the Pinecone login page and waits for you to complete + authentication. The CLI automatically sets a default target organization + and project. Use 'pc target' to change the target at any time. + + AGENTIC / NON-INTERACTIVE MODE (--json / -j, or non-TTY stdout) + + Uses a daemon-backed two-call flow designed for AI agents and scripts: + + First call — starts a background listener and returns immediately: + {"status":"pending","url":"","session_id":""} + + Open the URL in a browser to complete authentication. The background + listener captures the OAuth callback automatically. + + Second call (or any other command) — completes the flow: + {"status":"authenticated","email":"...","org_id":"...","org_name":"...","project_id":"...","project_name":"..."} + + If the process is interrupted between calls, the background listener keeps + running. The next invocation detects the pending session and resumes + automatically. After authentication is complete, the first subsequent + command also sets the target context automatically, so a separate + 'pc target' call is not required. `) ) @@ -26,7 +47,14 @@ func NewLoginCmd() *cobra.Command { Short: "Authenticate with Pinecone via user login in a web browser", Long: loginHelp, Example: help.Examples(` + # Interactive login (opens a browser) pc login + + # Agentic login — first call returns a pending URL + pc login --json + + # Agentic login — second call (or any command) completes the flow + pc login --json `), GroupID: help.GROUP_AUTH.ID, Run: func(cmd *cobra.Command, args []string) { diff --git a/internal/pkg/cli/command/target/target.go b/internal/pkg/cli/command/target/target.go index 38624a1..6f91402 100644 --- a/internal/pkg/cli/command/target/target.go +++ b/internal/pkg/cli/command/target/target.go @@ -43,16 +43,40 @@ var ( After authenticating through the CLI with user login or service account credentials, you can use this command to set the target organization or project context for control and data plane operations. - When using a default API key for authentication, there's no need to specify a project context, because the API + When using a default API key for authentication, there's no need to specify a project context, because the API key is already associated with a specific organization and project. + + INTERACTIVE MODE (default, TTY only) + + Running without flags launches an interactive selector to choose an + organization and project from your account. + + NON-INTERACTIVE / AGENTIC MODE + + Pass --org, --project, --organization-id, or --project-id to set the + target programmatically without a TTY. Use --json / -j to receive the + updated context as a JSON object. + + Running with --json and no targeting flags shows the current target + context as JSON (equivalent to --show --json). This works without + authentication and is safe to call at any time to inspect state. + + --show and --clear are local-state operations and do not require + authentication. `) targetExample = help.Examples(` # Interactively target from available organizations and projects pc target + # Show the current target context + pc target --show + + # Show the current target context as JSON (no auth required) + pc target --json + # Target an organization and project by name - pc target --org "organization-name" -project "project-name" + pc target --org "organization-name" --project "project-name" # Target a project by name pc target --project "project-name"