multi-claude-cli 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- multi_claude/__init__.py +3 -0
- multi_claude/__main__.py +12 -0
- multi_claude/accounts.py +434 -0
- multi_claude/actions.py +66 -0
- multi_claude/cli.py +1302 -0
- multi_claude/completion.py +281 -0
- multi_claude/config.py +488 -0
- multi_claude/doctor.py +221 -0
- multi_claude/env.py +46 -0
- multi_claude/fsutil.py +234 -0
- multi_claude/hook.py +90 -0
- multi_claude/identity.py +94 -0
- multi_claude/launcher.py +193 -0
- multi_claude/lock.py +47 -0
- multi_claude/migrate.py +709 -0
- multi_claude/platform.py +548 -0
- multi_claude/routes.py +133 -0
- multi_claude/sessions.py +195 -0
- multi_claude/shared.py +130 -0
- multi_claude/shellpath.py +33 -0
- multi_claude/statusline.py +196 -0
- multi_claude/usage.py +243 -0
- multi_claude_cli-0.4.0.dist-info/METADATA +457 -0
- multi_claude_cli-0.4.0.dist-info/RECORD +28 -0
- multi_claude_cli-0.4.0.dist-info/WHEEL +5 -0
- multi_claude_cli-0.4.0.dist-info/entry_points.txt +2 -0
- multi_claude_cli-0.4.0.dist-info/licenses/LICENSE +21 -0
- multi_claude_cli-0.4.0.dist-info/top_level.txt +1 -0
multi_claude/usage.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""账号用量:读 Claude Code 缓存在 `.claude.json` 里的 `cachedUsageUtilization`(方案 v0.2 §5.1.1),
|
|
2
|
+
以及 statusLine 钩子存下的快照(方案 feature-statusline-usage §5.1.5),取两者中较新的一份。
|
|
3
|
+
|
|
4
|
+
本模块不读凭据、不联网:用量数据是 Claude Code 运行时顺带写下的缓存,可能已经很旧
|
|
5
|
+
(实测正在使用的账号也可能几十个小时没刷新),所以每份结果都带获取时间,由调用方标注年龄。
|
|
6
|
+
该字段未写入官方文档,任何字段缺失或类型不符都降级为“无数据”,绝不抛异常。
|
|
7
|
+
`.claude.json` 里还有邮箱、组织名等信息,这里只取用量与 `oauthAccount.accountUuid` 两处。
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from typing import NamedTuple, Optional
|
|
15
|
+
|
|
16
|
+
from . import accounts, platform
|
|
17
|
+
from .config import IDENTITY_DEFAULT, Account, Config
|
|
18
|
+
|
|
19
|
+
# 超过这个时长的缓存视为过期:仍然展示数值,但标注 stale。
|
|
20
|
+
STALE_AFTER = timedelta(hours=1)
|
|
21
|
+
|
|
22
|
+
STATUS_OK = "ok"
|
|
23
|
+
STATUS_STALE = "stale"
|
|
24
|
+
STATUS_NO_DATA = "no-data"
|
|
25
|
+
# 缓存属于以前登录的另一个账号(同一配置目录换过登录),数值与当前账号无关,不展示。
|
|
26
|
+
STATUS_OTHER_ACCOUNT = "other-account"
|
|
27
|
+
STATUS_UNREADABLE = "unreadable"
|
|
28
|
+
|
|
29
|
+
# 数据来源:Claude Code 自己的缓存,或 statusLine 钩子的快照。
|
|
30
|
+
SOURCE_CACHE = "cache"
|
|
31
|
+
SOURCE_STATUSLINE = "statusline"
|
|
32
|
+
SNAPSHOT_SCHEMA_VERSION = 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class UsageWindow(NamedTuple):
|
|
36
|
+
# 0-100;缓存里实测为整数,也接受小数
|
|
37
|
+
percent: float
|
|
38
|
+
# 该窗口的重置时间(带时区);缺失或解析失败时为 None
|
|
39
|
+
resets_at: Optional[datetime]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class UsageReport(NamedTuple):
|
|
43
|
+
status: str
|
|
44
|
+
fetched_at: Optional[datetime]
|
|
45
|
+
five_hour: Optional[UsageWindow]
|
|
46
|
+
seven_day: Optional[UsageWindow]
|
|
47
|
+
source: str = SOURCE_CACHE
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
EMPTY = UsageReport(STATUS_NO_DATA, None, None, None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def global_state_path(identity: str, account_dir: str) -> str:
|
|
54
|
+
"""Claude 存放该账号全局状态(含用量缓存)的文件。
|
|
55
|
+
|
|
56
|
+
与 accounts.settings_proxy_overrides 的取法一致:default 身份不设 CLAUDE_CONFIG_DIR,
|
|
57
|
+
Claude 用 ~/.claude.json;dir 身份用 <账号目录>/.claude.json。
|
|
58
|
+
"""
|
|
59
|
+
if identity == IDENTITY_DEFAULT:
|
|
60
|
+
return os.path.join(os.path.expanduser("~"), ".claude.json")
|
|
61
|
+
return os.path.join(account_dir, ".claude.json")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def read_usage(path: str, now: datetime) -> UsageReport:
|
|
65
|
+
"""读取并解析用量缓存。now 必须带时区(用于判断过期与窗口是否已重置)。"""
|
|
66
|
+
try:
|
|
67
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
68
|
+
data = json.load(handle)
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
return EMPTY
|
|
71
|
+
except (OSError, ValueError):
|
|
72
|
+
# Claude 正在写这个文件时可能读到半截内容,按“读不出”处理,不影响其它账号。
|
|
73
|
+
return UsageReport(STATUS_UNREADABLE, None, None, None)
|
|
74
|
+
if not isinstance(data, dict):
|
|
75
|
+
return UsageReport(STATUS_UNREADABLE, None, None, None)
|
|
76
|
+
cache = data.get("cachedUsageUtilization")
|
|
77
|
+
if not isinstance(cache, dict):
|
|
78
|
+
return EMPTY
|
|
79
|
+
fetched_ms = cache.get("fetchedAtMs")
|
|
80
|
+
if not isinstance(fetched_ms, int) or isinstance(fetched_ms, bool):
|
|
81
|
+
return EMPTY
|
|
82
|
+
try:
|
|
83
|
+
fetched_at = datetime.fromtimestamp(fetched_ms / 1000.0, tz=timezone.utc)
|
|
84
|
+
except (OverflowError, OSError, ValueError):
|
|
85
|
+
# 超出平台时间范围的整数(文件被写坏)也只降级为无数据,不能让 usage / list --json 崩溃。
|
|
86
|
+
return EMPTY
|
|
87
|
+
|
|
88
|
+
cached_account = cache.get("accountUuid")
|
|
89
|
+
oauth = data.get("oauthAccount")
|
|
90
|
+
current_account = oauth.get("accountUuid") if isinstance(oauth, dict) else None
|
|
91
|
+
if isinstance(cached_account, str) and isinstance(current_account, str) and cached_account != current_account:
|
|
92
|
+
return UsageReport(STATUS_OTHER_ACCOUNT, fetched_at, None, None)
|
|
93
|
+
|
|
94
|
+
utilization = cache.get("utilization")
|
|
95
|
+
if not isinstance(utilization, dict):
|
|
96
|
+
return EMPTY
|
|
97
|
+
five_hour = _window(utilization.get("five_hour"))
|
|
98
|
+
seven_day = _window(utilization.get("seven_day"))
|
|
99
|
+
if five_hour is None and seven_day is None:
|
|
100
|
+
return EMPTY
|
|
101
|
+
status = STATUS_STALE if now - fetched_at > STALE_AFTER else STATUS_OK
|
|
102
|
+
return UsageReport(status, fetched_at, five_hour, seven_day)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _window(value: object) -> Optional[UsageWindow]:
|
|
106
|
+
if not isinstance(value, dict):
|
|
107
|
+
return None
|
|
108
|
+
percent = value.get("utilization")
|
|
109
|
+
if isinstance(percent, bool) or not isinstance(percent, (int, float)) or not math.isfinite(percent):
|
|
110
|
+
return None
|
|
111
|
+
return UsageWindow(percent, _parse_time(value.get("resets_at")))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _parse_time(value: object) -> Optional[datetime]:
|
|
115
|
+
if not isinstance(value, str):
|
|
116
|
+
return None
|
|
117
|
+
text = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
118
|
+
try:
|
|
119
|
+
parsed = datetime.fromisoformat(text)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
# 没有时区的时间无法与 now 比较,视为解析失败。
|
|
123
|
+
return parsed if parsed.tzinfo is not None else None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def window_is_reset(window: UsageWindow, now: datetime) -> bool:
|
|
127
|
+
"""窗口已过重置时间:缓存里的百分比不再有意义(与官方 statusline 丢弃过期窗口的语义一致)。"""
|
|
128
|
+
return window.resets_at is not None and window.resets_at <= now
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def report_to_dict(report: UsageReport, now: datetime) -> dict:
|
|
132
|
+
"""JSON 输出用的结构;时间用带时区的 ISO 8601。"""
|
|
133
|
+
def window_dict(window: Optional[UsageWindow]) -> Optional[dict]:
|
|
134
|
+
if window is None:
|
|
135
|
+
return None
|
|
136
|
+
return {"percent": window.percent,
|
|
137
|
+
"resets_at": window.resets_at.isoformat() if window.resets_at else None,
|
|
138
|
+
"reset": window_is_reset(window, now)}
|
|
139
|
+
return {
|
|
140
|
+
"status": report.status,
|
|
141
|
+
"fetched_at": report.fetched_at.isoformat() if report.fetched_at else None,
|
|
142
|
+
"age_seconds": int((now - report.fetched_at).total_seconds()) if report.fetched_at else None,
|
|
143
|
+
"five_hour": window_dict(report.five_hour),
|
|
144
|
+
"seven_day": window_dict(report.seven_day),
|
|
145
|
+
"source": report.source,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def snapshot_path(account: Account) -> str:
|
|
150
|
+
"""statusLine 快照的位置。按账号目录名而不是账号名存放:改名后目录不变,快照仍对得上。"""
|
|
151
|
+
return os.path.join(platform.state_dir(), "usage", account.dir + ".json")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def current_account_uuid(state_path: str) -> Optional[str]:
|
|
155
|
+
"""该账号全局状态文件里当前登录的 accountUuid;读不到时为 None。用来识别“同一目录换了登录”。"""
|
|
156
|
+
try:
|
|
157
|
+
with open(state_path, "r", encoding="utf-8") as handle:
|
|
158
|
+
data = json.load(handle)
|
|
159
|
+
except (OSError, ValueError):
|
|
160
|
+
return None
|
|
161
|
+
oauth = data.get("oauthAccount") if isinstance(data, dict) else None
|
|
162
|
+
value = oauth.get("accountUuid") if isinstance(oauth, dict) else None
|
|
163
|
+
return value if isinstance(value, str) else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def statusline_windows(rate_limits: object) -> Optional[dict]:
|
|
167
|
+
"""从 statusLine stdin 的 `rate_limits`(或快照里存的同名字段)中取出合法的窗口。
|
|
168
|
+
|
|
169
|
+
只保留 `used_percentage`(0-100)与 `resets_at`(Unix 秒):stdin 里的其它内容不落盘。
|
|
170
|
+
两个窗口都不合法时返回 None,调用方据此不写快照、不采用快照。
|
|
171
|
+
"""
|
|
172
|
+
if not isinstance(rate_limits, dict):
|
|
173
|
+
return None
|
|
174
|
+
windows = {}
|
|
175
|
+
for key in ("five_hour", "seven_day"):
|
|
176
|
+
value = rate_limits.get(key)
|
|
177
|
+
if not isinstance(value, dict) or not _is_number(value.get("used_percentage")):
|
|
178
|
+
continue
|
|
179
|
+
resets_at = value.get("resets_at")
|
|
180
|
+
windows[key] = {"used_percentage": value["used_percentage"],
|
|
181
|
+
"resets_at": resets_at if _is_number(resets_at) else None}
|
|
182
|
+
return windows or None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def read_snapshot(path: str, account_dir: str, current_uuid: Optional[str], now: datetime) -> Optional[UsageReport]:
|
|
186
|
+
"""读取 statusLine 快照;不能采用时返回 None(调用方退回缓存)。
|
|
187
|
+
|
|
188
|
+
以下情况都不能采用:文件缺失或损坏;快照记录的配置目录与账号当前目录不同(目录被换掉);
|
|
189
|
+
快照与当前登录的 accountUuid 不同(同一目录换了登录,数值属于另一个账号)。
|
|
190
|
+
"""
|
|
191
|
+
try:
|
|
192
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
193
|
+
data = json.load(handle)
|
|
194
|
+
except (OSError, ValueError):
|
|
195
|
+
return None
|
|
196
|
+
if not isinstance(data, dict) or data.get("schema_version") != SNAPSHOT_SCHEMA_VERSION:
|
|
197
|
+
return None
|
|
198
|
+
captured_at = _parse_time(data.get("captured_at"))
|
|
199
|
+
if captured_at is None or data.get("config_dir") != os.path.realpath(account_dir):
|
|
200
|
+
return None
|
|
201
|
+
snapshot_uuid = data.get("account_uuid")
|
|
202
|
+
if isinstance(snapshot_uuid, str) and isinstance(current_uuid, str) and snapshot_uuid != current_uuid:
|
|
203
|
+
return None
|
|
204
|
+
windows = statusline_windows(data.get("rate_limits"))
|
|
205
|
+
if windows is None:
|
|
206
|
+
return None
|
|
207
|
+
status = STATUS_STALE if now - captured_at > STALE_AFTER else STATUS_OK
|
|
208
|
+
return UsageReport(status, captured_at, _snapshot_window(windows.get("five_hour")),
|
|
209
|
+
_snapshot_window(windows.get("seven_day")), SOURCE_STATUSLINE)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def read_account_usage(config: Config, account: Account, now: datetime) -> UsageReport:
|
|
213
|
+
"""usage 与 list --json 用的入口:缓存与快照都可用时取时间较新的一份。
|
|
214
|
+
|
|
215
|
+
快照不可用时原样返回缓存的结果,保持 no-data / unreadable / other-account 的原有语义;
|
|
216
|
+
缓存没有可用数值时,快照即使已过期也比“无数据”有用,直接采用。
|
|
217
|
+
"""
|
|
218
|
+
directory = accounts.account_dir(config, account.name)
|
|
219
|
+
state_path = global_state_path(account.identity, directory)
|
|
220
|
+
cache = read_usage(state_path, now)
|
|
221
|
+
snapshot = read_snapshot(snapshot_path(account), directory, current_account_uuid(state_path), now)
|
|
222
|
+
if snapshot is None:
|
|
223
|
+
return cache
|
|
224
|
+
if cache.status not in (STATUS_OK, STATUS_STALE) or cache.fetched_at is None:
|
|
225
|
+
return snapshot
|
|
226
|
+
return snapshot if snapshot.fetched_at > cache.fetched_at else cache
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _is_number(value: object) -> bool:
|
|
230
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool) and math.isfinite(value)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _snapshot_window(value: Optional[dict]) -> Optional[UsageWindow]:
|
|
234
|
+
if value is None:
|
|
235
|
+
return None
|
|
236
|
+
resets_at = None
|
|
237
|
+
if value["resets_at"] is not None:
|
|
238
|
+
try:
|
|
239
|
+
resets_at = datetime.fromtimestamp(value["resets_at"], tz=timezone.utc)
|
|
240
|
+
except (OverflowError, OSError, ValueError):
|
|
241
|
+
# 越界的秒数只丢掉重置时间,百分比仍可展示。
|
|
242
|
+
resets_at = None
|
|
243
|
+
return UsageWindow(value["used_percentage"], resets_at)
|