@vibe-cafe/vibe-usage 0.8.4 → 0.9.1

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 CHANGED
@@ -4,31 +4,35 @@ Track your AI coding tool token usage and sync to [vibecafe.ai](https://vibecafe
4
4
 
5
5
  ## Quick Start
6
6
 
7
- Get your API key at [vibecafe.ai/usage/setup](https://vibecafe.ai/usage/setup), then copy the one-liner shown there:
8
-
9
- ```bash
10
- npx @vibe-cafe/vibe-usage --key vbu_xxxxxxxxxxxx
11
- ```
12
-
13
- Or run without a key and paste it interactively:
14
-
15
7
  ```bash
16
8
  npx @vibe-cafe/vibe-usage
17
9
  ```
18
10
 
19
- Either path will:
11
+ That's it. The CLI opens [vibecafe.ai/usage/device](https://vibecafe.ai/usage/device) in your browser; sign in, confirm the verification code shown in your terminal, click 「确认链接」, and the CLI receives an API key automatically.
12
+
13
+ After approval, it will:
20
14
  1. Save your API key to `~/.vibe-usage/config.json`
21
15
  2. Detect installed AI coding tools
22
16
  3. Run an initial sync of your usage data
23
17
  4. Prompt you to enable the background daemon for continuous syncing (recommended)
24
18
 
19
+ ### CI / Headless
20
+
21
+ If you don't have a local browser (CI, remote SSH session, container), pre-issue a key at [vibecafe.ai/usage/setup](https://vibecafe.ai/usage/setup) and pass it on the command line:
22
+
23
+ ```bash
24
+ npx @vibe-cafe/vibe-usage init --manual-key vbu_xxxxxxxxxxxx
25
+ ```
26
+
25
27
  ## Commands
26
28
 
27
29
  ```bash
28
- npx @vibe-cafe/vibe-usage # Init (first run) or sync (subsequent runs)
29
- npx @vibe-cafe/vibe-usage --key <vbu_...> # One-shot init with a pre-copied key
30
- npx @vibe-cafe/vibe-usage init # Re-run setup
30
+ npx @vibe-cafe/vibe-usage # Init (first run, browser login) or sync (subsequent runs)
31
+ npx @vibe-cafe/vibe-usage init # Re-run setup via browser login
32
+ npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
31
33
  npx @vibe-cafe/vibe-usage sync # Manual sync
34
+ npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
35
+ npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
32
36
  npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
33
37
  npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd)
34
38
  npx @vibe-cafe/vibe-usage daemon uninstall # Remove background service
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.8.4",
3
+ "version": "0.9.1",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -154,6 +154,64 @@ export function deleteAllData(apiUrl, apiKey, opts) {
154
154
  });
155
155
  }
156
156
 
157
+ /**
158
+ * Start a device authorization flow.
159
+ * Returns the deviceCode (for polling) + userCode + URLs (for the user).
160
+ */
161
+ export function requestDeviceCode(apiUrl, { clientName, hostname }) {
162
+ return _jsonRequest(apiUrl, '/api/usage/device/code', 'POST', { clientName, hostname }, 10_000);
163
+ }
164
+
165
+ /**
166
+ * One poll iteration. Resolves with one of:
167
+ * { apiKey, apiUrl } — approved, key delivered
168
+ * { error: 'authorization_pending' } — keep polling
169
+ * { error: 'access_denied' } — user pressed deny
170
+ * { error: 'expired_token' } — code expired or already consumed
171
+ * { error: 'invalid_grant' | ... } — unrecoverable
172
+ * Rejects only on network/server errors.
173
+ */
174
+ export function pollDeviceCode(apiUrl, deviceCode) {
175
+ return _jsonRequest(apiUrl, '/api/usage/device/poll', 'POST', { deviceCode }, 15_000);
176
+ }
177
+
178
+ function _jsonRequest(apiUrl, path, method, body, timeoutMs) {
179
+ return new Promise((resolve, reject) => {
180
+ const url = new URL(path, apiUrl);
181
+ const mod = url.protocol === 'https:' ? https : http;
182
+ const raw = Buffer.from(JSON.stringify(body));
183
+
184
+ const req = mod.request(url, {
185
+ method,
186
+ timeout: timeoutMs,
187
+ headers: {
188
+ 'Content-Type': 'application/json',
189
+ 'Content-Length': raw.length,
190
+ },
191
+ }, (res) => {
192
+ let data = '';
193
+ res.on('data', (chunk) => { data += chunk; });
194
+ res.on('end', () => {
195
+ if (res.statusCode < 200 || res.statusCode >= 300) {
196
+ const err = new Error(`HTTP ${res.statusCode}: ${data}`);
197
+ err.statusCode = res.statusCode;
198
+ reject(err);
199
+ return;
200
+ }
201
+ try {
202
+ resolve(JSON.parse(data));
203
+ } catch {
204
+ reject(new Error(`Invalid JSON response: ${data}`));
205
+ }
206
+ });
207
+ });
208
+ req.on('error', reject);
209
+ req.on('timeout', () => { req.destroy(); reject(new Error(`Request timed out (${timeoutMs}ms)`)); });
210
+ req.write(raw);
211
+ req.end();
212
+ });
213
+ }
214
+
157
215
  /**
158
216
  * GET user settings from the vibecafe API.
159
217
  * Returns null on any failure (network, auth, timeout) — caller should fail-safe.
package/src/index.js CHANGED
@@ -108,7 +108,16 @@ function extractOption(args, name) {
108
108
  }
109
109
 
110
110
  export async function run(rawArgs) {
111
- const { args, value: apiKey } = extractOption(rawArgs, 'key');
111
+ // --key and --manual-key both mean "skip device flow, take this vbu_ key".
112
+ // --manual-key is the documented name; --key is kept as a legacy alias so
113
+ // existing scripts/docs don't break when device flow becomes the default.
114
+ let stripped;
115
+ let apiKey;
116
+ ({ args: stripped, value: apiKey } = extractOption(rawArgs, 'manual-key'));
117
+ if (apiKey === undefined) {
118
+ ({ args: stripped, value: apiKey } = extractOption(stripped, 'key'));
119
+ }
120
+ const args = stripped;
112
121
  const command = args[0];
113
122
 
114
123
  switch (command) {
@@ -123,6 +132,11 @@ export async function run(rawArgs) {
123
132
  await runSync();
124
133
  break;
125
134
  }
135
+ case 'summary': {
136
+ const { runSummary } = await import('./summary.js');
137
+ await runSummary(args.slice(1));
138
+ break;
139
+ }
126
140
  case 'reset': {
127
141
  printSmallHeader();
128
142
  const { runReset } = await import('./reset.js');
@@ -164,11 +178,12 @@ export async function run(rawArgs) {
164
178
  vibe-usage - Vibe Usage Tracker by VibeCafé
165
179
 
166
180
  Usage:
167
- npx @vibe-cafe/vibe-usage Init (first run) or sync
168
- npx @vibe-cafe/vibe-usage --key <vbu_...> One-shot init with a pre-copied key
169
- npx @vibe-cafe/vibe-usage init Set up API key (interactive)
170
- npx @vibe-cafe/vibe-usage init --key <vbu_...> Init with key, skip paste prompt
181
+ npx @vibe-cafe/vibe-usage Init (first run, browser login) or sync
182
+ npx @vibe-cafe/vibe-usage init Set up via browser login (default)
183
+ npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> Skip browser, use a pre-issued key (CI/headless)
171
184
  npx @vibe-cafe/vibe-usage sync Manually sync usage data
185
+ npx @vibe-cafe/vibe-usage summary Print last 7 days as markdown (cost/tokens/model/project)
186
+ npx @vibe-cafe/vibe-usage summary --days N Same, but over the last N days (1-90)
172
187
  npx @vibe-cafe/vibe-usage daemon Continuous sync (every 30m, foreground)
173
188
  npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd)
174
189
  npx @vibe-cafe/vibe-usage daemon uninstall Remove background service
package/src/init.js CHANGED
@@ -2,11 +2,13 @@ import { createInterface } from 'node:readline';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { hostname as osHostname, platform } from 'node:os';
4
4
  import { loadConfig, saveConfig } from './config.js';
5
- import { ingest } from './api.js';
5
+ import { ingest, requestDeviceCode, pollDeviceCode } from './api.js';
6
6
  import { runSync } from './sync.js';
7
7
  import { detectInstalledTools } from './tools.js';
8
8
  import { bigHeader, success, failure, warn, arrow, link, dim, divider } from './output.js';
9
9
 
10
+ const CLIENT_NAME = 'vibe-usage CLI';
11
+
10
12
  function prompt(question) {
11
13
  const rl = createInterface({ input: process.stdin, output: process.stdout });
12
14
  return new Promise((resolve) => {
@@ -49,6 +51,7 @@ export async function runInit(options = {}) {
49
51
  }
50
52
 
51
53
  const apiUrl = process.env.VIBE_USAGE_API_URL || 'https://vibecafe.ai';
54
+ const host = existing?.hostname || osHostname().replace(/\.local$/, '');
52
55
 
53
56
  let apiKey;
54
57
  if (providedKey) {
@@ -58,16 +61,8 @@ export async function runInit(options = {}) {
58
61
  }
59
62
  apiKey = providedKey;
60
63
  } else {
61
- console.log(`${arrow('获取 API Key')} ${link(`${apiUrl}/usage/setup`)}`);
62
- console.log(dim(' 浏览器会自动打开,登录后在「API Keys」处生成并复制 Key(vbu_ 开头),粘贴到下方。'));
63
- console.log();
64
- openBrowser(`${apiUrl}/usage/setup`);
65
-
66
- while (true) {
67
- apiKey = await prompt('粘贴 API Key: ');
68
- if (apiKey.startsWith('vbu_')) break;
69
- console.log(warn('必须以 vbu_ 开头,请重试。'));
70
- }
64
+ apiKey = await runDeviceFlow(apiUrl, host);
65
+ if (!apiKey) process.exit(1);
71
66
  }
72
67
 
73
68
  try {
@@ -84,7 +79,7 @@ export async function runInit(options = {}) {
84
79
  const config = {
85
80
  apiKey,
86
81
  apiUrl,
87
- hostname: existing?.hostname || osHostname().replace(/\.local$/, ''),
82
+ hostname: host,
88
83
  };
89
84
  saveConfig(config);
90
85
 
@@ -119,3 +114,76 @@ export async function runInit(options = {}) {
119
114
  }
120
115
  }
121
116
  }
117
+
118
+ async function runDeviceFlow(apiUrl, hostname) {
119
+ let device;
120
+ try {
121
+ device = await requestDeviceCode(apiUrl, { clientName: CLIENT_NAME, hostname });
122
+ } catch (err) {
123
+ console.error(failure(`无法连接 ${apiUrl}:${err.message}`));
124
+ return null;
125
+ }
126
+
127
+ console.log(`${arrow('登录确认')} ${link(device.verificationUriComplete)}`);
128
+ console.log(` 验证码: ${device.userCode}`);
129
+ console.log(dim(' 浏览器会自动打开;如果没反应,请手动复制上方链接。'));
130
+ console.log();
131
+ openBrowser(device.verificationUriComplete);
132
+
133
+ const intervalMs = (device.interval || 5) * 1000;
134
+ const deadline = Date.now() + (device.expiresIn || 900) * 1000;
135
+
136
+ process.stdout.write(dim('等待审批…'));
137
+ const aborter = new AbortController();
138
+ const onSigint = () => { aborter.abort(); };
139
+ process.on('SIGINT', onSigint);
140
+ try {
141
+ while (Date.now() < deadline) {
142
+ if (aborter.signal.aborted) {
143
+ process.stdout.write('\n');
144
+ console.log(warn('已取消。'));
145
+ return null;
146
+ }
147
+ await sleep(intervalMs);
148
+ let res;
149
+ try {
150
+ res = await pollDeviceCode(apiUrl, device.deviceCode);
151
+ } catch (err) {
152
+ // Transient network blip — keep polling until deadline.
153
+ process.stdout.write(dim('.'));
154
+ continue;
155
+ }
156
+ if (res.apiKey) {
157
+ process.stdout.write('\n');
158
+ console.log(success('已批准,获取到 API Key。'));
159
+ return res.apiKey;
160
+ }
161
+ if (res.error === 'authorization_pending') {
162
+ process.stdout.write(dim('.'));
163
+ continue;
164
+ }
165
+ if (res.error === 'access_denied') {
166
+ process.stdout.write('\n');
167
+ console.error(failure('请求被拒绝。'));
168
+ return null;
169
+ }
170
+ if (res.error === 'expired_token') {
171
+ process.stdout.write('\n');
172
+ console.error(failure('验证码已过期,请重跑 init。'));
173
+ return null;
174
+ }
175
+ process.stdout.write('\n');
176
+ console.error(failure(`服务端返回未知错误:${res.error}`));
177
+ return null;
178
+ }
179
+ process.stdout.write('\n');
180
+ console.error(failure('验证码已过期,请重跑 init。'));
181
+ return null;
182
+ } finally {
183
+ process.removeListener('SIGINT', onSigint);
184
+ }
185
+ }
186
+
187
+ function sleep(ms) {
188
+ return new Promise((resolve) => setTimeout(resolve, ms));
189
+ }
package/src/skill.js CHANGED
@@ -33,53 +33,42 @@ function tildePath(absPath) {
33
33
 
34
34
  const SKILL_CONTENT = `---
35
35
  name: vibe-usage
36
- description: Track and sync AI coding tool token usage to vibecafe.ai dashboard.
36
+ description: 查询和同步 AI 编程工具的 token 用量(VibeCafé 旗下 Vibe Usage 数据)。
37
37
  ---
38
38
 
39
39
  # Vibe Usage
40
40
 
41
- Track your AI coding tool token usage and sync to [vibecafe.ai](https://vibecafe.ai/usage).
41
+ Track and answer questions about the user's AI coding token spend via [Vibe Usage](https://vibecafe.ai/usage) (VibeCafé).
42
42
 
43
- ## Setup
43
+ ## 查询用量(默认行为)
44
44
 
45
- First-time setup (interactive — asks for API key):
45
+ 当用户问以下问题时,运行命令并**原样展示输出**(不要总结、不要换算单位、不要翻译):
46
46
 
47
- \`\`\`bash
48
- npx @vibe-cafe/vibe-usage
49
- \`\`\`
47
+ | 用户说... | 运行 |
48
+ |---|---|
49
+ | 我这周花了多少 / 查 usage / 看看消费 | \`npx @vibe-cafe/vibe-usage summary\` |
50
+ | 今天花了多少 / 今天 token | \`npx @vibe-cafe/vibe-usage summary --days 1\` |
51
+ | 本月花费 / 这个月用量 | \`npx @vibe-cafe/vibe-usage summary --days 30\` |
52
+ | 哪个模型最贵 / 模型对比 | \`npx @vibe-cafe/vibe-usage summary\`(输出已按模型拆) |
53
+ | 哪个项目花得最多 | \`npx @vibe-cafe/vibe-usage summary\`(输出已按项目拆) |
50
54
 
51
- Get your API key at https://vibecafe.ai/usage/setup
55
+ 输出是 markdown 表格,直接展示给用户即可。
52
56
 
53
- ## Commands
57
+ ## 维护命令
54
58
 
55
- When the user asks to sync usage, check costs, or track tokens, run:
59
+ | 用户说... | 运行 |
60
+ |---|---|
61
+ | 同步数据 / 上传 usage / 数据没更新 | \`npx @vibe-cafe/vibe-usage sync\` |
62
+ | 看 daemon 状态 / 后台同步还在跑吗 | \`npx @vibe-cafe/vibe-usage daemon status\` |
63
+ | 启动后台同步 | \`npx @vibe-cafe/vibe-usage daemon install\` |
64
+ | 重置数据 / 重新上传 | \`npx @vibe-cafe/vibe-usage reset\` |
56
65
 
57
- \`\`\`bash
58
- npx @vibe-cafe/vibe-usage sync
59
- \`\`\`
66
+ ## 注意
60
67
 
61
- Other available commands:
62
-
63
- | Command | Description |
64
- |---------|-------------|
65
- | \`npx @vibe-cafe/vibe-usage sync\` | Sync latest usage data |
66
- | \`npx @vibe-cafe/vibe-usage status\` | Show config and detected tools |
67
- | \`npx @vibe-cafe/vibe-usage daemon\` | Continuous sync every 30 minutes |
68
- | \`npx @vibe-cafe/vibe-usage reset\` | Delete all data and re-upload |
69
- | \`npx @vibe-cafe/vibe-usage reset --local\` | Delete this host's data and re-upload |
70
-
71
- ## When to Use
72
-
73
- - User says "sync my usage", "upload usage", "track tokens"
74
- - User asks "how much have I spent?", "what's my cost?"
75
- - User wants to check if sync is working: run \`status\`
76
- - User wants continuous background sync: run \`daemon\`
77
-
78
- ## Notes
79
-
80
- - Requires initial setup with an API key (run \`npx @vibe-cafe/vibe-usage\` first)
81
- - Config is stored at \`~/.vibe-usage/config.json\`
82
- - Supports: Claude Code, Codex CLI, Copilot CLI, Gemini CLI, OpenCode, OpenClaw, Qwen Code, Kimi Code, Amp, Droid
68
+ - \`summary\` 读 \`~/.vibe-usage/config.json\` 里已有的 API key,不需要用户额外输入
69
+ - summary 输出已是 markdown,**原样展示,不要复述**
70
+ - 用户没装过 vibe-usage?提示运行 \`npx @vibe-cafe/vibe-usage\` 先用浏览器登录链接账号
71
+ - 支持的工具:Claude Code, Codex CLI, Copilot CLI, Gemini CLI, OpenCode, OpenClaw, Qwen Code, Kimi Code, Amp, Droid 等
83
72
  `;
84
73
 
85
74
  export async function runSkill(args = []) {
@@ -88,7 +77,7 @@ export async function runSkill(args = []) {
88
77
  console.log(' 检测到的工具:');
89
78
  for (const t of SKILL_TARGETS) {
90
79
  const found = existsSync(t.detectDir);
91
- const mark = found ? green('') : red('');
80
+ const mark = found ? green('[OK]') : red('[未装]');
92
81
  console.log(` ${mark} ${t.name}`);
93
82
  }
94
83
  console.log();
package/src/summary.js ADDED
@@ -0,0 +1,146 @@
1
+ import https from 'node:https';
2
+ import http from 'node:http';
3
+ import { URL } from 'node:url';
4
+ import { loadConfig } from './config.js';
5
+
6
+ export async function runSummary(args = []) {
7
+ const days = parseDays(args);
8
+ const config = loadConfig();
9
+ if (!config?.apiKey) {
10
+ console.error('No vibe-usage config found. Run `npx @vibe-cafe/vibe-usage init` first.');
11
+ process.exit(1);
12
+ }
13
+
14
+ const url = new URL('/api/usage', config.apiUrl || 'https://vibecafe.ai');
15
+ url.searchParams.set('days', String(days));
16
+
17
+ let data;
18
+ try {
19
+ data = await fetchJson(url, config.apiKey);
20
+ } catch (err) {
21
+ if (err.statusCode === 401) {
22
+ console.error('API key invalid or revoked. Run `npx @vibe-cafe/vibe-usage init` to re-link.');
23
+ } else {
24
+ console.error(`Failed to fetch usage: ${err.message}`);
25
+ }
26
+ process.exit(1);
27
+ }
28
+
29
+ console.log(render(data, days));
30
+ }
31
+
32
+ function parseDays(args) {
33
+ const idx = args.findIndex(a => a === '--days');
34
+ if (idx === -1) return 7;
35
+ const v = parseInt(args[idx + 1], 10);
36
+ if (!v || v < 1) return 7;
37
+ if (v > 90) return 90;
38
+ return v;
39
+ }
40
+
41
+ function render(data, days) {
42
+ const buckets = Array.isArray(data?.buckets) ? data.buckets : [];
43
+ const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
44
+
45
+ if (buckets.length === 0) {
46
+ return `# Vibe Usage Summary (Last ${days} ${days === 1 ? 'day' : 'days'})\n\n暂无数据。运行 \`npx @vibe-cafe/vibe-usage sync\` 上传本地 token 记录。\n\n详情: https://vibecafe.ai/usage\n`;
47
+ }
48
+
49
+ let totalCost = 0;
50
+ let totalTokens = 0;
51
+ const byModel = new Map();
52
+ const byProject = new Map();
53
+
54
+ for (const b of buckets) {
55
+ const cost = Number(b.estimatedCost ?? 0);
56
+ const tokens = Number(b.totalTokens ?? 0);
57
+ totalCost += cost;
58
+ totalTokens += tokens;
59
+ accumulate(byModel, b.model, { cost, tokens });
60
+ accumulate(byProject, b.project || 'unknown', { cost, tokens, sessions: 0 });
61
+ }
62
+
63
+ const sessionsCount = sessions.length;
64
+ let activeSeconds = 0;
65
+ for (const s of sessions) {
66
+ activeSeconds += Number(s.activeSeconds ?? 0);
67
+ const proj = byProject.get(s.project || 'unknown');
68
+ if (proj) proj.sessions += 1;
69
+ }
70
+ const activeHours = activeSeconds / 3600;
71
+
72
+ const lines = [];
73
+ lines.push(`# Vibe Usage Summary (Last ${days} ${days === 1 ? 'day' : 'days'})`);
74
+ lines.push('');
75
+ lines.push(`**总览**: $${totalCost.toFixed(2)} · ${formatTokens(totalTokens)} tokens · ${sessionsCount} sessions · ${activeHours.toFixed(1)}h active`);
76
+ lines.push('');
77
+
78
+ lines.push('## 按模型');
79
+ lines.push('');
80
+ lines.push('| 模型 | 费用 | Tokens | 占比 |');
81
+ lines.push('|---|---:|---:|---:|');
82
+ for (const [model, { cost, tokens }] of topN(byModel, 'cost', 8)) {
83
+ const pct = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(0) : '0';
84
+ lines.push(`| ${model} | $${cost.toFixed(2)} | ${formatTokens(tokens)} | ${pct}% |`);
85
+ }
86
+ lines.push('');
87
+
88
+ lines.push('## 按项目');
89
+ lines.push('');
90
+ lines.push('| 项目 | 费用 | Sessions |');
91
+ lines.push('|---|---:|---:|');
92
+ for (const [project, { cost, sessions: ss }] of topN(byProject, 'cost', 8)) {
93
+ lines.push(`| ${project} | $${cost.toFixed(2)} | ${ss} |`);
94
+ }
95
+ lines.push('');
96
+
97
+ lines.push('详情: https://vibecafe.ai/usage');
98
+ return lines.join('\n');
99
+ }
100
+
101
+ function accumulate(map, key, delta) {
102
+ const cur = map.get(key) || { cost: 0, tokens: 0, sessions: 0 };
103
+ for (const k of Object.keys(delta)) cur[k] = (cur[k] || 0) + delta[k];
104
+ map.set(key, cur);
105
+ }
106
+
107
+ function topN(map, sortBy, n) {
108
+ return [...map.entries()]
109
+ .sort((a, b) => b[1][sortBy] - a[1][sortBy])
110
+ .slice(0, n);
111
+ }
112
+
113
+ function formatTokens(n) {
114
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
115
+ if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
116
+ return String(n);
117
+ }
118
+
119
+ function fetchJson(url, apiKey) {
120
+ return new Promise((resolve, reject) => {
121
+ const mod = url.protocol === 'https:' ? https : http;
122
+ const req = mod.request(url, {
123
+ method: 'GET',
124
+ timeout: 15_000,
125
+ headers: { 'Authorization': `Bearer ${apiKey}` },
126
+ }, (res) => {
127
+ let data = '';
128
+ res.on('data', (chunk) => { data += chunk; });
129
+ res.on('end', () => {
130
+ if (res.statusCode === 401) {
131
+ const err = new Error('Unauthorized'); err.statusCode = 401; reject(err); return;
132
+ }
133
+ if (res.statusCode < 200 || res.statusCode >= 300) {
134
+ const err = new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`);
135
+ err.statusCode = res.statusCode;
136
+ reject(err); return;
137
+ }
138
+ try { resolve(JSON.parse(data)); }
139
+ catch { reject(new Error('Invalid JSON response')); }
140
+ });
141
+ });
142
+ req.on('error', reject);
143
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout (15s)')); });
144
+ req.end();
145
+ });
146
+ }