tokenforbes-cli 0.1.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.
package/bin.js ADDED
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ // TokenBoard 上报命令行。只上报数字和设备型号,不读、不传你的任何项目内容。
3
+ // 命令:register <昵称> | login <密钥> | report(默认)
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { exec } from 'node:child_process';
8
+ import { readUsage } from './ccusage.js';
9
+ import { detectDevice } from './device.js';
10
+ import { daemon } from './daemon.js';
11
+
12
+ const CONFIG = path.join(os.homedir(), '.tokenboard.json');
13
+ const DEFAULT_SERVER = process.env.TB_SERVER || 'https://ceorank.cn';
14
+
15
+ const loadConfig = () => { try { return JSON.parse(fs.readFileSync(CONFIG, 'utf8')); } catch { return {}; } };
16
+ const saveConfig = (c) => fs.writeFileSync(CONFIG, JSON.stringify(c, null, 2));
17
+
18
+ const [cmd, arg] = process.argv.slice(2);
19
+
20
+ if (cmd === 'login') {
21
+ if (!arg) die('用法: tokenforbes login <密钥>');
22
+ const c = loadConfig(); c.key = arg; c.server = c.server || DEFAULT_SERVER; saveConfig(c);
23
+ console.log('密钥已保存 →', CONFIG);
24
+ } else if (cmd === 'link') {
25
+ await link();
26
+ } else if (cmd === 'daemon') {
27
+ daemon(arg);
28
+ } else if (cmd === 'register') {
29
+ await register(arg);
30
+ } else if (cmd === 'report') {
31
+ await report();
32
+ } else if (!cmd) {
33
+ // 一条命令走完:没登录先引导登录(登录成功会自动上报一次),登录过就直接同步。对齐竞品 npx 体验。
34
+ const { key } = loadConfig();
35
+ if (key) await report(); else await link();
36
+ } else {
37
+ console.log('命令: link | register <昵称> | login <密钥> | report | daemon <install|status|uninstall>');
38
+ }
39
+
40
+ // 设备授权登录:发起 → 自动开浏览器批准 → 轮询拿密钥(免去手动复制粘贴密钥)。
41
+ async function link() {
42
+ const c = loadConfig();
43
+ const server = c.server || DEFAULT_SERVER;
44
+ const device = detectDevice();
45
+ const s = await post(server + '/api/device/start', { device });
46
+ const url = `${server}/link.html?code=${s.user_code}`;
47
+ console.log('\n→ 登录确认 ' + url);
48
+ console.log(' 验证码: ' + s.user_code);
49
+ console.log(' 浏览器会自动打开;没反应就手动复制上面的链接。\n');
50
+ openBrowser(url);
51
+
52
+ process.stdout.write('等待批准…');
53
+ const until = Date.now() + s.expires_in * 1000;
54
+ while (Date.now() < until) {
55
+ await sleep(s.interval * 1000);
56
+ const p = await post(server + '/api/device/poll', { device_code: s.device_code });
57
+ if (p.status === 'approved') {
58
+ c.key = p.key; c.server = server; saveConfig(c);
59
+ console.log('\n✓ 已批准,密钥已存到 ' + CONFIG);
60
+ console.log('\n顺手把你的本地用量传上去:\n');
61
+ await report(); // 登录即上报,一条命令闭环,别让用户卡在「登录完不知道下一步」
62
+ console.log('\n想让它每半小时自动后台上报? npm i -g tokenforbes-cli 后跑 tokenforbes daemon install');
63
+ return;
64
+ }
65
+ if (p.status === 'expired') break;
66
+ process.stdout.write('.');
67
+ }
68
+ die('\n登录超时或验证码已过期,重新跑一次 npx tokenforbes-cli link');
69
+ }
70
+
71
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
72
+
73
+ function openBrowser(url) {
74
+ const opener = process.platform === 'darwin' ? 'open'
75
+ : process.platform === 'win32' ? 'start ""'
76
+ : 'xdg-open';
77
+ exec(`${opener} "${url}"`, () => {}); // 打不开就算了,上面已打印链接供手动打开
78
+ }
79
+
80
+ async function register(nickname) {
81
+ if (!nickname) die('用法: tokenforbes register <昵称>');
82
+ const c = loadConfig();
83
+ const server = c.server || DEFAULT_SERVER;
84
+ const device = detectDevice();
85
+ const j = await post(server + '/api/register', { nickname, device });
86
+ c.key = j.key; c.server = server; saveConfig(c);
87
+ console.log(`注册成功,昵称「${j.nickname}」。密钥已存到 ${CONFIG}`);
88
+ console.log('现在跑 tokenforbes report 上报你的用量。');
89
+ }
90
+
91
+ async function report() {
92
+ const c = loadConfig();
93
+ if (!c.key) die('还没登录。先跑 npx tokenforbes-cli (浏览器一键登录),或 register / login');
94
+ const server = c.server || DEFAULT_SERVER;
95
+ console.log('读取本地用量(ccusage)…');
96
+ const days = readUsage();
97
+ if (!days.length) return console.log('没读到用量数据。');
98
+ const device = detectDevice();
99
+ const total = days.reduce((s, d) => s + d.input + d.output, 0);
100
+ console.log(`设备:${device}`);
101
+ console.log(`共 ${days.length} 天,合计约 ${Math.round(total / 1e4).toLocaleString()} 万 token,上报中…`);
102
+ const j = await post(server + '/api/report', { key: c.key, device, days });
103
+ console.log(`上报成功 ✓ 已记录 ${j.days} 天。`);
104
+ }
105
+
106
+ async function post(url, body) {
107
+ let res;
108
+ try {
109
+ res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
110
+ } catch (e) {
111
+ die(`连不上服务器(${url}):${e.message}`);
112
+ }
113
+ const j = await res.json().catch(() => ({}));
114
+ if (!res.ok) die('失败:' + (j.error || res.status));
115
+ return j;
116
+ }
117
+
118
+ function die(msg) { console.error(msg); process.exit(1); }
package/ccusage.js ADDED
@@ -0,0 +1,44 @@
1
+ // 读本地 AI 编码用量。只用 ccusage 的统计数字,绝不碰你的项目内容。
2
+ // 榜单口径:每天 input + output(不含缓存)。
3
+ import { execSync } from 'node:child_process';
4
+
5
+ const num = (x) => Math.max(0, Math.round(Number(x) || 0));
6
+
7
+ export function readUsage() {
8
+ let out;
9
+ try {
10
+ out = execSync('npx ccusage@latest daily --breakdown --json', {
11
+ encoding: 'utf8', timeout: 120000, maxBuffer: 64 * 1024 * 1024,
12
+ });
13
+ } catch {
14
+ throw new Error('读取用量失败:ccusage 跑不起来。先确认能联网、能跑 npx。');
15
+ }
16
+ let data;
17
+ try {
18
+ data = JSON.parse(out);
19
+ } catch {
20
+ throw new Error('读取用量失败:ccusage 输出看不懂(可能版本变了)。');
21
+ }
22
+ if (!data || !Array.isArray(data.daily)) {
23
+ throw new Error('读取用量失败:没找到每日数据(ccusage 输出结构变了)。');
24
+ }
25
+ return data.daily
26
+ .map((d) => {
27
+ const input = num(d.inputTokens), output = num(d.outputTokens);
28
+ return {
29
+ date: d.period,
30
+ input,
31
+ output,
32
+ models: d.modelsUsed || [],
33
+ tools: d.metadata?.agents || [], // 用了哪些工具:claude / codex …
34
+ breakdown: Array.isArray(d.modelBreakdowns)
35
+ ? d.modelBreakdowns.map((m) => ({
36
+ model: m.modelName,
37
+ input: num(m.inputTokens),
38
+ output: num(m.outputTokens),
39
+ }))
40
+ : [],
41
+ };
42
+ })
43
+ .filter((d) => d.date && d.input + d.output > 0);
44
+ }
package/daemon.js ADDED
@@ -0,0 +1,91 @@
1
+ // 后台自动同步:在 macOS 上装一个 launchd 定时任务,每 30 分钟自动跑一次 report。
2
+ // ponytail: 只做 macOS(launchd)—— 负责人在 Mac,对标的竞品也是这套。
3
+ // Linux(systemd/cron)、Windows(计划任务)等真有人要再加,先不抽象。
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { execFileSync } from 'node:child_process';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const LABEL = 'ai.tokenboard.report';
11
+ const PLIST = path.join(os.homedir(), 'Library', 'LaunchAgents', LABEL + '.plist');
12
+ const LOG = path.join(os.homedir(), '.tokenboard.log');
13
+ const CONFIG = path.join(os.homedir(), '.tokenboard.json');
14
+ const BIN = fileURLToPath(new URL('./bin.js', import.meta.url)); // 这个 CLI 的绝对路径
15
+ const INTERVAL = 30 * 60; // 秒
16
+
17
+ function ensureMac() {
18
+ if (process.platform !== 'darwin') {
19
+ console.error('后台自动同步目前只支持 macOS。其它系统可自己挂个定时任务跑 tokenforbes report');
20
+ process.exit(1);
21
+ }
22
+ }
23
+
24
+ // launchd 任务说明书(plist)。用绝对路径,日志重定向到文件,失败不静默。
25
+ export function buildPlist() {
26
+ return `<?xml version="1.0" encoding="UTF-8"?>
27
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
28
+ <plist version="1.0">
29
+ <dict>
30
+ <key>Label</key><string>${LABEL}</string>
31
+ <key>ProgramArguments</key>
32
+ <array>
33
+ <string>${process.execPath}</string>
34
+ <string>${BIN}</string>
35
+ <string>report</string>
36
+ </array>
37
+ <key>StartInterval</key><integer>${INTERVAL}</integer>
38
+ <key>RunAtLoad</key><true/>
39
+ <key>StandardOutPath</key><string>${LOG}</string>
40
+ <key>StandardErrorPath</key><string>${LOG}</string>
41
+ </dict>
42
+ </plist>
43
+ `;
44
+ }
45
+
46
+ function install() {
47
+ ensureMac();
48
+ // 没登录就别装,否则只会每半小时报一次「未登录」错。
49
+ let key;
50
+ try { key = JSON.parse(fs.readFileSync(CONFIG, 'utf8')).key; } catch {}
51
+ if (!key) { console.error('还没登录,先跑 npx tokenforbes-cli 登录后再装。'); process.exit(1); }
52
+
53
+ // ponytail: plist 里写死了 BIN 的绝对路径。装 daemon 请用全局安装(npm i -g),
54
+ // 别用 npx —— npx 缓存目录会被清,清掉后 launchd 每半小时报一次「文件不存在」。
55
+ fs.mkdirSync(path.dirname(PLIST), { recursive: true });
56
+ fs.writeFileSync(PLIST, buildPlist());
57
+ // ponytail: 用 load/unload(老接口,现 macOS 仍可用);若哪天失效,换 bootstrap/bootout gui/$UID。
58
+ // 先 unload 再 load = 幂等,重复装不报错。
59
+ try { execFileSync('launchctl', ['unload', PLIST], { stdio: 'ignore' }); } catch {}
60
+ execFileSync('launchctl', ['load', PLIST]);
61
+ console.log('✓ 已开启后台自动同步,每 30 分钟自动上报一次。');
62
+ console.log(' 任务文件: ' + PLIST);
63
+ console.log(' 日志: ' + LOG);
64
+ console.log(' 查看状态: tokenforbes daemon status');
65
+ console.log(' 关掉它: tokenforbes daemon uninstall');
66
+ }
67
+
68
+ function status() {
69
+ ensureMac();
70
+ if (!fs.existsSync(PLIST)) return console.log('未安装。装它: tokenforbes daemon install');
71
+ let loaded = false;
72
+ try { loaded = execFileSync('launchctl', ['list'], { encoding: 'utf8' }).includes(LABEL); } catch {}
73
+ console.log(loaded ? '✓ 已安装并在运行,每 30 分钟自动上报。' : '已安装但未加载(重新 install 一下)。');
74
+ console.log(' 任务文件: ' + PLIST);
75
+ console.log(' 日志: ' + LOG + '(看最近几次上报有没有出错)');
76
+ }
77
+
78
+ function uninstall() {
79
+ ensureMac();
80
+ if (!fs.existsSync(PLIST)) return console.log('本来就没装。');
81
+ try { execFileSync('launchctl', ['unload', PLIST], { stdio: 'ignore' }); } catch {}
82
+ fs.rmSync(PLIST, { force: true });
83
+ console.log('✓ 已关闭后台自动同步,删除了任务文件。');
84
+ }
85
+
86
+ export function daemon(sub) {
87
+ if (sub === 'install') return install();
88
+ if (sub === 'status') return status();
89
+ if (sub === 'uninstall' || sub === 'remove') return uninstall();
90
+ console.log('用法: tokenforbes daemon install | status | uninstall');
91
+ }
package/device.js ADDED
@@ -0,0 +1,26 @@
1
+ // 识别设备型号。Mac 走 system_profiler,其他系统用 Node 自带信息降级。
2
+ import { execSync } from 'node:child_process';
3
+ import os from 'node:os';
4
+
5
+ export function detectDevice() {
6
+ try {
7
+ if (process.platform === 'darwin') return mac();
8
+ } catch {}
9
+ return generic();
10
+ }
11
+
12
+ function mac() {
13
+ const out = execSync('system_profiler SPHardwareDataType -json', {
14
+ encoding: 'utf8', timeout: 8000,
15
+ });
16
+ const h = JSON.parse(out).SPHardwareDataType[0] || {};
17
+ return [h.machine_name, h.chip_type || h.cpu_type, h.physical_memory]
18
+ .filter(Boolean).join(' · ');
19
+ }
20
+
21
+ function generic() {
22
+ const plat = { win32: 'Windows', linux: 'Linux' }[process.platform] || process.platform;
23
+ const cpu = (os.cpus()[0]?.model || '').trim();
24
+ const mem = Math.round(os.totalmem() / 1e9) + ' GB';
25
+ return [plat, cpu, mem].filter(Boolean).join(' · ');
26
+ }
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "tokenforbes-cli",
3
+ "version": "0.1.0",
4
+ "description": "上报本地 AI 编程工具(Claude Code / Codex 等)的 token 用量到 Token 福布斯排行榜(ceorank.cn)。只读用量数字和设备型号,绝不碰你的代码内容。",
5
+ "type": "module",
6
+ "bin": {
7
+ "tokenforbes": "./bin.js"
8
+ },
9
+ "files": ["bin.js", "ccusage.js", "device.js", "daemon.js"],
10
+ "engines": {
11
+ "node": ">=18"
12
+ },
13
+ "keywords": ["tokenforbes", "ccusage", "claude-code", "codex", "token", "leaderboard", "ai-coding"],
14
+ "homepage": "https://ceorank.cn",
15
+ "license": "MIT"
16
+ }