diff --git a/internal/pkg/cli/command/auth/login.go b/internal/pkg/cli/command/auth/login.go index c305821..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) { @@ -42,7 +70,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..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) { @@ -34,7 +62,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..674eb19 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,29 @@ 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 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": {}, + "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 +117,24 @@ 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") + return + } }, 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..6f91402 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" @@ -42,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" @@ -72,6 +97,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). @@ -97,11 +124,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(). @@ -111,6 +134,25 @@ func NewTargetCmd() *cobra.Command { return } + // 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") + return + } + // Get the current access token and parse the orgID from the claims token, err := oauth.Token(cmd.Context()) if err != nil { @@ -256,11 +298,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 } @@ -282,6 +320,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 != "" { diff --git a/internal/pkg/utils/login/login.go b/internal/pkg/utils/login/login.go index 2d4d09a..4237428 100644 --- a/internal/pkg/utils/login/login.go +++ b/internal/pkg/utils/login/login.go @@ -263,8 +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. - // 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. + // 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) } @@ -480,27 +480,30 @@ 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 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 @@ -511,7 +514,7 @@ func RunPostAuthSetup(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{ @@ -519,18 +522,32 @@ func RunPostAuthSetup(ctx context.Context) error { Id: targetOrg.Id, }) - projectId := "" - projectName := "" + // 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, }) - projectId = targetProj.Id - projectName = targetProj.Name + } else { + state.TargetProj.Set(state.TargetProject{}) } + 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 { + email, err := applyAuthContext(ctx) + if err != nil { + return 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 +555,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: email, OrgId: targetOrg.Id, OrgName: targetOrg.Name, ProjectId: targetProj.Id, ProjectName: targetProj.Name})) return nil } @@ -630,6 +647,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)