Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -505,8 +505,8 @@ capabilities — the docs proxy being disabled, and each write tool skipped in
read-only mode — so a client can see why a tool it expected is missing.

Everything outside `internal/mcp` writes to stdout/stderr directly rather than
logging. Don't add log statements to CLI commands — print to
`cmd.OutOrStdout()`/`cmd.ErrOrStderr()`, or return an error.
logging. Don't add log statements to CLI commands — print with `cmd.Print*` /
`cmd.PrintErr*` (see "Output Streams"), or return an error.

### Dependencies

Expand Down Expand Up @@ -963,6 +963,42 @@ This architecture ensures Tiger CLI remains maintainable and testable as it grow

Tiger CLI follows established command-line interface patterns, particularly inspired by the GitHub CLI (`gh`) for consistency with modern CLI tools.

### Output Streams

`buildRootCmd` calls `cmd.SetOut(os.Stdout)` and `cmd.SetErr(os.Stderr)`. This is
required: cobra's `cmd.Print*` helpers write to `OutOrStderr()`, which falls back
to **stderr** when no out writer is set, so without it every `cmd.Printf` would
land on the wrong stream.

Commands print with the cobra helpers — `cmd.Print`/`Printf`/`Println` for
stdout, `cmd.PrintErr`/`PrintErrf`/`PrintErrln` for stderr. Don't use
`fmt.Print*` (bypasses the command's writers entirely, so tests can't capture it)
and don't spell out `fmt.Fprintf(cmd.OutOrStdout(), …)`. Reach for
`cmd.OutOrStdout()`/`cmd.ErrOrStderr()` only where an `io.Writer` is genuinely
required: `util.SerializeToJSON`/`SerializeToYAML`, `tablewriter.NewWriter`,
`tea.WithOutput`, and helpers in other packages (see below). Likewise use
`cmd.InOrStdin()` rather than `os.Stdin`.

What goes where:

1. **stdout** — the command's primary output: the data payload (table, JSON,
YAML, env vars) and, in the plain-text path, the result text itself.
2. **stderr** — errors and warnings; interactive UI (confirmation prompts,
password prompts, spinners); and progress/status messages that accompany
structured output, so they never pollute a piped stream. `tiger service
create` is the model: every status line is `cmd.PrintErrf` and only the final
service payload goes to stdout.

Helper functions inside `internal/cmd` take the `*cobra.Command` and print
through it. The exception is a helper whose whole job is rendering into a writer
— `outputServiceTable`, `outputVersionTable`, `outputCapabilitiesTable` — which
keeps an `io.Writer` parameter because `tablewriter` needs one anyway.

Code in other packages (`internal/common`, `internal/version`) must not import
cobra. Those take an `io.Writer` (`common.WaitForServiceArgs.Output`,
`common.NewSpinner`, `version.PrintUpdateWarning`) and the caller in
`internal/cmd` passes `cmd.ErrOrStderr()` or `cmd.OutOrStdout()`.

### Boolean Flag Patterns

When implementing boolean flags that can be enabled or disabled, follow the GitHub CLI pattern:
Expand Down Expand Up @@ -1012,7 +1048,7 @@ if len(args) < 1 {

// Interactive confirmation unless --confirm
if !confirmFlag {
fmt.Fprintf(cmd.ErrOrStderr(), "Type the service ID '%s' to confirm: ", serviceID)
cmd.PrintErrf("Type the service ID '%s' to confirm: ", serviceID)
var confirmation string
fmt.Scanln(&confirmation)
if confirmation != serviceID {
Expand Down
21 changes: 11 additions & 10 deletions internal/cmd/auth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ Examples:
authURL: cfg.ConsoleURL + "/oauth/authorize",
tokenURL: cfg.GatewayURL + "/idp/external/cli/token",
successURL: cfg.ConsoleURL + "/oauth/code/success",
out: cmd.OutOrStdout(),
out: cmd.ErrOrStderr(),
}

token, client, projectID, err := l.loginWithOAuth(cmd.Context())
Expand All @@ -124,7 +124,7 @@ Examples:
finishLogin(cmd, projectID)
return nil
} else if creds.publicKey == "" || creds.secretKey == "" {
creds, err = promptForCredentials(cmd.Context(), cfg.ConsoleURL, creds)
creds, err = promptForCredentials(cmd, cfg.ConsoleURL, creds)
if err != nil {
return fmt.Errorf("failed to get credentials: %w", err)
}
Expand All @@ -139,7 +139,7 @@ Examples:
return fmt.Errorf("failed to create client: %w", err)
}

fmt.Fprintln(cmd.OutOrStdout(), "Validating API key...")
cmd.PrintErrln("Validating API key...")
authInfo, err := validateAPIKey(cmd.Context(), cfg, client)
if err != nil {
return fmt.Errorf("API key validation failed: %w", err)
Expand All @@ -162,8 +162,8 @@ Examples:
}

func finishLogin(cmd *cobra.Command, projectID string) {
fmt.Fprintf(cmd.OutOrStdout(), "Successfully logged in (project: %s)\n", projectID)
fmt.Fprint(cmd.OutOrStdout(), nextStepsMessage)
cmd.Printf("Successfully logged in (project: %s)\n", projectID)
cmd.Print(nextStepsMessage)
}

func flagOrEnvVar(flagVal, envVarName string) string {
Expand All @@ -173,17 +173,18 @@ func flagOrEnvVar(flagVal, envVarName string) string {
return os.Getenv(envVarName)
}

func promptForCredentials(ctx context.Context, consoleURL string, creds credentials) (credentials, error) {
func promptForCredentials(cmd *cobra.Command, consoleURL string, creds credentials) (credentials, error) {
if !util.IsTerminal(os.Stdin) {
return credentials{}, fmt.Errorf("TTY not detected - credentials required. Use flags (--public-key, --secret-key) or environment variables (TIGER_PUBLIC_KEY, TIGER_SECRET_KEY)")
}

fmt.Printf("You can find your API credentials at: %s/dashboard/settings\n\n", consoleURL)
ctx := cmd.Context()
cmd.PrintErrf("You can find your API credentials at: %s/dashboard/settings\n\n", consoleURL)

reader := bufio.NewReader(os.Stdin)

if creds.publicKey == "" {
fmt.Print("Enter your public key: ")
cmd.PrintErr("Enter your public key: ")
publicKey, err := readString(ctx, func() (string, error) { return reader.ReadString('\n') })
if err != nil {
return credentials{}, err
Expand All @@ -192,15 +193,15 @@ func promptForCredentials(ctx context.Context, consoleURL string, creds credenti
}

if creds.secretKey == "" {
fmt.Print("Enter your secret key: ")
cmd.PrintErr("Enter your secret key: ")
password, err := readString(ctx, func() (string, error) {
val, err := term.ReadPassword(int(os.Stdin.Fd()))
return string(val), err
})
if err != nil {
return credentials{}, err
}
fmt.Println()
cmd.PrintErrln()
creds.secretKey = password
}

Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/auth_logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ func buildLogoutCmd(app *common.App) *cobra.Command {
return fmt.Errorf("failed to remove credentials: %w", err)
}

fmt.Fprintln(cmd.OutOrStdout(), "Successfully logged out and removed stored credentials")
cmd.Println("Successfully logged out and removed stored credentials")
return nil
},
}
Expand Down Expand Up @@ -65,6 +65,6 @@ func revokeOAuthSession(cmd *cobra.Command, app *common.App, cfg *config.Config)
body.RefreshToken = &rt
}
if _, err := client.LogoutWithResponse(ctx, body); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "warning: server-side logout failed: %v\n", err)
cmd.PrintErrf("warning: server-side logout failed: %v\n", err)
}
}
1 change: 1 addition & 0 deletions internal/cmd/auth_status.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ func buildStatusCmd(app *common.App) *cobra.Command {

// outputAuthInfo formats and outputs authentication information based on the specified format
func outputAuthInfo(cmd *cobra.Command, authInfo api.AuthInfo, format string) error {

outputWriter := cmd.OutOrStdout()

switch strings.ToLower(format) {
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/config_reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func buildConfigResetCmd(app *common.App) *cobra.Command {
return fmt.Errorf("failed to reset config: %w", err)
}

fmt.Fprintln(cmd.OutOrStdout(), "Configuration reset to defaults")
cmd.Println("Configuration reset to defaults")
return nil
},
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/config_set.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func buildConfigSetCmd(app *common.App) *cobra.Command {
return fmt.Errorf("failed to set config: %w", err)
}

fmt.Fprintf(cmd.OutOrStdout(), "Set %s = %s\n", key, value)
cmd.Printf("Set %s = %s\n", key, value)
return nil
},
}
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/config_unset.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func buildConfigUnsetCmd(app *common.App) *cobra.Command {
return fmt.Errorf("failed to unset config: %w", err)
}

fmt.Fprintf(cmd.OutOrStdout(), "Unset %s\n", key)
cmd.Printf("Unset %s\n", key)
return nil
},
}
Expand Down
3 changes: 1 addition & 2 deletions internal/cmd/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package cmd

import (
"context"
"fmt"
"time"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -57,7 +56,7 @@ func lookupConnectionTarget(cmd *cobra.Command, app *common.App, args []string)
// any. It is a no-op for a primary target or when there's nothing to warn.
func warnReplicaPooler(cmd *cobra.Command, target *common.ConnectionTarget, pooled bool) {
if warning := common.ReplicaPoolerWarning(target, pooled); warning != "" {
fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", warning)
cmd.PrintErrf("⚠️ Warning: %s\n", warning)
}
}

Expand Down
26 changes: 13 additions & 13 deletions internal/cmd/db_connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func selectConnection(
replicas, err := fetchReplicaSets(ctx, client, projectID, util.DerefStr(primary.ServiceId))
if err != nil {
// Don't block the connection if we can't list replicas.
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not list read replicas: %v\n", err)
cmd.PrintErrf("Warning: could not list read replicas: %v\n", err)
} else if connectable := connectableReplicas(replicas); len(connectable) > 0 {
choice, err := selectConnectTargetOption(cmd.ErrOrStderr(), primary, connectable)
if err != nil {
Expand All @@ -212,7 +212,7 @@ func selectConnection(
}

if chosen.IsReplica {
fmt.Fprintf(cmd.ErrOrStderr(), "Connecting to read replica '%s'...\n", util.DerefStr(chosen.ConnectionService.Name))
cmd.PrintErrf("Connecting to read replica '%s'...\n", util.DerefStr(chosen.ConnectionService.Name))
}
return details, nil
}
Expand Down Expand Up @@ -380,7 +380,7 @@ func connectWithPasswordMenu(
storage := common.GetPasswordStorage(cfg)
storedPassword, err := storage.Get(service, details.Role)
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not retrieve stored password: %v\n", err)
cmd.PrintErrf("Warning: could not retrieve stored password: %v\n", err)
}

// Try to connect with stored password first
Expand All @@ -396,7 +396,7 @@ func connectWithPasswordMenu(
return err
}
// Auth failed with stored password, continue to recovery menu
fmt.Fprintf(cmd.ErrOrStderr(), "%s\nStored password is likely invalid or expired.\n\n", err.Error())
cmd.PrintErrf("%s\nStored password is likely invalid or expired.\n\n", err.Error())

// Check if we're in a TTY for interactive menu
if !checkStdinIsTTY() {
Expand All @@ -415,22 +415,22 @@ func connectWithPasswordMenu(
switch option {
case optionEnterPassword:
// Prompt for password
fmt.Fprint(cmd.ErrOrStderr(), "Enter password: ")
cmd.PrintErr("Enter password: ")
password, err := readString(ctx, readPasswordFromTerminal)
fmt.Fprintln(cmd.ErrOrStderr()) // newline after password entry
cmd.PrintErrln() // newline after password entry
if err != nil {
if errors.Is(err, context.Canceled) {
return nil // user cancelled
}
fmt.Fprintf(cmd.ErrOrStderr(), "Error reading password: %v\n\n", err)
cmd.PrintErrf("Error reading password: %v\n\n", err)
continue
}

// Test, save, and launch
details.Password = password
if err = testSaveAndLaunchPsqlWithPassword(ctx, cmd, cfg, details, psqlPath, psqlFlags, service); err != nil {
if isAuthenticationError(err) {
fmt.Fprintf(cmd.ErrOrStderr(), "Password incorrect. Please try again.\n\n")
cmd.PrintErrf("Password incorrect. Please try again.\n\n")
continue
}
return fmt.Errorf("connection failed: %w", err)
Expand All @@ -439,15 +439,15 @@ func connectWithPasswordMenu(

case optionResetPassword:
// Prompt and reset
password, err := promptAndResetPassword(ctx, cfg, cmd.ErrOrStderr(), client, service, details.Role)
password, err := promptAndResetPassword(ctx, cmd, cfg, client, service, details.Role)
if err != nil {
if errors.Is(err, context.Canceled) {
return nil // user cancelled
}
fmt.Fprintf(cmd.ErrOrStderr(), "Error resetting password: %v\n\n", err)
cmd.PrintErrf("Error resetting password: %v\n\n", err)
continue
}
fmt.Fprintf(cmd.ErrOrStderr(), "✅ Master password for '%s' user updated successfully\n", details.Role)
cmd.PrintErrf("✅ Master password for '%s' user updated successfully\n", details.Role)
// Launch psql (password is now in storage)
details.Password = password
return launchPsql(cfg, details, psqlPath, psqlFlags, service, cmd)
Expand Down Expand Up @@ -613,9 +613,9 @@ func testSaveAndLaunchPsqlWithPassword(
// Password works! Save it
result, saveErr := common.SavePasswordWithResult(cfg, service, details.Password, details.Role)
if saveErr != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not save password: %v\n", saveErr)
cmd.PrintErrf("Warning: could not save password: %v\n", saveErr)
} else if result.Success {
fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", result.Message)
cmd.PrintErrf("%s\n", result.Message)
}

// Launch psql
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/db_connection_string.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Examples:
return fmt.Errorf("password not available to include in connection string")
}

fmt.Fprintln(cmd.OutOrStdout(), details.String())
cmd.Println(details.String())
return nil
},
}
Expand Down
12 changes: 6 additions & 6 deletions internal/cmd/db_create_role.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,9 @@ PostgreSQL Configuration Parameters That May Be Set:
// Save password to storage with the new role name
result, err := common.SavePasswordWithResult(cfg, service, rolePassword, roleName)
if err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", result.Message)
cmd.PrintErrf("⚠️ Warning: %s\n", result.Message)
} else if !result.Success {
fmt.Fprintf(cmd.ErrOrStderr(), "⚠️ Warning: %s\n", result.Message)
cmd.PrintErrf("⚠️ Warning: %s\n", result.Message)
}

// Output result in requested format
Expand Down Expand Up @@ -342,15 +342,15 @@ func outputCreateRoleResult(cmd *cobra.Command, roleName string, readOnly bool,
case "yaml":
return util.SerializeToYAML(outputWriter, result)
default: // table format
fmt.Fprintf(outputWriter, "✓ Role '%s' created successfully\n", roleName)
cmd.Printf("✓ Role '%s' created successfully\n", roleName)
if readOnly {
fmt.Fprintf(outputWriter, " Read-only enforcement: enabled (permanent, role-based)\n")
cmd.Printf(" Read-only enforcement: enabled (permanent, role-based)\n")
}
if statementTimeout > 0 {
fmt.Fprintf(outputWriter, " Statement timeout: %s\n", statementTimeout)
cmd.Printf(" Statement timeout: %s\n", statementTimeout)
}
if len(fromRoles) > 0 {
fmt.Fprintf(outputWriter, " Inherits from: %s\n", strings.Join(fromRoles, ", "))
cmd.Printf(" Inherits from: %s\n", strings.Join(fromRoles, ", "))
}
return nil
}
Expand Down
8 changes: 4 additions & 4 deletions internal/cmd/db_save_password.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@ Examples:
return fmt.Errorf("TTY not detected - password required. Use --password flag or TIGER_NEW_PASSWORD environment variable")
}

fmt.Fprint(cmd.OutOrStdout(), "Enter password: ")
cmd.PrintErr("Enter password: ")
passwordToSave, err = readString(cmd.Context(), readPasswordFromTerminal)
if err != nil {
return fmt.Errorf("failed to read password: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout()) // Print newline after hidden input
cmd.PrintErrln() // Print newline after hidden input
if passwordToSave == "" {
return fmt.Errorf("password cannot be empty")
}
Expand All @@ -100,10 +100,10 @@ Examples:
}

if target.IsReplica {
fmt.Fprintf(cmd.ErrOrStderr(), "Read replicas share the primary's credentials; saving against primary %s.\n",
cmd.PrintErrf("Read replicas share the primary's credentials; saving against primary %s.\n",
*service.ServiceId)
}
fmt.Fprintf(cmd.ErrOrStderr(), "Password saved successfully for service %s (role: %s)\n",
cmd.PrintErrf("Password saved successfully for service %s (role: %s)\n",
*service.ServiceId, dbSavePasswordRole)
return nil
},
Expand Down
4 changes: 1 addition & 3 deletions internal/cmd/db_schema.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
package cmd

import (
"fmt"

"github.com/spf13/cobra"

"github.com/timescale/tiger-cli/internal/common"
Expand Down Expand Up @@ -74,7 +72,7 @@ Examples:
return err
}

fmt.Fprint(cmd.OutOrStdout(), common.FormatSchema(schema))
cmd.Print(common.FormatSchema(schema))
return nil
},
}
Expand Down
Loading
Loading