feat(ccm): add claude_directory option to read Claude Code config
This commit is contained in:
+8
-8
@@ -76,14 +76,14 @@ func (c *CCMCredential) UnmarshalJSON(bytes []byte) error {
|
||||
}
|
||||
|
||||
type CCMDefaultCredentialOptions struct {
|
||||
CredentialPath string `json:"credential_path,omitempty"`
|
||||
StatePath string `json:"state_path,omitempty"`
|
||||
UsagesPath string `json:"usages_path,omitempty"`
|
||||
Detour string `json:"detour,omitempty"`
|
||||
Reserve5h uint8 `json:"reserve_5h"`
|
||||
ReserveWeekly uint8 `json:"reserve_weekly"`
|
||||
Limit5h uint8 `json:"limit_5h,omitempty"`
|
||||
LimitWeekly uint8 `json:"limit_weekly,omitempty"`
|
||||
CredentialPath string `json:"credential_path,omitempty"`
|
||||
ClaudeDirectory string `json:"claude_directory,omitempty"`
|
||||
UsagesPath string `json:"usages_path,omitempty"`
|
||||
Detour string `json:"detour,omitempty"`
|
||||
Reserve5h uint8 `json:"reserve_5h"`
|
||||
ReserveWeekly uint8 `json:"reserve_weekly"`
|
||||
Limit5h uint8 `json:"limit_5h,omitempty"`
|
||||
LimitWeekly uint8 `json:"limit_weekly,omitempty"`
|
||||
}
|
||||
|
||||
type CCMBalancerCredentialOptions struct {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package ccm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// claudeCodeConfig represents the persisted config written by Claude Code.
|
||||
//
|
||||
// ref (@anthropic-ai/claude-code @2.1.81):
|
||||
//
|
||||
// ref: cli.js P8() (line 174997) — reads config
|
||||
// ref: cli.js c8() (line 174919) — writes config
|
||||
// ref: cli.js _D() (line 39158-39163) — config file path resolution
|
||||
type claudeCodeConfig struct {
|
||||
UserID string `json:"userID"` // ref: cli.js XL() (line 175325) — random 32-byte hex, generated once
|
||||
OAuthAccount *claudeOAuthAccount `json:"oauthAccount"` // ref: cli.js fP6() / storeOAuthAccountInfo — from /api/oauth/profile
|
||||
}
|
||||
|
||||
type claudeOAuthAccount struct {
|
||||
AccountUUID string `json:"accountUuid"`
|
||||
}
|
||||
|
||||
// resolveClaudeConfigFile finds the Claude Code config file within the given directory.
|
||||
//
|
||||
// Config file path resolution mirrors cli.js _D() (line 39158-39163):
|
||||
// 1. claudeDirectory/.config.json — newer format, checked first
|
||||
// 2. claudeDirectory/.claude.json — used when CLAUDE_CONFIG_DIR is set
|
||||
// 3. filepath.Dir(claudeDirectory)/.claude.json — default ~/.claude case → ~/.claude.json
|
||||
//
|
||||
// Returns the first path that exists, or "" if none found.
|
||||
func resolveClaudeConfigFile(claudeDirectory string) string {
|
||||
candidates := []string{
|
||||
filepath.Join(claudeDirectory, ".config.json"),
|
||||
filepath.Join(claudeDirectory, ".claude.json"),
|
||||
filepath.Join(filepath.Dir(claudeDirectory), ".claude.json"),
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
_, err := os.Stat(candidate)
|
||||
if err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readClaudeCodeConfig(path string) (*claudeCodeConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var config claudeCodeConfig
|
||||
err = json.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &config, nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
@@ -29,9 +30,11 @@ type defaultCredential struct {
|
||||
tag string
|
||||
serviceContext context.Context
|
||||
credentialPath string
|
||||
claudeDirectory string
|
||||
credentialFilePath string
|
||||
configDir string
|
||||
statePath string
|
||||
deviceID string
|
||||
configLoaded bool
|
||||
credentials *oauthCredentials
|
||||
access sync.RWMutex
|
||||
state credentialState
|
||||
@@ -109,7 +112,7 @@ func newDefaultCredential(ctx context.Context, tag string, options option.CCMDef
|
||||
tag: tag,
|
||||
serviceContext: ctx,
|
||||
credentialPath: options.CredentialPath,
|
||||
statePath: options.StatePath,
|
||||
claudeDirectory: options.ClaudeDirectory,
|
||||
cap5h: cap5h,
|
||||
capWeekly: capWeekly,
|
||||
forwardHTTPClient: httpClient,
|
||||
@@ -129,13 +132,18 @@ func newDefaultCredential(ctx context.Context, tag string, options option.CCMDef
|
||||
}
|
||||
|
||||
func (c *defaultCredential) start() error {
|
||||
if c.claudeDirectory != "" {
|
||||
c.loadClaudeCodeConfig()
|
||||
if c.credentialPath == "" {
|
||||
c.credentialPath = filepath.Join(c.claudeDirectory, ".credentials.json")
|
||||
}
|
||||
}
|
||||
credentialFilePath, err := resolveCredentialFilePath(c.credentialPath)
|
||||
if err != nil {
|
||||
return E.Cause(err, "resolve credential path for ", c.tag)
|
||||
}
|
||||
c.credentialFilePath = credentialFilePath
|
||||
c.configDir = resolveConfigDir(c.credentialPath, credentialFilePath)
|
||||
c.loadPersistedState()
|
||||
err = c.ensureCredentialWatcher()
|
||||
if err != nil {
|
||||
c.logger.Debug("start credential watcher for ", c.tag, ": ", err)
|
||||
@@ -154,6 +162,28 @@ func (c *defaultCredential) start() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *defaultCredential) loadClaudeCodeConfig() {
|
||||
configFilePath := resolveClaudeConfigFile(c.claudeDirectory)
|
||||
if configFilePath == "" {
|
||||
return
|
||||
}
|
||||
config, err := readClaudeCodeConfig(configFilePath)
|
||||
if err != nil {
|
||||
c.logger.Warn("read claude code config for ", c.tag, ": ", err)
|
||||
return
|
||||
}
|
||||
c.stateAccess.Lock()
|
||||
if config.OAuthAccount != nil && config.OAuthAccount.AccountUUID != "" {
|
||||
c.state.accountUUID = config.OAuthAccount.AccountUUID
|
||||
}
|
||||
c.stateAccess.Unlock()
|
||||
if config.UserID != "" {
|
||||
c.deviceID = config.UserID
|
||||
}
|
||||
c.configLoaded = true
|
||||
c.logger.Debug("loaded claude code config for ", c.tag, ": account=", c.state.accountUUID, ", device=", c.deviceID)
|
||||
}
|
||||
|
||||
func (c *defaultCredential) setStatusSubscriber(subscriber *observable.Subscriber[struct{}]) {
|
||||
c.statusSubscriber = subscriber
|
||||
}
|
||||
@@ -697,7 +727,7 @@ func (c *defaultCredential) pollUsage() {
|
||||
}
|
||||
c.logger.Debug("poll usage for ", c.tag, ": 5h=", c.state.fiveHourUtilization, "%, weekly=", c.state.weeklyUtilization, "%", resetSuffix)
|
||||
}
|
||||
needsProfileFetch := c.state.rateLimitTier == ""
|
||||
needsProfileFetch := !c.configLoaded && c.state.rateLimitTier == ""
|
||||
shouldInterrupt := c.checkTransitionLocked()
|
||||
c.stateAccess.Unlock()
|
||||
if shouldInterrupt {
|
||||
@@ -782,7 +812,6 @@ func (c *defaultCredential) fetchProfile(httpClient *http.Client, accessToken st
|
||||
if shouldEmit {
|
||||
c.emitStatusUpdate()
|
||||
}
|
||||
c.savePersistedState()
|
||||
c.logger.Info("fetched profile for ", c.tag, ": type=", resolvedAccountType, ", tier=", rateLimitTier, ", weight=", ccmPlanWeight(resolvedAccountType, rateLimitTier))
|
||||
}
|
||||
|
||||
@@ -841,7 +870,7 @@ func (c *defaultCredential) buildProxyRequest(ctx context.Context, original *htt
|
||||
proxyURL := claudeAPIBaseURL + original.URL.RequestURI()
|
||||
var body io.Reader
|
||||
if bodyBytes != nil {
|
||||
bodyBytes = c.injectAccountUUID(bodyBytes)
|
||||
bodyBytes = c.injectMetadataFields(bodyBytes)
|
||||
body = bytes.NewReader(bodyBytes)
|
||||
} else {
|
||||
body = original.Body
|
||||
@@ -878,24 +907,20 @@ func (c *defaultCredential) buildProxyRequest(ctx context.Context, original *htt
|
||||
return proxyRequest, nil
|
||||
}
|
||||
|
||||
// injectAccountUUID fills in the account_uuid field in metadata.user_id
|
||||
// when the client sends it empty (e.g. using ANTHROPIC_AUTH_TOKEN).
|
||||
// injectMetadataFields fills in account_uuid and device_id in metadata.user_id
|
||||
// when the client sends them empty (e.g. using ANTHROPIC_AUTH_TOKEN).
|
||||
//
|
||||
// Claude Code >= 2.1.78 (@anthropic-ai/claude-code) sets metadata as:
|
||||
//
|
||||
// {user_id: JSON.stringify({device_id, account_uuid, session_id})}
|
||||
//
|
||||
// ref: cli.js L66() — metadata constructor
|
||||
//
|
||||
// account_uuid is populated from oauthAccount.accountUuid which comes from
|
||||
// the /api/oauth/profile endpoint (ref: cli.js EX1() → fP6()).
|
||||
// When the client uses ANTHROPIC_AUTH_TOKEN instead of Claude AI OAuth,
|
||||
// account_uuid is empty. We inject it from the fetchProfile result.
|
||||
func (c *defaultCredential) injectAccountUUID(bodyBytes []byte) []byte {
|
||||
func (c *defaultCredential) injectMetadataFields(bodyBytes []byte) []byte {
|
||||
c.stateAccess.RLock()
|
||||
accountUUID := c.state.accountUUID
|
||||
c.stateAccess.RUnlock()
|
||||
if accountUUID == "" {
|
||||
deviceID := c.deviceID
|
||||
if accountUUID == "" && deviceID == "" {
|
||||
return bodyBytes
|
||||
}
|
||||
|
||||
@@ -931,19 +956,43 @@ func (c *defaultCredential) injectAccountUUID(bodyBytes []byte) []byte {
|
||||
return bodyBytes
|
||||
}
|
||||
|
||||
existingRaw, hasExisting := userIDObject["account_uuid"]
|
||||
if hasExisting {
|
||||
var existing string
|
||||
if json.Unmarshal(existingRaw, &existing) == nil && existing != "" {
|
||||
return bodyBytes
|
||||
modified := false
|
||||
|
||||
if accountUUID != "" {
|
||||
existingRaw, hasExisting := userIDObject["account_uuid"]
|
||||
needsInject := !hasExisting
|
||||
if hasExisting {
|
||||
var existing string
|
||||
needsInject = json.Unmarshal(existingRaw, &existing) != nil || existing == ""
|
||||
}
|
||||
if needsInject {
|
||||
accountUUIDJSON, marshalErr := json.Marshal(accountUUID)
|
||||
if marshalErr == nil {
|
||||
userIDObject["account_uuid"] = json.RawMessage(accountUUIDJSON)
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accountUUIDJSON, err := json.Marshal(accountUUID)
|
||||
if err != nil {
|
||||
if deviceID != "" {
|
||||
existingRaw, hasExisting := userIDObject["device_id"]
|
||||
needsInject := !hasExisting
|
||||
if hasExisting {
|
||||
var existing string
|
||||
needsInject = json.Unmarshal(existingRaw, &existing) != nil || existing == ""
|
||||
}
|
||||
if needsInject {
|
||||
deviceIDJSON, marshalErr := json.Marshal(deviceID)
|
||||
if marshalErr == nil {
|
||||
userIDObject["device_id"] = json.RawMessage(deviceIDJSON)
|
||||
modified = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !modified {
|
||||
return bodyBytes
|
||||
}
|
||||
userIDObject["account_uuid"] = json.RawMessage(accountUUIDJSON)
|
||||
|
||||
newUserIDBytes, err := json.Marshal(userIDObject)
|
||||
if err != nil {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package ccm
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
// persistedState holds profile data fetched from /api/oauth/profile,
|
||||
// persisted to state_path so it survives restarts without re-fetching.
|
||||
//
|
||||
// Claude Code (@anthropic-ai/claude-code @2.1.81) stores equivalent data in
|
||||
// its config file (~/.claude/.config.json) under the oauthAccount key:
|
||||
//
|
||||
// ref: cli.js fP6() / storeOAuthAccountInfo — writes accountUuid, billingType, etc.
|
||||
// ref: cli.js P8() — reads config from $CLAUDE_CONFIG_DIR/.config.json
|
||||
type persistedState struct {
|
||||
AccountUUID string `json:"account_uuid,omitempty"`
|
||||
AccountType string `json:"account_type,omitempty"`
|
||||
RateLimitTier string `json:"rate_limit_tier,omitempty"`
|
||||
}
|
||||
|
||||
func (c *defaultCredential) loadPersistedState() {
|
||||
if c.statePath == "" {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(c.statePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var state persistedState
|
||||
err = json.Unmarshal(data, &state)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.stateAccess.Lock()
|
||||
if state.AccountUUID != "" {
|
||||
c.state.accountUUID = state.AccountUUID
|
||||
}
|
||||
if state.AccountType != "" {
|
||||
c.state.accountType = state.AccountType
|
||||
}
|
||||
if state.RateLimitTier != "" {
|
||||
c.state.rateLimitTier = state.RateLimitTier
|
||||
}
|
||||
c.stateAccess.Unlock()
|
||||
}
|
||||
|
||||
func (c *defaultCredential) savePersistedState() {
|
||||
if c.statePath == "" {
|
||||
return
|
||||
}
|
||||
c.stateAccess.RLock()
|
||||
state := persistedState{
|
||||
AccountUUID: c.state.accountUUID,
|
||||
AccountType: c.state.accountType,
|
||||
RateLimitTier: c.state.rateLimitTier,
|
||||
}
|
||||
c.stateAccess.RUnlock()
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
os.WriteFile(c.statePath, data, 0o600)
|
||||
}
|
||||
Reference in New Issue
Block a user