feat: add claude.ai API client and session key configuration
Introduce a client for fetching subscription data from the claude.ai web
API, enabling rate-limit monitoring and overage tracking in the dashboard.
New package internal/claudeai:
- Client authenticates via session cookie (sk-ant-sid... prefix validated)
- FetchAll() retrieves orgs, usage windows, and overage in one call,
returning partial data when individual requests fail
- FetchOrganizations/FetchUsage/FetchOverageLimit for granular access
- Defensive utilization parsing handles polymorphic API responses: int
(75), float (0.75 or 75.0), and string ("75%" or "0.75"), normalizing
all to 0.0-1.0 range
- 10s request timeout, 1MB body limit, proper status code handling
(401/403 -> ErrUnauthorized, 429 -> ErrRateLimited)
Types (claudeai/types.go):
- Organization, UsageResponse, UsageWindow (raw), OverageLimit
- SubscriptionData (TUI-ready aggregate), ParsedUsage, ParsedWindow
Config changes (config/config.go):
- Add ClaudeAIConfig struct with session_key and org_id fields
- Add GetSessionKey() with CLAUDE_SESSION_KEY env var fallback
- Fix directory permissions 0o755 -> 0o750 (gosec G301)
- Fix Save() to propagate encoder errors before closing file
This commit is contained in:
234
internal/claudeai/client.go
Normal file
234
internal/claudeai/client.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
59
internal/claudeai/types.go
Normal file
59
internal/claudeai/types.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package config handles cburn configuration loading, saving, and pricing.
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -10,11 +11,12 @@ import (
|
|||||||
|
|
||||||
// Config holds all cburn configuration.
|
// Config holds all cburn configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
General GeneralConfig `toml:"general"`
|
General GeneralConfig `toml:"general"`
|
||||||
AdminAPI AdminAPIConfig `toml:"admin_api"`
|
AdminAPI AdminAPIConfig `toml:"admin_api"`
|
||||||
Budget BudgetConfig `toml:"budget"`
|
ClaudeAI ClaudeAIConfig `toml:"claude_ai"`
|
||||||
|
Budget BudgetConfig `toml:"budget"`
|
||||||
Appearance AppearanceConfig `toml:"appearance"`
|
Appearance AppearanceConfig `toml:"appearance"`
|
||||||
Pricing PricingOverrides `toml:"pricing"`
|
Pricing PricingOverrides `toml:"pricing"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeneralConfig holds general preferences.
|
// GeneralConfig holds general preferences.
|
||||||
@@ -26,10 +28,16 @@ type GeneralConfig struct {
|
|||||||
|
|
||||||
// AdminAPIConfig holds Anthropic Admin API settings.
|
// AdminAPIConfig holds Anthropic Admin API settings.
|
||||||
type AdminAPIConfig struct {
|
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"`
|
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.
|
// BudgetConfig holds budget tracking settings.
|
||||||
type BudgetConfig struct {
|
type BudgetConfig struct {
|
||||||
MonthlyUSD *float64 `toml:"monthly_usd,omitempty"`
|
MonthlyUSD *float64 `toml:"monthly_usd,omitempty"`
|
||||||
@@ -67,8 +75,8 @@ func DefaultConfig() Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigDir returns the XDG-compliant config directory.
|
// Dir returns the XDG-compliant config directory.
|
||||||
func ConfigDir() string {
|
func Dir() string {
|
||||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||||
return filepath.Join(xdg, "cburn")
|
return filepath.Join(xdg, "cburn")
|
||||||
}
|
}
|
||||||
@@ -76,16 +84,16 @@ func ConfigDir() string {
|
|||||||
return filepath.Join(home, ".config", "cburn")
|
return filepath.Join(home, ".config", "cburn")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfigPath returns the full path to the config file.
|
// Path returns the full path to the config file.
|
||||||
func ConfigPath() string {
|
func Path() string {
|
||||||
return filepath.Join(ConfigDir(), "config.toml")
|
return filepath.Join(Dir(), "config.toml")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load reads the config file, returning defaults if it doesn't exist.
|
// Load reads the config file, returning defaults if it doesn't exist.
|
||||||
func Load() (Config, error) {
|
func Load() (Config, error) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
data, err := os.ReadFile(ConfigPath())
|
data, err := os.ReadFile(Path())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
@@ -102,19 +110,21 @@ func Load() (Config, error) {
|
|||||||
|
|
||||||
// Save writes the config to disk.
|
// Save writes the config to disk.
|
||||||
func Save(cfg Config) error {
|
func Save(cfg Config) error {
|
||||||
dir := ConfigDir()
|
dir := Dir()
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||||
return fmt.Errorf("creating config dir: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("creating config file: %w", err)
|
return fmt.Errorf("creating config file: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
enc := toml.NewEncoder(f)
|
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.
|
// 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
|
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.
|
// Exists returns true if a config file exists on disk.
|
||||||
func Exists() bool {
|
func Exists() bool {
|
||||||
_, err := os.Stat(ConfigPath())
|
_, err := os.Stat(Path())
|
||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user