Skip to content
Open
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
77 changes: 57 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,17 @@ go generate ./internal/api
- Run `go vet ./...` to catch potential issues before committing
- Run `go test ./...` to ensure all tests pass

### Error Messages

Error strings start with a **lowercase** letter, so they read correctly when a
caller wraps them: `fmt.Errorf("failed to get service: %w", err)`. Log messages
are the opposite — they start with a capital letter (see "Logging Architecture").

The exception is a leading proper noun or initialism, which keeps its
capitalization (`fmt.Errorf("API key validation failed: %w", err)`). If that
reads awkwardly, reword so the identifier isn't first — `missing required option:
ClientName` rather than `ClientName is required`.

### Configuration Management

**IMPORTANT:** Follow these rules when working with configuration:
Expand Down Expand Up @@ -299,7 +310,6 @@ Tiger CLI is a Go-based command-line interface for managing Tiger, the modern da
- **Configuration**: `internal/config/config.go` - `Config` struct plus load/write
helpers. Each `Load` uses its own viper instance (no global state); see
"Configuration Management" above
- **Logging**: `internal/logging/logging.go` - Structured logging with zap
- **API Client**: `internal/api/` - Generated OpenAPI client with mocks
- **MCP Server**: `internal/mcp/` - Model Context Protocol server implementation.
Each MCP tool lives in its own file, named to match the tool (see "One File Per
Expand Down Expand Up @@ -369,13 +379,14 @@ The Tiger MCP server provides AI assistants with programmatic access to Tiger re

**Server State:**

`NewServer(ctx, app)` takes the already-loaded `*common.App` and keeps it on the
`Server`. Read-only mode, the experimental gate, and the docs-proxy settings are
read once here at startup (a client must restart the server to pick those up),
while the analytics middleware calls `s.app.Load(ctx)` on every request so tool
handlers see current config and credentials. Handlers therefore never load
anything themselves — they read `s.app.GetAll()`, `s.app.GetClient()`, or
`s.app.GetConfig()`.
`NewServer(ctx, app, logger)` takes the already-loaded `*common.App` and keeps it
on the `Server`, along with the logger (nil is replaced with a discarding one;
see "Logging Architecture"). Read-only mode, the experimental gate, and the
docs-proxy settings are read once here at startup (a client must restart the
server to pick those up), while the analytics middleware calls `s.app.Load(ctx)`
on every request so tool handlers see current config and credentials. Handlers
therefore never load anything themselves — they read `s.app.GetAll()`,
`s.app.GetClient()`, or `s.app.GetConfig()`, and log via `s.logger`.

**One File Per MCP Tool:**

Expand Down Expand Up @@ -469,15 +480,39 @@ addTool(s, readOnly, newServiceCreateTool(), s.handleServiceCreate)

### Logging Architecture

Two-mode logging system using zap:
- **Production mode**: Minimal output, warn level and above, clean formatting
- **Debug mode**: Full development logging with colors and debug level
Only the MCP server logs. `newLogger(w io.Writer)`
(`internal/cmd/logger_helper.go`) points the standard `log` package at `w` and
returns `slog.Default()`; `tiger mcp start` (stdio and http) calls it with
`cmd.ErrOrStderr()` and passes the result to `mcp.NewServer`, which uses it for
its own output and hands it to the MCP SDK (`mcp.ServerOptions.Logger` and the
docs proxy's `mcp.ClientOptions.Logger`) — so the SDK's own session-lifecycle
lines land on the same stream. `mcp.NewServer(ctx, app, nil)` — used by
`mcp list`, `mcp get`, and completion, which only enumerate capabilities —
discards the output via `slog.New(slog.DiscardHandler)`.

There is no level configuration and no `--debug` flag. `slog.Default()` drops
anything below `Info` unless `slog.SetLogLoggerLevel` is called, so log at `Info`
or above; a `Debug` call would silently go nowhere. Attach errors with
`slog.Any("error", err)`, not `slog.String("error", err.Error())`. Log messages
start with a capital letter — error strings do the opposite (see "Error
Messages").

Because every statement is visible by default, keep them sparse: log failures
that would otherwise be swallowed (the docs-proxy registration errors are the
model), not per-step tracing of work that succeeded. A default `tiger mcp start`
is silent; the startup lines that do exist report configuration that removes
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.

### Dependencies

- **Cobra**: CLI framework and command structure
- **Viper**: Configuration management with multiple sources
- **Zap**: Structured logging
- **slog**: Structured logging for the MCP server
- **oapi-codegen**: OpenAPI client generation (build-time dependency)
- **gomock**: Mock generation for testing (build-time dependency)
- **go-sdk (MCP)**: Model Context Protocol SDK for AI assistant integration
Expand All @@ -494,7 +529,6 @@ tiger-cli/
│ ├── api/ # Generated OpenAPI client (oapi-codegen)
│ │ └── mocks/ # Generated mocks for testing
│ ├── config/ # Configuration management
│ ├── logging/ # Structured logging utilities
│ ├── mcp/ # MCP server implementation (one file per tool)
│ ├── common/ # Shared business logic (password storage, wait ops, error handling, log fetching)
│ ├── cmd/ # CLI commands (Cobra, one file per command)
Expand Down Expand Up @@ -548,6 +582,11 @@ RunE: func(cmd *cobra.Command, args []string) error {

This provides fine-grained control over when usage is displayed, improving user experience by showing help when it's relevant and hiding it when it's not.

`SilenceErrors` is a separate setting and is rarely needed: set it only on a
command that already reports its own errors, so cobra doesn't print them a second
time. `tiger mcp start http` sets it because the MCP server logs failures through
slog before returning them.

## Command Architecture: Pure Functional Builder Pattern

Tiger CLI uses a pure functional builder pattern with **zero global command state**. This architecture ensures perfect test isolation, eliminates shared state issues, and provides a clean, maintainable command structure.
Expand Down Expand Up @@ -580,11 +619,10 @@ order:

1. `app.SetFlags(cmd.Flags())` then `app.Load(ctx)` — the single config + API
client load for the invocation
2. `logging.Init(cfg.Debug)`, with `logging.Sync()` deferred
3. `color.NoColor` from `cfg.Color`
4. `versionCheck(...)` — starts a background release check, deferring the print
2. `color.NoColor` from `cfg.Color`
3. `versionCheck(...)` — starts a background release check, deferring the print
so it lands after the command's own output
5. analytics — deferred, so it records the command's outcome and re-reads the App
4. analytics — deferred, so it records the command's outcome and re-reads the App
(see "Configuration Management")

Commands cobra adds after `wrapCommands` runs — `help`, `completion`, and the
Expand Down Expand Up @@ -614,8 +652,8 @@ Place a helper by who calls it, working down this list until one matches:
1. **One command** → that command's file.
2. **Several commands in one group** → the group file (`service.go`, `db.go`).
3. **Across groups** → a package-level `<topic>_helper.go` file:
`completion_helper.go`, `flag_helper.go`, `terminal_helper.go`,
`password_helper.go`.
`completion_helper.go`, `flag_helper.go`, `logger_helper.go`,
`terminal_helper.go`, `password_helper.go`.
4. **A genuine standalone utility** — small and isolated, with no notion of a
command (`util.GenerateSecurePassword`) → `internal/util`. Anything shaped
around the CLI stays in `cmd` even if its signature looks generic.
Expand Down Expand Up @@ -659,7 +697,6 @@ func buildRootCmd(ctx context.Context) (*cobra.Command, error) {

// Set up persistent flags
cmd.PersistentFlags().String("config-dir", config.GetDefaultConfigDir(), "config directory")
cmd.PersistentFlags().Bool("debug", false, "enable debug logging")
skipUpdateCheck := cmd.PersistentFlags().Bool("skip-update-check", false, "skip checking for updates on startup")
// ... add remaining persistent flags

Expand Down
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,6 @@ All configuration options can be set via `tiger config set <key> <value>`:

- `analytics` - Enable/disable analytics (default: `true`)
- `color` - Enable/disable colored output (default: `true`)
- `debug` - Enable/disable debug logging (default: `false`)
- `docs_mcp` - Enable/disable docs MCP proxy (default: `true`)
- `mcp_max_rows` - Maximum number of rows the `db_execute_query` MCP tool returns per result set before truncating, to limit how much data lands in an AI agent's context. Only applies to the MCP tool, not CLI commands. Default: `100`
- `output` - Output format: `json`, `yaml`, or `table` (default: `table`)
Expand All @@ -261,7 +260,6 @@ Environment variables override configuration file values. All variables use the
- `TIGER_ANALYTICS` - Enable/disable analytics
- `TIGER_COLOR` - Enable/disable colored output
- `TIGER_CONFIG_DIR` - Path to configuration directory (default: `~/.config/tiger`)
- `TIGER_DEBUG` - Enable/disable debug logging
- `TIGER_DOCS_MCP` - Enable/disable docs MCP proxy
- `TIGER_OUTPUT` - Output format: `json`, `yaml`, or `table`
- `TIGER_PASSWORD_STORAGE` - Password storage method: `keyring`, `pgpass`, or `none`
Expand All @@ -278,7 +276,6 @@ These flags are available on all commands and take precedence over both environm
- `--analytics` - Enable/disable analytics
- `--color` - Enable/disable colored output
- `--config-dir <path>` - Path to configuration directory (default: `~/.config/tiger`)
- `--debug` - Enable/disable debug logging
- `--password-storage <method>` - Password storage method: `keyring`, `pgpass`, or `none`
- `--service-id <id>` - Specify service ID
- `--skip-update-check` - Skip checking for updates on startup (default: `false`)
Expand Down
9 changes: 2 additions & 7 deletions cmd/tiger/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ import (
"os/signal"
"syscall"

"go.uber.org/zap"

"github.com/timescale/tiger-cli/internal/cmd"
"github.com/timescale/tiger-cli/internal/logging"
)

func main() {
Expand Down Expand Up @@ -40,17 +37,15 @@ func run() (err error) {

// noifyContext sets up graceful shutdown handling and returns a context and
// cleanup function. This is nearly identical to [signal.NotifyContext], except
// that it logs a message when a signal is received and also restores the default
// signal handling behavior.
// that it also restores the default signal handling behavior.
func notifyContext(parent context.Context) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(parent)

sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
go func() {
select {
case sig := <-sigChan:
logging.Info("Received interrupt signal, press control-C again to exit", zap.Stringer("signal", sig))
case <-sigChan:
signal.Stop(sigChan) // Restore default signal handling behavior
cancel()
case <-ctx.Done():
Expand Down
14 changes: 7 additions & 7 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,6 @@ tiger-cli/
│ ├── api/ # Generated OpenAPI client (oapi-codegen)
│ │ └── mocks/ # Generated mocks for testing
│ ├── config/ # Configuration management
│ ├── logging/ # Structured logging utilities
│ ├── mcp/ # MCP server implementation (one file per tool)
│ ├── common/ # Shared business logic used by CLI and MCP
│ ├── cmd/ # CLI commands (Cobra, one file per command)
Expand All @@ -130,15 +129,13 @@ Tiger CLI is a Go-based command-line interface for managing Tiger resources. The
(`tiger service create` → `service_create.go`). `root.go` holds the root
command, global flags, and `wrapCommands`, which gives every command the same
per-invocation lifecycle: load config + API client once into a `common.App`,
initialize logging, apply color settings, check for a newer release, and track
analytics.
apply color settings, check for a newer release, and track analytics.
- **App**: `internal/common/app.go` - per-invocation config and API client, built
once by `wrapCommands` (or per request by the MCP analytics middleware) and read
by commands, MCP tool handlers, and completion functions
- **Configuration**: `internal/config/config.go` - `Config` struct plus load/write
helpers. `config.Load(flags)` resolves values through a per-call viper
instance (flag > env > file > default); there is no global config state
- **Logging**: `internal/logging/logging.go` - Structured logging with zap
- **API Client**: `internal/api/` - Generated OpenAPI client
- **MCP Server**: `internal/mcp/` - Model Context Protocol server
implementation. Each MCP tool lives in its own file, named to match the tool
Expand All @@ -154,9 +151,12 @@ The CLI uses a layered configuration approach (listed from lowest to highest pre

### Logging Architecture

Two-mode logging system using zap:
- **Production mode**: Minimal output, warn level and above, clean formatting
- **Debug mode**: Full development logging with colors and debug level. Enable with `--debug` or `TIGER_DEBUG=true`.
Only the MCP server logs. `tiger mcp start` builds a `*slog.Logger` with
`newLogger(cmd.ErrOrStderr())` (`internal/cmd/logger_helper.go`), which points
the standard `log` package at that writer and returns `slog.Default()`. That
logger is passed to `mcp.NewServer`, which uses it for its own output and hands
it to the MCP SDK (`mcp.ServerOptions.Logger`); a nil logger is discarded.
Everything else writes to stdout/stderr directly rather than logging.

## Code Generation

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ require (
github.com/tailscale/hujson v0.0.0-20250605163823-992244df8c5a
github.com/zalando/go-keyring v0.2.6
go.uber.org/mock v0.6.0
go.uber.org/zap v1.27.1
golang.org/x/oauth2 v0.35.0
golang.org/x/term v0.40.0
golang.org/x/text v0.34.0
Expand Down Expand Up @@ -172,6 +171,7 @@ require (
go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.ngrok.com/muxado/v2 v2.0.1 // indirect
Expand Down
47 changes: 4 additions & 43 deletions internal/analytics/analytics.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import (
"github.com/spf13/pflag"
"github.com/timescale/tiger-cli/internal/api"
"github.com/timescale/tiger-cli/internal/config"
"github.com/timescale/tiger-cli/internal/logging"
"go.uber.org/zap"
)

// A list of properties that should never be recorded in analytics events.
Expand Down Expand Up @@ -132,20 +130,14 @@ func (a *Analytics) Identify(options ...Option) {
properties["project_id"] = a.projectID
}

logger := logging.GetLogger().With(
zap.Any("properties", properties),
)

// Check if analytics is disabled
if !a.Enabled() {
logger.Debug("Analytics identify skipped (analytics disabled)")
return
}

// Check for cases where the client was not initialized
// (e.g. because API credentials are not available)
if a.client == nil {
logger.Debug("Analytics identify skipped (client not initialized)")
return
}

Expand All @@ -155,22 +147,10 @@ func (a *Analytics) Identify(options ...Option) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Send the event
resp, err := a.client.IdentifyUserWithResponse(ctx, api.IdentifyUserJSONRequestBody{
// Send the event, ignoring failures - analytics should never block user actions
a.client.IdentifyUserWithResponse(ctx, api.IdentifyUserJSONRequestBody{
Properties: &properties,
})
if err != nil {
// Log error but don't fail the operation - analytics should never block user actions
logger.Debug("Failed to send analytics identify", zap.Error(err))
return
}

if resp.JSON200 == nil || resp.JSON200.Status == nil {
logger.Debug("Failed to retrieve response from analytics endpoint")
return
}

logger.Debug("Analytics identify sent", zap.String("status", *resp.JSON200.Status))
}

// Track sends an analytics event with the provided event name and properties.
Expand All @@ -196,21 +176,14 @@ func (a *Analytics) Track(event string, options ...Option) {
properties["project_id"] = a.projectID
}

logger := logging.GetLogger().With(
zap.String("event", event),
zap.Any("properties", properties),
)

// Check if analytics is disabled
if !a.Enabled() {
logger.Debug("Analytics event skipped (analytics disabled)")
return
}

// Check for cases where the client was not initialized
// (e.g. because API credentials are not available)
if a.client == nil {
logger.Debug("Analytics event skipped (client not initialized)")
return
}

Expand All @@ -220,23 +193,11 @@ func (a *Analytics) Track(event string, options ...Option) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

// Send the event
resp, err := a.client.TrackEventWithResponse(ctx, api.TrackEventJSONRequestBody{
// Send the event, ignoring failures - analytics should never block user actions
a.client.TrackEventWithResponse(ctx, api.TrackEventJSONRequestBody{
Event: event,
Properties: &properties,
})
if err != nil {
// Log error but don't fail the operation - analytics should never block user actions
logger.Debug("Failed to send analytics event", zap.Error(err))
return
}

if resp.JSON200 == nil || resp.JSON200.Status == nil {
logger.Debug("Failed to retrieve response from analytics endpoint")
return
}

logger.Debug("Analytics event sent", zap.String("status", *resp.JSON200.Status))
}

// Enabled reports whether analytics events will actually be sent given the
Expand Down
2 changes: 1 addition & 1 deletion internal/cmd/completion_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ func mcpGetCompletion(app *common.App) cobra.CompletionFunc {
}

// Create MCP server to get capabilities
server, err := mcp.NewServer(cmd.Context(), app)
server, err := mcp.NewServer(cmd.Context(), app, nil)
if err != nil {
return nil, cobra.ShellCompDirectiveNoFileComp
}
Expand Down
2 changes: 0 additions & 2 deletions internal/cmd/config_reset.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"github.com/spf13/cobra"

"github.com/timescale/tiger-cli/internal/common"
"github.com/timescale/tiger-cli/internal/logging"
)

func buildConfigResetCmd(app *common.App) *cobra.Command {
Expand All @@ -25,7 +24,6 @@ func buildConfigResetCmd(app *common.App) *cobra.Command {
return fmt.Errorf("failed to reset config: %w", err)
}

logging.Info("Configuration reset to defaults")
fmt.Fprintln(cmd.OutOrStdout(), "Configuration reset to defaults")
return nil
},
Expand Down
Loading
Loading