project-cypher 0.21.2 → 0.22.0

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.
Files changed (2) hide show
  1. package/bin/cypher.js +69 -0
  2. package/package.json +1 -1
package/bin/cypher.js CHANGED
@@ -12,6 +12,61 @@ function readRegistry() {
12
12
  }
13
13
  const writeRegistry = list => { fs.mkdirSync(ROOT_DIR, { recursive: true }); fs.writeFileSync(REG, JSON.stringify(list, null, 2)); };
14
14
  const SERVER = 'https://hub.reubenreny.in/cypher', OLD_SERVERS = ['http://140.238.248.30:3300'];
15
+ // Claude plan usage of THIS computer's Claude sign-in (same numbers as /usage in Claude Code). Read-only: never refreshes or rewrites the token.
16
+ let usageCache = null;
17
+ async function claudeUsage() {
18
+ if (usageCache && Date.now() - usageCache.at < 60000) return usageCache.r;
19
+ const dir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
20
+ let cred = null;
21
+ try { cred = JSON.parse(fs.readFileSync(path.join(dir, '.credentials.json'), 'utf8')).claudeAiOauth; } catch {}
22
+ if (!cred && process.platform === 'darwin') try { cred = JSON.parse(require('child_process').execFileSync('security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], { encoding: 'utf8', timeout: 5000 })).claudeAiOauth; } catch {}
23
+ const token = cred?.accessToken || process.env.CLAUDE_CODE_OAUTH_TOKEN;
24
+ const plan = cred?.subscriptionType ? String(cred.subscriptionType) : '';
25
+ if (!token) return { ok: false, message: process.env.ANTHROPIC_API_KEY ? 'Claude here uses an API key, which has no plan limits.' : 'Claude is not signed in on this computer. Run claude and log in.' };
26
+ if (cred?.expiresAt && cred.expiresAt < Date.now()) return { ok: false, plan, message: 'The Claude sign-in on this computer has expired for now. It renews the next time Claude runs here; send any message and try again.' };
27
+ let r, j;
28
+ try { r = await fetch('https://api.anthropic.com/api/oauth/usage', { headers: { authorization: 'Bearer ' + token, 'anthropic-beta': 'oauth-2025-04-20', 'content-type': 'application/json' }, signal: AbortSignal.timeout(15000) }); j = await r.json(); }
29
+ catch { return { ok: false, plan, message: 'Could not reach Anthropic from this computer.' }; }
30
+ if (r.status === 401) return { ok: false, plan, message: 'The Claude sign-in on this computer was not accepted. It renews the next time Claude runs here.' };
31
+ if (!r.ok) return { ok: false, plan, message: `Anthropic answered ${r.status}. Try again later.` };
32
+ const lab = { session: 'Current session (5 h)', weekly_all: 'This week, all models' }, limits = [];
33
+ if (Array.isArray(j.limits) && j.limits.length) j.limits.forEach(l => limits.push({ label: lab[l.kind] || (l.group === 'weekly' ? 'This week, ' + (l.scope?.model?.display_name || l.scope?.surface || 'scoped') : String(l.kind || 'limit').replace(/_/g, ' ')), percent: +l.percent || 0, resets: l.resets_at || null, severity: l.severity || '' }));
34
+ else [['five_hour', 'Current session (5 h)'], ['seven_day', 'This week, all models'], ['seven_day_opus', 'This week, Opus'], ['seven_day_sonnet', 'This week, Sonnet']].forEach(([k, label]) => j[k] && limits.push({ label, percent: +j[k].utilization || 0, resets: j[k].resets_at || null }));
35
+ const x = j.extra_usage, d = 10 ** (x?.decimal_places ?? 2);
36
+ const extra = x?.is_enabled ? { used: x.used_credits != null ? x.used_credits / d : null, limit: x.monthly_limit != null ? x.monthly_limit / d : null, currency: x.currency || 'USD' } : null;
37
+ const breakdown = (j.seven_day_breakdown?.rows || []).filter(b => b.percent > 0).map(b => ({ label: String(b.display_name || b.key), percent: +b.percent || 0 }));
38
+ const res = { ok: true, plan, limits, extra, breakdown, at: Date.now() };
39
+ usageCache = { at: Date.now(), r: res }; return res;
40
+ }
41
+ // Daily activity of the last 7 days from Claude Code's own transcripts (~/.claude/projects/**/*.jsonl), bucketed by the
42
+ // asking device's timezone (tz = minutes, like Date#getTimezoneOffset). Each API reply counted once (message.id + requestId).
43
+ let actCache = null;
44
+ async function claudeActivity(tz = 0) {
45
+ tz = Math.max(-840, Math.min(840, +tz || 0));
46
+ if (actCache && actCache.tz === tz && Date.now() - actCache.at < 60000) return actCache.r;
47
+ const dir = path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects');
48
+ const dayOf = ms => new Date(ms - tz * 60000).toISOString().slice(0, 10);
49
+ const days = []; for (let i = 6; i >= 0; i--) days.push({ date: dayOf(Date.now() - i * 86400000), replies: 0, sessions: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, models: {} });
50
+ const by = Object.fromEntries(days.map(d => [d.date, d])), since = Date.now() - 8 * 86400000, seen = new Set(), sess = new Set();
51
+ const files = []; const walk = (d, depth) => { let es = []; try { es = fs.readdirSync(d, { withFileTypes: true }); } catch {} for (const e of es) { const p = path.join(d, e.name); if (e.isDirectory() && depth < 4) walk(p, depth + 1); else if (e.name.endsWith('.jsonl')) { try { if (fs.statSync(p).mtimeMs >= since) files.push(p); } catch {} } } };
52
+ walk(dir, 0);
53
+ for (const f of files) {
54
+ let txt = ''; try { txt = await fs.promises.readFile(f, 'utf8'); } catch { continue; }
55
+ for (const line of txt.split('\n')) {
56
+ if (!line.includes('"usage"') || !line.includes('"assistant"')) continue;
57
+ let j; try { j = JSON.parse(line); } catch { continue; }
58
+ const u = j.message?.usage, t = Date.parse(j.timestamp); if (j.type !== 'assistant' || !u || !t) continue;
59
+ const d = by[dayOf(t)]; if (!d) continue;
60
+ const k = (j.message.id || '') + ':' + (j.requestId || ''); if (k !== ':') { if (seen.has(k)) continue; seen.add(k); }
61
+ const model = String(j.message.model || ''); if (model === '<synthetic>') continue;
62
+ d.replies++; d.input += u.input_tokens || 0; d.output += u.output_tokens || 0; d.cacheRead += u.cache_read_input_tokens || 0; d.cacheWrite += u.cache_creation_input_tokens || 0;
63
+ const short = model.replace(/^claude-/, '').replace(/-\d{8}$/, ''); if (short) d.models[short] = (d.models[short] || 0) + 1;
64
+ if (j.sessionId && !sess.has(d.date + j.sessionId)) { sess.add(d.date + j.sessionId); d.sessions++; }
65
+ }
66
+ await new Promise(r => setImmediate(r)); // keep the agent responsive while scanning
67
+ }
68
+ const r = { days }; actCache = { at: Date.now(), tz, r }; return r;
69
+ }
15
70
  const rawCfg = () => { try { const c = JSON.parse(fs.readFileSync(CFG, 'utf8')); if (!c.server || OLD_SERVERS.includes(String(c.server).replace(/\/$/, ''))) c.server = SERVER; return c; } catch { return { server: SERVER, token: '', sessions: {}, permissionMode: 'acceptEdits' }; } };
16
71
  const load = () => { const c = rawCfg(); if (c.projects?.length && !fs.existsSync(REG)) writeRegistry(c.projects); delete c.projects; return { ...c, projects: readRegistry() }; };
17
72
  const save = c => { const { projects, ...rest } = c; fs.mkdirSync(CFG_DIR, { recursive: true }); fs.writeFileSync(CFG, JSON.stringify(rest, null, 2), { mode: 0o600 }); };
@@ -254,6 +309,17 @@ const cmds = {
254
309
  console.log(`service: ${SVC.running() ? 'running' : 'stopped'}${SVC.enabled() ? ' (starts automatically)' : ' (auto-start off)'}\ninternet/server: ${r?.ok ? 'reachable' : 'unreachable'} ${c.server}\nlinked: ${c.token ? 'yes, as "' + devName() + '"' : 'no (run cypher init)'}\nprojects: ${c.projects.length}\nlog: ${LOG}`);
255
310
  try { const t = fs.readFileSync(LOG, 'utf8').trim().split('\n').slice(-3).join('\n'); if (t) console.log('\nrecent:\n' + t); } catch {}
256
311
  },
312
+ async usage() {
313
+ const [u, a] = await Promise.all([claudeUsage(), claudeActivity(new Date().getTimezoneOffset())]);
314
+ const t = s => s ? new Date(s).toLocaleString([], { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }) : '';
315
+ if (!u.ok) console.log(u.message); else {
316
+ console.log(`Claude plan: ${u.plan || 'unknown'}`); u.limits.forEach(l => console.log(`${l.label.padEnd(28)} ${String(Math.round(l.percent)).padStart(3)}% used${l.resets ? ' resets ' + t(l.resets) : ''}`));
317
+ if (u.extra) console.log(`Extra usage: ${u.extra.used ?? 0}${u.extra.limit != null ? ' / ' + u.extra.limit : ''} ${u.extra.currency}`);
318
+ }
319
+ const n = x => x >= 1e6 ? (x / 1e6).toFixed(1) + 'M' : x >= 1e3 ? (x / 1e3).toFixed(1) + 'k' : String(x);
320
+ console.log('\nLast 7 days (this computer) replies sessions output total tokens');
321
+ a.days.forEach(d => console.log(`${d.date.padEnd(32)} ${String(d.replies).padStart(8)} ${String(d.sessions).padStart(9)} ${n(d.output).padStart(8)} ${n(d.input + d.output + d.cacheRead + d.cacheWrite).padStart(14)}`));
322
+ },
257
323
  version() { console.log(require('../package.json').version); },
258
324
  update() { // npm install -g project-cypher@latest, then restart the background service
259
325
  const cur = require('../package.json').version; const here = fs.realpathSync(__filename);
@@ -404,6 +470,8 @@ const cmds = {
404
470
  });
405
471
  return;
406
472
  }
473
+ if (m.type === 'usage') return Promise.all([claudeUsage().catch(e => ({ ok: false, message: String(e.message || e).slice(0, 200) })), claudeActivity(m.tz).catch(() => null)])
474
+ .then(([u, a]) => tx({ type: 'usage', reqId: m.reqId, ...u, activity: a }));
407
475
  if (m.type === 'sessions') { if (!proj) return; return tx({ ...ctx, type: 'sessions', list: listSessions(proj.path, load().sessions?.[proj.id]) }); }
408
476
  if (m.type === 'usesession') { // switch the project to an older Claude session and send its chat back
409
477
  if (!proj || !SID.test(String(m.id))) return tx({ ...ctx, type: 'error', message: 'Unknown session.' });
@@ -476,6 +544,7 @@ const help = () => console.log(`Project Cypher
476
544
  cypher resume [project-id] continue the phone's Claude session for this folder in the terminal
477
545
  cypher update | version install the newest CLI from npm and restart the service / show version
478
546
  cypher stats | logs service + connection status / recent log
547
+ cypher usage Claude plan usage (session / weekly limits) of this computer
479
548
  cypher status | latest server check / latest app build
480
549
 
481
550
  Config: ~/.cypher/config.json (permissionMode defaults to acceptEdits)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-cypher",
3
- "version": "0.21.2",
3
+ "version": "0.22.0",
4
4
  "description": "Connect your machine's Claude Code projects to the Project Cypher phone app.",
5
5
  "bin": {
6
6
  "cypher": "bin/cypher.js"