claude-multiacc 1.0.13 → 1.0.15
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.
- package/README.md +276 -15
- package/bin/claude +565 -46
- package/bin/claude-accounts +447 -75
- package/bin/codex +254 -26
- package/bin/codex-accounts +157 -52
- package/install.sh +196 -69
- package/lib/common.sh +262 -2
- package/lib/credential.py +336 -0
- package/lib/report.py +434 -0
- package/package.json +1 -1
- package/tests/run-tests.sh +1556 -8
package/lib/report.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Machine-readable pool report — the ONE place that builds the JSON `list --json`,
|
|
2
|
+
`status --json` and `limits --json` emit, for BOTH providers, so a consumer (the
|
|
3
|
+
app-robot panel/runner daemon) parses exactly one shape no matter which verb or
|
|
4
|
+
provider produced it.
|
|
5
|
+
|
|
6
|
+
Run: python3 lib/report.py <acc-root> <claude|codex> <mac|linux> [list|status|limits]
|
|
7
|
+
|
|
8
|
+
The document is a single JSON object on stdout:
|
|
9
|
+
|
|
10
|
+
schema "claude-multiacc/pool.v1" — bumped only on a BREAKING change
|
|
11
|
+
provider claude | codex
|
|
12
|
+
kind which verb rendered it (list|status|limits)
|
|
13
|
+
pool root, manifest path, threshold, sync target/mode/role, peers
|
|
14
|
+
accounts[] id, email, home, home_dir, status, state, credential_class, usage, ...
|
|
15
|
+
summary counts a dashboard can render without walking the list
|
|
16
|
+
warnings[] duplicate emails, unreadable manifest bits — never fatal
|
|
17
|
+
|
|
18
|
+
Every field is best-effort: one corrupt account file degrades that account only,
|
|
19
|
+
never the document (a --json call that dies is worse than one that says 'unknown').
|
|
20
|
+
|
|
21
|
+
Account status vocabulary (the panel's contract):
|
|
22
|
+
active selectable right now
|
|
23
|
+
limited authenticates, but a >=threshold bucket is parked until limit_reset_at
|
|
24
|
+
expired has auth material that cannot authenticate — needs an interactive login
|
|
25
|
+
blocked authenticates, but the org/workspace disabled the CLI for it
|
|
26
|
+
missing no auth material on this machine (and this machine should own it)
|
|
27
|
+
remote no auth here on purpose — the manifest says another machine owns it
|
|
28
|
+
|
|
29
|
+
Credential classes (what may be COPIED between machines):
|
|
30
|
+
portable claude setup-token (server.token) — safe to export/import anywhere
|
|
31
|
+
machine-local claude .credentials.json / codex auth.json — rotating refresh grant;
|
|
32
|
+
copying it makes two machines fight over one grant and breaks both
|
|
33
|
+
none nothing on disk here
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import os
|
|
38
|
+
import re
|
|
39
|
+
import socket
|
|
40
|
+
import sys
|
|
41
|
+
import time
|
|
42
|
+
|
|
43
|
+
SCHEMA = 'claude-multiacc/pool.v1'
|
|
44
|
+
|
|
45
|
+
# Must match the SHIM's ranking window, or this document calls data "fresh" that
|
|
46
|
+
# selection has already stopped ranking on. The two providers differ on purpose:
|
|
47
|
+
# api.anthropic.com hands out Retry-After: 3600, so bin/claude trusts an hour;
|
|
48
|
+
# bin/codex still uses 900 against chatgpt.com, which has not been measured.
|
|
49
|
+
_STALE_DEFAULT = {'claude': 3600, 'codex': 900}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _stale_after(provider):
|
|
53
|
+
env = {'claude': 'CLAUDE_MULTIACC_STALE_AFTER',
|
|
54
|
+
'codex': 'CODEX_MULTIACC_STALE_AFTER'}.get(provider)
|
|
55
|
+
default = _STALE_DEFAULT.get(provider, 900)
|
|
56
|
+
try:
|
|
57
|
+
v = int(os.environ.get(env) or default) if env else default
|
|
58
|
+
except ValueError:
|
|
59
|
+
v = default
|
|
60
|
+
return v if v > 0 else default
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
VALID_ID = re.compile(r'acct-\d{2}')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _iso(epoch):
|
|
67
|
+
try:
|
|
68
|
+
epoch = float(epoch)
|
|
69
|
+
except (TypeError, ValueError):
|
|
70
|
+
return None
|
|
71
|
+
if epoch <= 0:
|
|
72
|
+
return None
|
|
73
|
+
try:
|
|
74
|
+
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(epoch))
|
|
75
|
+
except (ValueError, OSError, OverflowError): # absurd epoch in a corrupt file
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _iso_ms(value):
|
|
80
|
+
"""A milliseconds field (credentials carry them) -> ISO, tolerating whatever a
|
|
81
|
+
corrupt credential holds: a string, a list, null. One bad file must degrade its
|
|
82
|
+
own account, never the document."""
|
|
83
|
+
try:
|
|
84
|
+
return _iso(float(value or 0) / 1000.0)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _size(path):
|
|
90
|
+
try:
|
|
91
|
+
return os.path.getsize(path)
|
|
92
|
+
except OSError:
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _mtime(path):
|
|
97
|
+
try:
|
|
98
|
+
return os.path.getmtime(path)
|
|
99
|
+
except OSError:
|
|
100
|
+
return 0.0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _load_json(path):
|
|
104
|
+
try:
|
|
105
|
+
with open(path) as f:
|
|
106
|
+
doc = json.load(f)
|
|
107
|
+
return doc if isinstance(doc, dict) else None
|
|
108
|
+
except Exception:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _marker(path):
|
|
113
|
+
"""(reset_epoch, detail) of a .limited/.expired marker; (0, '') when absent."""
|
|
114
|
+
try:
|
|
115
|
+
lines = open(path, errors='replace').read().splitlines()
|
|
116
|
+
except OSError:
|
|
117
|
+
return 0, ''
|
|
118
|
+
reset = int(lines[0]) if lines and lines[0].strip().isdigit() else 0
|
|
119
|
+
detail = lines[1].strip() if len(lines) > 1 else ''
|
|
120
|
+
return reset, detail
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _sync_block(root, manifest):
|
|
124
|
+
"""Where this pool syncs to. The env values are what lib/common.sh RESOLVED
|
|
125
|
+
(env override > manifest), passed through so the report can never disagree with
|
|
126
|
+
what `sync` would actually do; without them the manifest is the authority."""
|
|
127
|
+
target = os.environ.get('MULTIACC_REPORT_SYNC_TARGET')
|
|
128
|
+
if target is None:
|
|
129
|
+
target = str(manifest.get('server') or '')
|
|
130
|
+
sroot = os.environ.get('MULTIACC_REPORT_SYNC_ROOT') or str(manifest.get('server_root') or '')
|
|
131
|
+
srepo = os.environ.get('MULTIACC_REPORT_SYNC_REPO') or str(manifest.get('server_repo') or '')
|
|
132
|
+
mode = os.environ.get('MULTIACC_REPORT_SYNC_MODE')
|
|
133
|
+
if not mode:
|
|
134
|
+
mode = 'local' if target.strip().lower() in ('', 'none', 'local', 'off', 'disabled') else 'server'
|
|
135
|
+
role = 'source'
|
|
136
|
+
try:
|
|
137
|
+
with open(os.path.join(root, 'sync-role'), errors='replace') as f:
|
|
138
|
+
if any(line.strip().lower() == 'replica' for line in f):
|
|
139
|
+
role = 'replica'
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
peers = []
|
|
143
|
+
raw = manifest.get('peers')
|
|
144
|
+
if isinstance(raw, list):
|
|
145
|
+
for p in raw:
|
|
146
|
+
if isinstance(p, dict):
|
|
147
|
+
peers.append({'target': str(p.get('target') or ''),
|
|
148
|
+
'root': str(p.get('root') or ''),
|
|
149
|
+
'repo': str(p.get('repo') or '')})
|
|
150
|
+
return {'mode': mode, 'target': None if mode == 'local' else target,
|
|
151
|
+
'root': sroot or None, 'repo': srepo or None, 'role': role, 'peers': peers}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _claude_credentials(d):
|
|
155
|
+
"""(credential_class, credentials-detail) for a claude account dir."""
|
|
156
|
+
cpath = os.path.join(d, '.credentials.json')
|
|
157
|
+
tpath = os.path.join(d, 'server.token')
|
|
158
|
+
has_oauth = _size(cpath) > 0
|
|
159
|
+
has_token = _size(tpath) > 0
|
|
160
|
+
detail = {'oauth': has_oauth, 'token': has_token,
|
|
161
|
+
'oauth_expires_at': None, 'oauth_refresh_expires_at': None,
|
|
162
|
+
'token_minted_at': None, 'token_age_days': None}
|
|
163
|
+
if has_oauth:
|
|
164
|
+
doc = _load_json(cpath) or {}
|
|
165
|
+
o = doc.get('claudeAiOauth')
|
|
166
|
+
if isinstance(o, dict):
|
|
167
|
+
detail['oauth_expires_at'] = _iso_ms(o.get('expiresAt'))
|
|
168
|
+
detail['oauth_refresh_expires_at'] = _iso_ms(o.get('refreshTokenExpiresAt'))
|
|
169
|
+
if has_token:
|
|
170
|
+
mt = _mtime(tpath)
|
|
171
|
+
detail['token_minted_at'] = _iso(mt)
|
|
172
|
+
if mt:
|
|
173
|
+
detail['token_age_days'] = int((time.time() - mt) / 86400)
|
|
174
|
+
# A setup-token is the only claude credential that survives being copied.
|
|
175
|
+
if has_token:
|
|
176
|
+
return 'portable', detail
|
|
177
|
+
if has_oauth:
|
|
178
|
+
return 'machine-local', detail
|
|
179
|
+
return 'none', detail
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _codex_credentials(d, jwt_claims):
|
|
183
|
+
"""(credential_class, credentials-detail) for a codex account dir. Codex has NO
|
|
184
|
+
portable class: auth.json carries a rotating refresh token."""
|
|
185
|
+
cpath = os.path.join(d, 'auth.json')
|
|
186
|
+
has_auth = _size(cpath) > 0
|
|
187
|
+
detail = {'oauth': has_auth, 'token': False, 'auth_expires_at': None,
|
|
188
|
+
'refresh_token': False, 'plan': None, 'auth_mode': None}
|
|
189
|
+
if not has_auth:
|
|
190
|
+
return 'none', detail
|
|
191
|
+
doc = _load_json(cpath) or {}
|
|
192
|
+
detail['auth_mode'] = str(doc.get('auth_mode') or '') or None
|
|
193
|
+
tokens = doc.get('tokens')
|
|
194
|
+
if isinstance(tokens, dict):
|
|
195
|
+
detail['refresh_token'] = bool(tokens.get('refresh_token'))
|
|
196
|
+
detail['auth_expires_at'] = _iso(jwt_claims(tokens.get('access_token')).get('exp'))
|
|
197
|
+
plan = (jwt_claims(tokens.get('id_token')).get('https://api.openai.com/auth') or {})
|
|
198
|
+
if isinstance(plan, dict):
|
|
199
|
+
detail['plan'] = plan.get('chatgpt_plan_type') or None
|
|
200
|
+
return 'machine-local', detail
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _usage(d, stale_after):
|
|
204
|
+
lim = _load_json(os.path.join(d, 'limits.json'))
|
|
205
|
+
if not lim:
|
|
206
|
+
return None
|
|
207
|
+
buckets = []
|
|
208
|
+
for b in (lim.get('buckets') or []):
|
|
209
|
+
if not isinstance(b, dict):
|
|
210
|
+
continue
|
|
211
|
+
buckets.append({'name': str(b.get('name') or ''), 'kind': str(b.get('kind') or ''),
|
|
212
|
+
'group': str(b.get('group') or ''), 'percent': b.get('percent'),
|
|
213
|
+
'resets_at': b.get('resets_at')})
|
|
214
|
+
fetched = lim.get('fetched_at') or 0
|
|
215
|
+
out = {'fetched_at': _iso(fetched), 'source': lim.get('source'),
|
|
216
|
+
'max_percent': lim.get('max_percent'), 'weekly_percent': lim.get('weekly_percent'),
|
|
217
|
+
'session_percent': lim.get('session_percent'), 'buckets': buckets}
|
|
218
|
+
try:
|
|
219
|
+
out['age_seconds'] = max(0, int(time.time() - float(fetched)))
|
|
220
|
+
except (TypeError, ValueError):
|
|
221
|
+
out['age_seconds'] = None
|
|
222
|
+
# Past this window the shim stops ranking on these numbers entirely (bin/claude
|
|
223
|
+
# STALE_AFTER). A panel that shows "weekly 2%" without it is reporting a number
|
|
224
|
+
# nothing is using — which is how an 11-day telemetry outage stayed invisible.
|
|
225
|
+
out['stale'] = out['age_seconds'] is None or out['age_seconds'] > stale_after
|
|
226
|
+
# The horizon a stale weekly reading stays true until (bin/claude stale_weekly).
|
|
227
|
+
# Absent on documents written before it existed, which is itself the signal that
|
|
228
|
+
# the reading cannot be used for degraded ranking.
|
|
229
|
+
if lim.get('weekly_resets_epoch'):
|
|
230
|
+
out['weekly_resets_epoch'] = lim['weekly_resets_epoch']
|
|
231
|
+
if lim.get('last_error'):
|
|
232
|
+
out['last_error'] = lim['last_error']
|
|
233
|
+
if lim.get('last_error_at'):
|
|
234
|
+
out['last_error_at'] = _iso(lim['last_error_at'])
|
|
235
|
+
if lim.get('plan'):
|
|
236
|
+
out['plan'] = lim['plan']
|
|
237
|
+
if lim.get('retry_after'):
|
|
238
|
+
out['retry_after'] = _iso(lim['retry_after'])
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _last_pick(root, aid):
|
|
243
|
+
path = os.path.join(root, 'selection.log')
|
|
244
|
+
last = None
|
|
245
|
+
try:
|
|
246
|
+
with open(path, errors='replace') as f:
|
|
247
|
+
for line in f:
|
|
248
|
+
parts = line.split()
|
|
249
|
+
if len(parts) >= 2 and parts[1] == aid:
|
|
250
|
+
last = parts[0]
|
|
251
|
+
except OSError:
|
|
252
|
+
pass
|
|
253
|
+
return last
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def telemetry_state(accounts, now=None):
|
|
258
|
+
"""Three-state verdict on how the shim is CURRENTLY ranking this pool, over the
|
|
259
|
+
same candidate set bin/claude uses: the eligible accounts, or — when every one of
|
|
260
|
+
them is limit-marked — the valid ones it falls back to.
|
|
261
|
+
|
|
262
|
+
'fresh' at least one candidate has telemetry inside the ranking window
|
|
263
|
+
'degraded' none do, but EVERY candidate still has a weekly reading whose bucket
|
|
264
|
+
has not reset yet, so the shim ranks on those (all-or-nothing: one
|
|
265
|
+
unusable reading and the comparison is not apples-to-apples)
|
|
266
|
+
'blind' nothing usable anywhere — every account scores the same and selection
|
|
267
|
+
is effectively a coin flip
|
|
268
|
+
'none' no candidates at all; nothing to say
|
|
269
|
+
|
|
270
|
+
Kept here rather than beside either caller because `status` and `--json` disagreeing
|
|
271
|
+
with the shim is exactly how an eleven-day outage stayed invisible.
|
|
272
|
+
"""
|
|
273
|
+
now = time.time() if now is None else now
|
|
274
|
+
usable = [a for a in accounts if a.get('status') in ('active', 'limited')]
|
|
275
|
+
cands = [a for a in usable if a.get('status') == 'active'] or usable
|
|
276
|
+
if not cands:
|
|
277
|
+
return 'none'
|
|
278
|
+
if any(a.get('usage') and not a['usage'].get('stale') for a in cands):
|
|
279
|
+
return 'fresh'
|
|
280
|
+
for a in cands:
|
|
281
|
+
u = a.get('usage') or {}
|
|
282
|
+
horizon = u.get('weekly_resets_epoch') or 0
|
|
283
|
+
pct = u.get('weekly_percent')
|
|
284
|
+
# bool is an int in Python but not a number to the shim's digit parser, so a
|
|
285
|
+
# document carrying `weekly_percent: true` must read as unusable HERE too —
|
|
286
|
+
# otherwise this says 'degraded' while the shim ranks blind.
|
|
287
|
+
if isinstance(horizon, bool) or isinstance(pct, bool) \
|
|
288
|
+
or not isinstance(horizon, (int, float)) or horizon <= now \
|
|
289
|
+
or not isinstance(pct, (int, float)):
|
|
290
|
+
return 'blind'
|
|
291
|
+
return 'degraded'
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _account_row(root, a, aid, d, provider, jwt_claims, audit_account, now, kind, machine):
|
|
295
|
+
"""One account's row. Everything it reads is already tolerant; build() catches
|
|
296
|
+
anything unexpected on top of that and degrades this account alone."""
|
|
297
|
+
st = audit_account(root, a, now=now, machine=machine)
|
|
298
|
+
if provider == 'codex':
|
|
299
|
+
cclass, cdetail = _codex_credentials(d, jwt_claims)
|
|
300
|
+
else:
|
|
301
|
+
cclass, cdetail = _claude_credentials(d)
|
|
302
|
+
reset_epoch, marker_detail = _marker(os.path.join(d, '.limited'))
|
|
303
|
+
limited = reset_epoch > now or (reset_epoch == 0 and os.path.isfile(os.path.join(d, '.limited')))
|
|
304
|
+
status = st['state']
|
|
305
|
+
if status == 'ok':
|
|
306
|
+
status = 'limited' if limited else 'active'
|
|
307
|
+
row = {
|
|
308
|
+
'id': aid,
|
|
309
|
+
'email': a.get('email', ''),
|
|
310
|
+
'home': a.get('home', ''),
|
|
311
|
+
'added_at': a.get('added_at'),
|
|
312
|
+
'home_dir': d,
|
|
313
|
+
'exists': os.path.isdir(d),
|
|
314
|
+
'adopted': os.path.islink(d),
|
|
315
|
+
'status': status,
|
|
316
|
+
'state': st['state'],
|
|
317
|
+
'label': st['label'],
|
|
318
|
+
'reason': st['reason'],
|
|
319
|
+
'fix': st['fix'],
|
|
320
|
+
'selectable': st['state'] == 'ok' and not limited,
|
|
321
|
+
'needs_login': st['state'] in ('expired', 'blocked', 'missing'),
|
|
322
|
+
'credential_class': cclass,
|
|
323
|
+
'portable': cclass == 'portable',
|
|
324
|
+
'credentials': cdetail,
|
|
325
|
+
'limited': bool(limited),
|
|
326
|
+
'limit_reset_at': _iso(reset_epoch) if limited and reset_epoch else None,
|
|
327
|
+
'limit_reset_epoch': reset_epoch if limited and reset_epoch else None,
|
|
328
|
+
'limit_detail': marker_detail if limited else None,
|
|
329
|
+
'usage': _usage(d, _stale_after(provider)),
|
|
330
|
+
}
|
|
331
|
+
if kind == 'status':
|
|
332
|
+
row['last_picked'] = _last_pick(root, aid)
|
|
333
|
+
expired_reset, expired_detail = _marker(os.path.join(d, '.expired'))
|
|
334
|
+
row['expired_marker'] = expired_detail or None
|
|
335
|
+
return row
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def build(root, provider, machine, kind='list'):
|
|
339
|
+
if provider == 'codex':
|
|
340
|
+
from codex_audit import audit_account, jwt_claims
|
|
341
|
+
else:
|
|
342
|
+
from audit import audit_account
|
|
343
|
+
jwt_claims = None
|
|
344
|
+
now = time.time()
|
|
345
|
+
warnings = []
|
|
346
|
+
manifest_path = os.path.join(root, 'accounts.json')
|
|
347
|
+
manifest = _load_json(manifest_path)
|
|
348
|
+
if manifest is None:
|
|
349
|
+
manifest = {}
|
|
350
|
+
warnings.append(f'manifest unreadable or missing: {manifest_path}')
|
|
351
|
+
|
|
352
|
+
raw_accounts = manifest.get('accounts')
|
|
353
|
+
if not isinstance(raw_accounts, list):
|
|
354
|
+
raw_accounts = []
|
|
355
|
+
if manifest:
|
|
356
|
+
warnings.append("manifest has no well-formed 'accounts' array")
|
|
357
|
+
|
|
358
|
+
accounts = []
|
|
359
|
+
seen_emails = {}
|
|
360
|
+
for a in raw_accounts:
|
|
361
|
+
if not isinstance(a, dict) or not VALID_ID.fullmatch(str(a.get('id', ''))):
|
|
362
|
+
warnings.append('skipped a manifest entry with a malformed id')
|
|
363
|
+
continue
|
|
364
|
+
aid = a['id']
|
|
365
|
+
d = os.path.join(root, aid)
|
|
366
|
+
try:
|
|
367
|
+
row = _account_row(root, a, aid, d, provider, jwt_claims, audit_account,
|
|
368
|
+
now, kind, machine)
|
|
369
|
+
except Exception as e: # one unreadable account must not empty the report
|
|
370
|
+
warnings.append(f'{aid}: could not be read ({str(e)[:120]})')
|
|
371
|
+
row = {'id': aid, 'email': a.get('email', ''), 'home': a.get('home', ''),
|
|
372
|
+
'home_dir': d, 'status': 'unknown', 'state': 'unknown',
|
|
373
|
+
'label': 'UNKNOWN', 'reason': f'unreadable: {str(e)[:120]}',
|
|
374
|
+
'fix': '', 'selectable': False, 'needs_login': False,
|
|
375
|
+
'credential_class': 'unknown', 'portable': False,
|
|
376
|
+
'credentials': {}, 'limited': False, 'limit_reset_at': None,
|
|
377
|
+
'usage': None}
|
|
378
|
+
accounts.append(row)
|
|
379
|
+
seen_emails.setdefault(str(a.get('email', '')).lower(), []).append(aid)
|
|
380
|
+
|
|
381
|
+
for email, ids in seen_emails.items():
|
|
382
|
+
if len(ids) > 1:
|
|
383
|
+
warnings.append(f"{email} is registered {len(ids)}x ({', '.join(ids)}) "
|
|
384
|
+
f"— run '{provider}-accounts dedupe'")
|
|
385
|
+
|
|
386
|
+
threshold = manifest.get('threshold', 90)
|
|
387
|
+
try:
|
|
388
|
+
threshold = int(threshold)
|
|
389
|
+
except (TypeError, ValueError):
|
|
390
|
+
threshold = 90
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
'schema': SCHEMA,
|
|
394
|
+
'provider': provider,
|
|
395
|
+
'kind': kind,
|
|
396
|
+
'generated_at': _iso(now),
|
|
397
|
+
'machine': machine,
|
|
398
|
+
'host': socket.gethostname(),
|
|
399
|
+
'pool': {
|
|
400
|
+
'root': root,
|
|
401
|
+
'manifest': manifest_path,
|
|
402
|
+
'threshold': threshold,
|
|
403
|
+
'sync': _sync_block(root, manifest),
|
|
404
|
+
},
|
|
405
|
+
'accounts': accounts,
|
|
406
|
+
'summary': {
|
|
407
|
+
'total': len(accounts),
|
|
408
|
+
'active': sum(1 for a in accounts if a['status'] == 'active'),
|
|
409
|
+
'limited': sum(1 for a in accounts if a['status'] == 'limited'),
|
|
410
|
+
'needs_login': sum(1 for a in accounts if a['needs_login']),
|
|
411
|
+
'portable': sum(1 for a in accounts if a['portable']),
|
|
412
|
+
'selectable': sum(1 for a in accounts if a['selectable']),
|
|
413
|
+
# The pool-wide verdict, so a dashboard can show the outage rather than a
|
|
414
|
+
# grid of reassuring percentages nothing is ranking on.
|
|
415
|
+
# fresh ranking on current data
|
|
416
|
+
# degraded ranking on old-but-still-true weekly readings
|
|
417
|
+
# blind every account scores the same; selection is a coin flip
|
|
418
|
+
'telemetry': telemetry_state(accounts, now),
|
|
419
|
+
# Kept as its own boolean because a dashboard alerting on one field should
|
|
420
|
+
# not have to know the vocabulary. True ONLY for the coin-flip case.
|
|
421
|
+
'ranking_blind': telemetry_state(accounts, now) == 'blind',
|
|
422
|
+
},
|
|
423
|
+
'warnings': warnings,
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == '__main__':
|
|
428
|
+
root = sys.argv[1]
|
|
429
|
+
provider = sys.argv[2] if len(sys.argv) > 2 else 'claude'
|
|
430
|
+
machine = sys.argv[3] if len(sys.argv) > 3 else 'mac'
|
|
431
|
+
kind = sys.argv[4] if len(sys.argv) > 4 else 'list'
|
|
432
|
+
sys.path = [os.path.dirname(os.path.abspath(__file__))] + \
|
|
433
|
+
[p for p in sys.path if p not in ('', '.')]
|
|
434
|
+
print(json.dumps(build(root, provider, machine, kind), indent=2, sort_keys=False))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-multiacc",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.15",
|
|
4
4
|
"description": "Multi-account addon for Claude Code and OpenAI Codex CLI: every claude / claude -p and every codex / codex exec runs under a randomly-picked subscription account with the most usage headroom. Mirrors to a deploy server. No API keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|