From 24f5f226b20478a3914d9e547e62b73a51f96212 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Thu, 2 Jul 2026 22:32:32 -0500 Subject: [PATCH 01/19] Add shared cmdutil helpers for agent workflows and role resolution. Extract ResolveAccessRole and PostgresInheritedRoles for shell and sql, add agent next_steps command builders, JSONReportedError, and TryOpenBrowser. Co-authored-by: Cursor --- internal/cmd/shell/shell.go | 29 +++------------ internal/cmdutil/agent_steps.go | 53 ++++++++++++++++++++++++++++ internal/cmdutil/agent_steps_test.go | 28 +++++++++++++++ internal/cmdutil/browser.go | 15 ++++---- internal/cmdutil/cmdutil.go | 29 +++++++++++++++ internal/cmdutil/errors.go | 7 ++++ internal/cmdutil/errors_test.go | 13 +++++++ 7 files changed, 141 insertions(+), 33 deletions(-) create mode 100644 internal/cmdutil/agent_steps.go create mode 100644 internal/cmdutil/agent_steps_test.go create mode 100644 internal/cmdutil/errors_test.go diff --git a/internal/cmd/shell/shell.go b/internal/cmd/shell/shell.go index 07eaff1b1..8f54979fc 100644 --- a/internal/cmd/shell/shell.go +++ b/internal/cmd/shell/shell.go @@ -122,14 +122,9 @@ second argument: } } - role := cmdutil.AdministratorRole - if flags.role != "" { - role, err = cmdutil.RoleFromString(flags.role) - if err != nil { - return err - } - } else if flags.replica { - role = cmdutil.ReaderRole + role, err := cmdutil.ResolveAccessRole(flags.role, flags.replica, cmdutil.AdministratorRole) + if err != nil { + return err } // check whether database and branch exist @@ -316,23 +311,7 @@ func startShellForPostgres(ctx context.Context, ch *cmdutil.Helper, client *ps.C } // Map role flags to Postgres role inheritance - var inheritedRoles []string - var successor string - - switch role { - case cmdutil.ReaderRole: - inheritedRoles = []string{"pg_read_all_data"} - case cmdutil.WriterRole: - inheritedRoles = []string{"pg_write_all_data"} - case cmdutil.ReadWriterRole: - inheritedRoles = []string{"pg_read_all_data", "pg_write_all_data"} - case cmdutil.AdministratorRole: - inheritedRoles = []string{"postgres"} - successor = "postgres" - default: - // Default to empty array for unknown roles - inheritedRoles = []string{} - } + inheritedRoles, successor := cmdutil.PostgresInheritedRoles(role) // Create a temporary role for Postgres pgRole, err := roleutil.New(ctx, client, roleutil.Options{ diff --git a/internal/cmdutil/agent_steps.go b/internal/cmdutil/agent_steps.go new file mode 100644 index 000000000..81f5a9468 --- /dev/null +++ b/internal/cmdutil/agent_steps.go @@ -0,0 +1,53 @@ +package cmdutil + +import "fmt" + +// Agent command strings for next_steps — flags go after the subcommand (not on pscale root). + +func AgentAuthCheckCmd() string { + return "pscale auth check --format json" +} + +func AgentAuthLoginCmd() string { + return "pscale auth login --format json" +} + +func AgentOrgListCmd() string { + return "pscale org list --format json" +} + +func AgentDatabaseListCmd(org string) string { + if org == "" { + return "pscale database list --org --format json" + } + return fmt.Sprintf("pscale database list --org %s --format json", org) +} + +func AgentBranchListCmd(org, database string) string { + if database == "" { + database = "" + } + if org == "" { + return fmt.Sprintf("pscale branch list %s --org --format json", database) + } + return fmt.Sprintf("pscale branch list %s --org %s --format json", database, org) +} + +func AgentSQLCmd(org, database, branch string, force bool) string { + if database == "" { + database = "" + } + if branch == "" { + branch = "" + } + query := "SELECT 1" + forceFlag := "" + if force { + forceFlag = " --force" + query = "" + } + if org == "" { + return fmt.Sprintf("pscale sql %s %s --org --format json%s --query \"%s\"", database, branch, forceFlag, query) + } + return fmt.Sprintf("pscale sql %s %s --org %s --format json%s --query \"%s\"", database, branch, org, forceFlag, query) +} diff --git a/internal/cmdutil/agent_steps_test.go b/internal/cmdutil/agent_steps_test.go new file mode 100644 index 000000000..5a9a9b984 --- /dev/null +++ b/internal/cmdutil/agent_steps_test.go @@ -0,0 +1,28 @@ +package cmdutil + +import "testing" + +func TestAgentStepsFlagOrder(t *testing.T) { + tests := []struct { + name string + got string + }{ + {name: "database list", got: AgentDatabaseListCmd("bb")}, + {name: "branch list", got: AgentBranchListCmd("bb", "mydb")}, + {name: "sql", got: AgentSQLCmd("bb", "mydb", "main", false)}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if containsBeforeSubcommand(tt.got, "--org") { + t.Fatalf("bad flag order: %q", tt.got) + } + }) + } +} + +func containsBeforeSubcommand(cmd, flag string) bool { + // Reject "pscale --org" (root-level org); want "pscale ... --org". + const bad = "pscale --org" + return len(cmd) >= len(bad) && cmd[:len(bad)] == bad +} diff --git a/internal/cmdutil/browser.go b/internal/cmdutil/browser.go index 2b1a16c6d..ed55fb629 100644 --- a/internal/cmdutil/browser.go +++ b/internal/cmdutil/browser.go @@ -4,19 +4,12 @@ import ( "os" "strings" - "github.com/planetscale/cli/internal/printer" - exec "golang.org/x/sys/execabs" ) const ApplicationURL = "https://app.planetscale.com" -// OpenBrowser opens a web browser at the specified url. -func OpenBrowser(goos, url string) *exec.Cmd { - if !printer.IsTTY { - panic("OpenBrowser called without a TTY") - } - +func browserCommand(goos, url string) *exec.Cmd { exe := "open" var args []string switch goos { @@ -36,6 +29,12 @@ func OpenBrowser(goos, url string) *exec.Cmd { return cmd } +// TryOpenBrowser opens the default web browser at url. It does not require a TTY. +// Callers should print the URL when this returns an error (headless CI, SSH, etc.). +func TryOpenBrowser(goos, url string) error { + return browserCommand(goos, url).Run() +} + func linuxExe() string { exe := "xdg-open" diff --git a/internal/cmdutil/cmdutil.go b/internal/cmdutil/cmdutil.go index 98bbe6531..d5f4733cc 100644 --- a/internal/cmdutil/cmdutil.go +++ b/internal/cmdutil/cmdutil.go @@ -69,6 +69,35 @@ func RoleFromString(r string) (PasswordRole, error) { return 0, fmt.Errorf("invalid role [%v] requested", r) } +// ResolveAccessRole picks the credential access level from --role and --replica, +// using defaultRole when neither is set (shell uses AdministratorRole; sql uses ReaderRole). +func ResolveAccessRole(roleFlag string, replica bool, defaultRole PasswordRole) (PasswordRole, error) { + if roleFlag != "" { + return RoleFromString(roleFlag) + } + if replica { + return ReaderRole, nil + } + return defaultRole, nil +} + +// PostgresInheritedRoles maps --role to Postgres inherited roles (used by shell and sql). +// MySQL/Vitess uses branch passwords via passwordutil instead — same --role flag, different API. +func PostgresInheritedRoles(role PasswordRole) (inherited []string, successor string) { + switch role { + case ReaderRole: + return []string{"pg_read_all_data"}, "" + case WriterRole: + return []string{"pg_write_all_data"}, "" + case ReadWriterRole: + return []string{"pg_read_all_data", "pg_write_all_data"}, "" + case AdministratorRole: + return []string{"postgres"}, "postgres" + default: + return nil, "" + } +} + // Helper is passed to every single command and is used by individual // subcommands. type Helper struct { diff --git a/internal/cmdutil/errors.go b/internal/cmdutil/errors.go index 2dd48bafd..af6ae70d3 100644 --- a/internal/cmdutil/errors.go +++ b/internal/cmdutil/errors.go @@ -20,10 +20,17 @@ type Error struct { Msg string // Status ExitCode int + // Handled suppresses Execute's stderr message when JSON was already printed to stdout. + Handled bool } func (e *Error) Error() string { return e.Msg } +// JSONReportedError returns an exit error after structured JSON was written to stdout. +func JSONReportedError(exitCode int) *Error { + return &Error{ExitCode: exitCode, Handled: true} +} + // ErrCode returns the code from a *planetscale.Error, if available. If the // error is not of type *planetscale.Error or is nil, it returns an empty, // undefined error code. diff --git a/internal/cmdutil/errors_test.go b/internal/cmdutil/errors_test.go new file mode 100644 index 000000000..b9176cbf9 --- /dev/null +++ b/internal/cmdutil/errors_test.go @@ -0,0 +1,13 @@ +package cmdutil + +import "testing" + +func TestJSONReportedErrorHandled(t *testing.T) { + err := JSONReportedError(ActionRequestedExitCode) + if !err.Handled { + t.Fatal("expected Handled") + } + if err.Error() != "" { + t.Fatalf("expected empty error message, got %q", err.Error()) + } +} From 7222549cc741d488eea36d206f0f0f1c31419b99 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Thu, 2 Jul 2026 22:32:41 -0500 Subject: [PATCH 02/19] Add JSON auth onboarding for automated agents. auth check and login emit structured status, issues, and next_steps for non-interactive device flow onboarding. Co-authored-by: Cursor --- internal/cmd/auth/check.go | 61 +++++------ internal/cmd/auth/login.go | 126 +++++++++++++++-------- internal/cmd/auth/onboarding.go | 147 +++++++++++++++++++++++++++ internal/cmd/auth/onboarding_test.go | 42 ++++++++ 4 files changed, 303 insertions(+), 73 deletions(-) create mode 100644 internal/cmd/auth/onboarding.go create mode 100644 internal/cmd/auth/onboarding_test.go diff --git a/internal/cmd/auth/check.go b/internal/cmd/auth/check.go index 440d7a987..89a660005 100644 --- a/internal/cmd/auth/check.go +++ b/internal/cmd/auth/check.go @@ -1,8 +1,9 @@ package auth import ( - "github.com/planetscale/cli/internal/auth" + psauth "github.com/planetscale/cli/internal/auth" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" "github.com/spf13/cobra" ) @@ -17,43 +18,43 @@ func CheckCmd(ch *cmdutil.Helper) *cobra.Command { Args: cobra.NoArgs, Short: "Check if you are authenticated", RunE: func(cmd *cobra.Command, args []string) error { - var errorMessage string - - if ch.Config.ServiceTokenIsSet() { - errorMessage = "You are not authenticated. Please ensure your service token is valid and properly configured." - } else if ch.Config.AccessToken != "" { - errorMessage = "You are not authenticated. Please run `pscale auth login` to authenticate." - } else { - errorMessage = "You are not authenticated. Please run `pscale auth login` to authenticate or set a service token." + ctx := cmd.Context() + resp := buildAuthCheckResponse(ctx, ch) + + if ch.Printer.Format() == printer.JSON { + if err := ch.Printer.PrintJSON(resp); err != nil { + return err + } + if !resp.Authenticated { + return cmdutil.JSONReportedError(cmdutil.ActionRequestedExitCode) + } + return nil } - if err := ch.Config.IsAuthenticated(); err != nil { + + if !resp.Authenticated { + msg := "You are not authenticated." + if len(resp.Issues) > 0 { + msg = resp.Issues[0].Message + } return &cmdutil.Error{ - Msg: errorMessage, + Msg: msg, ExitCode: cmdutil.ActionRequestedExitCode, } - } else { - ctx := cmd.Context() - client, err := ch.Client() - if err != nil { - return err - } + } - _, err = client.Organizations.List(ctx) - if err != nil { - return &cmdutil.Error{ - Msg: errorMessage, - ExitCode: cmdutil.ActionRequestedExitCode, - } - } else { - ch.Printer.Println("You are authenticated.") - return nil - } + ch.Printer.Println("You are authenticated.") + if resp.Organization != "" { + ch.Printer.Printf("Organization: %s\n", resp.Organization) + } + for _, issue := range resp.Issues { + ch.Printer.Printf("Note: %s — %s\n", issue.Message, issue.Remediation) } + return nil }, } - cmd.Flags().StringVar(&clientID, "client-id", auth.OAuthClientID, "The client ID for the PlanetScale CLI application.") - cmd.Flags().StringVar(&clientSecret, "client-secret", auth.OAuthClientSecret, "The client ID for the PlanetScale CLI application") - cmd.Flags().StringVar(&apiURL, "api-url", auth.DefaultBaseURL, "The PlanetScale base API URL.") + cmd.Flags().StringVar(&clientID, "client-id", psauth.OAuthClientID, "The client ID for the PlanetScale CLI application.") + cmd.Flags().StringVar(&clientSecret, "client-secret", psauth.OAuthClientSecret, "The client ID for the PlanetScale CLI application") + cmd.Flags().StringVar(&apiURL, "api-url", psauth.DefaultBaseURL, "The PlanetScale base API URL.") return cmd } diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 1b5dd92ab..73fbef8c4 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -5,9 +5,10 @@ import ( "errors" "fmt" "runtime" + "slices" "github.com/hashicorp/go-cleanhttp" - "github.com/planetscale/cli/internal/auth" + psauth "github.com/planetscale/cli/internal/auth" "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" "github.com/planetscale/cli/internal/printer" @@ -28,11 +29,12 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { Args: cobra.ExactArgs(0), Short: "Authenticate with the PlanetScale API", RunE: func(cmd *cobra.Command, args []string) error { - if !printer.IsTTY { - return errors.New("the 'login' command requires an interactive shell") + jsonMode := ch.Printer.Format() == printer.JSON + if !jsonMode && !printer.IsTTY { + return errors.New("the 'login' command requires an interactive shell (use --format json; browser opens when possible, then polls until approved)") } - authenticator, err := auth.New(cleanhttp.DefaultClient(), clientID, clientSecret, auth.SetBaseURL(authURL)) + authenticator, err := psauth.New(cleanhttp.DefaultClient(), clientID, clientSecret, psauth.SetBaseURL(authURL)) if err != nil { return err } @@ -44,21 +46,45 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { return err } - openCmd := cmdutil.OpenBrowser(runtime.GOOS, deviceVerification.VerificationCompleteURL) - err = openCmd.Run() - if err != nil { - ch.Printer.Printf("Failed to open a browser: %s\n", printer.BoldRed(err.Error())) - } + browserOpened := cmdutil.TryOpenBrowser(runtime.GOOS, deviceVerification.VerificationCompleteURL) == nil + + if jsonMode { + message := loginPendingMessage(browserOpened) + pending := LoginPendingResponse{ + Status: "pending", + VerificationURL: deviceVerification.VerificationCompleteURL, + UserCode: deviceVerification.UserCode, + BrowserOpened: browserOpened, + Message: message, + NextSteps: []string{ + "Approve access in the browser", + cmdutil.AgentAuthCheckCmd(), + }, + } + if err := ch.Printer.PrintJSON(pending); err != nil { + return err + } + } else { + if !browserOpened { + ch.Printer.Printf("Failed to open a browser; open this URL manually: %s\n", printer.Bold(deviceVerification.VerificationCompleteURL)) + } - bold := color.New(color.Bold) - bold.Printf("\nConfirmation Code: ") - boldGreen := bold.Add(color.FgGreen) - boldGreen.Fprintln(color.Output, deviceVerification.UserCode) + bold := color.New(color.Bold) + bold.Printf("\nConfirmation Code: ") + boldGreen := bold.Add(color.FgGreen) + boldGreen.Fprintln(color.Output, deviceVerification.UserCode) + + ch.Printer.Printf("\nIf something goes wrong, copy and paste this URL into your browser: %s\n\n", printer.Bold(deviceVerification.VerificationCompleteURL)) + } - ch.Printer.Printf("\nIf something goes wrong, copy and paste this URL into your browser: %s\n\n", printer.Bold(deviceVerification.VerificationCompleteURL)) + var end func() + if jsonMode { + fmt.Fprintln(cmd.ErrOrStderr(), "Waiting for browser authorization...") + } else { + end = ch.Printer.PrintProgress("Waiting for confirmation...") + defer end() + } - end := ch.Printer.PrintProgress("Waiting for confirmation...") - defer end() accessToken, err := authenticator.GetAccessTokenForDevice(ctx, *deviceVerification) if err != nil { return err @@ -73,40 +99,59 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { return fmt.Errorf("error logging in: %w\n\nPlease ensure you have write permissions to the configuration directory: %s", err, configDir) } - // We explicitly stop here so we can replace the spinner with our success - // message. - end() - ch.Printer.Println("Successfully logged in.") - - writeConfig := false - cfg, err := ch.ConfigFS.DefaultConfig() - if err != nil { - writeConfig = true + if end != nil { + end() } - if !writeConfig && cfg.Organization != "" { - hasOrg, _ := hasOrg(ctx, cfg.Organization, accessToken, authURL) - writeConfig = !hasOrg + if err := writeDefaultOrganizationIfNeeded(ctx, ch, accessToken, authURL); err != nil { + return err } - if writeConfig || cfg.Organization == "" { - err = writeDefaultOrganization(ctx, accessToken, authURL) - if err != nil { - return err + if jsonMode { + ok := LoginOKResponse{ + Status: "ok", + Message: "Successfully logged in.", + NextSteps: []string{ + cmdutil.AgentAuthCheckCmd(), + cmdutil.AgentOrgListCmd(), + }, } + return ch.Printer.PrintJSON(ok) } + ch.Printer.Println("Successfully logged in.") return nil }, } - cmd.Flags().StringVar(&clientID, "client-id", auth.OAuthClientID, "The client ID for the PlanetScale CLI application.") - cmd.Flags().StringVar(&clientSecret, "client-secret", auth.OAuthClientSecret, "The client ID for the PlanetScale CLI application") - cmd.Flags().StringVar(&authURL, "api-url", auth.DefaultBaseURL, "The PlanetScale Auth API base URL.") + cmd.Flags().StringVar(&clientID, "client-id", psauth.OAuthClientID, "The client ID for the PlanetScale CLI application.") + cmd.Flags().StringVar(&clientSecret, "client-secret", psauth.OAuthClientSecret, "The client ID for the PlanetScale CLI application") + cmd.Flags().StringVar(&authURL, "api-url", psauth.DefaultBaseURL, "The PlanetScale Auth API base URL.") return cmd } +func writeDefaultOrganizationIfNeeded(ctx context.Context, ch *cmdutil.Helper, accessToken, authURL string) error { + writeConfig := false + cfg, err := ch.ConfigFS.DefaultConfig() + if err != nil { + writeConfig = true + } + + if !writeConfig && cfg.Organization != "" { + hasOrg, err := hasOrg(ctx, cfg.Organization, accessToken, authURL) + if err != nil { + return err + } + writeConfig = !hasOrg + } + + if writeConfig || cfg.Organization == "" { + return writeDefaultOrganization(ctx, accessToken, authURL) + } + return nil +} + func writeDefaultOrganization(ctx context.Context, accessToken, authURL string) error { orgs, err := listCurrentOrgs(ctx, accessToken, authURL) if err != nil { @@ -134,13 +179,9 @@ func hasOrg(ctx context.Context, org, accessToken, authURL string) (bool, error) return false, err } - for _, o := range currentOrgs { - if o.Name == org { - return true, nil - } - } - - return false, nil + return slices.ContainsFunc(currentOrgs, func(o *planetscale.Organization) bool { + return o.Name == org + }), nil } func listCurrentOrgs(ctx context.Context, accessToken, authURL string) ([]*planetscale.Organization, error) { @@ -158,5 +199,4 @@ func listCurrentOrgs(ctx context.Context, accessToken, authURL string) ([]*plane } return orgs, nil - } diff --git a/internal/cmd/auth/onboarding.go b/internal/cmd/auth/onboarding.go new file mode 100644 index 000000000..bec4f5526 --- /dev/null +++ b/internal/cmd/auth/onboarding.go @@ -0,0 +1,147 @@ +package auth + +import ( + "context" + + "github.com/planetscale/cli/internal/cmdutil" +) + +// AuthIssue describes a blocking onboarding problem for agent/human callers. +type AuthIssue struct { + Code string `json:"code"` + Message string `json:"message"` + Remediation string `json:"remediation,omitempty"` +} + +// AuthCheckResponse is the JSON payload for `pscale auth check --format json`. +type AuthCheckResponse struct { + Status string `json:"status"` + Authenticated bool `json:"authenticated"` + AuthMethod string `json:"auth_method,omitempty"` + Organization string `json:"organization,omitempty"` + APIURL string `json:"api_url,omitempty"` + Issues []AuthIssue `json:"issues,omitempty"` + NextSteps []string `json:"next_steps,omitempty"` +} + +func buildAuthCheckResponse(ctx context.Context, ch *cmdutil.Helper) AuthCheckResponse { + resp := AuthCheckResponse{ + APIURL: ch.Config.BaseURL, + } + + org := configuredOrganization(ch) + if org != "" { + resp.Organization = org + } + + switch { + case ch.Config.ServiceTokenIsSet(): + resp.AuthMethod = "service_token" + case ch.Config.AccessToken != "": + resp.AuthMethod = "oauth" + default: + resp.AuthMethod = "none" + } + + if err := ch.Config.IsAuthenticated(); err != nil { + resp.Status = "action_required" + resp.Authenticated = false + resp.Issues = append(resp.Issues, AuthIssue{ + Code: "NO_AUTH", + Message: err.Error(), + Remediation: "Run `pscale auth login --format json` (browser opens when possible, polls until approved)", + }) + resp.NextSteps = []string{ + cmdutil.AgentAuthLoginCmd(), + cmdutil.AgentAuthCheckCmd(), + } + return resp + } + + client, err := ch.Client() + if err != nil { + resp.Status = "action_required" + resp.Authenticated = false + resp.Issues = append(resp.Issues, AuthIssue{ + Code: "CLIENT_INIT_FAILED", + Message: err.Error(), + Remediation: "Verify API credentials and network connectivity", + }) + return resp + } + + if _, err := client.Organizations.List(ctx); err != nil { + resp.Status = "action_required" + resp.Authenticated = false + resp.Issues = append(resp.Issues, AuthIssue{ + Code: "AUTH_INVALID", + Message: "API authentication failed", + Remediation: "Run `pscale auth login --format json` (browser opens when possible)", + }) + resp.NextSteps = []string{cmdutil.AgentAuthLoginCmd()} + return resp + } + + resp.Status = "ok" + resp.Authenticated = true + + if org == "" { + resp.Issues = append(resp.Issues, AuthIssue{ + Code: "NO_ORG", + Message: "No organization configured", + Remediation: "Run `pscale org switch ` or set org in ~/.config/planetscale/pscale.yml", + }) + resp.NextSteps = append(resp.NextSteps, cmdutil.AgentOrgListCmd()) + } else { + resp.NextSteps = append(resp.NextSteps, + cmdutil.AgentDatabaseListCmd(org), + cmdutil.AgentBranchListCmd(org, ""), + ) + } + + if len(resp.Issues) > 0 { + resp.Status = "action_required" + } + + return resp +} + +func configuredOrganization(ch *cmdutil.Helper) string { + if ch.Config.Organization != "" { + return ch.Config.Organization + } + if ch.ConfigFS == nil { + return "" + } + if fc, err := ch.ConfigFS.DefaultConfig(); err == nil && fc.Organization != "" { + return fc.Organization + } + if fc, err := ch.ConfigFS.ProjectConfig(); err == nil && fc.Organization != "" { + return fc.Organization + } + return "" +} + +func loginPendingMessage(browserOpened bool) string { + if browserOpened { + return "Approve access in the browser to continue" + } + return "Open verification_url in a browser and approve access to continue" +} + +// LoginPendingResponse is emitted while waiting for browser authorization. +type LoginPendingResponse struct { + Status string `json:"status"` + VerificationURL string `json:"verification_url"` + UserCode string `json:"user_code"` + BrowserOpened bool `json:"browser_opened"` + Message string `json:"message"` + NextSteps []string `json:"next_steps,omitempty"` +} + +// LoginOKResponse is emitted after successful login. +type LoginOKResponse struct { + Status string `json:"status"` + Message string `json:"message"` + NextSteps []string `json:"next_steps,omitempty"` +} diff --git a/internal/cmd/auth/onboarding_test.go b/internal/cmd/auth/onboarding_test.go new file mode 100644 index 000000000..870ed9a94 --- /dev/null +++ b/internal/cmd/auth/onboarding_test.go @@ -0,0 +1,42 @@ +package auth + +import ( + "testing" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" +) + +func TestBuildAuthCheckResponseNoAuth(t *testing.T) { + ch := &cmdutil.Helper{ + Config: &config.Config{BaseURL: "https://api.example.com/v1"}, + } + resp := buildAuthCheckResponse(t.Context(), ch) + if resp.Authenticated { + t.Fatal("expected unauthenticated") + } + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } + if len(resp.Issues) == 0 || resp.Issues[0].Code != "NO_AUTH" { + t.Fatalf("issues = %#v", resp.Issues) + } +} + +func TestLoginPendingMessage(t *testing.T) { + if got := loginPendingMessage(true); got != "Approve access in the browser to continue" { + t.Fatalf("browser opened message = %q", got) + } + if got := loginPendingMessage(false); got != "Open verification_url in a browser and approve access to continue" { + t.Fatalf("browser closed message = %q", got) + } +} + +func TestConfiguredOrganization(t *testing.T) { + ch := &cmdutil.Helper{ + Config: &config.Config{Organization: "from-flag"}, + } + if got := configuredOrganization(ch); got != "from-flag" { + t.Fatalf("org = %q", got) + } +} From 3b5eeea9b86db8afb1aa37e81e2366ab4b4edf6a Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Thu, 2 Jul 2026 22:32:49 -0500 Subject: [PATCH 03/19] Add pscale sql for non-interactive agent queries. Run read/write SQL with JSON output, reader default role, and destructive query gating that requires explicit user approval via --force. Co-authored-by: Cursor --- internal/cmd/root.go | 14 +- internal/cmd/sql/json.go | 50 ++++ internal/cmd/sql/sql.go | 104 ++++++++ internal/cmd/sql/sql_test.go | 85 +++++++ internal/sqlquery/destructive.go | 44 ++++ internal/sqlquery/destructive_test.go | 58 +++++ internal/sqlquery/sqlquery.go | 336 ++++++++++++++++++++++++++ internal/sqlquery/sqlquery_test.go | 76 ++++++ 8 files changed, 766 insertions(+), 1 deletion(-) create mode 100644 internal/cmd/sql/json.go create mode 100644 internal/cmd/sql/sql.go create mode 100644 internal/cmd/sql/sql_test.go create mode 100644 internal/sqlquery/destructive.go create mode 100644 internal/sqlquery/destructive_test.go create mode 100644 internal/sqlquery/sqlquery.go create mode 100644 internal/sqlquery/sqlquery_test.go diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 38f03e090..2916f2431 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -48,6 +48,7 @@ import ( "github.com/planetscale/cli/internal/cmd/region" "github.com/planetscale/cli/internal/cmd/shell" "github.com/planetscale/cli/internal/cmd/signup" + "github.com/planetscale/cli/internal/cmd/sql" "github.com/planetscale/cli/internal/cmd/token" "github.com/planetscale/cli/internal/cmd/version" "github.com/planetscale/cli/internal/cmd/webhook" @@ -118,6 +119,14 @@ func Execute(ctx context.Context, sigc chan os.Signal, signals []os.Signal, ver, return 0 } + var cmdErr *cmdutil.Error + if errors.As(err, &cmdErr) && cmdErr.Handled { + if cmdErr.ExitCode != 0 { + return cmdErr.ExitCode + } + return cmdutil.ActionRequestedExitCode + } + // print any user specific messages first switch format { case printer.JSON: @@ -127,7 +136,6 @@ func Execute(ctx context.Context, sigc chan os.Signal, signals []os.Signal, ver, } // check if a sub command wants to return a specific exit code - var cmdErr *cmdutil.Error if errors.As(err, &cmdErr) { return cmdErr.ExitCode } @@ -312,6 +320,10 @@ func runCmd(ctx context.Context, ver, commit, buildDate string, format *printer. shellCmd.GroupID = "database" rootCmd.AddCommand(shellCmd) + sqlCmd := sql.SQLCmd(ch) + sqlCmd.GroupID = "database" + rootCmd.AddCommand(sqlCmd) + workflowCmd := workflow.WorkflowCmd(ch) workflowCmd.GroupID = "vitess" rootCmd.AddCommand(workflowCmd) diff --git a/internal/cmd/sql/json.go b/internal/cmd/sql/json.go new file mode 100644 index 000000000..87d215095 --- /dev/null +++ b/internal/cmd/sql/json.go @@ -0,0 +1,50 @@ +package sql + +import ( + "errors" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" + "github.com/planetscale/cli/internal/sqlquery" +) + +func reportJSON(ch *cmdutil.Helper, v any) error { + if err := ch.Printer.PrintJSON(v); err != nil { + return err + } + return cmdutil.JSONReportedError(cmdutil.ActionRequestedExitCode) +} + +func handleExecuteError(ch *cmdutil.Helper, err error, database, branch string) error { + if ch.Printer.Format() != printer.JSON { + return err + } + + if _, ok := errors.AsType[*sqlquery.DestructiveQueryError](err); ok { + return reportJSON(ch, map[string]any{ + "status": "action_required", + "query_kind": "destructive", + "message": err.Error(), + "issues": []map[string]string{ + { + "code": "DESTRUCTIVE_SQL", + "message": "Query would delete or drop data or schema objects", + "remediation": "Ask the user to approve, then re-run the same command with --force", + }, + }, + "next_steps": []string{ + "Ask the user to approve this destructive query", + cmdutil.AgentSQLCmd(ch.Config.Organization, database, branch, true), + }, + }) + } + + return reportJSON(ch, map[string]any{ + "status": "error", + "error": err.Error(), + "next_steps": []string{ + cmdutil.AgentAuthCheckCmd(), + cmdutil.AgentSQLCmd(ch.Config.Organization, database, branch, false), + }, + }) +} diff --git a/internal/cmd/sql/sql.go b/internal/cmd/sql/sql.go new file mode 100644 index 000000000..d038940a9 --- /dev/null +++ b/internal/cmd/sql/sql.go @@ -0,0 +1,104 @@ +package sql + +import ( + "github.com/spf13/cobra" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" + "github.com/planetscale/cli/internal/sqlquery" +) + +// SQLCmd runs queries without an interactive shell. +func SQLCmd(ch *cmdutil.Helper) *cobra.Command { + var flags struct { + query string + keyspace string + postgresDB string + role string + replica bool + force bool + } + + cmd := &cobra.Command{ + Use: "sql ", + Short: "Execute a SQL query without an interactive shell", + Long: `Execute a single SQL query against a database branch using ephemeral credentials. + +Use --format json for machine-readable output. This command is intended for agents and scripts; +for interactive sessions use pscale shell instead. + +Access flags match pscale shell: --role (reader, writer, readwriter, admin) and --replica. +Unlike shell, the default role is reader. Pass --role admin (or writer/readwriter) for writes. + +Destructive SQL containing DELETE, DROP, or TRUNCATE is blocked unless --force is passed. +Agents must ask the user for approval before using --force. + +MySQL (Vitess) databases use the primary keyspace by default (same as pscale shell -D @primary). +Pass --keyspace when targeting a specific keyspace in a multi-keyspace database. + +PostgreSQL databases use --dbname (default postgres). + +Place flags after positional arguments (see Usage). --org is required: + + pscale sql --org --format json --query "SELECT 1"`, + Args: cmdutil.RequiredArgs("database", "branch"), + Example: ` # Read query (default reader role) + pscale sql --org --format json --query "SELECT 1" + + # Read from replica + pscale sql --org --format json --replica --query "SELECT 1" + + # MySQL — keyspace optional (@primary default) + pscale sql --org --format json --keyspace --query "SELECT 1"`, + PersistentPreRunE: cmdutil.CheckAuthentication(ch.Config), + RunE: func(cmd *cobra.Command, args []string) error { + result, err := sqlquery.Execute(cmd.Context(), ch, sqlquery.Options{ + Organization: ch.Config.Organization, + Database: args[0], + Branch: args[1], + Query: flags.query, + Keyspace: flags.keyspace, + PostgresDB: flags.postgresDB, + Role: flags.role, + Replica: flags.replica, + Force: flags.force, + }) + if err != nil { + return handleExecuteError(ch, err, args[0], args[1]) + } + + switch ch.Printer.Format() { + case printer.JSON: + return ch.Printer.PrintJSON(result) + case printer.Human: + if result.RowsAffected > 0 && result.RowCount == 0 { + ch.Printer.Printf("Rows affected: %d\n", result.RowsAffected) + return nil + } + ch.Printer.Printf("Returned %d row(s)\n", result.RowCount) + for i, row := range result.Rows { + ch.Printer.Printf("%d: %v\n", i+1, row) + } + return nil + default: + return ch.Printer.PrintResource(result.Rows) + } + }, + } + + cmd.PersistentFlags().StringVar(&ch.Config.Organization, "org", ch.Config.Organization, + "The organization for the current user") + cmd.Flags().StringVar(&flags.query, "query", "", "SQL query to execute") + cmd.Flags().StringVar(&flags.keyspace, "keyspace", "", "Vitess keyspace (optional; defaults to @primary, same as pscale shell)") + cmd.Flags().StringVar(&flags.postgresDB, "dbname", "postgres", "PostgreSQL database name") + cmd.Flags().StringVar(&flags.role, "role", + "", "Role defines the access level, allowed values are: reader, writer, readwriter, admin. Defaults to reader (use --role admin for writes).") + cmd.Flags().BoolVar(&flags.replica, "replica", false, + "When enabled, the password will route all reads to the branch's primary replicas and all read-only regions.") + cmd.Flags().BoolVar(&flags.force, "force", false, + "Allow destructive SQL (DELETE, DROP, TRUNCATE). Only use after the user explicitly approves.") + cmd.MarkFlagRequired("query") // nolint:errcheck + cmd.MarkPersistentFlagRequired("org") // nolint:errcheck + + return cmd +} diff --git a/internal/cmd/sql/sql_test.go b/internal/cmd/sql/sql_test.go new file mode 100644 index 000000000..d7b6654d4 --- /dev/null +++ b/internal/cmd/sql/sql_test.go @@ -0,0 +1,85 @@ +package sql + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "testing" + + "github.com/spf13/cobra" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/printer" +) + +func TestSQLCmdHasOrgPersistentFlag(t *testing.T) { + format := printer.Human + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + Config: &config.Config{}, + } + cmd := SQLCmd(ch) + if cmd.PersistentFlags().Lookup("org") == nil { + t.Fatal("expected --org persistent flag on sql subcommand") + } +} + +func TestSQLCmdParsesPositionalsBeforeOrgFlag(t *testing.T) { + format := printer.JSON + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + Config: &config.Config{AccessToken: "token"}, + } + cmd := SQLCmd(ch) + cmd.RunE = func(_ *cobra.Command, args []string) error { + if ch.Config.Organization != "acme" { + t.Fatalf("org = %q, want acme", ch.Config.Organization) + } + if len(args) != 2 || args[0] != "mydb" || args[1] != "main" { + t.Fatalf("args = %v, want [mydb main]", args) + } + return nil + } + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs([]string{"mydb", "main", "--org", "acme", "--query", "SELECT 1"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } +} + +func TestSQLCmdDestructiveQueryReturnsActionRequiredJSON(t *testing.T) { + format := printer.JSON + var out bytes.Buffer + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + Config: &config.Config{Organization: "acme", AccessToken: "token"}, + } + ch.Printer.SetResourceOutput(&out) + cmd := SQLCmd(ch) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + ch.Printer.SetResourceOutput(&out) + cmd.SetArgs([]string{"mydb", "main", "--org", "acme", "--query", "DELETE FROM users"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error") + } + var cmdErr *cmdutil.Error + if !errors.As(err, &cmdErr) || !cmdErr.Handled { + t.Fatalf("expected handled JSONReportedError, got %v", err) + } + + var resp map[string]any + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("json: %v", err) + } + if resp["status"] != "action_required" { + t.Fatalf("status = %v", resp["status"]) + } + if resp["query_kind"] != "destructive" { + t.Fatalf("query_kind = %v", resp["query_kind"]) + } +} diff --git a/internal/sqlquery/destructive.go b/internal/sqlquery/destructive.go new file mode 100644 index 000000000..bd916e83a --- /dev/null +++ b/internal/sqlquery/destructive.go @@ -0,0 +1,44 @@ +package sqlquery + +import ( + "slices" + "strings" +) + +var destructiveWords = []string{"DELETE", "DROP", "TRUNCATE"} + +// DestructiveQueryError is returned when a query would delete or drop data or +// schema objects and --force was not passed. +type DestructiveQueryError struct{} + +func (e *DestructiveQueryError) Error() string { + return "destructive SQL requires explicit user approval (ask the user, then re-run with --force)" +} + +// IsDestructiveQuery reports whether the query deletes or drops resources. +// This is a best-effort guardrail for agents, not a SQL parser. Any statement +// containing the words DELETE, DROP, or TRUNCATE requires approval. +func IsDestructiveQuery(query string) bool { + return slices.ContainsFunc(splitSQLStatements(query), isDestructiveStatement) +} + +func splitSQLStatements(query string) []string { + out := make([]string, 0, strings.Count(query, ";")+1) + for part := range strings.SplitSeq(query, ";") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + return out +} + +func isDestructiveStatement(stmt string) bool { + upper := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(strings.ToUpper(stmt)) + return slices.ContainsFunc(destructiveWords, func(word string) bool { + return containsWord(upper, word) + }) +} + +func containsWord(q, word string) bool { + return strings.Contains(" "+q+" ", " "+word+" ") +} diff --git a/internal/sqlquery/destructive_test.go b/internal/sqlquery/destructive_test.go new file mode 100644 index 000000000..dd54d3a6c --- /dev/null +++ b/internal/sqlquery/destructive_test.go @@ -0,0 +1,58 @@ +package sqlquery + +import ( + "errors" + "testing" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" +) + +func TestIsDestructiveQuery(t *testing.T) { + tests := []struct { + query string + want bool + }{ + {query: "SELECT 1", want: false}, + {query: "INSERT INTO t VALUES (1)", want: false}, + {query: "UPDATE t SET x = 1", want: false}, + {query: "DELETE FROM users WHERE id = 1", want: true}, + {query: "drop table users", want: true}, + {query: "TRUNCATE TABLE users", want: true}, + {query: "ALTER TABLE users DROP COLUMN email", want: true}, + {query: "SELECT 1; DELETE FROM users", want: true}, + {query: " delete from users", want: true}, + {query: "SELECT 1\nDELETE FROM users", want: true}, + {query: "WITH c AS (SELECT 1) DELETE FROM users", want: true}, + {query: "SELECT deleted_at FROM users", want: false}, + {query: "SELECT is_deleted FROM users", want: false}, + {query: "SELECT * FROM delete_queue", want: false}, + } + + for _, tt := range tests { + t.Run(tt.query, func(t *testing.T) { + if got := IsDestructiveQuery(tt.query); got != tt.want { + t.Fatalf("IsDestructiveQuery(%q) = %v, want %v", tt.query, got, tt.want) + } + }) + } +} + +func TestExecuteBlocksDestructiveWithoutForce(t *testing.T) { + ch := &cmdutil.Helper{ + Config: &config.Config{Organization: "bb"}, + } + + _, err := Execute(t.Context(), ch, Options{ + Organization: "bb", + Database: "db", + Branch: "main", + Query: "DELETE FROM users", + }) + if err == nil { + t.Fatal("expected error") + } + if _, ok := errors.AsType[*DestructiveQueryError](err); !ok { + t.Fatalf("error = %T (%v), want *DestructiveQueryError", err, err) + } +} diff --git a/internal/sqlquery/sqlquery.go b/internal/sqlquery/sqlquery.go new file mode 100644 index 000000000..86ab576c0 --- /dev/null +++ b/internal/sqlquery/sqlquery.go @@ -0,0 +1,336 @@ +package sqlquery + +import ( + "context" + "database/sql" + "fmt" + "net" + "slices" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" + _ "github.com/lib/pq" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/passwordutil" + "github.com/planetscale/cli/internal/proxyutil" + "github.com/planetscale/cli/internal/roleutil" + ps "github.com/planetscale/planetscale-go/planetscale" + "vitess.io/vitess/go/mysql" +) + +// Options selects the branch and SQL to execute. +type Options struct { + Organization string + Database string + Branch string + Query string + // Keyspace is the Vitess keyspace for MySQL. Optional; defaults to @primary (same as pscale shell). + Keyspace string + // PostgresDB is the PostgreSQL database name. Defaults to postgres. + PostgresDB string + // Role is reader, writer, readwriter, or admin (same as pscale shell --role). + Role string + // Replica routes reads to replicas when true (same as pscale shell --replica). + Replica bool + // Force allows destructive SQL (DELETE, DROP, TRUNCATE) after explicit user approval. + Force bool +} + +// Result is returned for `pscale sql --format json`. +type Result struct { + Status string `json:"status"` + Database string `json:"database"` + Branch string `json:"branch"` + Kind string `json:"kind"` + Role string `json:"role,omitempty"` + Replica bool `json:"replica,omitempty"` + RowCount int `json:"row_count"` + RowsAffected int64 `json:"rows_affected,omitempty"` + Columns []string `json:"columns,omitempty"` + Rows []map[string]any `json:"rows,omitempty"` + NextSteps []string `json:"next_steps,omitempty"` +} + +type queryOutcome struct { + columns []string + rows []map[string]any + rowsAffected int64 +} + +// Execute runs SQL against MySQL or PostgreSQL using ephemeral credentials. +func Execute(ctx context.Context, ch *cmdutil.Helper, opts Options) (*Result, error) { + if opts.Query == "" { + return nil, fmt.Errorf("query is required") + } + if !opts.Force && IsDestructiveQuery(opts.Query) { + return nil, &DestructiveQueryError{} + } + if opts.Organization == "" { + return nil, fmt.Errorf("organization is required (use --org or set org in pscale.yml)") + } + if opts.Database == "" || opts.Branch == "" { + return nil, fmt.Errorf("database and branch are required") + } + + role, err := cmdutil.ResolveAccessRole(opts.Role, opts.Replica, cmdutil.ReaderRole) + if err != nil { + return nil, err + } + + client, err := ch.Client() + if err != nil { + return nil, err + } + + dbInfo, err := client.Databases.Get(ctx, &ps.GetDatabaseRequest{ + Organization: opts.Organization, + Database: opts.Database, + }) + if err != nil { + return nil, fmt.Errorf("database lookup: %w", err) + } + + dbBranch, err := client.DatabaseBranches.Get(ctx, &ps.GetDatabaseBranchRequest{ + Organization: opts.Organization, + Database: opts.Database, + Branch: opts.Branch, + }) + if err != nil { + return nil, fmt.Errorf("branch lookup: %w", err) + } + if !dbBranch.Ready { + return nil, fmt.Errorf("database branch is not ready yet") + } + + result := &Result{ + Status: "ok", + Database: opts.Database, + Branch: opts.Branch, + Kind: string(dbInfo.Kind), + Role: role.ToString(), + Replica: opts.Replica, + } + + var outcome *queryOutcome + + switch string(dbInfo.Kind) { + case "mysql": + outcome, err = queryMySQL(ctx, ch, opts, role) + case "postgresql", "horizon": + pgDB := opts.PostgresDB + if pgDB == "" { + pgDB = "postgres" + } + outcome, err = queryPostgres(ctx, ch, opts, pgDB, role) + default: + return nil, fmt.Errorf("unsupported database kind %q", dbInfo.Kind) + } + if err != nil { + return nil, err + } + + result.Columns = outcome.columns + result.Rows = outcome.rows + result.RowCount = len(outcome.rows) + result.RowsAffected = outcome.rowsAffected + result.NextSteps = []string{ + cmdutil.AgentSQLCmd(opts.Organization, opts.Database, opts.Branch, false), + } + return result, nil +} + +func queryMySQL(ctx context.Context, ch *cmdutil.Helper, opts Options, role cmdutil.PasswordRole) (*queryOutcome, error) { + client, err := ch.Client() + if err != nil { + return nil, err + } + + pw, err := passwordutil.New(ctx, client, passwordutil.Options{ + Organization: opts.Organization, + Database: opts.Database, + Branch: opts.Branch, + Role: role, + Name: passwordutil.GenerateName("pscale-cli-sql"), + TTL: 5 * time.Minute, + Replica: opts.Replica, + }) + if err != nil { + return nil, err + } + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = pw.Cleanup(cleanupCtx) + }() + + proxy := proxyutil.New(proxyutil.Config{ + Logger: cmdutil.NewZapLogger(ch.Debug()), + UpstreamAddr: pw.Password.Hostname, + Username: pw.Password.Username, + Password: pw.Password.PlainText, + }) + defer proxy.Close() + + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + defer l.Close() + + errCh := make(chan error, 1) + go func() { + errCh <- proxy.Serve(l, mysql.CachingSha2Password) + }() + + db, err := sql.Open("mysql", fmt.Sprintf("root@tcp(%s)/%s", l.Addr().String(), mysqlDSNDatabase(opts))) + if err != nil { + return nil, err + } + defer db.Close() + db.SetConnMaxLifetime(30 * time.Second) + + outcome, err := runQuery(ctx, db, opts.Query) + if err != nil { + return nil, err + } + + proxy.Close() + l.Close() + <-errCh + + return outcome, nil +} + +// mysqlDSNDatabase picks the MySQL database name in the DSN, matching pscale shell: +// default @primary for the main keyspace, no default when --replica is set, or an explicit --keyspace. +func mysqlDSNDatabase(opts Options) string { + if opts.Keyspace != "" { + return opts.Keyspace + } + if opts.Replica { + return "" + } + return "@primary" +} + +func queryPostgres(ctx context.Context, ch *cmdutil.Helper, opts Options, pgDB string, role cmdutil.PasswordRole) (*queryOutcome, error) { + client, err := ch.Client() + if err != nil { + return nil, err + } + + inheritedRoles, successor := cmdutil.PostgresInheritedRoles(role) + + pgRole, err := roleutil.New(ctx, client, roleutil.Options{ + Organization: opts.Organization, + Database: opts.Database, + Branch: opts.Branch, + Name: passwordutil.GenerateName("pscale-cli-sql"), + TTL: 5 * time.Minute, + InheritedRoles: inheritedRoles, + }) + if err != nil { + return nil, err + } + defer func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = pgRole.Cleanup(cleanupCtx, successor) + }() + + username := pgRole.Role.Username + if opts.Replica { + username = username + "|replica" + } + + remoteHost, remotePort, err := net.SplitHostPort(pgRole.Role.AccessHostURL) + if err != nil { + remoteHost = pgRole.Role.AccessHostURL + remotePort = "5432" + } + + connStr := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=require", + remoteHost, remotePort, username, pgRole.Role.Password, pgDB) + + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, err + } + defer db.Close() + db.SetConnMaxLifetime(30 * time.Second) + + if err := db.PingContext(ctx); err != nil { + return nil, err + } + + return runQuery(ctx, db, opts.Query) +} + +var readQueryPrefixes = []string{"SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "TABLE"} + +func isReadQuery(query string) bool { + q := strings.TrimSpace(strings.ToUpper(query)) + return slices.ContainsFunc(readQueryPrefixes, func(prefix string) bool { + return strings.HasPrefix(q, prefix) + }) +} + +func runQuery(ctx context.Context, db *sql.DB, query string) (*queryOutcome, error) { + if isReadQuery(query) { + rows, err := db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + scannedRows, cols, err := scanRows(rows) + if err != nil { + return nil, err + } + return &queryOutcome{rows: scannedRows, columns: cols}, nil + } + + res, err := db.ExecContext(ctx, query) + if err != nil { + return nil, err + } + affected, _ := res.RowsAffected() + return &queryOutcome{rowsAffected: affected}, nil +} + +func scanRows(rows *sql.Rows) ([]map[string]any, []string, error) { + columns, err := rows.Columns() + if err != nil { + return nil, nil, err + } + + values := make([]any, len(columns)) + scanArgs := make([]any, len(columns)) + for i := range values { + scanArgs[i] = &values[i] + } + + var out []map[string]any + for rows.Next() { + if err := rows.Scan(scanArgs...); err != nil { + return nil, nil, err + } + rowMap := make(map[string]any, len(columns)) + for i, col := range columns { + val := values[i] + switch v := val.(type) { + case []byte: + rowMap[col] = string(v) + default: + rowMap[col] = v + } + } + out = append(out, rowMap) + } + if err := rows.Err(); err != nil { + return nil, nil, err + } + return out, columns, nil +} diff --git a/internal/sqlquery/sqlquery_test.go b/internal/sqlquery/sqlquery_test.go new file mode 100644 index 000000000..b0701b29e --- /dev/null +++ b/internal/sqlquery/sqlquery_test.go @@ -0,0 +1,76 @@ +package sqlquery + +import ( + "context" + "testing" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/config" +) + +func TestIsReadQuery(t *testing.T) { + if !isReadQuery("SELECT 1") { + t.Fatal("SELECT should be read") + } + if !isReadQuery(" with x as (select 1) select * from x") { + t.Fatal("WITH should be read") + } + if isReadQuery("INSERT INTO t VALUES (1)") { + t.Fatal("INSERT should not be read") + } + if isReadQuery("UPDATE t SET x = 1") { + t.Fatal("UPDATE should not be read") + } +} + +func TestMySQLDSNDatabase(t *testing.T) { + if got := mysqlDSNDatabase(Options{}); got != "@primary" { + t.Fatalf("default = %q, want @primary", got) + } + if got := mysqlDSNDatabase(Options{Replica: true}); got != "" { + t.Fatalf("replica = %q, want empty", got) + } + if got := mysqlDSNDatabase(Options{Keyspace: "commerce"}); got != "commerce" { + t.Fatalf("explicit = %q", got) + } +} + +func TestExecuteValidation(t *testing.T) { + ch := &cmdutil.Helper{ + Config: &config.Config{Organization: "bb"}, + } + + tests := []struct { + name string + opts Options + wantErr string + }{ + { + name: "missing query", + opts: Options{Organization: "bb", Database: "db", Branch: "main"}, + wantErr: "query is required", + }, + { + name: "missing org", + opts: Options{Query: "SELECT 1", Database: "db", Branch: "main"}, + wantErr: "organization is required (use --org or set org in pscale.yml)", + }, + { + name: "missing database", + opts: Options{Organization: "bb", Query: "SELECT 1", Branch: "main"}, + wantErr: "database and branch are required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Execute(context.Background(), ch, tt.opts) + if err == nil { + t.Fatal("expected error") + } + if err.Error() != tt.wantErr { + t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) + } + }) + } +} From f945d7d04b161183d9bf5668e9e18ab733e466df Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Thu, 2 Jul 2026 22:32:59 -0500 Subject: [PATCH 04/19] Add AGENTS.md guide for automated pscale usage. Document flag placement, auth workflow, sql conventions, and destructive query approval for any agent or script using the CLI. Co-authored-by: Cursor --- AGENTS.md | 147 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 + 2 files changed, 149 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..0930c858c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,147 @@ +# PlanetScale CLI — agent guide + +For **any** automated agent or script using `pscale`. Always pass **`--format json`**. Substitute placeholders from the user's request or from prior command output (`org list`, `database list`, `branch list`). + +| Placeholder | Meaning | +|-------------|---------| +| `` | Organization name | +| `` | Database name | +| `` | Branch name (pick one with `"ready": true` from `branch list`) | + +## Flag placement + +- **`--org`** is a flag on resource subcommands (`database`, `branch`, `sql`, `api`, …). It is **not** on root `pscale` — `pscale --org …` fails. +- **`--format json`** is a global flag. It can go on `pscale` or on the subcommand. +- Commands with positional args (`sql`, `branch list`, …): put **positionals first**, then flags. + +```bash +# Correct +pscale auth check --format json +pscale org list --format json +pscale database list --org --format json +pscale branch list --org --format json +pscale sql --org --format json --query "SELECT 1" + +# Also valid — global --format +pscale --format json database list --org + +# Wrong — unknown flag: --org +pscale --org database list --format json +``` + +## Workflow + +1. **Auth** — check before anything else: + + ```bash + pscale auth check --format json + ``` + + `"status": "ok"` and `"authenticated": true` means proceed. `"status": "action_required"` means log in or fix credentials (see `issues` and `next_steps` in the JSON). + +2. **Login** (when not authenticated): + + ```bash + pscale auth login --format json + ``` + + Pending JSON has `verification_url`, `user_code`, and `browser_opened`. Open `verification_url` manually if the browser does not open. Do not retry login in a loop without browser access. + +3. **Organization** — use `"organization"` from `auth check`, ask the user, or list orgs: + + ```bash + pscale org list --format json + ``` + + Pass `--org ` on resource commands (`database`, `branch`, `sql`, `api`, …). Not on `org list`. + +4. **Discover resources** before SQL: + + ```bash + pscale database list --org --format json + pscale branch list --org --format json + ``` + +5. **Query** (read-only default): + + ```bash + pscale sql --org --format json --query "SELECT 1" + ``` + +## Flags + +| Flag | Purpose | +|------|---------| +| `--format json` | JSON on stdout | +| `--org ` | Organization (on resource subcommands only) | +| `--api-url` | Non-production API base URL — pass on every command when not using production | + +## Authentication + +`pscale auth login` stores credentials in the OS keychain; agents on the same machine reuse them. + +Headless / CI: pass `--service-token-id` and `--service-token` on the subcommand that needs auth. + +## SQL + +Non-interactive queries. Default **`--role` is `reader`** (unlike `pscale shell`, which defaults to admin). Use `pscale shell` for interactive sessions. + +```bash +# Read (default) +pscale sql --org --format json --query "SELECT 1" + +# Read from replica +pscale sql --org --format json --replica --query "SELECT 1" + +# PostgreSQL — optional --dbname (default postgres) +pscale sql --org --format json --query "SELECT 1" + +# MySQL multi-keyspace — optional --keyspace (default @primary) +pscale sql --org --format json --keyspace --query "SELECT 1" +``` + +| Flag | Purpose | +|------|---------| +| `--role` | `reader` (default), `writer`, `readwriter`, `admin` — same names as `pscale shell` | +| `--replica` | Route reads to replicas | +| `--dbname` | PostgreSQL database name (default `postgres`) | +| `--keyspace` | MySQL keyspace (default `@primary`) | +| `--force` | Allow destructive SQL after explicit user approval | + +**`--role` by engine** (same as `pscale shell`): + +| `--role` | MySQL (Vitess) | PostgreSQL | +|----------|----------------|------------| +| `reader` | Branch password, reader role | Ephemeral role inheriting `pg_read_all_data` | +| `writer` | Branch password, writer role | Role inheriting `pg_write_all_data` | +| `readwriter` | Branch password, readwriter role | Role inheriting read + write | +| `admin` | Branch password, admin role | Role inheriting `postgres` | + +### Destructive SQL + +`DELETE`, `DROP`, and `TRUNCATE` anywhere in a query are blocked by default (word match, not substring — `deleted_at` is fine). Returns `"status": "action_required"` with `"query_kind": "destructive"`. + +1. Ask the user to approve the query. +2. Re-run with `--force` only after they approve: + +```bash +pscale sql --org --format json --force --query "DELETE FROM ..." +``` + +Never use `--force` without explicit user approval. + +### SQL JSON + +Success: `status`, `database`, `branch`, `kind` (`mysql` or `postgresql`), `role`, `row_count`, `columns`, `rows`; `replica` when `--replica` was used. + +MySQL may return synthetic column names (e.g. `:vtg1 /* INT64 */`). PostgreSQL may use names like `?column?`. + +Error: one JSON object on stdout with `status: "error"`, `error`, and `next_steps`. + +Destructive SQL without `--force`: `status: "action_required"`, `query_kind: "destructive"`, `issues`, and `next_steps` (includes `--force` retry command). + +## API passthrough + +```bash +pscale api --org organizations//databases +``` diff --git a/README.md b/README.md index 30a74dd95..115121970 100644 --- a/README.md +++ b/README.md @@ -124,3 +124,5 @@ And then use the `pscale` binary built in `cmd/pscale/` for testing: ## Documentation Please checkout our Documentation page: [planetscale.com/docs](https://planetscale.com/docs/reference/planetscale-cli) + +For AI agents and automation, see [AGENTS.md](./AGENTS.md). From c96ed8d59f0da21a2aaa53281c16aae8c9ff5e9c Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Thu, 2 Jul 2026 22:41:03 -0500 Subject: [PATCH 05/19] Fix JSON auth exit codes and login stdout envelope. auth check exits non-zero for action_required (including NO_ORG), login writes pending JSON to stderr and always emits a final stdout envelope after credentials are saved, and org setup failures report ORG_SETUP_FAILED. Co-authored-by: Cursor --- AGENTS.md | 4 +- internal/cmd/auth/check.go | 5 +- internal/cmd/auth/login.go | 74 ++++++++++++++++++---------- internal/cmd/auth/onboarding.go | 26 ++++++++-- internal/cmd/auth/onboarding_test.go | 42 ++++++++++++++++ 5 files changed, 116 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0930c858c..619e3b519 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,7 @@ pscale --org database list --format json pscale auth check --format json ``` - `"status": "ok"` and `"authenticated": true` means proceed. `"status": "action_required"` means log in or fix credentials (see `issues` and `next_steps` in the JSON). + `"status": "ok"` and `"authenticated": true` with no blocking `issues` means proceed. `"status": "action_required"` exits non-zero — log in, pick an org, or fix credentials (see `issues` and `next_steps`). 2. **Login** (when not authenticated): @@ -45,7 +45,7 @@ pscale --org database list --format json pscale auth login --format json ``` - Pending JSON has `verification_url`, `user_code`, and `browser_opened`. Open `verification_url` manually if the browser does not open. Do not retry login in a loop without browser access. + Pending JSON is written to **stderr** while waiting; **stdout** has a single final JSON object when login completes (`status: ok` or `action_required` if org setup fails after credentials are saved). Fields include `verification_url`, `user_code`, and `browser_opened`. Open `verification_url` manually if the browser does not open. Do not retry login in a loop without browser access. 3. **Organization** — use `"organization"` from `auth check`, ask the user, or list orgs: diff --git a/internal/cmd/auth/check.go b/internal/cmd/auth/check.go index 89a660005..ec34adb43 100644 --- a/internal/cmd/auth/check.go +++ b/internal/cmd/auth/check.go @@ -25,10 +25,7 @@ func CheckCmd(ch *cmdutil.Helper) *cobra.Command { if err := ch.Printer.PrintJSON(resp); err != nil { return err } - if !resp.Authenticated { - return cmdutil.JSONReportedError(cmdutil.ActionRequestedExitCode) - } - return nil + return authCheckExitCode(resp) } if !resp.Authenticated { diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 73fbef8c4..f8a939805 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "runtime" - "slices" "github.com/hashicorp/go-cleanhttp" psauth "github.com/planetscale/cli/internal/auth" @@ -49,21 +48,21 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { browserOpened := cmdutil.TryOpenBrowser(runtime.GOOS, deviceVerification.VerificationCompleteURL) == nil if jsonMode { - message := loginPendingMessage(browserOpened) pending := LoginPendingResponse{ Status: "pending", VerificationURL: deviceVerification.VerificationCompleteURL, UserCode: deviceVerification.UserCode, BrowserOpened: browserOpened, - Message: message, + Message: loginPendingMessage(browserOpened), NextSteps: []string{ "Approve access in the browser", cmdutil.AgentAuthCheckCmd(), }, } - if err := ch.Printer.PrintJSON(pending); err != nil { + if err := printJSONEnvelope(cmd.ErrOrStderr(), pending); err != nil { return err } + fmt.Fprintln(cmd.ErrOrStderr(), "Waiting for browser authorization...") } else { if !browserOpened { ch.Printer.Printf("Failed to open a browser; open this URL manually: %s\n", printer.Bold(deviceVerification.VerificationCompleteURL)) @@ -78,9 +77,7 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { } var end func() - if jsonMode { - fmt.Fprintln(cmd.ErrOrStderr(), "Waiting for browser authorization...") - } else { + if !jsonMode { end = ch.Printer.PrintProgress("Waiting for confirmation...") defer end() } @@ -103,20 +100,14 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { end() } - if err := writeDefaultOrganizationIfNeeded(ctx, ch, accessToken, authURL); err != nil { - return err - } + orgSetupErr := writeDefaultOrganizationIfNeeded(ctx, ch, accessToken, authURL) if jsonMode { - ok := LoginOKResponse{ - Status: "ok", - Message: "Successfully logged in.", - NextSteps: []string{ - cmdutil.AgentAuthCheckCmd(), - cmdutil.AgentOrgListCmd(), - }, - } - return ch.Printer.PrintJSON(ok) + return finishLoginJSON(ch, orgSetupErr) + } + + if orgSetupErr != nil { + return orgSetupErr } ch.Printer.Println("Successfully logged in.") @@ -131,6 +122,36 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { return cmd } +func finishLoginJSON(ch *cmdutil.Helper, orgSetupErr error) error { + resp := LoginOKResponse{ + Status: "ok", + Message: "Successfully logged in.", + NextSteps: []string{ + cmdutil.AgentAuthCheckCmd(), + cmdutil.AgentOrgListCmd(), + }, + } + if orgSetupErr != nil { + resp.Status = "action_required" + resp.Issues = []AuthIssue{{ + Code: "ORG_SETUP_FAILED", + Message: orgSetupErr.Error(), + Remediation: "Credentials were saved; run `pscale org list --format json` and `pscale org switch `", + }} + resp.NextSteps = []string{ + cmdutil.AgentOrgListCmd(), + cmdutil.AgentAuthCheckCmd(), + } + } + if err := ch.Printer.PrintJSON(resp); err != nil { + return err + } + if resp.Status == "action_required" { + return cmdutil.JSONReportedError(cmdutil.ActionRequestedExitCode) + } + return nil +} + func writeDefaultOrganizationIfNeeded(ctx context.Context, ch *cmdutil.Helper, accessToken, authURL string) error { writeConfig := false cfg, err := ch.ConfigFS.DefaultConfig() @@ -139,10 +160,7 @@ func writeDefaultOrganizationIfNeeded(ctx context.Context, ch *cmdutil.Helper, a } if !writeConfig && cfg.Organization != "" { - hasOrg, err := hasOrg(ctx, cfg.Organization, accessToken, authURL) - if err != nil { - return err - } + hasOrg, _ := hasOrg(ctx, cfg.Organization, accessToken, authURL) writeConfig = !hasOrg } @@ -179,9 +197,13 @@ func hasOrg(ctx context.Context, org, accessToken, authURL string) (bool, error) return false, err } - return slices.ContainsFunc(currentOrgs, func(o *planetscale.Organization) bool { - return o.Name == org - }), nil + for _, o := range currentOrgs { + if o.Name == org { + return true, nil + } + } + + return false, nil } func listCurrentOrgs(ctx context.Context, accessToken, authURL string) ([]*planetscale.Organization, error) { diff --git a/internal/cmd/auth/onboarding.go b/internal/cmd/auth/onboarding.go index bec4f5526..495f069cc 100644 --- a/internal/cmd/auth/onboarding.go +++ b/internal/cmd/auth/onboarding.go @@ -2,6 +2,9 @@ package auth import ( "context" + "encoding/json" + "fmt" + "io" "github.com/planetscale/cli/internal/cmdutil" ) @@ -141,7 +144,24 @@ type LoginPendingResponse struct { // LoginOKResponse is emitted after successful login. type LoginOKResponse struct { - Status string `json:"status"` - Message string `json:"message"` - NextSteps []string `json:"next_steps,omitempty"` + Status string `json:"status"` + Message string `json:"message"` + Issues []AuthIssue `json:"issues,omitempty"` + NextSteps []string `json:"next_steps,omitempty"` +} + +func printJSONEnvelope(w io.Writer, v any) error { + buf, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(buf)) + return err +} + +func authCheckExitCode(resp AuthCheckResponse) error { + if resp.Status == "action_required" { + return cmdutil.JSONReportedError(cmdutil.ActionRequestedExitCode) + } + return nil } diff --git a/internal/cmd/auth/onboarding_test.go b/internal/cmd/auth/onboarding_test.go index 870ed9a94..3b69f7fcb 100644 --- a/internal/cmd/auth/onboarding_test.go +++ b/internal/cmd/auth/onboarding_test.go @@ -1,10 +1,14 @@ package auth import ( + "bytes" + "encoding/json" + "errors" "testing" "github.com/planetscale/cli/internal/cmdutil" "github.com/planetscale/cli/internal/config" + "github.com/planetscale/cli/internal/printer" ) func TestBuildAuthCheckResponseNoAuth(t *testing.T) { @@ -40,3 +44,41 @@ func TestConfiguredOrganization(t *testing.T) { t.Fatalf("org = %q", got) } } + +func TestAuthCheckExitCode(t *testing.T) { + if err := authCheckExitCode(AuthCheckResponse{Status: "ok"}); err != nil { + t.Fatalf("ok status: %v", err) + } + if err := authCheckExitCode(AuthCheckResponse{Status: "action_required", Authenticated: true}); err == nil { + t.Fatal("expected non-zero exit for action_required") + } +} + +func TestFinishLoginJSONOrgSetupFailure(t *testing.T) { + format := printer.JSON + var out bytes.Buffer + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + } + ch.Printer.SetResourceOutput(&out) + + err := finishLoginJSON(ch, errors.New("org list unavailable")) + if err == nil { + t.Fatal("expected exit error") + } + var cmdErr *cmdutil.Error + if !errors.As(err, &cmdErr) || !cmdErr.Handled { + t.Fatalf("expected handled JSON error, got %v", err) + } + + var resp LoginOKResponse + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("stdout json: %v", err) + } + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } + if len(resp.Issues) == 0 || resp.Issues[0].Code != "ORG_SETUP_FAILED" { + t.Fatalf("issues = %#v", resp.Issues) + } +} From 5dba8697007de424b13868fa5d78a3e9182cb483 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Fri, 3 Jul 2026 14:25:45 -0400 Subject: [PATCH 06/19] More agent improvements - Add pscale agent-guide - Improve help text (pg vs mysql) - Update mcp install to use new hosted server --- AGENTS.md | 37 +++- README.md | 2 +- agentguide_content.go | 8 + internal/cmd/agentguide/agentguide.go | 66 +++++++ internal/cmd/agentguide/agentguide_test.go | 41 +++++ internal/cmd/auth/onboarding.go | 18 +- internal/cmd/mcp/install.go | 193 +++++++++++---------- internal/cmd/mcp/install_test.go | 83 +++++++++ internal/cmd/mcp/mcp.go | 13 +- internal/cmd/mcp/server.go | 7 +- internal/cmd/root.go | 68 +++++++- internal/cmdutil/agent_steps.go | 4 + internal/cmdutil/agent_steps_test.go | 22 ++- internal/cmdutil/cmdutil.go | 42 +++++ internal/printer/printer.go | 9 +- internal/printer/printer_test.go | 26 +++ 16 files changed, 513 insertions(+), 126 deletions(-) create mode 100644 agentguide_content.go create mode 100644 internal/cmd/agentguide/agentguide.go create mode 100644 internal/cmd/agentguide/agentguide_test.go create mode 100644 internal/cmd/mcp/install_test.go create mode 100644 internal/printer/printer_test.go diff --git a/AGENTS.md b/AGENTS.md index 619e3b519..c799cb76f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,15 @@ For **any** automated agent or script using `pscale`. Always pass **`--format json`**. Substitute placeholders from the user's request or from prior command output (`org list`, `database list`, `branch list`). +If you only have the installed `pscale` binary, start here: + +```bash +pscale agent-guide --format json +pscale auth check --format json +``` + +Use direct CLI automation for shell commands and scripts. Use the hosted PlanetScale MCP server for MCP clients. + | Placeholder | Meaning | |-------------|---------| | `` | Organization name | @@ -31,7 +40,13 @@ pscale --org database list --format json ## Workflow -1. **Auth** — check before anything else: +1. **Guide** — discover machine-readable conventions when you do not have this file: + + ```bash + pscale agent-guide --format json + ``` + +2. **Auth** — check before anything else: ```bash pscale auth check --format json @@ -39,7 +54,7 @@ pscale --org database list --format json `"status": "ok"` and `"authenticated": true` with no blocking `issues` means proceed. `"status": "action_required"` exits non-zero — log in, pick an org, or fix credentials (see `issues` and `next_steps`). -2. **Login** (when not authenticated): +3. **Login** (when not authenticated): ```bash pscale auth login --format json @@ -47,7 +62,7 @@ pscale --org database list --format json Pending JSON is written to **stderr** while waiting; **stdout** has a single final JSON object when login completes (`status: ok` or `action_required` if org setup fails after credentials are saved). Fields include `verification_url`, `user_code`, and `browser_opened`. Open `verification_url` manually if the browser does not open. Do not retry login in a loop without browser access. -3. **Organization** — use `"organization"` from `auth check`, ask the user, or list orgs: +4. **Organization** — use `"organization"` from `auth check`, ask the user, or list orgs: ```bash pscale org list --format json @@ -55,14 +70,14 @@ pscale --org database list --format json Pass `--org ` on resource commands (`database`, `branch`, `sql`, `api`, …). Not on `org list`. -4. **Discover resources** before SQL: +5. **Discover resources** before SQL: ```bash pscale database list --org --format json pscale branch list --org --format json ``` -5. **Query** (read-only default): +6. **Query** (read-only default): ```bash pscale sql --org --format json --query "SELECT 1" @@ -145,3 +160,15 @@ Destructive SQL without `--force`: `status: "action_required"`, `query_kind: "de ```bash pscale api --org organizations//databases ``` + +## MCP + +For MCP clients, use the hosted PlanetScale MCP server: + +```text +https://mcp.pscale.dev/mcp/planetscale +``` + +See the current MCP docs: https://planetscale.com/docs/connect/mcp + +Do not use the deprecated local `pscale mcp server` path unless you explicitly need backward compatibility with an old setup. diff --git a/README.md b/README.md index 115121970..e7448a4a3 100644 --- a/README.md +++ b/README.md @@ -125,4 +125,4 @@ And then use the `pscale` binary built in `cmd/pscale/` for testing: Please checkout our Documentation page: [planetscale.com/docs](https://planetscale.com/docs/reference/planetscale-cli) -For AI agents and automation, see [AGENTS.md](./AGENTS.md). +For AI agents and automation, run `pscale agent-guide` or see [AGENTS.md](./AGENTS.md). diff --git a/agentguide_content.go b/agentguide_content.go new file mode 100644 index 000000000..7aad91f5f --- /dev/null +++ b/agentguide_content.go @@ -0,0 +1,8 @@ +package cli + +import _ "embed" + +// AgentGuide is the embedded agent guide shipped with the pscale binary. +// +//go:embed AGENTS.md +var AgentGuide string diff --git a/internal/cmd/agentguide/agentguide.go b/internal/cmd/agentguide/agentguide.go new file mode 100644 index 000000000..0b81d783d --- /dev/null +++ b/internal/cmd/agentguide/agentguide.go @@ -0,0 +1,66 @@ +package agentguide + +import ( + "github.com/spf13/cobra" + + clicontent "github.com/planetscale/cli" + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" +) + +const ( + HostedMCPURL = "https://mcp.pscale.dev/mcp/planetscale" + MCPDocsURL = "https://planetscale.com/docs/connect/mcp" +) + +type response struct { + Status string `json:"status"` + Guide string `json:"guide"` + FirstCommand string `json:"first_command"` + AgentGuideCommand string `json:"agent_guide_command"` + HostedMCPURL string `json:"hosted_mcp_url"` + MCPDocsURL string `json:"mcp_docs_url"` + SupportedEngines []string `json:"supported_engines"` + Conventions []string `json:"conventions"` + NextSteps []string `json:"next_steps"` +} + +// AgentGuideCmd prints the embedded guide for agents and automation. +func AgentGuideCmd(ch *cmdutil.Helper) *cobra.Command { + cmd := &cobra.Command{ + Use: "agent-guide", + Short: "Show guidance for AI agents and automation", + Long: `Show guidance for AI agents and automation using pscale. + +Use --format json for a machine-readable bootstrap response with first commands, +supported engines, and hosted MCP details.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if ch.Printer.Format() == printer.JSON { + return ch.Printer.PrintJSON(response{ + Status: "ok", + Guide: clicontent.AgentGuide, + FirstCommand: cmdutil.AgentAuthCheckCmd(), + AgentGuideCommand: cmdutil.AgentGuideCmd(), + HostedMCPURL: HostedMCPURL, + MCPDocsURL: MCPDocsURL, + SupportedEngines: []string{"mysql", "postgresql"}, + Conventions: []string{ + "Always pass --format json for automation", + "Put --org on resource subcommands, not on pscale root", + "Put positional arguments before flags for commands like sql and branch list", + "Use hosted MCP for MCP clients; direct CLI automation should start with auth check", + }, + NextSteps: []string{ + cmdutil.AgentAuthCheckCmd(), + }, + }) + } + + ch.Printer.Print(clicontent.AgentGuide) + return nil + }, + } + + return cmd +} diff --git a/internal/cmd/agentguide/agentguide_test.go b/internal/cmd/agentguide/agentguide_test.go new file mode 100644 index 000000000..875bb7db0 --- /dev/null +++ b/internal/cmd/agentguide/agentguide_test.go @@ -0,0 +1,41 @@ +package agentguide + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" +) + +func TestAgentGuideJSONBootstrap(t *testing.T) { + format := printer.JSON + var out bytes.Buffer + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + } + ch.Printer.SetResourceOutput(&out) + + cmd := AgentGuideCmd(ch) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + + var resp response + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("json: %v", err) + } + if resp.Status != "ok" { + t.Fatalf("status = %q", resp.Status) + } + if resp.FirstCommand != cmdutil.AgentAuthCheckCmd() { + t.Fatalf("first command = %q", resp.FirstCommand) + } + if resp.HostedMCPURL != HostedMCPURL { + t.Fatalf("hosted MCP URL = %q", resp.HostedMCPURL) + } + if resp.Guide == "" { + t.Fatal("expected embedded guide") + } +} diff --git a/internal/cmd/auth/onboarding.go b/internal/cmd/auth/onboarding.go index 495f069cc..57ad61f4b 100644 --- a/internal/cmd/auth/onboarding.go +++ b/internal/cmd/auth/onboarding.go @@ -18,18 +18,20 @@ type AuthIssue struct { // AuthCheckResponse is the JSON payload for `pscale auth check --format json`. type AuthCheckResponse struct { - Status string `json:"status"` - Authenticated bool `json:"authenticated"` - AuthMethod string `json:"auth_method,omitempty"` - Organization string `json:"organization,omitempty"` - APIURL string `json:"api_url,omitempty"` - Issues []AuthIssue `json:"issues,omitempty"` - NextSteps []string `json:"next_steps,omitempty"` + Status string `json:"status"` + Authenticated bool `json:"authenticated"` + AuthMethod string `json:"auth_method,omitempty"` + Organization string `json:"organization,omitempty"` + APIURL string `json:"api_url,omitempty"` + AgentGuideCommand string `json:"agent_guide_command,omitempty"` + Issues []AuthIssue `json:"issues,omitempty"` + NextSteps []string `json:"next_steps,omitempty"` } func buildAuthCheckResponse(ctx context.Context, ch *cmdutil.Helper) AuthCheckResponse { resp := AuthCheckResponse{ - APIURL: ch.Config.BaseURL, + APIURL: ch.Config.BaseURL, + AgentGuideCommand: cmdutil.AgentGuideCmd(), } org := configuredOrganization(ch) diff --git a/internal/cmd/mcp/install.go b/internal/cmd/mcp/install.go index 7fc27ded4..631357b5b 100644 --- a/internal/cmd/mcp/install.go +++ b/internal/cmd/mcp/install.go @@ -5,75 +5,133 @@ import ( "fmt" "os" "path/filepath" - "runtime" "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" "github.com/spf13/cobra" "github.com/tidwall/jsonc" ) +const ( + hostedMCPURL = "https://mcp.pscale.dev/mcp/planetscale" + mcpDocsURL = "https://planetscale.com/docs/connect/mcp" + claudeCodeCmd = `claude mcp add --transport http "planetscale" https://mcp.pscale.dev/mcp/planetscale` +) + +type installResponse struct { + Status string `json:"status"` + Target string `json:"target,omitempty"` + ConfigPath string `json:"config_path,omitempty"` + BackupPath string `json:"backup_path,omitempty"` + HostedMCPURL string `json:"hosted_mcp_url"` + DocsURL string `json:"docs_url"` + Command string `json:"command,omitempty"` + Message string `json:"message"` + NextSteps []string `json:"next_steps,omitempty"` +} + // InstallCmd returns a new cobra.Command for the mcp install command. func InstallCmd(ch *cmdutil.Helper) *cobra.Command { var target string cmd := &cobra.Command{ Use: "install", - Short: "Install the MCP server", - Long: `Install the PlanetScale model context protocol (MCP) server.`, + Short: "Install the hosted MCP server", + Long: `Install the hosted PlanetScale model context protocol (MCP) server. + +For clients this command cannot safely configure, it prints the current hosted +MCP setup instructions instead of installing the deprecated local stdio server.`, RunE: func(cmd *cobra.Command, args []string) error { var configPath string var err error switch target { - case "claude": - configPath, err = getClaudeConfigPath() - if err != nil { - return fmt.Errorf("failed to determine Claude config path: %w", err) - } - if err := installMCPServer(configPath, target, modifyClaudeConfig); err != nil { - return err - } case "cursor": configPath, err = getCursorConfigPath() if err != nil { return fmt.Errorf("failed to determine Cursor config path: %w", err) } - // Cursor uses the same config structure as Claude - if err := installMCPServer(configPath, target, modifyClaudeConfig); err != nil { - return err - } - case "zed": - configPath, err = getZedConfigPath() + backupPath, err := installMCPServer(configPath, target, modifyCursorConfig) if err != nil { - return fmt.Errorf("failed to determine Zed config path: %w", err) - } - if err := installMCPServer(configPath, target, modifyZedConfig); err != nil { return err } + return printInstallResponse(ch, installResponse{ + Status: "ok", + Target: target, + ConfigPath: configPath, + BackupPath: backupPath, + HostedMCPURL: hostedMCPURL, + DocsURL: mcpDocsURL, + Message: fmt.Sprintf("Hosted MCP server configured for %s", target), + }) + case "claude", "claude-code": + return printInstallResponse(ch, installResponse{ + Status: "action_required", + Target: "claude-code", + HostedMCPURL: hostedMCPURL, + DocsURL: mcpDocsURL, + Command: claudeCodeCmd, + Message: "Claude Code uses the hosted MCP server. Run the command in next_steps to install it.", + NextSteps: []string{ + claudeCodeCmd, + }, + }) + case "zed": + return printInstallResponse(ch, installResponse{ + Status: "action_required", + Target: target, + HostedMCPURL: hostedMCPURL, + DocsURL: mcpDocsURL, + Message: "Zed hosted MCP setup is documented in the current PlanetScale MCP docs.", + NextSteps: []string{ + mcpDocsURL, + }, + }) default: - return fmt.Errorf("invalid target vendor: %s (supported values: claude, cursor, zed)", target) + return fmt.Errorf("invalid target vendor: %s (supported values: cursor, claude-code, zed)", target) } - - fmt.Printf("MCP server successfully configured for %s at %s\n", target, configPath) - return nil }, } - cmd.Flags().StringVar(&target, "target", "", "Target vendor for MCP installation (required). Possible values: [claude, cursor, zed]") + cmd.Flags().StringVar(&target, "target", "", "Target vendor for MCP installation (required). Possible values: [cursor, claude-code, zed]") cmd.MarkFlagRequired("target") cmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return []string{"claude", "cursor", "zed"}, cobra.ShellCompDirectiveDefault + return []string{"cursor", "claude-code", "zed"}, cobra.ShellCompDirectiveDefault }) return cmd } +func printInstallResponse(ch *cmdutil.Helper, resp installResponse) error { + if ch.Printer.Format() == printer.JSON { + return ch.Printer.PrintJSON(resp) + } + + switch resp.Status { + case "ok": + fmt.Printf("%s\n", resp.Message) + if resp.ConfigPath != "" { + fmt.Printf("Config path: %s\n", resp.ConfigPath) + } + if resp.BackupPath != "" { + fmt.Printf("Created backup at %s\n", resp.BackupPath) + } + default: + fmt.Printf("%s\n", resp.Message) + if resp.Command != "" { + fmt.Printf("\n %s\n", resp.Command) + } + fmt.Printf("\nSee %s\n", resp.DocsURL) + } + return nil +} + // installMCPServer handles common file I/O for all editors -func installMCPServer(configPath string, target string, modifyConfig func(map[string]any) error) error { +func installMCPServer(configPath string, target string, modifyConfig func(map[string]any) error) (string, error) { // Check if config directory exists configDir := filepath.Dir(configPath) if _, err := os.Stat(configDir); os.IsNotExist(err) { - return fmt.Errorf("no %s installation: path %s not found", target, configDir) + return "", fmt.Errorf("no %s installation: path %s not found", target, configDir) } // Read existing config or create empty settings @@ -81,44 +139,44 @@ func installMCPServer(configPath string, target string, modifyConfig func(map[st if fileData, err := os.ReadFile(configPath); err == nil { cleanJSON := jsonc.ToJSON(fileData) if err := json.Unmarshal(cleanJSON, &fullSettings); err != nil { - return fmt.Errorf("failed to parse %s config file: %w", target, err) + return "", fmt.Errorf("failed to parse %s config file: %w", target, err) } } else if !os.IsNotExist(err) { - return fmt.Errorf("failed to read %s config file: %w", target, err) + return "", fmt.Errorf("failed to read %s config file: %w", target, err) } else { fullSettings = make(map[string]any) } // Let the editor-specific function modify the config if err := modifyConfig(fullSettings); err != nil { - return err + return "", err } // Marshal updated config updatedData, err := json.MarshalIndent(fullSettings, "", " ") if err != nil { - return fmt.Errorf("failed to marshal %s config: %w", target, err) + return "", fmt.Errorf("failed to marshal %s config: %w", target, err) } // Backup existing file before writing + var backupPath string if _, err := os.Stat(configPath); err == nil { - backupPath := configPath + "~" + backupPath = configPath + "~" if err := os.Rename(configPath, backupPath); err != nil { - return fmt.Errorf("failed to create backup: %w", err) + return "", fmt.Errorf("failed to create backup: %w", err) } - fmt.Printf("Created backup at %s\n", backupPath) } // Write updated config if err := os.WriteFile(configPath, updatedData, 0644); err != nil { - return fmt.Errorf("failed to write %s config file: %w", target, err) + return "", fmt.Errorf("failed to write %s config file: %w", target, err) } - return nil + return backupPath, nil } -// modifyClaudeConfig adds planetscale to mcpServers (Claude and Cursor both use this) -func modifyClaudeConfig(settings map[string]any) error { +// modifyCursorConfig adds the hosted PlanetScale MCP server to Cursor. +func modifyCursorConfig(settings map[string]any) error { var mcpServers map[string]any if existingServers, ok := settings["mcpServers"].(map[string]any); ok { mcpServers = existingServers @@ -127,51 +185,13 @@ func modifyClaudeConfig(settings map[string]any) error { } mcpServers["planetscale"] = map[string]any{ - "command": "pscale", - "args": []string{"mcp", "server"}, + "url": hostedMCPURL, } settings["mcpServers"] = mcpServers return nil } -// modifyZedConfig adds planetscale to context_servers (Zed-specific) -func modifyZedConfig(settings map[string]any) error { - var contextServers map[string]any - if existingServers, ok := settings["context_servers"].(map[string]any); ok { - contextServers = existingServers - } else { - contextServers = make(map[string]any) - } - - contextServers["planetscale"] = map[string]any{ - "source": "custom", - "command": "pscale", - "args": []string{"mcp", "server"}, - } - - settings["context_servers"] = contextServers - return nil -} - -// getClaudeConfigPath returns the path to the Claude Desktop config file based on the OS -func getClaudeConfigPath() (string, error) { - switch runtime.GOOS { - case "darwin": - // macOS path: ~/Library/Application Support/Claude/claude_desktop_config.json - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("could not determine user home directory: %w", err) - } - return filepath.Join(homeDir, "Library", "Application Support", "Claude", "claude_desktop_config.json"), nil - case "windows": - // Windows path: %APPDATA%\Claude\claude_desktop_config.json - return filepath.Join(os.Getenv("APPDATA"), "Claude", "claude_desktop_config.json"), nil - default: - return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) - } -} - // getCursorConfigPath returns the path to the Cursor MCP config file func getCursorConfigPath() (string, error) { homeDir, err := os.UserHomeDir() @@ -182,20 +202,3 @@ func getCursorConfigPath() (string, error) { // Cursor uses ~/.cursor/mcp.json for its MCP configuration return filepath.Join(homeDir, ".cursor", "mcp.json"), nil } - -// getZedConfigPath returns the path to the Zed config file based on the OS -func getZedConfigPath() (string, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("could not determine user home directory: %w", err) - } - - switch runtime.GOOS { - case "darwin", "linux": - return filepath.Join(homeDir, ".config", "zed", "settings.json"), nil - case "windows": - return filepath.Join(os.Getenv("APPDATA"), "Zed", "settings.json"), nil - default: - return "", fmt.Errorf("unsupported operating system: %s", runtime.GOOS) - } -} diff --git a/internal/cmd/mcp/install_test.go b/internal/cmd/mcp/install_test.go new file mode 100644 index 000000000..7471a5e32 --- /dev/null +++ b/internal/cmd/mcp/install_test.go @@ -0,0 +1,83 @@ +package mcp + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/planetscale/cli/internal/cmdutil" + "github.com/planetscale/cli/internal/printer" +) + +func TestModifyCursorConfigUsesHostedMCP(t *testing.T) { + settings := map[string]any{} + + if err := modifyCursorConfig(settings); err != nil { + t.Fatalf("modify cursor config: %v", err) + } + + servers, ok := settings["mcpServers"].(map[string]any) + if !ok { + t.Fatalf("mcpServers = %#v", settings["mcpServers"]) + } + planetscale, ok := servers["planetscale"].(map[string]any) + if !ok { + t.Fatalf("planetscale server = %#v", servers["planetscale"]) + } + if got := planetscale["url"]; got != hostedMCPURL { + t.Fatalf("url = %v, want %s", got, hostedMCPURL) + } + if _, ok := planetscale["command"]; ok { + t.Fatalf("hosted config should not use local command: %#v", planetscale) + } +} + +func TestInstallMCPServerReturnsBackupPath(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "mcp.json") + if err := os.WriteFile(configPath, []byte(`{"mcpServers":{}}`), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + backupPath, err := installMCPServer(configPath, "cursor", modifyCursorConfig) + if err != nil { + t.Fatalf("install: %v", err) + } + if backupPath == "" { + t.Fatal("expected backup path") + } + if _, err := os.Stat(backupPath); err != nil { + t.Fatalf("backup stat: %v", err) + } +} + +func TestInstallCmdClaudeCodeJSON(t *testing.T) { + format := printer.JSON + var out bytes.Buffer + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + } + ch.Printer.SetResourceOutput(&out) + + cmd := InstallCmd(ch) + cmd.SetArgs([]string{"--target", "claude-code"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute: %v", err) + } + + var resp installResponse + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("json: %v", err) + } + if resp.Status != "action_required" { + t.Fatalf("status = %q", resp.Status) + } + if resp.Command != claudeCodeCmd { + t.Fatalf("command = %q", resp.Command) + } + if resp.HostedMCPURL != hostedMCPURL { + t.Fatalf("hosted MCP URL = %q", resp.HostedMCPURL) + } +} diff --git a/internal/cmd/mcp/mcp.go b/internal/cmd/mcp/mcp.go index 2a7350f70..943a20d86 100644 --- a/internal/cmd/mcp/mcp.go +++ b/internal/cmd/mcp/mcp.go @@ -8,10 +8,15 @@ import ( // McpCmd returns a new cobra.Command for the mcp command. func McpCmd(ch *cmdutil.Helper) *cobra.Command { cmd := &cobra.Command{ - Use: "mcp ", - Short: "Manage and use the MCP server", - Long: `Manage and use the PlanetScale model context protocol (MCP) server.`, - Deprecated: "use the hosted PlanetScale MCP server: https://planetscale.com/docs/connect/mcp\n", + Use: "mcp ", + Short: "Manage PlanetScale MCP configuration", + Long: `Manage PlanetScale model context protocol (MCP) configuration. + +PlanetScale recommends the hosted MCP server: + + https://mcp.pscale.dev/mcp/planetscale + +See https://planetscale.com/docs/connect/mcp for current setup instructions.`, } cmd.AddCommand(InstallCmd(ch)) diff --git a/internal/cmd/mcp/server.go b/internal/cmd/mcp/server.go index 33e8c7711..3f77d6aa0 100644 --- a/internal/cmd/mcp/server.go +++ b/internal/cmd/mcp/server.go @@ -170,9 +170,10 @@ func getToolDefinitions() []ToolDef { // ServerCmd returns a new cobra.Command for the mcp server command. func ServerCmd(ch *cmdutil.Helper) *cobra.Command { cmd := &cobra.Command{ - Use: "server", - Short: "Start the MCP server", - Long: `Start the PlanetScale model context protocol (MCP) server.`, + Use: "server", + Short: "Start the deprecated local MCP server", + Long: `Start the deprecated local PlanetScale model context protocol (MCP) server.`, + Deprecated: "use the hosted PlanetScale MCP server: https://planetscale.com/docs/connect/mcp\n", RunE: func(cmd *cobra.Command, args []string) error { // Create a new MCP server s := server.NewMCPServer( diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 2916f2431..6c0a7409b 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -17,6 +17,7 @@ package cmd import ( "context" + "encoding/json" "errors" "fmt" "io/fs" @@ -25,6 +26,7 @@ import ( "strings" "time" + "github.com/planetscale/cli/internal/cmd/agentguide" "github.com/planetscale/cli/internal/cmd/mcp" "github.com/planetscale/cli/internal/cmd/role" "github.com/planetscale/cli/internal/cmd/size" @@ -71,9 +73,14 @@ var ( // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ - Use: "pscale", - Short: "A CLI for PlanetScale", - Long: `pscale is a CLI library for communicating with PlanetScale's API.`, + Use: "pscale", + Short: "A CLI for PlanetScale", + Long: `pscale is a CLI for communicating with PlanetScale's API. + +Agents and automation should start with: + + pscale agent-guide --format json + pscale auth check --format json`, TraverseChildren: true, } @@ -127,6 +134,10 @@ func Execute(ctx context.Context, sigc chan os.Signal, signals []os.Signal, ver, return cmdutil.ActionRequestedExitCode } + if isRootOrgFlagError(err) { + return printRootOrgFlagError(format, err) + } + // print any user specific messages first switch format { case printer.JSON: @@ -143,6 +154,47 @@ func Execute(ctx context.Context, sigc chan os.Signal, signals []os.Signal, ver, return cmdutil.FatalErrExitCode } +func isRootOrgFlagError(err error) bool { + if err == nil { + return false + } + return strings.Contains(err.Error(), "unknown flag: --org") +} + +func printRootOrgFlagError(format printer.Format, err error) int { + const hint = "--org belongs on resource commands, not on pscale root. Example: pscale database list --org --format json" + + if format == printer.JSON { + resp := struct { + Status string `json:"status"` + Code string `json:"code"` + Error string `json:"error"` + Hint string `json:"hint"` + NextSteps []string `json:"next_steps"` + }{ + Status: "error", + Code: "INVALID_FLAG_PLACEMENT", + Error: err.Error(), + Hint: hint, + NextSteps: []string{ + cmdutil.AgentGuideCmd(), + cmdutil.AgentAuthCheckCmd(), + cmdutil.AgentDatabaseListCmd(""), + }, + } + buf, marshalErr := json.MarshalIndent(resp, "", " ") + if marshalErr != nil { + fmt.Fprintf(os.Stderr, `{"error": "%s"}`, err) + return cmdutil.FatalErrExitCode + } + fmt.Fprintln(os.Stderr, string(buf)) + return cmdutil.FatalErrExitCode + } + + fmt.Fprintf(os.Stderr, "Error: %s\nHint: %s\n", err, hint) + return cmdutil.FatalErrExitCode +} + // runCmd adds all child commands to the root command, sets flags // appropriately, and runs the root command. func runCmd(ctx context.Context, ver, commit, buildDate string, format *printer.Format, debug *bool, sigc chan os.Signal, signals []os.Signal) error { @@ -216,9 +268,9 @@ func runCmd(ctx context.Context, ver, commit, buildDate string, format *printer. rootCmd.PersistentFlags().Lookup("api-token").DefValue = "" // Add command groups for better organization - rootCmd.AddGroup(&cobra.Group{ID: "database", Title: printer.Bold("General database commands:")}) - rootCmd.AddGroup(&cobra.Group{ID: "vitess", Title: printer.Bold("Vitess-specific commands:")}) - rootCmd.AddGroup(&cobra.Group{ID: "postgres", Title: printer.Bold("Postgres-specific commands:")}) + rootCmd.AddGroup(&cobra.Group{ID: "database", Title: printer.Bold("MySQL & PostgreSQL database commands:")}) + rootCmd.AddGroup(&cobra.Group{ID: "vitess", Title: printer.Bold("Vitess/MySQL-specific commands:")}) + rootCmd.AddGroup(&cobra.Group{ID: "postgres", Title: printer.Bold("PostgreSQL-specific commands:")}) rootCmd.AddGroup(&cobra.Group{ID: "platform", Title: printer.Bold("Platform & account management:")}) loginCmd := auth.LoginCmd(ch) @@ -230,6 +282,10 @@ func runCmd(ctx context.Context, ver, commit, buildDate string, format *printer. rootCmd.AddCommand(logoutCmd) // Platform & Account Management commands + agentGuideCmd := agentguide.AgentGuideCmd(ch) + agentGuideCmd.GroupID = "platform" + rootCmd.AddCommand(agentGuideCmd) + apiCmd := api.ApiCmd(ch, userAgent, headers) apiCmd.GroupID = "platform" rootCmd.AddCommand(apiCmd) diff --git a/internal/cmdutil/agent_steps.go b/internal/cmdutil/agent_steps.go index 81f5a9468..0058ef9a8 100644 --- a/internal/cmdutil/agent_steps.go +++ b/internal/cmdutil/agent_steps.go @@ -4,6 +4,10 @@ import "fmt" // Agent command strings for next_steps — flags go after the subcommand (not on pscale root). +func AgentGuideCmd() string { + return "pscale agent-guide --format json" +} + func AgentAuthCheckCmd() string { return "pscale auth check --format json" } diff --git a/internal/cmdutil/agent_steps_test.go b/internal/cmdutil/agent_steps_test.go index 5a9a9b984..f68812374 100644 --- a/internal/cmdutil/agent_steps_test.go +++ b/internal/cmdutil/agent_steps_test.go @@ -1,6 +1,12 @@ package cmdutil -import "testing" +import ( + "errors" + "testing" + + "github.com/planetscale/cli/internal/config" + "github.com/spf13/cobra" +) func TestAgentStepsFlagOrder(t *testing.T) { tests := []struct { @@ -26,3 +32,17 @@ func containsBeforeSubcommand(cmd, flag string) bool { const bad = "pscale --org" return len(cmd) >= len(bad) && cmd[:len(bad)] == bad } + +func TestCheckAuthenticationJSONHandledError(t *testing.T) { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("format", "json", "") + + err := CheckAuthentication(&config.Config{})(cmd, nil) + if err == nil { + t.Fatal("expected auth error") + } + var cmdErr *Error + if !errors.As(err, &cmdErr) || !cmdErr.Handled { + t.Fatalf("expected handled JSON error, got %v", err) + } +} diff --git a/internal/cmdutil/cmdutil.go b/internal/cmdutil/cmdutil.go index d5f4733cc..133b6df7c 100644 --- a/internal/cmdutil/cmdutil.go +++ b/internal/cmdutil/cmdutil.go @@ -2,6 +2,7 @@ package cmdutil import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -146,6 +147,40 @@ func RequiredArgs(reqArgs ...string) cobra.PositionalArgs { func CheckAuthentication(cfg *config.Config) func(cmd *cobra.Command, args []string) error { return func(cmd *cobra.Command, args []string) error { if err := cfg.IsAuthenticated(); err != nil { + if commandFormat(cmd) == printer.JSON.String() { + resp := struct { + Status string `json:"status"` + Authenticated bool `json:"authenticated"` + Code string `json:"code"` + Error string `json:"error"` + AgentGuideCommand string `json:"agent_guide_command"` + Issues []map[string]string `json:"issues"` + NextSteps []string `json:"next_steps"` + }{ + Status: "action_required", + Authenticated: false, + Code: "NO_AUTH", + Error: err.Error(), + AgentGuideCommand: AgentGuideCmd(), + Issues: []map[string]string{ + { + "code": "NO_AUTH", + "message": err.Error(), + "remediation": "Run `pscale auth login --format json`", + }, + }, + NextSteps: []string{ + AgentAuthLoginCmd(), + AgentAuthCheckCmd(), + }, + } + buf, marshalErr := json.MarshalIndent(resp, "", " ") + if marshalErr != nil { + return marshalErr + } + fmt.Fprintln(os.Stdout, string(buf)) + return JSONReportedError(ActionRequestedExitCode) + } return fmt.Errorf("%s\nError: %s", WarnAuthMessage, err.Error()) } @@ -153,6 +188,13 @@ func CheckAuthentication(cfg *config.Config) func(cmd *cobra.Command, args []str } } +func commandFormat(cmd *cobra.Command) string { + if flag := cmd.Flag("format"); flag != nil { + return flag.Value.String() + } + return printer.Human.String() +} + // NewZapLogger returns a logger to be used with the sql-proxy. By default it // only outputs error leveled messages, unless debug is true. func NewZapLogger(debug bool) *zap.Logger { diff --git a/internal/printer/printer.go b/internal/printer/printer.go index fdbc1749d..596b5f918 100644 --- a/internal/printer/printer.go +++ b/internal/printer/printer.go @@ -236,12 +236,15 @@ func (p *Printer) PrintJSON(v interface{}) error { out = p.resourceOut } - buf, err := json.MarshalIndent(v, "", " ") - if err != nil { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { return err } - fmt.Fprintln(out, string(buf)) + fmt.Fprint(out, buf.String()) return nil } diff --git a/internal/printer/printer_test.go b/internal/printer/printer_test.go new file mode 100644 index 000000000..92ade3ff9 --- /dev/null +++ b/internal/printer/printer_test.go @@ -0,0 +1,26 @@ +package printer + +import ( + "bytes" + "strings" + "testing" +) + +func TestPrintJSONDoesNotEscapeHTMLPlaceholders(t *testing.T) { + format := JSON + var out bytes.Buffer + p := NewPrinter(&format) + p.SetResourceOutput(&out) + + if err := p.PrintJSON(map[string]string{"next_step": "pscale branch list --org --format json"}); err != nil { + t.Fatalf("print json: %v", err) + } + + got := out.String() + if !strings.Contains(got, "") || !strings.Contains(got, "") { + t.Fatalf("expected unescaped placeholders, got %s", got) + } + if strings.Contains(got, `\u003c`) || strings.Contains(got, `\u003e`) { + t.Fatalf("expected no escaped angle brackets, got %s", got) + } +} From d74bdf65069cdf97e113d394ba46b86de797c66f Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Fri, 3 Jul 2026 14:58:47 -0400 Subject: [PATCH 07/19] Fix agent action-required recovery paths. Co-authored-by: Cursor --- internal/cmd/auth/onboarding.go | 27 +++++++++++++++++++++------ internal/cmd/auth/onboarding_test.go | 20 ++++++++++++++++++++ internal/cmd/mcp/install.go | 8 +++++++- internal/cmd/mcp/install_test.go | 10 ++++++++-- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/internal/cmd/auth/onboarding.go b/internal/cmd/auth/onboarding.go index 57ad61f4b..929b10f89 100644 --- a/internal/cmd/auth/onboarding.go +++ b/internal/cmd/auth/onboarding.go @@ -76,14 +76,11 @@ func buildAuthCheckResponse(ctx context.Context, ch *cmdutil.Helper) AuthCheckRe } if _, err := client.Organizations.List(ctx); err != nil { + issue, nextSteps := invalidAuthIssueAndNextSteps(resp.AuthMethod) resp.Status = "action_required" resp.Authenticated = false - resp.Issues = append(resp.Issues, AuthIssue{ - Code: "AUTH_INVALID", - Message: "API authentication failed", - Remediation: "Run `pscale auth login --format json` (browser opens when possible)", - }) - resp.NextSteps = []string{cmdutil.AgentAuthLoginCmd()} + resp.Issues = append(resp.Issues, issue) + resp.NextSteps = nextSteps return resp } @@ -111,6 +108,24 @@ func buildAuthCheckResponse(ctx context.Context, ch *cmdutil.Helper) AuthCheckRe return resp } +func invalidAuthIssueAndNextSteps(authMethod string) (AuthIssue, []string) { + if authMethod == "service_token" { + issue := AuthIssue{ + Code: "SERVICE_TOKEN_INVALID", + Message: "API authentication failed", + Remediation: "Verify --service-token-id and --service-token, then re-run `pscale auth check --format json`", + } + return issue, []string{cmdutil.AgentAuthCheckCmd()} + } + + issue := AuthIssue{ + Code: "AUTH_INVALID", + Message: "API authentication failed", + Remediation: "Run `pscale auth login --format json` (browser opens when possible)", + } + return issue, []string{cmdutil.AgentAuthLoginCmd()} +} + func configuredOrganization(ch *cmdutil.Helper) string { if ch.Config.Organization != "" { return ch.Config.Organization diff --git a/internal/cmd/auth/onboarding_test.go b/internal/cmd/auth/onboarding_test.go index 3b69f7fcb..6cbd30817 100644 --- a/internal/cmd/auth/onboarding_test.go +++ b/internal/cmd/auth/onboarding_test.go @@ -54,6 +54,26 @@ func TestAuthCheckExitCode(t *testing.T) { } } +func TestInvalidAuthIssueAndNextStepsServiceToken(t *testing.T) { + issue, nextSteps := invalidAuthIssueAndNextSteps("service_token") + if issue.Code != "SERVICE_TOKEN_INVALID" { + t.Fatalf("code = %q", issue.Code) + } + if len(nextSteps) != 1 || nextSteps[0] != cmdutil.AgentAuthCheckCmd() { + t.Fatalf("next steps = %#v", nextSteps) + } +} + +func TestInvalidAuthIssueAndNextStepsOAuth(t *testing.T) { + issue, nextSteps := invalidAuthIssueAndNextSteps("oauth") + if issue.Code != "AUTH_INVALID" { + t.Fatalf("code = %q", issue.Code) + } + if len(nextSteps) != 1 || nextSteps[0] != cmdutil.AgentAuthLoginCmd() { + t.Fatalf("next steps = %#v", nextSteps) + } +} + func TestFinishLoginJSONOrgSetupFailure(t *testing.T) { format := printer.JSON var out bytes.Buffer diff --git a/internal/cmd/mcp/install.go b/internal/cmd/mcp/install.go index 631357b5b..1fa5a7a19 100644 --- a/internal/cmd/mcp/install.go +++ b/internal/cmd/mcp/install.go @@ -104,7 +104,13 @@ MCP setup instructions instead of installing the deprecated local stdio server.` func printInstallResponse(ch *cmdutil.Helper, resp installResponse) error { if ch.Printer.Format() == printer.JSON { - return ch.Printer.PrintJSON(resp) + if err := ch.Printer.PrintJSON(resp); err != nil { + return err + } + if resp.Status == "action_required" { + return cmdutil.JSONReportedError(cmdutil.ActionRequestedExitCode) + } + return nil } switch resp.Status { diff --git a/internal/cmd/mcp/install_test.go b/internal/cmd/mcp/install_test.go index 7471a5e32..906a46146 100644 --- a/internal/cmd/mcp/install_test.go +++ b/internal/cmd/mcp/install_test.go @@ -3,6 +3,7 @@ package mcp import ( "bytes" "encoding/json" + "errors" "os" "path/filepath" "testing" @@ -63,8 +64,13 @@ func TestInstallCmdClaudeCodeJSON(t *testing.T) { cmd := InstallCmd(ch) cmd.SetArgs([]string{"--target", "claude-code"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("execute: %v", err) + err := cmd.Execute() + if err == nil { + t.Fatal("expected action_required exit") + } + var cmdErr *cmdutil.Error + if !errors.As(err, &cmdErr) || !cmdErr.Handled { + t.Fatalf("expected handled JSON error, got %v", err) } var resp installResponse From f31c891a31bab71326f77db69d1a010953221ee8 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Fri, 3 Jul 2026 15:02:59 -0400 Subject: [PATCH 08/19] Fix CTE write query routing. Co-authored-by: Cursor --- internal/sqlquery/sqlquery.go | 111 ++++++++++++++++++++++++++++- internal/sqlquery/sqlquery_test.go | 31 +++++--- 2 files changed, 129 insertions(+), 13 deletions(-) diff --git a/internal/sqlquery/sqlquery.go b/internal/sqlquery/sqlquery.go index 86ab576c0..f51cefaa3 100644 --- a/internal/sqlquery/sqlquery.go +++ b/internal/sqlquery/sqlquery.go @@ -268,15 +268,120 @@ func queryPostgres(ctx context.Context, ch *cmdutil.Helper, opts Options, pgDB s return runQuery(ctx, db, opts.Query) } -var readQueryPrefixes = []string{"SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "TABLE"} +var readQueryPrefixes = []string{"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "TABLE"} func isReadQuery(query string) bool { - q := strings.TrimSpace(strings.ToUpper(query)) + q := strings.TrimSpace(query) + upper := strings.ToUpper(q) + if hasKeywordPrefix(upper, "WITH") { + afterCTEs, ok := queryAfterCTEs(q) + if !ok { + // Preserve the old behavior for unusual CTE syntax we cannot parse. + return true + } + return isReadQuery(afterCTEs) + } return slices.ContainsFunc(readQueryPrefixes, func(prefix string) bool { - return strings.HasPrefix(q, prefix) + return hasKeywordPrefix(upper, prefix) }) } +func queryAfterCTEs(query string) (string, bool) { + rest := strings.TrimSpace(query) + if !hasKeywordPrefix(strings.ToUpper(rest), "WITH") { + return rest, true + } + rest = strings.TrimSpace(rest[len("WITH"):]) + if hasKeywordPrefix(strings.ToUpper(rest), "RECURSIVE") { + rest = strings.TrimSpace(rest[len("RECURSIVE"):]) + } + + for { + asIdx := topLevelKeywordIndex(rest, "AS") + if asIdx < 0 { + return "", false + } + rest = strings.TrimSpace(rest[asIdx+len("AS"):]) + if !strings.HasPrefix(rest, "(") { + return "", false + } + end := matchingParenIndex(rest) + if end < 0 { + return "", false + } + rest = strings.TrimSpace(rest[end+1:]) + if strings.HasPrefix(rest, ",") { + rest = strings.TrimSpace(rest[1:]) + continue + } + return rest, true + } +} + +func topLevelKeywordIndex(s, keyword string) int { + upper := strings.ToUpper(s) + depth := 0 + for i := 0; i < len(upper); i++ { + switch upper[i] { + case '(': + depth++ + case ')': + if depth > 0 { + depth-- + } + default: + if depth == 0 && (i == 0 || !isIdentifierChar(upper[i-1])) && hasKeywordPrefix(upper[i:], keyword) { + return i + } + } + } + return -1 +} + +func matchingParenIndex(s string) int { + depth := 0 + quote := byte(0) + for i := 0; i < len(s); i++ { + c := s[i] + if quote != 0 { + if c == quote { + if i+1 < len(s) && s[i+1] == quote { + i++ + continue + } + quote = 0 + } + continue + } + switch c { + case '\'', '"', '`': + quote = c + case '(': + depth++ + case ')': + depth-- + if depth == 0 { + return i + } + } + } + return -1 +} + +func hasKeywordPrefix(s, keyword string) bool { + if !strings.HasPrefix(s, keyword) { + return false + } + if len(s) == len(keyword) { + return true + } + return !isIdentifierChar(s[len(keyword)]) +} + +func isIdentifierChar(c byte) bool { + return c == '_' || (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') +} + func runQuery(ctx context.Context, db *sql.DB, query string) (*queryOutcome, error) { if isReadQuery(query) { rows, err := db.QueryContext(ctx, query) diff --git a/internal/sqlquery/sqlquery_test.go b/internal/sqlquery/sqlquery_test.go index b0701b29e..d2b728b65 100644 --- a/internal/sqlquery/sqlquery_test.go +++ b/internal/sqlquery/sqlquery_test.go @@ -9,17 +9,28 @@ import ( ) func TestIsReadQuery(t *testing.T) { - if !isReadQuery("SELECT 1") { - t.Fatal("SELECT should be read") - } - if !isReadQuery(" with x as (select 1) select * from x") { - t.Fatal("WITH should be read") - } - if isReadQuery("INSERT INTO t VALUES (1)") { - t.Fatal("INSERT should not be read") + tests := []struct { + name string + query string + want bool + }{ + {name: "select", query: "SELECT 1", want: true}, + {name: "with select", query: " with x as (select 1) select * from x", want: true}, + {name: "with multiple ctes select", query: "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT * FROM b", want: true}, + {name: "with string containing paren", query: "WITH x AS (SELECT ')' AS val) SELECT * FROM x", want: true}, + {name: "insert", query: "INSERT INTO t VALUES (1)", want: false}, + {name: "update", query: "UPDATE t SET x = 1", want: false}, + {name: "with insert", query: "WITH x AS (SELECT 1) INSERT INTO t VALUES (1)", want: false}, + {name: "with update", query: "WITH x AS (SELECT 1) UPDATE t SET x = 1", want: false}, + {name: "with merge", query: "WITH x AS (SELECT 1) MERGE INTO t USING x ON t.id = x.id WHEN MATCHED THEN UPDATE SET x = 1", want: false}, } - if isReadQuery("UPDATE t SET x = 1") { - t.Fatal("UPDATE should not be read") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isReadQuery(tt.query); got != tt.want { + t.Fatalf("isReadQuery(%q) = %v, want %v", tt.query, got, tt.want) + } + }) } } From faf043da637b3fb4e447d69af878b92bd8fd8c48 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Fri, 3 Jul 2026 15:07:03 -0400 Subject: [PATCH 09/19] Fix destructive SQL string parsing. Co-authored-by: Cursor --- internal/sqlquery/destructive.go | 68 +++++++++++++++++++++++++-- internal/sqlquery/destructive_test.go | 4 ++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/internal/sqlquery/destructive.go b/internal/sqlquery/destructive.go index bd916e83a..d510e03ae 100644 --- a/internal/sqlquery/destructive.go +++ b/internal/sqlquery/destructive.go @@ -24,21 +24,81 @@ func IsDestructiveQuery(query string) bool { func splitSQLStatements(query string) []string { out := make([]string, 0, strings.Count(query, ";")+1) - for part := range strings.SplitSeq(query, ";") { - if trimmed := strings.TrimSpace(part); trimmed != "" { - out = append(out, trimmed) + start := 0 + quote := byte(0) + for i := 0; i < len(query); i++ { + c := query[i] + if quote != 0 { + if c == '\\' && quote == '\'' && i+1 < len(query) { + i++ + continue + } + if c == quote { + if i+1 < len(query) && query[i+1] == quote { + i++ + continue + } + quote = 0 + } + continue } + switch c { + case '\'', '"', '`': + quote = c + case ';': + if trimmed := strings.TrimSpace(query[start:i]); trimmed != "" { + out = append(out, trimmed) + } + start = i + 1 + } + } + if trimmed := strings.TrimSpace(query[start:]); trimmed != "" { + out = append(out, trimmed) } return out } func isDestructiveStatement(stmt string) bool { - upper := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(strings.ToUpper(stmt)) + upper := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(strings.ToUpper(stripQuotedSQL(stmt))) return slices.ContainsFunc(destructiveWords, func(word string) bool { return containsWord(upper, word) }) } +func stripQuotedSQL(stmt string) string { + var out strings.Builder + out.Grow(len(stmt)) + quote := byte(0) + for i := 0; i < len(stmt); i++ { + c := stmt[i] + if quote != 0 { + out.WriteByte(' ') + if c == '\\' && quote == '\'' && i+1 < len(stmt) { + i++ + out.WriteByte(' ') + continue + } + if c == quote { + if i+1 < len(stmt) && stmt[i+1] == quote { + i++ + out.WriteByte(' ') + continue + } + quote = 0 + } + continue + } + switch c { + case '\'', '"', '`': + quote = c + out.WriteByte(' ') + default: + out.WriteByte(c) + } + } + return out.String() +} + func containsWord(q, word string) bool { return strings.Contains(" "+q+" ", " "+word+" ") } diff --git a/internal/sqlquery/destructive_test.go b/internal/sqlquery/destructive_test.go index dd54d3a6c..3e4dedd1a 100644 --- a/internal/sqlquery/destructive_test.go +++ b/internal/sqlquery/destructive_test.go @@ -24,6 +24,10 @@ func TestIsDestructiveQuery(t *testing.T) { {query: " delete from users", want: true}, {query: "SELECT 1\nDELETE FROM users", want: true}, {query: "WITH c AS (SELECT 1) DELETE FROM users", want: true}, + {query: "SELECT '; DELETE FROM users' AS sample", want: false}, + {query: "SELECT 'DROP TABLE users' AS sample", want: false}, + {query: "SELECT \"TRUNCATE\" FROM users", want: false}, + {query: "INSERT INTO logs VALUES ('created; still same statement')", want: false}, {query: "SELECT deleted_at FROM users", want: false}, {query: "SELECT is_deleted FROM users", want: false}, {query: "SELECT * FROM delete_queue", want: false}, From e2069508abe99a17c0ab605b318072201618e665 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Fri, 3 Jul 2026 15:12:51 -0400 Subject: [PATCH 10/19] Fix destructive SQL comment parsing. Co-authored-by: Cursor --- internal/cmd/root.go | 10 ++++-- internal/sqlquery/destructive.go | 44 +++++++++++++++++++++++++-- internal/sqlquery/destructive_test.go | 5 +++ 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6c0a7409b..d0120d85e 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -16,6 +16,7 @@ limitations under the License. package cmd import ( + "bytes" "context" "encoding/json" "errors" @@ -182,12 +183,15 @@ func printRootOrgFlagError(format printer.Format, err error) int { cmdutil.AgentDatabaseListCmd(""), }, } - buf, marshalErr := json.MarshalIndent(resp, "", " ") - if marshalErr != nil { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if marshalErr := enc.Encode(resp); marshalErr != nil { fmt.Fprintf(os.Stderr, `{"error": "%s"}`, err) return cmdutil.FatalErrExitCode } - fmt.Fprintln(os.Stderr, string(buf)) + fmt.Fprint(os.Stderr, buf.String()) return cmdutil.FatalErrExitCode } diff --git a/internal/sqlquery/destructive.go b/internal/sqlquery/destructive.go index d510e03ae..f4a80cb3d 100644 --- a/internal/sqlquery/destructive.go +++ b/internal/sqlquery/destructive.go @@ -19,7 +19,7 @@ func (e *DestructiveQueryError) Error() string { // This is a best-effort guardrail for agents, not a SQL parser. Any statement // containing the words DELETE, DROP, or TRUNCATE requires approval. func IsDestructiveQuery(query string) bool { - return slices.ContainsFunc(splitSQLStatements(query), isDestructiveStatement) + return slices.ContainsFunc(splitSQLStatements(stripSQLGuardIgnoredText(query)), isDestructiveStatement) } func splitSQLStatements(query string) []string { @@ -59,18 +59,38 @@ func splitSQLStatements(query string) []string { } func isDestructiveStatement(stmt string) bool { - upper := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(strings.ToUpper(stripQuotedSQL(stmt))) + upper := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(strings.ToUpper(stmt)) return slices.ContainsFunc(destructiveWords, func(word string) bool { return containsWord(upper, word) }) } -func stripQuotedSQL(stmt string) string { +func stripSQLGuardIgnoredText(stmt string) string { var out strings.Builder out.Grow(len(stmt)) quote := byte(0) + lineComment := false + blockComment := false for i := 0; i < len(stmt); i++ { c := stmt[i] + if lineComment { + if c == '\n' { + lineComment = false + out.WriteByte(c) + } else { + out.WriteByte(' ') + } + continue + } + if blockComment { + out.WriteByte(' ') + if c == '*' && i+1 < len(stmt) && stmt[i+1] == '/' { + i++ + out.WriteByte(' ') + blockComment = false + } + continue + } if quote != 0 { out.WriteByte(' ') if c == '\\' && quote == '\'' && i+1 < len(stmt) { @@ -89,6 +109,24 @@ func stripQuotedSQL(stmt string) string { continue } switch c { + case '-': + if i+1 < len(stmt) && stmt[i+1] == '-' { + lineComment = true + out.WriteByte(' ') + i++ + out.WriteByte(' ') + } else { + out.WriteByte(c) + } + case '/': + if i+1 < len(stmt) && stmt[i+1] == '*' { + blockComment = true + out.WriteByte(' ') + i++ + out.WriteByte(' ') + } else { + out.WriteByte(c) + } case '\'', '"', '`': quote = c out.WriteByte(' ') diff --git a/internal/sqlquery/destructive_test.go b/internal/sqlquery/destructive_test.go index 3e4dedd1a..93dceb5a5 100644 --- a/internal/sqlquery/destructive_test.go +++ b/internal/sqlquery/destructive_test.go @@ -28,6 +28,11 @@ func TestIsDestructiveQuery(t *testing.T) { {query: "SELECT 'DROP TABLE users' AS sample", want: false}, {query: "SELECT \"TRUNCATE\" FROM users", want: false}, {query: "INSERT INTO logs VALUES ('created; still same statement')", want: false}, + {query: "SELECT 1 -- DELETE FROM users", want: false}, + {query: "SELECT 1 /* DROP TABLE users */", want: false}, + {query: "SELECT 1 -- ; DELETE FROM users\nSELECT 2", want: false}, + {query: "SELECT 1 /* ; TRUNCATE users */; SELECT 2", want: false}, + {query: "SELECT 1; /* comment */ DELETE FROM users", want: true}, {query: "SELECT deleted_at FROM users", want: false}, {query: "SELECT is_deleted FROM users", want: false}, {query: "SELECT * FROM delete_queue", want: false}, From f07303dbd36b7babd3f62f40b9045999b50ec7be Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Fri, 3 Jul 2026 15:16:15 -0400 Subject: [PATCH 11/19] Return rows from SQL RETURNING queries. Co-authored-by: Cursor --- internal/sqlquery/sqlquery.go | 7 ++++++- internal/sqlquery/sqlquery_test.go | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/internal/sqlquery/sqlquery.go b/internal/sqlquery/sqlquery.go index f51cefaa3..b907db1f8 100644 --- a/internal/sqlquery/sqlquery.go +++ b/internal/sqlquery/sqlquery.go @@ -286,6 +286,11 @@ func isReadQuery(query string) bool { }) } +func queryReturnsRows(query string) bool { + upper := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(strings.ToUpper(stripSQLGuardIgnoredText(query))) + return containsWord(upper, "RETURNING") +} + func queryAfterCTEs(query string) (string, bool) { rest := strings.TrimSpace(query) if !hasKeywordPrefix(strings.ToUpper(rest), "WITH") { @@ -383,7 +388,7 @@ func isIdentifierChar(c byte) bool { } func runQuery(ctx context.Context, db *sql.DB, query string) (*queryOutcome, error) { - if isReadQuery(query) { + if isReadQuery(query) || queryReturnsRows(query) { rows, err := db.QueryContext(ctx, query) if err != nil { return nil, err diff --git a/internal/sqlquery/sqlquery_test.go b/internal/sqlquery/sqlquery_test.go index d2b728b65..096b5360e 100644 --- a/internal/sqlquery/sqlquery_test.go +++ b/internal/sqlquery/sqlquery_test.go @@ -34,6 +34,28 @@ func TestIsReadQuery(t *testing.T) { } } +func TestQueryReturnsRows(t *testing.T) { + tests := []struct { + name string + query string + want bool + }{ + {name: "insert returning", query: "INSERT INTO t VALUES (1) RETURNING id", want: true}, + {name: "update returning", query: "UPDATE t SET x = 1 RETURNING id", want: true}, + {name: "with insert returning", query: "WITH x AS (SELECT 1) INSERT INTO t VALUES (1) RETURNING id", want: true}, + {name: "returning in string", query: "SELECT 'RETURNING id' AS sample", want: false}, + {name: "returning in comment", query: "SELECT 1 -- RETURNING id", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := queryReturnsRows(tt.query); got != tt.want { + t.Fatalf("queryReturnsRows(%q) = %v, want %v", tt.query, got, tt.want) + } + }) + } +} + func TestMySQLDSNDatabase(t *testing.T) { if got := mysqlDSNDatabase(Options{}); got != "@primary" { t.Fatalf("default = %q, want @primary", got) From 7289562c224f2b0b277c543d1c8e28fbda81d944 Mon Sep 17 00:00:00 2001 From: Mike Coutermarsh Date: Fri, 3 Jul 2026 15:21:23 -0400 Subject: [PATCH 12/19] Fix remaining agent JSON edge cases. Co-authored-by: Cursor --- internal/cmd/root.go | 2 +- internal/cmd/sql/json.go | 8 +++---- internal/cmd/sql/sql_test.go | 33 +++++++++++++++++++++++++++ internal/sqlquery/destructive.go | 3 +++ internal/sqlquery/destructive_test.go | 2 ++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index d0120d85e..682aaa4a2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -191,7 +191,7 @@ func printRootOrgFlagError(format printer.Format, err error) int { fmt.Fprintf(os.Stderr, `{"error": "%s"}`, err) return cmdutil.FatalErrExitCode } - fmt.Fprint(os.Stderr, buf.String()) + fmt.Fprint(os.Stdout, buf.String()) return cmdutil.FatalErrExitCode } diff --git a/internal/cmd/sql/json.go b/internal/cmd/sql/json.go index 87d215095..a0ace00e0 100644 --- a/internal/cmd/sql/json.go +++ b/internal/cmd/sql/json.go @@ -8,11 +8,11 @@ import ( "github.com/planetscale/cli/internal/sqlquery" ) -func reportJSON(ch *cmdutil.Helper, v any) error { +func reportJSON(ch *cmdutil.Helper, v any, exitCode int) error { if err := ch.Printer.PrintJSON(v); err != nil { return err } - return cmdutil.JSONReportedError(cmdutil.ActionRequestedExitCode) + return cmdutil.JSONReportedError(exitCode) } func handleExecuteError(ch *cmdutil.Helper, err error, database, branch string) error { @@ -36,7 +36,7 @@ func handleExecuteError(ch *cmdutil.Helper, err error, database, branch string) "Ask the user to approve this destructive query", cmdutil.AgentSQLCmd(ch.Config.Organization, database, branch, true), }, - }) + }, cmdutil.ActionRequestedExitCode) } return reportJSON(ch, map[string]any{ @@ -46,5 +46,5 @@ func handleExecuteError(ch *cmdutil.Helper, err error, database, branch string) cmdutil.AgentAuthCheckCmd(), cmdutil.AgentSQLCmd(ch.Config.Organization, database, branch, false), }, - }) + }, cmdutil.FatalErrExitCode) } diff --git a/internal/cmd/sql/sql_test.go b/internal/cmd/sql/sql_test.go index d7b6654d4..85a809a41 100644 --- a/internal/cmd/sql/sql_test.go +++ b/internal/cmd/sql/sql_test.go @@ -71,6 +71,9 @@ func TestSQLCmdDestructiveQueryReturnsActionRequiredJSON(t *testing.T) { if !errors.As(err, &cmdErr) || !cmdErr.Handled { t.Fatalf("expected handled JSONReportedError, got %v", err) } + if cmdErr.ExitCode != cmdutil.ActionRequestedExitCode { + t.Fatalf("exit code = %d", cmdErr.ExitCode) + } var resp map[string]any if err := json.Unmarshal(out.Bytes(), &resp); err != nil { @@ -83,3 +86,33 @@ func TestSQLCmdDestructiveQueryReturnsActionRequiredJSON(t *testing.T) { t.Fatalf("query_kind = %v", resp["query_kind"]) } } + +func TestHandleExecuteErrorReturnsFatalExitForJSONError(t *testing.T) { + format := printer.JSON + var out bytes.Buffer + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + Config: &config.Config{Organization: "acme"}, + } + ch.Printer.SetResourceOutput(&out) + + err := handleExecuteError(ch, errors.New("query failed"), "mydb", "main") + if err == nil { + t.Fatal("expected error") + } + var cmdErr *cmdutil.Error + if !errors.As(err, &cmdErr) || !cmdErr.Handled { + t.Fatalf("expected handled JSONReportedError, got %v", err) + } + if cmdErr.ExitCode != cmdutil.FatalErrExitCode { + t.Fatalf("exit code = %d", cmdErr.ExitCode) + } + + var resp map[string]any + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("json: %v", err) + } + if resp["status"] != "error" { + t.Fatalf("status = %v", resp["status"]) + } +} diff --git a/internal/sqlquery/destructive.go b/internal/sqlquery/destructive.go index f4a80cb3d..bb70ab952 100644 --- a/internal/sqlquery/destructive.go +++ b/internal/sqlquery/destructive.go @@ -109,6 +109,9 @@ func stripSQLGuardIgnoredText(stmt string) string { continue } switch c { + case '#': + lineComment = true + out.WriteByte(' ') case '-': if i+1 < len(stmt) && stmt[i+1] == '-' { lineComment = true diff --git a/internal/sqlquery/destructive_test.go b/internal/sqlquery/destructive_test.go index 93dceb5a5..85e9f3df6 100644 --- a/internal/sqlquery/destructive_test.go +++ b/internal/sqlquery/destructive_test.go @@ -29,8 +29,10 @@ func TestIsDestructiveQuery(t *testing.T) { {query: "SELECT \"TRUNCATE\" FROM users", want: false}, {query: "INSERT INTO logs VALUES ('created; still same statement')", want: false}, {query: "SELECT 1 -- DELETE FROM users", want: false}, + {query: "SELECT 1 # DELETE FROM users", want: false}, {query: "SELECT 1 /* DROP TABLE users */", want: false}, {query: "SELECT 1 -- ; DELETE FROM users\nSELECT 2", want: false}, + {query: "SELECT 1 # ; DELETE FROM users\nSELECT 2", want: false}, {query: "SELECT 1 /* ; TRUNCATE users */; SELECT 2", want: false}, {query: "SELECT 1; /* comment */ DELETE FROM users", want: true}, {query: "SELECT deleted_at FROM users", want: false}, From 05a56435a41f79e324402a0dafc4d0e5941eca81 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 6 Jul 2026 10:51:03 -0500 Subject: [PATCH 13/19] Link agent-guide to planetscale/skills for operational workflows. Document the CLI vs project AGENTS.md split, expose skills install metadata in agent-guide JSON bootstrap, and point README readers at the skills pack. Co-authored-by: Cursor --- AGENTS.md | 13 ++++++ README.md | 2 + internal/cmd/agentguide/agentguide.go | 53 ++++++++++++++-------- internal/cmd/agentguide/agentguide_test.go | 12 +++++ 4 files changed, 61 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c799cb76f..81cd98f1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,8 @@ pscale auth check --format json Use direct CLI automation for shell commands and scripts. Use the hosted PlanetScale MCP server for MCP clients. +This file documents **how to invoke `pscale`**. For database assessment, safety review, and operational workflows, install the [PlanetScale skills pack](https://github.com/planetscale/skills) (`14-pscale-cli-automation` covers CLI automation; `00-safe-orchestrator` runs the full review). In application repositories, add a separate **project** `AGENTS.md` with org, database, branch, and approval rules (see skill `09-mcp-agent-operating-model` in that repo). + | Placeholder | Meaning | |-------------|---------| | `` | Organization name | @@ -172,3 +174,14 @@ https://mcp.pscale.dev/mcp/planetscale See the current MCP docs: https://planetscale.com/docs/connect/mcp Do not use the deprecated local `pscale mcp server` path unless you explicitly need backward compatibility with an old setup. + +## PlanetScale agent skills + +Operational workflows (inventory, safety review, Insights, schema recommendations, Traffic Control) live in the public skills repo — not in this file. + +```bash +git clone https://github.com/planetscale/skills.git && cd skills && script/setup +# or: npx skills add planetscale/skills -g -y +``` + +After installing skills, load `14-pscale-cli-automation` for CLI conventions (or re-run `pscale agent-guide --format json` from any `pscale` binary that includes agent onboarding). Use `00-safe-orchestrator` when the user asks for a full PlanetScale assessment. diff --git a/README.md b/README.md index e7448a4a3..2597b3a66 100644 --- a/README.md +++ b/README.md @@ -126,3 +126,5 @@ And then use the `pscale` binary built in `cmd/pscale/` for testing: Please checkout our Documentation page: [planetscale.com/docs](https://planetscale.com/docs/reference/planetscale-cli) For AI agents and automation, run `pscale agent-guide` or see [AGENTS.md](./AGENTS.md). +For operational workflows (inventory, safety review, schema recommendations), install +[planetscale/skills](https://github.com/planetscale/skills). diff --git a/internal/cmd/agentguide/agentguide.go b/internal/cmd/agentguide/agentguide.go index 0b81d783d..d6227ec94 100644 --- a/internal/cmd/agentguide/agentguide.go +++ b/internal/cmd/agentguide/agentguide.go @@ -9,20 +9,28 @@ import ( ) const ( - HostedMCPURL = "https://mcp.pscale.dev/mcp/planetscale" - MCPDocsURL = "https://planetscale.com/docs/connect/mcp" + HostedMCPURL = "https://mcp.pscale.dev/mcp/planetscale" + MCPDocsURL = "https://planetscale.com/docs/connect/mcp" + SkillsRepoURL = "https://github.com/planetscale/skills" + SkillsSetupCmd = "git clone https://github.com/planetscale/skills.git && cd skills && script/setup" + SkillsNPXInstall = "npx skills add planetscale/skills -g -y" + SkillsCLIAutomation = "planetscale-pscale-cli-automation" ) type response struct { - Status string `json:"status"` - Guide string `json:"guide"` - FirstCommand string `json:"first_command"` - AgentGuideCommand string `json:"agent_guide_command"` - HostedMCPURL string `json:"hosted_mcp_url"` - MCPDocsURL string `json:"mcp_docs_url"` - SupportedEngines []string `json:"supported_engines"` - Conventions []string `json:"conventions"` - NextSteps []string `json:"next_steps"` + Status string `json:"status"` + Guide string `json:"guide"` + FirstCommand string `json:"first_command"` + AgentGuideCommand string `json:"agent_guide_command"` + HostedMCPURL string `json:"hosted_mcp_url"` + MCPDocsURL string `json:"mcp_docs_url"` + SkillsRepoURL string `json:"skills_repo_url"` + SkillsSetupCommand string `json:"skills_setup_command"` + SkillsNPXCommand string `json:"skills_npx_command"` + SkillsCLIAutomation string `json:"skills_cli_automation_skill"` + SupportedEngines []string `json:"supported_engines"` + Conventions []string `json:"conventions"` + NextSteps []string `json:"next_steps"` } // AgentGuideCmd prints the embedded guide for agents and automation. @@ -33,26 +41,33 @@ func AgentGuideCmd(ch *cmdutil.Helper) *cobra.Command { Long: `Show guidance for AI agents and automation using pscale. Use --format json for a machine-readable bootstrap response with first commands, -supported engines, and hosted MCP details.`, +supported engines, hosted MCP details, and PlanetScale skills install hints.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { if ch.Printer.Format() == printer.JSON { return ch.Printer.PrintJSON(response{ - Status: "ok", - Guide: clicontent.AgentGuide, - FirstCommand: cmdutil.AgentAuthCheckCmd(), - AgentGuideCommand: cmdutil.AgentGuideCmd(), - HostedMCPURL: HostedMCPURL, - MCPDocsURL: MCPDocsURL, - SupportedEngines: []string{"mysql", "postgresql"}, + Status: "ok", + Guide: clicontent.AgentGuide, + FirstCommand: cmdutil.AgentAuthCheckCmd(), + AgentGuideCommand: cmdutil.AgentGuideCmd(), + HostedMCPURL: HostedMCPURL, + MCPDocsURL: MCPDocsURL, + SkillsRepoURL: SkillsRepoURL, + SkillsSetupCommand: SkillsSetupCmd, + SkillsNPXCommand: SkillsNPXInstall, + SkillsCLIAutomation: SkillsCLIAutomation, + SupportedEngines: []string{"mysql", "postgresql"}, Conventions: []string{ "Always pass --format json for automation", "Put --org on resource subcommands, not on pscale root", "Put positional arguments before flags for commands like sql and branch list", "Use hosted MCP for MCP clients; direct CLI automation should start with auth check", + "Install planetscale/skills for operational workflows; this guide covers pscale invocation only", + "Project AGENTS.md is for org/database/branch targeting — separate from the CLI agent guide", }, NextSteps: []string{ cmdutil.AgentAuthCheckCmd(), + SkillsSetupCmd, }, }) } diff --git a/internal/cmd/agentguide/agentguide_test.go b/internal/cmd/agentguide/agentguide_test.go index 875bb7db0..803d5fc8e 100644 --- a/internal/cmd/agentguide/agentguide_test.go +++ b/internal/cmd/agentguide/agentguide_test.go @@ -35,6 +35,18 @@ func TestAgentGuideJSONBootstrap(t *testing.T) { if resp.HostedMCPURL != HostedMCPURL { t.Fatalf("hosted MCP URL = %q", resp.HostedMCPURL) } + if resp.SkillsRepoURL != SkillsRepoURL { + t.Fatalf("skills repo URL = %q", resp.SkillsRepoURL) + } + if resp.SkillsSetupCommand != SkillsSetupCmd { + t.Fatalf("skills setup command = %q", resp.SkillsSetupCommand) + } + if resp.SkillsCLIAutomation != SkillsCLIAutomation { + t.Fatalf("skills CLI automation skill = %q", resp.SkillsCLIAutomation) + } + if len(resp.NextSteps) < 2 || resp.NextSteps[1] != SkillsSetupCmd { + t.Fatalf("next_steps = %#v", resp.NextSteps) + } if resp.Guide == "" { t.Fatal("expected embedded guide") } From 2c584847346dca944d4e91250d4e27c1bc54fe26 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 6 Jul 2026 11:04:41 -0500 Subject: [PATCH 14/19] Fix Bugbot auth JSON issues and go fmt lint failure. Add next_steps for CLIENT_INIT_FAILED, align login message with action_required status, and format agentguide constants. Co-authored-by: Cursor --- internal/cmd/agentguide/agentguide.go | 10 +++++----- internal/cmd/auth/login.go | 1 + internal/cmd/auth/onboarding.go | 4 ++++ internal/cmd/auth/onboarding_test.go | 3 +++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/internal/cmd/agentguide/agentguide.go b/internal/cmd/agentguide/agentguide.go index d6227ec94..a3fa74f84 100644 --- a/internal/cmd/agentguide/agentguide.go +++ b/internal/cmd/agentguide/agentguide.go @@ -9,11 +9,11 @@ import ( ) const ( - HostedMCPURL = "https://mcp.pscale.dev/mcp/planetscale" - MCPDocsURL = "https://planetscale.com/docs/connect/mcp" - SkillsRepoURL = "https://github.com/planetscale/skills" - SkillsSetupCmd = "git clone https://github.com/planetscale/skills.git && cd skills && script/setup" - SkillsNPXInstall = "npx skills add planetscale/skills -g -y" + HostedMCPURL = "https://mcp.pscale.dev/mcp/planetscale" + MCPDocsURL = "https://planetscale.com/docs/connect/mcp" + SkillsRepoURL = "https://github.com/planetscale/skills" + SkillsSetupCmd = "git clone https://github.com/planetscale/skills.git && cd skills && script/setup" + SkillsNPXInstall = "npx skills add planetscale/skills -g -y" SkillsCLIAutomation = "planetscale-pscale-cli-automation" ) diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index f8a939805..0d35e6a9a 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -133,6 +133,7 @@ func finishLoginJSON(ch *cmdutil.Helper, orgSetupErr error) error { } if orgSetupErr != nil { resp.Status = "action_required" + resp.Message = "Credentials saved, but organization setup failed." resp.Issues = []AuthIssue{{ Code: "ORG_SETUP_FAILED", Message: orgSetupErr.Error(), diff --git a/internal/cmd/auth/onboarding.go b/internal/cmd/auth/onboarding.go index 929b10f89..6474652c1 100644 --- a/internal/cmd/auth/onboarding.go +++ b/internal/cmd/auth/onboarding.go @@ -72,6 +72,10 @@ func buildAuthCheckResponse(ctx context.Context, ch *cmdutil.Helper) AuthCheckRe Message: err.Error(), Remediation: "Verify API credentials and network connectivity", }) + resp.NextSteps = []string{ + cmdutil.AgentAuthCheckCmd(), + cmdutil.AgentGuideCmd(), + } return resp } diff --git a/internal/cmd/auth/onboarding_test.go b/internal/cmd/auth/onboarding_test.go index 6cbd30817..da951b4d5 100644 --- a/internal/cmd/auth/onboarding_test.go +++ b/internal/cmd/auth/onboarding_test.go @@ -98,6 +98,9 @@ func TestFinishLoginJSONOrgSetupFailure(t *testing.T) { if resp.Status != "action_required" { t.Fatalf("status = %q", resp.Status) } + if resp.Message == "Successfully logged in." { + t.Fatalf("message = %q", resp.Message) + } if len(resp.Issues) == 0 || resp.Issues[0].Code != "ORG_SETUP_FAILED" { t.Fatalf("issues = %#v", resp.Issues) } From 46b5619b47ef069c83c2496f2e122bfac4d8d528 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 6 Jul 2026 11:12:20 -0500 Subject: [PATCH 15/19] Address remaining Bugbot JSON and destructive SQL findings. Match destructive SQL by statement-leading keywords instead of identifier false positives, keep login stderr JSON-only, and return structured JSON errors for login failures. Co-authored-by: Cursor --- internal/cmd/auth/login.go | 13 +++- internal/cmd/auth/onboarding.go | 24 ++++++++ internal/cmd/auth/onboarding_test.go | 29 +++++++++ internal/sqlquery/destructive.go | 88 +++++++++++++++++++++++++-- internal/sqlquery/destructive_test.go | 3 + 5 files changed, 150 insertions(+), 7 deletions(-) diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index 0d35e6a9a..ea8099cc8 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -35,6 +35,9 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { authenticator, err := psauth.New(cleanhttp.DefaultClient(), clientID, clientSecret, psauth.SetBaseURL(authURL)) if err != nil { + if jsonMode { + return finishLoginErrorJSON(ch, "AUTH_INIT_FAILED", "Failed to initialize login", err) + } return err } @@ -42,6 +45,9 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { deviceVerification, err := authenticator.VerifyDevice(ctx) if err != nil { + if jsonMode { + return finishLoginErrorJSON(ch, "DEVICE_VERIFY_FAILED", "Failed to start device authorization", err) + } return err } @@ -62,7 +68,6 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { if err := printJSONEnvelope(cmd.ErrOrStderr(), pending); err != nil { return err } - fmt.Fprintln(cmd.ErrOrStderr(), "Waiting for browser authorization...") } else { if !browserOpened { ch.Printer.Printf("Failed to open a browser; open this URL manually: %s\n", printer.Bold(deviceVerification.VerificationCompleteURL)) @@ -84,11 +89,17 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { accessToken, err := authenticator.GetAccessTokenForDevice(ctx, *deviceVerification) if err != nil { + if jsonMode { + return finishLoginErrorJSON(ch, "DEVICE_AUTH_FAILED", "Device authorization failed or timed out", err) + } return err } err = config.WriteAccessToken(accessToken) if err != nil { + if jsonMode { + return finishLoginErrorJSON(ch, "TOKEN_SAVE_FAILED", "Failed to save access token", err) + } configDir, configErr := config.ConfigDir() if configErr != nil { ch.Printer.Printf("Error looking up configuration directory: %s\n", printer.BoldRed(configErr.Error())) diff --git a/internal/cmd/auth/onboarding.go b/internal/cmd/auth/onboarding.go index 6474652c1..af5b05c6c 100644 --- a/internal/cmd/auth/onboarding.go +++ b/internal/cmd/auth/onboarding.go @@ -171,6 +171,30 @@ type LoginOKResponse struct { NextSteps []string `json:"next_steps,omitempty"` } +// LoginErrorResponse is emitted when login fails in JSON mode. +type LoginErrorResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Issues []AuthIssue `json:"issues,omitempty"` + NextSteps []string `json:"next_steps,omitempty"` +} + +func finishLoginErrorJSON(ch *cmdutil.Helper, code, message string, err error) error { + resp := LoginErrorResponse{ + Status: "error", + Message: message, + Issues: []AuthIssue{{ + Code: code, + Message: err.Error(), + }}, + NextSteps: []string{cmdutil.AgentAuthLoginCmd()}, + } + if err := ch.Printer.PrintJSON(resp); err != nil { + return err + } + return cmdutil.JSONReportedError(cmdutil.FatalErrExitCode) +} + func printJSONEnvelope(w io.Writer, v any) error { buf, err := json.MarshalIndent(v, "", " ") if err != nil { diff --git a/internal/cmd/auth/onboarding_test.go b/internal/cmd/auth/onboarding_test.go index da951b4d5..1fbc313b7 100644 --- a/internal/cmd/auth/onboarding_test.go +++ b/internal/cmd/auth/onboarding_test.go @@ -74,6 +74,35 @@ func TestInvalidAuthIssueAndNextStepsOAuth(t *testing.T) { } } +func TestFinishLoginErrorJSON(t *testing.T) { + format := printer.JSON + var out bytes.Buffer + ch := &cmdutil.Helper{ + Printer: printer.NewPrinter(&format), + } + ch.Printer.SetResourceOutput(&out) + + err := finishLoginErrorJSON(ch, "DEVICE_AUTH_FAILED", "Device authorization failed or timed out", errors.New("authorization pending")) + if err == nil { + t.Fatal("expected exit error") + } + var cmdErr *cmdutil.Error + if !errors.As(err, &cmdErr) || !cmdErr.Handled { + t.Fatalf("expected handled JSON error, got %v", err) + } + + var resp LoginErrorResponse + if err := json.Unmarshal(out.Bytes(), &resp); err != nil { + t.Fatalf("stdout json: %v", err) + } + if resp.Status != "error" { + t.Fatalf("status = %q", resp.Status) + } + if len(resp.Issues) == 0 || resp.Issues[0].Code != "DEVICE_AUTH_FAILED" { + t.Fatalf("issues = %#v", resp.Issues) + } +} + func TestFinishLoginJSONOrgSetupFailure(t *testing.T) { format := printer.JSON var out bytes.Buffer diff --git a/internal/sqlquery/destructive.go b/internal/sqlquery/destructive.go index bb70ab952..9dfea1369 100644 --- a/internal/sqlquery/destructive.go +++ b/internal/sqlquery/destructive.go @@ -16,8 +16,8 @@ func (e *DestructiveQueryError) Error() string { } // IsDestructiveQuery reports whether the query deletes or drops resources. -// This is a best-effort guardrail for agents, not a SQL parser. Any statement -// containing the words DELETE, DROP, or TRUNCATE requires approval. +// This is a best-effort guardrail for agents, not a SQL parser. Statements +// led by DELETE, DROP, or TRUNCATE (including after CTEs) require approval. func IsDestructiveQuery(query string) bool { return slices.ContainsFunc(splitSQLStatements(stripSQLGuardIgnoredText(query)), isDestructiveStatement) } @@ -59,10 +59,86 @@ func splitSQLStatements(query string) []string { } func isDestructiveStatement(stmt string) bool { - upper := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(strings.ToUpper(stmt)) - return slices.ContainsFunc(destructiveWords, func(word string) bool { - return containsWord(upper, word) - }) + return slices.ContainsFunc(splitDestructiveSegments(stmt), isDestructiveSegment) +} + +func splitDestructiveSegments(stmt string) []string { + var out []string + start := 0 + for i := 0; i < len(stmt); i++ { + if stmt[i] != '\n' { + continue + } + j := i + 1 + for j < len(stmt) && (stmt[j] == ' ' || stmt[j] == '\t' || stmt[j] == '\r') { + j++ + } + if j >= len(stmt) { + continue + } + rest := strings.ToUpper(stmt[j:]) + if !slices.ContainsFunc(destructiveWords, func(word string) bool { + return hasKeywordPrefix(rest, word) + }) { + continue + } + if trimmed := strings.TrimSpace(stmt[start:i]); trimmed != "" { + out = append(out, trimmed) + } + start = j + i = j - 1 + } + if trimmed := strings.TrimSpace(stmt[start:]); trimmed != "" { + out = append(out, trimmed) + } + if len(out) == 0 { + return []string{stmt} + } + return out +} + +func isDestructiveSegment(stmt string) bool { + trimmed := strings.TrimSpace(stmt) + upper := strings.ToUpper(trimmed) + + if hasKeywordPrefix(upper, "WITH") { + after, ok := queryAfterCTEs(trimmed) + if !ok { + return false + } + return isDestructiveSegment(after) + } + + switch leadingStatementKeyword(trimmed) { + case "DELETE", "DROP", "TRUNCATE": + return true + case "ALTER": + upperNorm := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(upper) + return containsWord(upperNorm, "DROP") + default: + return false + } +} + +func leadingStatementKeyword(stmt string) string { + trimmed := strings.TrimSpace(stmt) + for i := 0; i < len(trimmed); { + for i < len(trimmed) && trimmed[i] <= ' ' { + i++ + } + if i >= len(trimmed) { + break + } + start := i + for i < len(trimmed) && isIdentifierChar(trimmed[i]) { + i++ + } + if start < i { + return strings.ToUpper(trimmed[start:i]) + } + i++ + } + return "" } func stripSQLGuardIgnoredText(stmt string) string { diff --git a/internal/sqlquery/destructive_test.go b/internal/sqlquery/destructive_test.go index 85e9f3df6..bf721b3dd 100644 --- a/internal/sqlquery/destructive_test.go +++ b/internal/sqlquery/destructive_test.go @@ -38,6 +38,9 @@ func TestIsDestructiveQuery(t *testing.T) { {query: "SELECT deleted_at FROM users", want: false}, {query: "SELECT is_deleted FROM users", want: false}, {query: "SELECT * FROM delete_queue", want: false}, + {query: "SELECT drop FROM t", want: false}, + {query: "SELECT 1 AS drop", want: false}, + {query: "SELECT truncate FROM t", want: false}, } for _, tt := range tests { From 5e97aba90596e786f1008a2736dd78554a231fc9 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 6 Jul 2026 11:27:54 -0500 Subject: [PATCH 16/19] Set login JSON message only after status is known. Avoid defaulting to a success message before org setup failure is handled. Co-authored-by: Cursor --- internal/cmd/auth/login.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/cmd/auth/login.go b/internal/cmd/auth/login.go index ea8099cc8..d9a6079a6 100644 --- a/internal/cmd/auth/login.go +++ b/internal/cmd/auth/login.go @@ -135,8 +135,6 @@ func LoginCmd(ch *cmdutil.Helper) *cobra.Command { func finishLoginJSON(ch *cmdutil.Helper, orgSetupErr error) error { resp := LoginOKResponse{ - Status: "ok", - Message: "Successfully logged in.", NextSteps: []string{ cmdutil.AgentAuthCheckCmd(), cmdutil.AgentOrgListCmd(), @@ -154,6 +152,9 @@ func finishLoginJSON(ch *cmdutil.Helper, orgSetupErr error) error { cmdutil.AgentOrgListCmd(), cmdutil.AgentAuthCheckCmd(), } + } else { + resp.Status = "ok" + resp.Message = "Successfully logged in." } if err := ch.Printer.PrintJSON(resp); err != nil { return err From 3ce40b99f46adf38a2b41de72a9fe985d1254289 Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 6 Jul 2026 11:34:38 -0500 Subject: [PATCH 17/19] Flag MERGE ... WHEN MATCHED THEN DELETE as destructive SQL. Catch row-deleting MERGE statements that bypass leading-keyword checks. Co-authored-by: Cursor --- internal/sqlquery/destructive.go | 3 +++ internal/sqlquery/destructive_test.go | 2 ++ 2 files changed, 5 insertions(+) diff --git a/internal/sqlquery/destructive.go b/internal/sqlquery/destructive.go index 9dfea1369..82f13eb11 100644 --- a/internal/sqlquery/destructive.go +++ b/internal/sqlquery/destructive.go @@ -115,6 +115,9 @@ func isDestructiveSegment(stmt string) bool { case "ALTER": upperNorm := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(upper) return containsWord(upperNorm, "DROP") + case "MERGE": + upperNorm := strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(upper) + return strings.Contains(" "+upperNorm+" ", " THEN DELETE ") default: return false } diff --git a/internal/sqlquery/destructive_test.go b/internal/sqlquery/destructive_test.go index bf721b3dd..896423db4 100644 --- a/internal/sqlquery/destructive_test.go +++ b/internal/sqlquery/destructive_test.go @@ -41,6 +41,8 @@ func TestIsDestructiveQuery(t *testing.T) { {query: "SELECT drop FROM t", want: false}, {query: "SELECT 1 AS drop", want: false}, {query: "SELECT truncate FROM t", want: false}, + {query: "MERGE INTO target USING source ON target.id = source.id WHEN MATCHED THEN DELETE", want: true}, + {query: "MERGE INTO target USING source ON target.id = source.id WHEN NOT MATCHED THEN INSERT VALUES (1)", want: false}, } for _, tt := range tests { From 90ed4ed8ef2bdd0c721c9798bae891a189e1586f Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 6 Jul 2026 12:11:28 -0500 Subject: [PATCH 18/19] Handle MATERIALIZED CTE syntax in destructive SQL guard. Parse AS MATERIALIZED and AS NOT MATERIALIZED when walking CTE prefixes so DELETE statements are not missed. Co-authored-by: Cursor --- internal/sqlquery/destructive_test.go | 2 ++ internal/sqlquery/sqlquery.go | 17 +++++++++++++++++ internal/sqlquery/sqlquery_test.go | 1 + 3 files changed, 20 insertions(+) diff --git a/internal/sqlquery/destructive_test.go b/internal/sqlquery/destructive_test.go index 896423db4..af0891b71 100644 --- a/internal/sqlquery/destructive_test.go +++ b/internal/sqlquery/destructive_test.go @@ -43,6 +43,8 @@ func TestIsDestructiveQuery(t *testing.T) { {query: "SELECT truncate FROM t", want: false}, {query: "MERGE INTO target USING source ON target.id = source.id WHEN MATCHED THEN DELETE", want: true}, {query: "MERGE INTO target USING source ON target.id = source.id WHEN NOT MATCHED THEN INSERT VALUES (1)", want: false}, + {query: "WITH c AS MATERIALIZED (SELECT 1) DELETE FROM users", want: true}, + {query: "WITH c AS NOT MATERIALIZED (SELECT 1) DELETE FROM users", want: true}, } for _, tt := range tests { diff --git a/internal/sqlquery/sqlquery.go b/internal/sqlquery/sqlquery.go index b907db1f8..a3fbd257e 100644 --- a/internal/sqlquery/sqlquery.go +++ b/internal/sqlquery/sqlquery.go @@ -307,6 +307,7 @@ func queryAfterCTEs(query string) (string, bool) { return "", false } rest = strings.TrimSpace(rest[asIdx+len("AS"):]) + rest = skipCTEMaterializedModifier(rest) if !strings.HasPrefix(rest, "(") { return "", false } @@ -323,6 +324,22 @@ func queryAfterCTEs(query string) (string, bool) { } } +func skipCTEMaterializedModifier(rest string) string { + trimmed := strings.TrimSpace(rest) + upper := strings.ToUpper(trimmed) + if hasKeywordPrefix(upper, "NOT") { + trimmed = strings.TrimSpace(trimmed[len("NOT"):]) + upper = strings.ToUpper(trimmed) + if hasKeywordPrefix(upper, "MATERIALIZED") { + return strings.TrimSpace(trimmed[len("MATERIALIZED"):]) + } + } + if hasKeywordPrefix(upper, "MATERIALIZED") { + return strings.TrimSpace(trimmed[len("MATERIALIZED"):]) + } + return trimmed +} + func topLevelKeywordIndex(s, keyword string) int { upper := strings.ToUpper(s) depth := 0 diff --git a/internal/sqlquery/sqlquery_test.go b/internal/sqlquery/sqlquery_test.go index 096b5360e..f12707c9d 100644 --- a/internal/sqlquery/sqlquery_test.go +++ b/internal/sqlquery/sqlquery_test.go @@ -23,6 +23,7 @@ func TestIsReadQuery(t *testing.T) { {name: "with insert", query: "WITH x AS (SELECT 1) INSERT INTO t VALUES (1)", want: false}, {name: "with update", query: "WITH x AS (SELECT 1) UPDATE t SET x = 1", want: false}, {name: "with merge", query: "WITH x AS (SELECT 1) MERGE INTO t USING x ON t.id = x.id WHEN MATCHED THEN UPDATE SET x = 1", want: false}, + {name: "with materialized insert", query: "WITH x AS MATERIALIZED (SELECT 1) INSERT INTO t VALUES (1)", want: false}, } for _, tt := range tests { From ad2015e0a066321897f0d5592c933c5e4d19004c Mon Sep 17 00:00:00 2001 From: Elom Gomez Date: Mon, 6 Jul 2026 12:22:07 -0500 Subject: [PATCH 19/19] Fix leading-comment read query routing and skills skill id. Strip SQL comments before classifying read queries and align skills_cli_automation_skill with AGENTS.md. Co-authored-by: Cursor --- internal/cmd/agentguide/agentguide.go | 2 +- internal/sqlquery/sqlquery.go | 2 +- internal/sqlquery/sqlquery_test.go | 3 +++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/cmd/agentguide/agentguide.go b/internal/cmd/agentguide/agentguide.go index a3fa74f84..5293822a8 100644 --- a/internal/cmd/agentguide/agentguide.go +++ b/internal/cmd/agentguide/agentguide.go @@ -14,7 +14,7 @@ const ( SkillsRepoURL = "https://github.com/planetscale/skills" SkillsSetupCmd = "git clone https://github.com/planetscale/skills.git && cd skills && script/setup" SkillsNPXInstall = "npx skills add planetscale/skills -g -y" - SkillsCLIAutomation = "planetscale-pscale-cli-automation" + SkillsCLIAutomation = "14-pscale-cli-automation" ) type response struct { diff --git a/internal/sqlquery/sqlquery.go b/internal/sqlquery/sqlquery.go index a3fbd257e..296c3eb91 100644 --- a/internal/sqlquery/sqlquery.go +++ b/internal/sqlquery/sqlquery.go @@ -271,7 +271,7 @@ func queryPostgres(ctx context.Context, ch *cmdutil.Helper, opts Options, pgDB s var readQueryPrefixes = []string{"SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "TABLE"} func isReadQuery(query string) bool { - q := strings.TrimSpace(query) + q := strings.TrimSpace(stripSQLGuardIgnoredText(query)) upper := strings.ToUpper(q) if hasKeywordPrefix(upper, "WITH") { afterCTEs, ok := queryAfterCTEs(q) diff --git a/internal/sqlquery/sqlquery_test.go b/internal/sqlquery/sqlquery_test.go index f12707c9d..4c04a3331 100644 --- a/internal/sqlquery/sqlquery_test.go +++ b/internal/sqlquery/sqlquery_test.go @@ -15,6 +15,9 @@ func TestIsReadQuery(t *testing.T) { want bool }{ {name: "select", query: "SELECT 1", want: true}, + {name: "leading line comment select", query: "-- load users\nSELECT 1", want: true}, + {name: "leading hash comment select", query: "# load users\nSELECT 1", want: true}, + {name: "leading block comment select", query: "/* load users */ SELECT 1", want: true}, {name: "with select", query: " with x as (select 1) select * from x", want: true}, {name: "with multiple ctes select", query: "WITH a AS (SELECT 1), b AS (SELECT 2) SELECT * FROM b", want: true}, {name: "with string containing paren", query: "WITH x AS (SELECT ')' AS val) SELECT * FROM x", want: true},