diff --git a/internal/claudeai/client.go b/internal/claudeai/client.go new file mode 100644 index 0000000..e384c1c --- /dev/null +++ b/internal/claudeai/client.go @@ -0,0 +1,234 @@ +// Package claudeai provides a client for fetching subscription and usage data from claude.ai. +package claudeai + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" +) + +const ( + baseURL = "https://claude.ai/api" + requestTimeout = 10 * time.Second + maxBodySize = 1 << 20 // 1 MB + keyPrefix = "sk-ant-sid" +) + +var ( + // ErrUnauthorized indicates the session key is expired or invalid. + ErrUnauthorized = errors.New("claudeai: unauthorized (session key expired or invalid)") + // ErrRateLimited indicates the API rate limit was hit. + ErrRateLimited = errors.New("claudeai: rate limited") +) + +// Client fetches subscription data from the claude.ai web API. +type Client struct { + sessionKey string + http *http.Client +} + +// NewClient creates a client for the given session key. +// Returns nil if the key is empty or has the wrong prefix. +func NewClient(sessionKey string) *Client { + sessionKey = strings.TrimSpace(sessionKey) + if sessionKey == "" { + return nil + } + if !strings.HasPrefix(sessionKey, keyPrefix) { + return nil + } + return &Client{ + sessionKey: sessionKey, + http: &http.Client{}, + } +} + +// FetchAll fetches orgs, usage, and overage for the first organization. +// Partial data is returned even if some requests fail. +func (c *Client) FetchAll(ctx context.Context) *SubscriptionData { + result := &SubscriptionData{FetchedAt: time.Now()} + + orgs, err := c.FetchOrganizations(ctx) + if err != nil { + result.Error = err + return result + } + if len(orgs) == 0 { + result.Error = errors.New("claudeai: no organizations found") + return result + } + + result.Org = orgs[0] + orgID := orgs[0].UUID + + // Fetch usage and overage independently — partial results are fine + usage, usageErr := c.FetchUsage(ctx, orgID) + if usageErr == nil { + result.Usage = usage + } + + overage, overageErr := c.FetchOverageLimit(ctx, orgID) + if overageErr == nil { + result.Overage = overage + } + + // Surface first non-nil error for status display + if usageErr != nil { + result.Error = usageErr + } else if overageErr != nil { + result.Error = overageErr + } + + return result +} + +// FetchOrganizations returns the list of organizations for this session. +func (c *Client) FetchOrganizations(ctx context.Context) ([]Organization, error) { + body, err := c.get(ctx, "/organizations") + if err != nil { + return nil, err + } + + var orgs []Organization + if err := json.Unmarshal(body, &orgs); err != nil { + return nil, fmt.Errorf("claudeai: parsing organizations: %w", err) + } + return orgs, nil +} + +// FetchUsage returns parsed usage windows for the given organization. +func (c *Client) FetchUsage(ctx context.Context, orgID string) (*ParsedUsage, error) { + body, err := c.get(ctx, fmt.Sprintf("/organizations/%s/usage", orgID)) + if err != nil { + return nil, err + } + + var raw UsageResponse + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("claudeai: parsing usage: %w", err) + } + + return &ParsedUsage{ + FiveHour: parseWindow(raw.FiveHour), + SevenDay: parseWindow(raw.SevenDay), + SevenDayOpus: parseWindow(raw.SevenDayOpus), + SevenDaySonnet: parseWindow(raw.SevenDaySonnet), + }, nil +} + +// FetchOverageLimit returns overage spend limit data for the given organization. +func (c *Client) FetchOverageLimit(ctx context.Context, orgID string) (*OverageLimit, error) { + body, err := c.get(ctx, fmt.Sprintf("/organizations/%s/overage_spend_limit", orgID)) + if err != nil { + return nil, err + } + + var ol OverageLimit + if err := json.Unmarshal(body, &ol); err != nil { + return nil, fmt.Errorf("claudeai: parsing overage limit: %w", err) + } + return &ol, nil +} + +// get performs an authenticated GET request and returns the response body. +func (c *Client) get(ctx context.Context, path string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, requestTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, nil) + if err != nil { + return nil, fmt.Errorf("claudeai: creating request: %w", err) + } + + req.Header.Set("Cookie", "sessionKey="+c.sessionKey) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "cburn/1.0") + + //nolint:gosec // URL is constructed from const baseURL + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("claudeai: request failed: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return nil, ErrUnauthorized + case http.StatusTooManyRequests: + return nil, ErrRateLimited + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("claudeai: unexpected status %d", resp.StatusCode) + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBodySize)) + if err != nil { + return nil, fmt.Errorf("claudeai: reading response: %w", err) + } + return body, nil +} + +// parseWindow converts a raw UsageWindow into a normalized ParsedWindow. +// Returns nil if the input is nil or unparseable. +func parseWindow(w *UsageWindow) *ParsedWindow { + if w == nil { + return nil + } + + pct, ok := parseUtilization(w.Utilization) + if !ok { + return nil + } + + pw := &ParsedWindow{Pct: pct} + + if w.ResetsAt != nil { + if t, err := time.Parse(time.RFC3339, *w.ResetsAt); err == nil { + pw.ResetsAt = t + } + } + + return pw +} + +// parseUtilization defensively parses the polymorphic utilization field. +// Handles int (75), float (0.75 or 75.0), and string ("75%" or "0.75"). +// Returns value normalized to 0.0-1.0 range. +func parseUtilization(raw json.RawMessage) (float64, bool) { + if len(raw) == 0 { + return 0, false + } + + // Try number first (covers both int and float JSON) + var f float64 + if err := json.Unmarshal(raw, &f); err == nil { + return normalizeUtilization(f), true + } + + // Try string + var s string + if err := json.Unmarshal(raw, &s); err == nil { + s = strings.TrimSuffix(strings.TrimSpace(s), "%") + if v, err := strconv.ParseFloat(s, 64); err == nil { + return normalizeUtilization(v), true + } + } + + return 0, false +} + +// normalizeUtilization converts a value to 0.0-1.0 range. +// Values > 1.0 are assumed to be percentages (0-100 scale). +func normalizeUtilization(v float64) float64 { + if v > 1.0 { + return v / 100.0 + } + return v +} diff --git a/internal/claudeai/types.go b/internal/claudeai/types.go new file mode 100644 index 0000000..49af601 --- /dev/null +++ b/internal/claudeai/types.go @@ -0,0 +1,59 @@ +package claudeai + +import ( + "encoding/json" + "time" +) + +// Organization represents a claude.ai organization. +type Organization struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Capabilities []string `json:"capabilities"` +} + +// UsageResponse is the raw API response from the usage endpoint. +type UsageResponse struct { + FiveHour *UsageWindow `json:"five_hour"` + SevenDay *UsageWindow `json:"seven_day"` + SevenDayOpus *UsageWindow `json:"seven_day_opus"` + SevenDaySonnet *UsageWindow `json:"seven_day_sonnet"` +} + +// UsageWindow is a single rate-limit window from the API. +// Utilization can be int, float, or string — kept as raw JSON for defensive parsing. +type UsageWindow struct { + Utilization json.RawMessage `json:"utilization"` + ResetsAt *string `json:"resets_at"` +} + +// OverageLimit is the raw API response from the overage spend limit endpoint. +type OverageLimit struct { + IsEnabled bool `json:"isEnabled"` + UsedCredits float64 `json:"usedCredits"` + MonthlyCreditLimit float64 `json:"monthlyCreditLimit"` + Currency string `json:"currency"` +} + +// SubscriptionData is the parsed, TUI-ready aggregate of all claude.ai API data. +type SubscriptionData struct { + Org Organization + Usage *ParsedUsage + Overage *OverageLimit + FetchedAt time.Time + Error error +} + +// ParsedUsage holds normalized usage windows. +type ParsedUsage struct { + FiveHour *ParsedWindow + SevenDay *ParsedWindow + SevenDayOpus *ParsedWindow + SevenDaySonnet *ParsedWindow +} + +// ParsedWindow is a single rate-limit window, normalized for display. +type ParsedWindow struct { + Pct float64 // 0.0-1.0 + ResetsAt time.Time +} diff --git a/internal/config/config.go b/internal/config/config.go index db0fca7..4102db9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,3 +1,4 @@ +// Package config handles cburn configuration loading, saving, and pricing. package config import ( @@ -10,11 +11,12 @@ import ( // Config holds all cburn configuration. type Config struct { - General GeneralConfig `toml:"general"` - AdminAPI AdminAPIConfig `toml:"admin_api"` - Budget BudgetConfig `toml:"budget"` + General GeneralConfig `toml:"general"` + AdminAPI AdminAPIConfig `toml:"admin_api"` + ClaudeAI ClaudeAIConfig `toml:"claude_ai"` + Budget BudgetConfig `toml:"budget"` Appearance AppearanceConfig `toml:"appearance"` - Pricing PricingOverrides `toml:"pricing"` + Pricing PricingOverrides `toml:"pricing"` } // GeneralConfig holds general preferences. @@ -26,10 +28,16 @@ type GeneralConfig struct { // AdminAPIConfig holds Anthropic Admin API settings. type AdminAPIConfig struct { - APIKey string `toml:"api_key,omitempty"` + APIKey string `toml:"api_key,omitempty"` //nolint:gosec // config field, not a secret BaseURL string `toml:"base_url,omitempty"` } +// ClaudeAIConfig holds claude.ai session key settings for subscription data. +type ClaudeAIConfig struct { + SessionKey string `toml:"session_key,omitempty"` //nolint:gosec // config field, not a secret + OrgID string `toml:"org_id,omitempty"` // auto-cached after first fetch +} + // BudgetConfig holds budget tracking settings. type BudgetConfig struct { MonthlyUSD *float64 `toml:"monthly_usd,omitempty"` @@ -67,8 +75,8 @@ func DefaultConfig() Config { } } -// ConfigDir returns the XDG-compliant config directory. -func ConfigDir() string { +// Dir returns the XDG-compliant config directory. +func Dir() string { if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { return filepath.Join(xdg, "cburn") } @@ -76,16 +84,16 @@ func ConfigDir() string { return filepath.Join(home, ".config", "cburn") } -// ConfigPath returns the full path to the config file. -func ConfigPath() string { - return filepath.Join(ConfigDir(), "config.toml") +// Path returns the full path to the config file. +func Path() string { + return filepath.Join(Dir(), "config.toml") } // Load reads the config file, returning defaults if it doesn't exist. func Load() (Config, error) { cfg := DefaultConfig() - data, err := os.ReadFile(ConfigPath()) + data, err := os.ReadFile(Path()) if err != nil { if os.IsNotExist(err) { return cfg, nil @@ -102,19 +110,21 @@ func Load() (Config, error) { // Save writes the config to disk. func Save(cfg Config) error { - dir := ConfigDir() - if err := os.MkdirAll(dir, 0o755); err != nil { + dir := Dir() + if err := os.MkdirAll(dir, 0o750); err != nil { return fmt.Errorf("creating config dir: %w", err) } - f, err := os.OpenFile(ConfigPath(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + f, err := os.OpenFile(Path(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return fmt.Errorf("creating config file: %w", err) } - defer f.Close() - enc := toml.NewEncoder(f) - return enc.Encode(cfg) + if err := enc.Encode(cfg); err != nil { + _ = f.Close() + return err + } + return f.Close() } // GetAdminAPIKey returns the API key from env var or config, in that order. @@ -125,8 +135,16 @@ func GetAdminAPIKey(cfg Config) string { return cfg.AdminAPI.APIKey } +// GetSessionKey returns the session key from env var or config, in that order. +func GetSessionKey(cfg Config) string { + if key := os.Getenv("CLAUDE_SESSION_KEY"); key != "" { + return key + } + return cfg.ClaudeAI.SessionKey +} + // Exists returns true if a config file exists on disk. func Exists() bool { - _, err := os.Stat(ConfigPath()) + _, err := os.Stat(Path()) return err == nil }