// Package claudecode reads the OAuth credential Claude Code stores on disk, so // a Codey 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"` } // Present reports whether Claude Code has a credentials file in home, without // reading it. It is what decides whether a question about borrowing the login // is worth asking, and asking is all it is for: whether the file holds a // usable token is Load's business, and only once someone has said yes. func Present(home string) bool { if home == "" { return false } _, err := os.Stat(filepath.Join(home, Filename)) return err == nil } // 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)) }