Skip to content

Commit 441c988

Browse files
committed
feat(ccm): add claude_directory option to read Claude Code config
1 parent bb2169b commit 441c988

4 files changed

Lines changed: 139 additions & 95 deletions

File tree

option/ccm.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,14 @@ func (c *CCMCredential) UnmarshalJSON(bytes []byte) error {
7676
}
7777

7878
type CCMDefaultCredentialOptions struct {
79-
CredentialPath string `json:"credential_path,omitempty"`
80-
StatePath string `json:"state_path,omitempty"`
81-
UsagesPath string `json:"usages_path,omitempty"`
82-
Detour string `json:"detour,omitempty"`
83-
Reserve5h uint8 `json:"reserve_5h"`
84-
ReserveWeekly uint8 `json:"reserve_weekly"`
85-
Limit5h uint8 `json:"limit_5h,omitempty"`
86-
LimitWeekly uint8 `json:"limit_weekly,omitempty"`
79+
CredentialPath string `json:"credential_path,omitempty"`
80+
ClaudeDirectory string `json:"claude_directory,omitempty"`
81+
UsagesPath string `json:"usages_path,omitempty"`
82+
Detour string `json:"detour,omitempty"`
83+
Reserve5h uint8 `json:"reserve_5h"`
84+
ReserveWeekly uint8 `json:"reserve_weekly"`
85+
Limit5h uint8 `json:"limit_5h,omitempty"`
86+
LimitWeekly uint8 `json:"limit_weekly,omitempty"`
8787
}
8888

8989
type CCMBalancerCredentialOptions struct {
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package ccm
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
)
8+
9+
// claudeCodeConfig represents the persisted config written by Claude Code.
10+
//
11+
// ref (@anthropic-ai/claude-code @2.1.81):
12+
//
13+
// ref: cli.js P8() (line 174997) — reads config
14+
// ref: cli.js c8() (line 174919) — writes config
15+
// ref: cli.js _D() (line 39158-39163) — config file path resolution
16+
type claudeCodeConfig struct {
17+
UserID string `json:"userID"` // ref: cli.js XL() (line 175325) — random 32-byte hex, generated once
18+
OAuthAccount *claudeOAuthAccount `json:"oauthAccount"` // ref: cli.js fP6() / storeOAuthAccountInfo — from /api/oauth/profile
19+
}
20+
21+
type claudeOAuthAccount struct {
22+
AccountUUID string `json:"accountUuid"`
23+
}
24+
25+
// resolveClaudeConfigFile finds the Claude Code config file within the given directory.
26+
//
27+
// Config file path resolution mirrors cli.js _D() (line 39158-39163):
28+
// 1. claudeDirectory/.config.json — newer format, checked first
29+
// 2. claudeDirectory/.claude.json — used when CLAUDE_CONFIG_DIR is set
30+
// 3. filepath.Dir(claudeDirectory)/.claude.json — default ~/.claude case → ~/.claude.json
31+
//
32+
// Returns the first path that exists, or "" if none found.
33+
func resolveClaudeConfigFile(claudeDirectory string) string {
34+
candidates := []string{
35+
filepath.Join(claudeDirectory, ".config.json"),
36+
filepath.Join(claudeDirectory, ".claude.json"),
37+
filepath.Join(filepath.Dir(claudeDirectory), ".claude.json"),
38+
}
39+
for _, candidate := range candidates {
40+
_, err := os.Stat(candidate)
41+
if err == nil {
42+
return candidate
43+
}
44+
}
45+
return ""
46+
}
47+
48+
func readClaudeCodeConfig(path string) (*claudeCodeConfig, error) {
49+
data, err := os.ReadFile(path)
50+
if err != nil {
51+
return nil, err
52+
}
53+
var config claudeCodeConfig
54+
err = json.Unmarshal(data, &config)
55+
if err != nil {
56+
return nil, err
57+
}
58+
return &config, nil
59+
}

service/ccm/credential_default.go

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"math"
1010
"net"
1111
"net/http"
12+
"path/filepath"
1213
"slices"
1314
"strconv"
1415
"sync"
@@ -29,9 +30,11 @@ type defaultCredential struct {
2930
tag string
3031
serviceContext context.Context
3132
credentialPath string
33+
claudeDirectory string
3234
credentialFilePath string
3335
configDir string
34-
statePath string
36+
deviceID string
37+
configLoaded bool
3538
credentials *oauthCredentials
3639
access sync.RWMutex
3740
state credentialState
@@ -109,7 +112,7 @@ func newDefaultCredential(ctx context.Context, tag string, options option.CCMDef
109112
tag: tag,
110113
serviceContext: ctx,
111114
credentialPath: options.CredentialPath,
112-
statePath: options.StatePath,
115+
claudeDirectory: options.ClaudeDirectory,
113116
cap5h: cap5h,
114117
capWeekly: capWeekly,
115118
forwardHTTPClient: httpClient,
@@ -129,13 +132,18 @@ func newDefaultCredential(ctx context.Context, tag string, options option.CCMDef
129132
}
130133

131134
func (c *defaultCredential) start() error {
135+
if c.claudeDirectory != "" {
136+
c.loadClaudeCodeConfig()
137+
if c.credentialPath == "" {
138+
c.credentialPath = filepath.Join(c.claudeDirectory, ".credentials.json")
139+
}
140+
}
132141
credentialFilePath, err := resolveCredentialFilePath(c.credentialPath)
133142
if err != nil {
134143
return E.Cause(err, "resolve credential path for ", c.tag)
135144
}
136145
c.credentialFilePath = credentialFilePath
137146
c.configDir = resolveConfigDir(c.credentialPath, credentialFilePath)
138-
c.loadPersistedState()
139147
err = c.ensureCredentialWatcher()
140148
if err != nil {
141149
c.logger.Debug("start credential watcher for ", c.tag, ": ", err)
@@ -154,6 +162,28 @@ func (c *defaultCredential) start() error {
154162
return nil
155163
}
156164

165+
func (c *defaultCredential) loadClaudeCodeConfig() {
166+
configFilePath := resolveClaudeConfigFile(c.claudeDirectory)
167+
if configFilePath == "" {
168+
return
169+
}
170+
config, err := readClaudeCodeConfig(configFilePath)
171+
if err != nil {
172+
c.logger.Warn("read claude code config for ", c.tag, ": ", err)
173+
return
174+
}
175+
c.stateAccess.Lock()
176+
if config.OAuthAccount != nil && config.OAuthAccount.AccountUUID != "" {
177+
c.state.accountUUID = config.OAuthAccount.AccountUUID
178+
}
179+
c.stateAccess.Unlock()
180+
if config.UserID != "" {
181+
c.deviceID = config.UserID
182+
}
183+
c.configLoaded = true
184+
c.logger.Debug("loaded claude code config for ", c.tag, ": account=", c.state.accountUUID, ", device=", c.deviceID)
185+
}
186+
157187
func (c *defaultCredential) setStatusSubscriber(subscriber *observable.Subscriber[struct{}]) {
158188
c.statusSubscriber = subscriber
159189
}
@@ -697,7 +727,7 @@ func (c *defaultCredential) pollUsage() {
697727
}
698728
c.logger.Debug("poll usage for ", c.tag, ": 5h=", c.state.fiveHourUtilization, "%, weekly=", c.state.weeklyUtilization, "%", resetSuffix)
699729
}
700-
needsProfileFetch := c.state.rateLimitTier == ""
730+
needsProfileFetch := !c.configLoaded && c.state.rateLimitTier == ""
701731
shouldInterrupt := c.checkTransitionLocked()
702732
c.stateAccess.Unlock()
703733
if shouldInterrupt {
@@ -782,7 +812,6 @@ func (c *defaultCredential) fetchProfile(httpClient *http.Client, accessToken st
782812
if shouldEmit {
783813
c.emitStatusUpdate()
784814
}
785-
c.savePersistedState()
786815
c.logger.Info("fetched profile for ", c.tag, ": type=", resolvedAccountType, ", tier=", rateLimitTier, ", weight=", ccmPlanWeight(resolvedAccountType, rateLimitTier))
787816
}
788817

@@ -841,7 +870,7 @@ func (c *defaultCredential) buildProxyRequest(ctx context.Context, original *htt
841870
proxyURL := claudeAPIBaseURL + original.URL.RequestURI()
842871
var body io.Reader
843872
if bodyBytes != nil {
844-
bodyBytes = c.injectAccountUUID(bodyBytes)
873+
bodyBytes = c.injectMetadataFields(bodyBytes)
845874
body = bytes.NewReader(bodyBytes)
846875
} else {
847876
body = original.Body
@@ -878,24 +907,20 @@ func (c *defaultCredential) buildProxyRequest(ctx context.Context, original *htt
878907
return proxyRequest, nil
879908
}
880909

881-
// injectAccountUUID fills in the account_uuid field in metadata.user_id
882-
// when the client sends it empty (e.g. using ANTHROPIC_AUTH_TOKEN).
910+
// injectMetadataFields fills in account_uuid and device_id in metadata.user_id
911+
// when the client sends them empty (e.g. using ANTHROPIC_AUTH_TOKEN).
883912
//
884913
// Claude Code >= 2.1.78 (@anthropic-ai/claude-code) sets metadata as:
885914
//
886915
// {user_id: JSON.stringify({device_id, account_uuid, session_id})}
887916
//
888917
// ref: cli.js L66() — metadata constructor
889-
//
890-
// account_uuid is populated from oauthAccount.accountUuid which comes from
891-
// the /api/oauth/profile endpoint (ref: cli.js EX1() → fP6()).
892-
// When the client uses ANTHROPIC_AUTH_TOKEN instead of Claude AI OAuth,
893-
// account_uuid is empty. We inject it from the fetchProfile result.
894-
func (c *defaultCredential) injectAccountUUID(bodyBytes []byte) []byte {
918+
func (c *defaultCredential) injectMetadataFields(bodyBytes []byte) []byte {
895919
c.stateAccess.RLock()
896920
accountUUID := c.state.accountUUID
897921
c.stateAccess.RUnlock()
898-
if accountUUID == "" {
922+
deviceID := c.deviceID
923+
if accountUUID == "" && deviceID == "" {
899924
return bodyBytes
900925
}
901926

@@ -931,19 +956,43 @@ func (c *defaultCredential) injectAccountUUID(bodyBytes []byte) []byte {
931956
return bodyBytes
932957
}
933958

934-
existingRaw, hasExisting := userIDObject["account_uuid"]
935-
if hasExisting {
936-
var existing string
937-
if json.Unmarshal(existingRaw, &existing) == nil && existing != "" {
938-
return bodyBytes
959+
modified := false
960+
961+
if accountUUID != "" {
962+
existingRaw, hasExisting := userIDObject["account_uuid"]
963+
needsInject := !hasExisting
964+
if hasExisting {
965+
var existing string
966+
needsInject = json.Unmarshal(existingRaw, &existing) != nil || existing == ""
967+
}
968+
if needsInject {
969+
accountUUIDJSON, marshalErr := json.Marshal(accountUUID)
970+
if marshalErr == nil {
971+
userIDObject["account_uuid"] = json.RawMessage(accountUUIDJSON)
972+
modified = true
973+
}
939974
}
940975
}
941976

942-
accountUUIDJSON, err := json.Marshal(accountUUID)
943-
if err != nil {
977+
if deviceID != "" {
978+
existingRaw, hasExisting := userIDObject["device_id"]
979+
needsInject := !hasExisting
980+
if hasExisting {
981+
var existing string
982+
needsInject = json.Unmarshal(existingRaw, &existing) != nil || existing == ""
983+
}
984+
if needsInject {
985+
deviceIDJSON, marshalErr := json.Marshal(deviceID)
986+
if marshalErr == nil {
987+
userIDObject["device_id"] = json.RawMessage(deviceIDJSON)
988+
modified = true
989+
}
990+
}
991+
}
992+
993+
if !modified {
944994
return bodyBytes
945995
}
946-
userIDObject["account_uuid"] = json.RawMessage(accountUUIDJSON)
947996

948997
newUserIDBytes, err := json.Marshal(userIDObject)
949998
if err != nil {

service/ccm/credential_state_file.go

Lines changed: 0 additions & 64 deletions
This file was deleted.

0 commit comments

Comments
 (0)