claude-multiacc 1.0.13 → 1.0.14

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/lib/report.py ADDED
@@ -0,0 +1,355 @@
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
+ VALID_ID = re.compile(r'acct-\d{2}')
45
+
46
+
47
+ def _iso(epoch):
48
+ try:
49
+ epoch = float(epoch)
50
+ except (TypeError, ValueError):
51
+ return None
52
+ if epoch <= 0:
53
+ return None
54
+ try:
55
+ return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(epoch))
56
+ except (ValueError, OSError, OverflowError): # absurd epoch in a corrupt file
57
+ return None
58
+
59
+
60
+ def _iso_ms(value):
61
+ """A milliseconds field (credentials carry them) -> ISO, tolerating whatever a
62
+ corrupt credential holds: a string, a list, null. One bad file must degrade its
63
+ own account, never the document."""
64
+ try:
65
+ return _iso(float(value or 0) / 1000.0)
66
+ except (TypeError, ValueError):
67
+ return None
68
+
69
+
70
+ def _size(path):
71
+ try:
72
+ return os.path.getsize(path)
73
+ except OSError:
74
+ return 0
75
+
76
+
77
+ def _mtime(path):
78
+ try:
79
+ return os.path.getmtime(path)
80
+ except OSError:
81
+ return 0.0
82
+
83
+
84
+ def _load_json(path):
85
+ try:
86
+ with open(path) as f:
87
+ doc = json.load(f)
88
+ return doc if isinstance(doc, dict) else None
89
+ except Exception:
90
+ return None
91
+
92
+
93
+ def _marker(path):
94
+ """(reset_epoch, detail) of a .limited/.expired marker; (0, '') when absent."""
95
+ try:
96
+ lines = open(path, errors='replace').read().splitlines()
97
+ except OSError:
98
+ return 0, ''
99
+ reset = int(lines[0]) if lines and lines[0].strip().isdigit() else 0
100
+ detail = lines[1].strip() if len(lines) > 1 else ''
101
+ return reset, detail
102
+
103
+
104
+ def _sync_block(root, manifest):
105
+ """Where this pool syncs to. The env values are what lib/common.sh RESOLVED
106
+ (env override > manifest), passed through so the report can never disagree with
107
+ what `sync` would actually do; without them the manifest is the authority."""
108
+ target = os.environ.get('MULTIACC_REPORT_SYNC_TARGET')
109
+ if target is None:
110
+ target = str(manifest.get('server') or '')
111
+ sroot = os.environ.get('MULTIACC_REPORT_SYNC_ROOT') or str(manifest.get('server_root') or '')
112
+ srepo = os.environ.get('MULTIACC_REPORT_SYNC_REPO') or str(manifest.get('server_repo') or '')
113
+ mode = os.environ.get('MULTIACC_REPORT_SYNC_MODE')
114
+ if not mode:
115
+ mode = 'local' if target.strip().lower() in ('', 'none', 'local', 'off', 'disabled') else 'server'
116
+ role = 'source'
117
+ try:
118
+ with open(os.path.join(root, 'sync-role'), errors='replace') as f:
119
+ if any(line.strip().lower() == 'replica' for line in f):
120
+ role = 'replica'
121
+ except OSError:
122
+ pass
123
+ peers = []
124
+ raw = manifest.get('peers')
125
+ if isinstance(raw, list):
126
+ for p in raw:
127
+ if isinstance(p, dict):
128
+ peers.append({'target': str(p.get('target') or ''),
129
+ 'root': str(p.get('root') or ''),
130
+ 'repo': str(p.get('repo') or '')})
131
+ return {'mode': mode, 'target': None if mode == 'local' else target,
132
+ 'root': sroot or None, 'repo': srepo or None, 'role': role, 'peers': peers}
133
+
134
+
135
+ def _claude_credentials(d):
136
+ """(credential_class, credentials-detail) for a claude account dir."""
137
+ cpath = os.path.join(d, '.credentials.json')
138
+ tpath = os.path.join(d, 'server.token')
139
+ has_oauth = _size(cpath) > 0
140
+ has_token = _size(tpath) > 0
141
+ detail = {'oauth': has_oauth, 'token': has_token,
142
+ 'oauth_expires_at': None, 'oauth_refresh_expires_at': None,
143
+ 'token_minted_at': None, 'token_age_days': None}
144
+ if has_oauth:
145
+ doc = _load_json(cpath) or {}
146
+ o = doc.get('claudeAiOauth')
147
+ if isinstance(o, dict):
148
+ detail['oauth_expires_at'] = _iso_ms(o.get('expiresAt'))
149
+ detail['oauth_refresh_expires_at'] = _iso_ms(o.get('refreshTokenExpiresAt'))
150
+ if has_token:
151
+ mt = _mtime(tpath)
152
+ detail['token_minted_at'] = _iso(mt)
153
+ if mt:
154
+ detail['token_age_days'] = int((time.time() - mt) / 86400)
155
+ # A setup-token is the only claude credential that survives being copied.
156
+ if has_token:
157
+ return 'portable', detail
158
+ if has_oauth:
159
+ return 'machine-local', detail
160
+ return 'none', detail
161
+
162
+
163
+ def _codex_credentials(d, jwt_claims):
164
+ """(credential_class, credentials-detail) for a codex account dir. Codex has NO
165
+ portable class: auth.json carries a rotating refresh token."""
166
+ cpath = os.path.join(d, 'auth.json')
167
+ has_auth = _size(cpath) > 0
168
+ detail = {'oauth': has_auth, 'token': False, 'auth_expires_at': None,
169
+ 'refresh_token': False, 'plan': None, 'auth_mode': None}
170
+ if not has_auth:
171
+ return 'none', detail
172
+ doc = _load_json(cpath) or {}
173
+ detail['auth_mode'] = str(doc.get('auth_mode') or '') or None
174
+ tokens = doc.get('tokens')
175
+ if isinstance(tokens, dict):
176
+ detail['refresh_token'] = bool(tokens.get('refresh_token'))
177
+ detail['auth_expires_at'] = _iso(jwt_claims(tokens.get('access_token')).get('exp'))
178
+ plan = (jwt_claims(tokens.get('id_token')).get('https://api.openai.com/auth') or {})
179
+ if isinstance(plan, dict):
180
+ detail['plan'] = plan.get('chatgpt_plan_type') or None
181
+ return 'machine-local', detail
182
+
183
+
184
+ def _usage(d):
185
+ lim = _load_json(os.path.join(d, 'limits.json'))
186
+ if not lim:
187
+ return None
188
+ buckets = []
189
+ for b in (lim.get('buckets') or []):
190
+ if not isinstance(b, dict):
191
+ continue
192
+ buckets.append({'name': str(b.get('name') or ''), 'kind': str(b.get('kind') or ''),
193
+ 'group': str(b.get('group') or ''), 'percent': b.get('percent'),
194
+ 'resets_at': b.get('resets_at')})
195
+ fetched = lim.get('fetched_at') or 0
196
+ out = {'fetched_at': _iso(fetched), 'source': lim.get('source'),
197
+ 'max_percent': lim.get('max_percent'), 'weekly_percent': lim.get('weekly_percent'),
198
+ 'session_percent': lim.get('session_percent'), 'buckets': buckets}
199
+ try:
200
+ out['age_seconds'] = max(0, int(time.time() - float(fetched)))
201
+ except (TypeError, ValueError):
202
+ out['age_seconds'] = None
203
+ if lim.get('plan'):
204
+ out['plan'] = lim['plan']
205
+ if lim.get('retry_after'):
206
+ out['retry_after'] = _iso(lim['retry_after'])
207
+ return out
208
+
209
+
210
+ def _last_pick(root, aid):
211
+ path = os.path.join(root, 'selection.log')
212
+ last = None
213
+ try:
214
+ with open(path, errors='replace') as f:
215
+ for line in f:
216
+ parts = line.split()
217
+ if len(parts) >= 2 and parts[1] == aid:
218
+ last = parts[0]
219
+ except OSError:
220
+ pass
221
+ return last
222
+
223
+
224
+ def _account_row(root, a, aid, d, provider, jwt_claims, audit_account, now, kind, machine):
225
+ """One account's row. Everything it reads is already tolerant; build() catches
226
+ anything unexpected on top of that and degrades this account alone."""
227
+ st = audit_account(root, a, now=now, machine=machine)
228
+ if provider == 'codex':
229
+ cclass, cdetail = _codex_credentials(d, jwt_claims)
230
+ else:
231
+ cclass, cdetail = _claude_credentials(d)
232
+ reset_epoch, marker_detail = _marker(os.path.join(d, '.limited'))
233
+ limited = reset_epoch > now or (reset_epoch == 0 and os.path.isfile(os.path.join(d, '.limited')))
234
+ status = st['state']
235
+ if status == 'ok':
236
+ status = 'limited' if limited else 'active'
237
+ row = {
238
+ 'id': aid,
239
+ 'email': a.get('email', ''),
240
+ 'home': a.get('home', ''),
241
+ 'added_at': a.get('added_at'),
242
+ 'home_dir': d,
243
+ 'exists': os.path.isdir(d),
244
+ 'adopted': os.path.islink(d),
245
+ 'status': status,
246
+ 'state': st['state'],
247
+ 'label': st['label'],
248
+ 'reason': st['reason'],
249
+ 'fix': st['fix'],
250
+ 'selectable': st['state'] == 'ok' and not limited,
251
+ 'needs_login': st['state'] in ('expired', 'blocked', 'missing'),
252
+ 'credential_class': cclass,
253
+ 'portable': cclass == 'portable',
254
+ 'credentials': cdetail,
255
+ 'limited': bool(limited),
256
+ 'limit_reset_at': _iso(reset_epoch) if limited and reset_epoch else None,
257
+ 'limit_reset_epoch': reset_epoch if limited and reset_epoch else None,
258
+ 'limit_detail': marker_detail if limited else None,
259
+ 'usage': _usage(d),
260
+ }
261
+ if kind == 'status':
262
+ row['last_picked'] = _last_pick(root, aid)
263
+ expired_reset, expired_detail = _marker(os.path.join(d, '.expired'))
264
+ row['expired_marker'] = expired_detail or None
265
+ return row
266
+
267
+
268
+ def build(root, provider, machine, kind='list'):
269
+ if provider == 'codex':
270
+ from codex_audit import audit_account, jwt_claims
271
+ else:
272
+ from audit import audit_account
273
+ jwt_claims = None
274
+ now = time.time()
275
+ warnings = []
276
+ manifest_path = os.path.join(root, 'accounts.json')
277
+ manifest = _load_json(manifest_path)
278
+ if manifest is None:
279
+ manifest = {}
280
+ warnings.append(f'manifest unreadable or missing: {manifest_path}')
281
+
282
+ raw_accounts = manifest.get('accounts')
283
+ if not isinstance(raw_accounts, list):
284
+ raw_accounts = []
285
+ if manifest:
286
+ warnings.append("manifest has no well-formed 'accounts' array")
287
+
288
+ accounts = []
289
+ seen_emails = {}
290
+ for a in raw_accounts:
291
+ if not isinstance(a, dict) or not VALID_ID.fullmatch(str(a.get('id', ''))):
292
+ warnings.append('skipped a manifest entry with a malformed id')
293
+ continue
294
+ aid = a['id']
295
+ d = os.path.join(root, aid)
296
+ try:
297
+ row = _account_row(root, a, aid, d, provider, jwt_claims, audit_account,
298
+ now, kind, machine)
299
+ except Exception as e: # one unreadable account must not empty the report
300
+ warnings.append(f'{aid}: could not be read ({str(e)[:120]})')
301
+ row = {'id': aid, 'email': a.get('email', ''), 'home': a.get('home', ''),
302
+ 'home_dir': d, 'status': 'unknown', 'state': 'unknown',
303
+ 'label': 'UNKNOWN', 'reason': f'unreadable: {str(e)[:120]}',
304
+ 'fix': '', 'selectable': False, 'needs_login': False,
305
+ 'credential_class': 'unknown', 'portable': False,
306
+ 'credentials': {}, 'limited': False, 'limit_reset_at': None,
307
+ 'usage': None}
308
+ accounts.append(row)
309
+ seen_emails.setdefault(str(a.get('email', '')).lower(), []).append(aid)
310
+
311
+ for email, ids in seen_emails.items():
312
+ if len(ids) > 1:
313
+ warnings.append(f"{email} is registered {len(ids)}x ({', '.join(ids)}) "
314
+ f"— run '{provider}-accounts dedupe'")
315
+
316
+ threshold = manifest.get('threshold', 90)
317
+ try:
318
+ threshold = int(threshold)
319
+ except (TypeError, ValueError):
320
+ threshold = 90
321
+
322
+ return {
323
+ 'schema': SCHEMA,
324
+ 'provider': provider,
325
+ 'kind': kind,
326
+ 'generated_at': _iso(now),
327
+ 'machine': machine,
328
+ 'host': socket.gethostname(),
329
+ 'pool': {
330
+ 'root': root,
331
+ 'manifest': manifest_path,
332
+ 'threshold': threshold,
333
+ 'sync': _sync_block(root, manifest),
334
+ },
335
+ 'accounts': accounts,
336
+ 'summary': {
337
+ 'total': len(accounts),
338
+ 'active': sum(1 for a in accounts if a['status'] == 'active'),
339
+ 'limited': sum(1 for a in accounts if a['status'] == 'limited'),
340
+ 'needs_login': sum(1 for a in accounts if a['needs_login']),
341
+ 'portable': sum(1 for a in accounts if a['portable']),
342
+ 'selectable': sum(1 for a in accounts if a['selectable']),
343
+ },
344
+ 'warnings': warnings,
345
+ }
346
+
347
+
348
+ if __name__ == '__main__':
349
+ root = sys.argv[1]
350
+ provider = sys.argv[2] if len(sys.argv) > 2 else 'claude'
351
+ machine = sys.argv[3] if len(sys.argv) > 3 else 'mac'
352
+ kind = sys.argv[4] if len(sys.argv) > 4 else 'list'
353
+ sys.path = [os.path.dirname(os.path.abspath(__file__))] + \
354
+ [p for p in sys.path if p not in ('', '.')]
355
+ 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.13",
3
+ "version": "1.0.14",
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": {