// Package claudecode reads the OAuth credential Claude Code stores on disk, so
// a harness run can borrow a login that already exists.
//
// This is Claude Code's private storage, not a documented interface: the
// schema can change without notice. Everything that knows its shape lives
// here, and parses strictly, so a change surfaces as a clear error rather than
// as a puzzling 401 somewhere else.
package claudecode
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// Filename is the credential store, relative to the home directory.
const Filename = ".claude/.credentials.json"
// skew is how long before its stated expiry a token starts counting as
// expired. A token with seconds left would expire during the request we are
// about to send.
const skew = 60 * time.Second
// ErrNotFound means there is no credentials file: not logged in, rather than
// broken. Callers fall through to whatever source is next.
var ErrNotFound = errors.New("claudecode: no credentials file")
// Credentials is the claudeAiOauth object, with its millisecond timestamps
// converted to something Go can compare.
type Credentials struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
Scopes []string
SubscriptionType string
}
// stored mirrors the on-disk JSON. Timestamps are epoch milliseconds.
type stored struct {
ClaudeAIOAuth struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"`
Scopes []string `json:"scopes"`
SubscriptionType string `json:"subscriptionType"`
} `json:"claudeAiOauth"`
}
// Load reads and parses the credential store. home is passed in rather than
// looked up so tests can point it at a fixture, matching skills.Discover.
func Load(home string) (*Credentials, error) {
path := filepath.Join(home, Filename)
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("claudecode: reading %s: %w", path, err)
}
var s stored
if err := json.Unmarshal(raw, &s); err != nil {
return nil, fmt.Errorf("claudecode: parsing %s: %w", path, err)
}
o := s.ClaudeAIOAuth
if o.AccessToken == "" {
return nil, fmt.Errorf("claudecode: %s has no access token", path)
}
if o.ExpiresAt == 0 {
return nil, fmt.Errorf("claudecode: %s has no expiresAt", path)
}
return &Credentials{
AccessToken: o.AccessToken,
RefreshToken: o.RefreshToken,
ExpiresAt: time.UnixMilli(o.ExpiresAt),
Scopes: o.Scopes,
SubscriptionType: o.SubscriptionType,
}, nil
}
// Expired reports whether the access token is past its expiry, or close enough
// that it would lapse mid-request.
func (c *Credentials) Expired(now time.Time) bool {
return !now.Before(c.ExpiresAt.Add(-skew))
}